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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user