From 227780e1a99edb59fcc12b85d5014b9283a85370 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Tue, 17 Mar 2026 16:09:42 +0200 Subject: [PATCH] feat: redesign home screen date selector with navigation controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/lib/features/home/home_screen.dart | 250 +++++++++++++++++----- client/pubspec.lock | 20 +- 2 files changed, 209 insertions(+), 61 deletions(-) diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart index 7a74c8a..a4c56d3 100644 --- a/client/lib/features/home/home_screen.dart +++ b/client/lib/features/home/home_screen.dart @@ -135,7 +135,7 @@ class _AppBar extends StatelessWidget { // ── Date selector ───────────────────────────────────────────── -class _DateSelector extends StatelessWidget { +class _DateSelector extends StatefulWidget { final DateTime selectedDate; final ValueChanged 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, + ), + ), + ], ), - ], - ), - ), - ); - }, - ), + ), + ); + }, + ), + ), + ], ); } } diff --git a/client/pubspec.lock b/client/pubspec.lock index df7fdde..79c5cdf 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -668,26 +668,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -993,10 +993,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" typed_data: dependency: transitive description: