feat: redesign home screen date selector with navigation controls
Replace static 7-day ListView with a scrollable strip (365 days back,
today at the right edge) and a header row with:
- chevron buttons to step one day at a time
- selected date label ("Вт, 18 марта")
- "Сегодня" shortcut that appears when a past date is selected
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -135,7 +135,7 @@ class _AppBar extends StatelessWidget {
|
||||
|
||||
// ── Date selector ─────────────────────────────────────────────
|
||||
|
||||
class _DateSelector extends StatelessWidget {
|
||||
class _DateSelector extends StatefulWidget {
|
||||
final DateTime selectedDate;
|
||||
final ValueChanged<DateTime> onDateSelected;
|
||||
|
||||
@@ -144,7 +144,101 @@ class _DateSelector extends StatelessWidget {
|
||||
required this.onDateSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_DateSelector> createState() => _DateSelectorState();
|
||||
}
|
||||
|
||||
class _DateSelectorState extends State<_DateSelector> {
|
||||
static const _weekDayShort = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
static const _monthNames = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
|
||||
];
|
||||
// Total days available in the past (index 0 = today, index N-1 = oldest)
|
||||
static const _totalDays = 365;
|
||||
static const _pillWidth = 48.0;
|
||||
static const _pillSpacing = 6.0;
|
||||
|
||||
late final ScrollController _scrollController;
|
||||
|
||||
String _formatSelectedDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final dayName = _weekDayShort[date.weekday - 1];
|
||||
final month = _monthNames[date.month - 1];
|
||||
final yearSuffix = date.year != now.year ? ' ${date.year}' : '';
|
||||
return '$dayName, ${date.day} $month$yearSuffix';
|
||||
}
|
||||
|
||||
// Index in the reversed list: 0 = today, 1 = yesterday, …
|
||||
int _indexForDate(DateTime date) {
|
||||
final today = DateTime.now();
|
||||
final todayNormalized = DateTime(today.year, today.month, today.day);
|
||||
final dateNormalized = DateTime(date.year, date.month, date.day);
|
||||
return todayNormalized.difference(dateNormalized).inDays.clamp(0, _totalDays - 1);
|
||||
}
|
||||
|
||||
double _offsetForIndex(int index) => index * (_pillWidth + _pillSpacing);
|
||||
|
||||
void _selectPreviousDay() {
|
||||
final previousDay = widget.selectedDate.subtract(const Duration(days: 1));
|
||||
final previousDayNormalized =
|
||||
DateTime(previousDay.year, previousDay.month, previousDay.day);
|
||||
final today = DateTime.now();
|
||||
final oldestAllowed = DateTime(today.year, today.month, today.day)
|
||||
.subtract(const Duration(days: _totalDays - 1));
|
||||
if (!previousDayNormalized.isBefore(oldestAllowed)) {
|
||||
widget.onDateSelected(previousDayNormalized);
|
||||
}
|
||||
}
|
||||
|
||||
void _selectNextDay() {
|
||||
final today = DateTime.now();
|
||||
final todayNormalized = DateTime(today.year, today.month, today.day);
|
||||
final nextDay = widget.selectedDate.add(const Duration(days: 1));
|
||||
final nextDayNormalized =
|
||||
DateTime(nextDay.year, nextDay.month, nextDay.day);
|
||||
if (!nextDayNormalized.isAfter(todayNormalized)) {
|
||||
widget.onDateSelected(nextDayNormalized);
|
||||
}
|
||||
}
|
||||
|
||||
void _jumpToToday() {
|
||||
final today = DateTime.now();
|
||||
widget.onDateSelected(DateTime(today.year, today.month, today.day));
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_DateSelector oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final oldIndex = _indexForDate(oldWidget.selectedDate);
|
||||
final newIndex = _indexForDate(widget.selectedDate);
|
||||
if (oldIndex != newIndex && _scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_offsetForIndex(newIndex),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -152,60 +246,114 @@ class _DateSelector extends StatelessWidget {
|
||||
final today = DateTime.now();
|
||||
final todayNormalized = DateTime(today.year, today.month, today.day);
|
||||
final selectedNormalized = DateTime(
|
||||
selectedDate.year, selectedDate.month, selectedDate.day);
|
||||
widget.selectedDate.year, widget.selectedDate.month, widget.selectedDate.day);
|
||||
final isToday = selectedNormalized == todayNormalized;
|
||||
|
||||
return SizedBox(
|
||||
height: 64,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 7,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final date = todayNormalized.subtract(Duration(days: 6 - index));
|
||||
final isSelected = date == selectedNormalized;
|
||||
final isToday = date == todayNormalized;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Header row ──────────────────────────────────────────
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: _selectPreviousDay,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_formatSelectedDate(widget.selectedDate),
|
||||
style: theme.textTheme.labelMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (!isToday)
|
||||
TextButton(
|
||||
onPressed: _jumpToToday,
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
child: Text(
|
||||
'Сегодня',
|
||||
style: theme.textTheme.labelMedium
|
||||
?.copyWith(color: theme.colorScheme.primary),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// ── Day strip ────────────────────────────────────────────
|
||||
SizedBox(
|
||||
height: 56,
|
||||
// reverse: true → index 0 (today) sits at the right edge
|
||||
child: ListView.separated(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
reverse: true,
|
||||
itemCount: _totalDays,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: _pillSpacing),
|
||||
itemBuilder: (listContext, index) {
|
||||
final date = todayNormalized.subtract(Duration(days: index));
|
||||
final isSelected = date == selectedNormalized;
|
||||
final isDayToday = date == todayNormalized;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onDateSelected(date),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_weekDayShort[date.weekday - 1],
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: isSelected
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onDateSelected(date),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: _pillWidth,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${date.day}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight:
|
||||
isSelected || isToday ? FontWeight.w700 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? theme.colorScheme.onPrimary
|
||||
: isToday
|
||||
? theme.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_weekDayShort[date.weekday - 1],
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: isSelected
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${date.day}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: isSelected || isDayToday
|
||||
? FontWeight.w700
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? theme.colorScheme.onPrimary
|
||||
: isDayToday
|
||||
? theme.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user