Commit bcb756606d819ba4d7c6df7e8122e043d23d5a4b

Authored by 刘宏哲
1 parent 2426dcda

feat(app): update date picker

... ... @@ -10,9 +10,15 @@ Future<DateTime?> showReportDatePickerSheet({
required ReportPeriod period,
required DateTime selectedDate,
required DateTime weekStart,
DateTime? minDate,
DateTime? maxDate,
}) async {
final backgroundColor = const Color(0xFFF8F4FF);
final barrierAlpha = period == ReportPeriod.day ? 0.25 : 0.70;
final range = ReportDatePickerRange(
minDate: minDate ?? ReportDateRangeConfig.firstDate(),
maxDate: maxDate ?? ReportDateRangeConfig.lastDate(),
);
final picked = await showModalBottomSheet<DateTime>(
context: context,
... ... @@ -24,13 +30,25 @@ Future<DateTime?> showReportDatePickerSheet({
builder: (context) {
switch (period) {
case ReportPeriod.day:
return _DayDatePickerSheet(initialDate: selectedDate);
return _DayDatePickerSheet(
initialDate: range.clamp(selectedDate),
range: range,
);
case ReportPeriod.week:
return _WeekDatePickerSheet(initialWeekStart: weekStart);
return _WeekDatePickerSheet(
initialWeekStart: range.clampWeekStart(weekStart),
range: range,
);
case ReportPeriod.month:
return _MonthDatePickerSheet(initialDate: selectedDate);
return _MonthDatePickerSheet(
initialDate: range.clampMonth(selectedDate),
range: range,
);
case ReportPeriod.year:
return _YearDatePickerSheet(initialDate: selectedDate);
return _YearDatePickerSheet(
initialDate: range.clampYear(selectedDate),
range: range,
);
}
},
);
... ... @@ -43,10 +61,75 @@ Future<DateTime?> showReportDatePickerSheet({
return picked;
}
class ReportDatePickerRange {
ReportDatePickerRange({
required DateTime minDate,
required DateTime maxDate,
}) : minDate = DateTime(minDate.year, minDate.month, minDate.day),
maxDate = DateTime(maxDate.year, maxDate.month, maxDate.day) {
if (this.maxDate.isBefore(this.minDate)) {
throw ArgumentError.value(
maxDate,
'maxDate',
'must not be before minDate',
);
}
}
final DateTime minDate;
final DateTime maxDate;
int get firstYear => minDate.year;
int get lastYear => maxDate.year;
DateTime get firstMonth => DateTime(minDate.year, minDate.month);
DateTime get lastMonth => DateTime(maxDate.year, maxDate.month);
DateTime get firstWeekStart => _weekStartOf(minDate);
DateTime get lastWeekStart => _weekStartOf(maxDate);
DateTime clamp(DateTime date) {
final normalized = DateTime(date.year, date.month, date.day);
if (normalized.isBefore(minDate)) return minDate;
if (normalized.isAfter(maxDate)) return maxDate;
return normalized;
}
DateTime clampMonth(DateTime date) {
final normalized = DateTime(date.year, date.month);
if (normalized.isBefore(firstMonth)) return firstMonth;
if (normalized.isAfter(lastMonth)) return lastMonth;
return normalized;
}
DateTime clampYear(DateTime date) {
return DateTime(date.year.clamp(firstYear, lastYear));
}
DateTime clampWeekStart(DateTime date) {
final normalized = _weekStartOf(date);
if (normalized.isBefore(firstWeekStart)) return firstWeekStart;
if (normalized.isAfter(lastWeekStart)) return lastWeekStart;
return normalized;
}
static DateTime _weekStartOf(DateTime date) {
final normalized = DateTime(date.year, date.month, date.day);
return normalized.subtract(Duration(days: normalized.weekday - 1));
}
}
class _YearDatePickerSheet extends StatefulWidget {
const _YearDatePickerSheet({required this.initialDate});
const _YearDatePickerSheet({
required this.initialDate,
required this.range,
});
final DateTime initialDate;
final ReportDatePickerRange range;
@override
State<_YearDatePickerSheet> createState() => _YearDatePickerSheetState();
... ... @@ -62,8 +145,11 @@ class _YearDatePickerSheetState extends State<_YearDatePickerSheet> {
@override
void initState() {
super.initState();
_yearRange = _DatePickerYearRange.current();
_year = widget.initialDate.year.clamp(_yearRange.first, _yearRange.last);
_yearRange = _DatePickerYearRange(
first: widget.range.firstYear,
last: widget.range.lastYear,
);
_year = widget.range.clampYear(widget.initialDate).year;
_yearController =
FixedExtentScrollController(initialItem: _year - _yearRange.first);
}
... ... @@ -109,7 +195,9 @@ class _YearDatePickerSheetState extends State<_YearDatePickerSheet> {
const Spacer(),
_ConfirmButton(
width: 280,
onTap: () => Navigator.of(context).pop(DateTime(_year)),
onTap: () => Navigator.of(context).pop(
widget.range.clampYear(DateTime(_year)),
),
),
],
),
... ... @@ -120,9 +208,13 @@ class _YearDatePickerSheetState extends State<_YearDatePickerSheet> {
}
class _DayDatePickerSheet extends StatefulWidget {
const _DayDatePickerSheet({required this.initialDate});
const _DayDatePickerSheet({
required this.initialDate,
required this.range,
});
final DateTime initialDate;
final ReportDatePickerRange range;
@override
State<_DayDatePickerSheet> createState() => _DayDatePickerSheetState();
... ... @@ -136,20 +228,30 @@ class _DayDatePickerSheetState extends State<_DayDatePickerSheet> {
late int _day;
late final _DatePickerYearRange _yearRange;
late final FixedExtentScrollController _yearController;
late final FixedExtentScrollController _monthController;
late FixedExtentScrollController _monthController;
late FixedExtentScrollController _dayController;
@override
void initState() {
super.initState();
_yearRange = _DatePickerYearRange.current();
_year = widget.initialDate.year.clamp(_yearRange.first, _yearRange.last);
_month = widget.initialDate.month;
_day = widget.initialDate.day.clamp(1, _daysInMonth(_year, _month));
_yearRange = _DatePickerYearRange(
first: widget.range.firstYear,
last: widget.range.lastYear,
);
final initialDate = widget.range.clamp(widget.initialDate);
_year = initialDate.year;
_month = initialDate.month.clamp(
_firstSelectableMonth,
_lastSelectableMonth,
);
_day = initialDate.day.clamp(_firstSelectableDay, _lastSelectableDay);
_yearController =
FixedExtentScrollController(initialItem: _year - _yearRange.first);
_monthController = FixedExtentScrollController(initialItem: _month - 1);
_dayController = FixedExtentScrollController(initialItem: _day - 1);
_monthController = FixedExtentScrollController(
initialItem: _month - _firstSelectableMonth,
);
_dayController =
FixedExtentScrollController(initialItem: _day - _firstSelectableDay);
}
@override
... ... @@ -162,6 +264,10 @@ class _DayDatePickerSheetState extends State<_DayDatePickerSheet> {
@override
Widget build(BuildContext context) {
final firstMonth = _firstSelectableMonth;
final lastMonth = _lastSelectableMonth;
final firstDay = _firstSelectableDay;
final lastDay = _lastSelectableDay;
final yearPicker = Expanded(
child: _picker(
controller: _yearController,
... ... @@ -173,7 +279,7 @@ class _DayDatePickerSheetState extends State<_DayDatePickerSheet> {
onSelectedItemChanged: (index) {
setState(() {
_year = _yearRange.first + index;
_clampDay();
_clampMonthAndDay();
});
},
),
... ... @@ -181,13 +287,13 @@ class _DayDatePickerSheetState extends State<_DayDatePickerSheet> {
final monthPicker = Expanded(
child: _picker(
controller: _monthController,
count: 12,
selectedIndex: _month - 1,
count: lastMonth - firstMonth + 1,
selectedIndex: _month - firstMonth,
labelBuilder: (index) =>
context.l10n.reportDatePickerMonthOption(index + 1),
context.l10n.reportDatePickerMonthOption(firstMonth + index),
onSelectedItemChanged: (index) {
setState(() {
_month = index + 1;
_month = firstMonth + index;
_clampDay();
});
},
... ... @@ -196,12 +302,12 @@ class _DayDatePickerSheetState extends State<_DayDatePickerSheet> {
final dayPicker = Expanded(
child: _picker(
controller: _dayController,
count: _daysInMonth(_year, _month),
selectedIndex: _day - 1,
count: lastDay - firstDay + 1,
selectedIndex: _day - firstDay,
labelBuilder: (index) =>
context.l10n.reportDatePickerDayOption(index + 1),
context.l10n.reportDatePickerDayOption(firstDay + index),
onSelectedItemChanged: (index) {
setState(() => _day = index + 1);
setState(() => _day = firstDay + index);
},
),
);
... ... @@ -233,8 +339,9 @@ class _DayDatePickerSheetState extends State<_DayDatePickerSheet> {
),
const Spacer(),
_ConfirmButton(
onTap: () =>
Navigator.of(context).pop(DateTime(_year, _month, _day)),
onTap: () => Navigator.of(context).pop(
widget.range.clamp(DateTime(_year, _month, _day)),
),
),
],
),
... ... @@ -265,8 +372,9 @@ class _DayDatePickerSheetState extends State<_DayDatePickerSheet> {
labelBuilder(index),
maxLines: 1,
style: TextStyle(
color:
selected ? _ReportPickerColors.h1 : _ReportPickerColors.fade,
color: selected
? _ReportPickerColors.h1
: _ReportPickerColors.fade,
fontSize: selected ? 18 : 15,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
height: 1.2,
... ... @@ -277,23 +385,64 @@ class _DayDatePickerSheetState extends State<_DayDatePickerSheet> {
);
}
int get _firstSelectableMonth {
return _year == widget.range.minDate.year ? widget.range.minDate.month : 1;
}
int get _lastSelectableMonth {
return _year == widget.range.maxDate.year ? widget.range.maxDate.month : 12;
}
int get _firstSelectableDay {
return _isSameMonth(_year, _month, widget.range.minDate)
? widget.range.minDate.day
: 1;
}
int get _lastSelectableDay {
return _isSameMonth(_year, _month, widget.range.maxDate)
? widget.range.maxDate.day
: _daysInMonth(_year, _month);
}
void _clampMonthAndDay() {
final firstMonth = _firstSelectableMonth;
final lastMonth = _lastSelectableMonth;
final nextMonth = _month.clamp(firstMonth, lastMonth);
_month = nextMonth;
_monthController.dispose();
_monthController =
FixedExtentScrollController(initialItem: _month - firstMonth);
_clampDay();
}
void _clampDay() {
final days = _daysInMonth(_year, _month);
if (_day <= days) return;
_day = days;
final firstDay = _firstSelectableDay;
final lastDay = _lastSelectableDay;
final nextDay = _day.clamp(firstDay, lastDay);
_day = nextDay;
_dayController.dispose();
_dayController = FixedExtentScrollController(initialItem: _day - 1);
_dayController =
FixedExtentScrollController(initialItem: _day - firstDay);
}
static int _daysInMonth(int year, int month) {
return DateTime(year, month + 1, 0).day;
}
static bool _isSameMonth(int year, int month, DateTime date) {
return year == date.year && month == date.month;
}
}
class _MonthDatePickerSheet extends StatefulWidget {
const _MonthDatePickerSheet({required this.initialDate});
const _MonthDatePickerSheet({
required this.initialDate,
required this.range,
});
final DateTime initialDate;
final ReportDatePickerRange range;
@override
State<_MonthDatePickerSheet> createState() => _MonthDatePickerSheetState();
... ... @@ -306,17 +455,26 @@ class _MonthDatePickerSheetState extends State<_MonthDatePickerSheet> {
late int _month;
late final _DatePickerYearRange _yearRange;
late final FixedExtentScrollController _yearController;
late final FixedExtentScrollController _monthController;
late FixedExtentScrollController _monthController;
@override
void initState() {
super.initState();
_yearRange = _DatePickerYearRange.current();
_year = widget.initialDate.year.clamp(_yearRange.first, _yearRange.last);
_month = widget.initialDate.month;
_yearRange = _DatePickerYearRange(
first: widget.range.firstYear,
last: widget.range.lastYear,
);
final initialMonth = widget.range.clampMonth(widget.initialDate);
_year = initialMonth.year;
_month = initialMonth.month.clamp(
_firstSelectableMonth,
_lastSelectableMonth,
);
_yearController =
FixedExtentScrollController(initialItem: _year - _yearRange.first);
_monthController = FixedExtentScrollController(initialItem: _month - 1);
_monthController = FixedExtentScrollController(
initialItem: _month - _firstSelectableMonth,
);
}
@override
... ... @@ -328,6 +486,8 @@ class _MonthDatePickerSheetState extends State<_MonthDatePickerSheet> {
@override
Widget build(BuildContext context) {
final firstMonth = _firstSelectableMonth;
final lastMonth = _lastSelectableMonth;
final yearPicker = Expanded(
child: _picker(
controller: _yearController,
... ... @@ -337,19 +497,22 @@ class _MonthDatePickerSheetState extends State<_MonthDatePickerSheet> {
_yearRange.first + index,
),
onSelectedItemChanged: (index) {
setState(() => _year = _yearRange.first + index);
setState(() {
_year = _yearRange.first + index;
_clampMonth();
});
},
),
);
final monthPicker = Expanded(
child: _picker(
controller: _monthController,
count: 12,
selectedIndex: _month - 1,
count: lastMonth - firstMonth + 1,
selectedIndex: _month - firstMonth,
labelBuilder: (index) =>
context.l10n.reportDatePickerMonthOption(index + 1),
context.l10n.reportDatePickerMonthOption(firstMonth + index),
onSelectedItemChanged: (index) {
setState(() => _month = index + 1);
setState(() => _month = firstMonth + index);
},
),
);
... ... @@ -382,7 +545,9 @@ class _MonthDatePickerSheetState extends State<_MonthDatePickerSheet> {
const Spacer(),
_ConfirmButton(
width: 280,
onTap: () => Navigator.of(context).pop(DateTime(_year, _month)),
onTap: () => Navigator.of(context).pop(
widget.range.clampMonth(DateTime(_year, _month)),
),
),
],
),
... ... @@ -407,12 +572,34 @@ class _MonthDatePickerSheetState extends State<_MonthDatePickerSheet> {
itemExtent: _itemExtent,
);
}
int get _firstSelectableMonth {
return _year == widget.range.minDate.year ? widget.range.minDate.month : 1;
}
int get _lastSelectableMonth {
return _year == widget.range.maxDate.year ? widget.range.maxDate.month : 12;
}
void _clampMonth() {
final firstMonth = _firstSelectableMonth;
final lastMonth = _lastSelectableMonth;
final nextMonth = _month.clamp(firstMonth, lastMonth);
_month = nextMonth;
_monthController.dispose();
_monthController =
FixedExtentScrollController(initialItem: _month - firstMonth);
}
}
class _WeekDatePickerSheet extends StatefulWidget {
const _WeekDatePickerSheet({required this.initialWeekStart});
const _WeekDatePickerSheet({
required this.initialWeekStart,
required this.range,
});
final DateTime initialWeekStart;
final ReportDatePickerRange range;
@override
State<_WeekDatePickerSheet> createState() => _WeekDatePickerSheetState();
... ... @@ -477,8 +664,9 @@ class _WeekDatePickerSheetState extends State<_WeekDatePickerSheet> {
const Spacer(),
_ConfirmButton(
width: 280,
onTap: () =>
Navigator.of(context).pop(_weekStarts[_selectedIndex]),
onTap: () => Navigator.of(context).pop(
widget.range.clampWeekStart(_weekStarts[_selectedIndex]),
),
),
],
),
... ... @@ -488,8 +676,8 @@ class _WeekDatePickerSheetState extends State<_WeekDatePickerSheet> {
}
List<DateTime> _buildWeekStarts() {
final lastDate = ReportDateRangeConfig.lastWeekStart();
var cursor = ReportDateRangeConfig.firstWeekStart();
final lastDate = widget.range.lastWeekStart;
var cursor = widget.range.firstWeekStart;
final result = <DateTime>[];
while (!cursor.isAfter(lastDate)) {
result.add(cursor);
... ... @@ -686,13 +874,6 @@ class _DatePickerYearRange {
final int last;
int get count => last - first + 1;
factory _DatePickerYearRange.current() {
return _DatePickerYearRange(
first: ReportDateRangeConfig.firstYear(),
last: ReportDateRangeConfig.lastYear(),
);
}
}
abstract final class _ReportPickerColors {
... ...
import 'dart:async';
import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart';
import 'package:doublefeel_flutter/app/modules/friends/data/friends_repository.dart';
import 'package:doublefeel_flutter/app/modules/friends/data/self_health_repository.dart';
import 'package:doublefeel_flutter/app/modules/friends/models/friend_health_data.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('refresh loads friends and self health independently', () async {
final friendsRepository = _FakeFriendsRepository();
final selfHealthRepository = _FakeSelfHealthRepository();
final controller = FriendsController(
repository: friendsRepository,
selfHealthRepository: selfHealthRepository,
);
final refresh = controller.refreshData();
expect(friendsRepository.getFriendsCallCount, 1);
expect(selfHealthRepository.getHealthDataCallCount, 1);
expect(selfHealthRepository.getStressScoreCallCount, 1);
expect(controller.isLoading.value, isTrue);
expect(controller.isSelfHealthLoading.value, isTrue);
selfHealthRepository.healthDataCompleter.complete(
const V2HealthData(steps: 8424, sleepScore: 92),
);
await Future<void>.delayed(Duration.zero);
expect(controller.selfHealthData.value?.steps, 8424);
expect(controller.isSelfHealthLoading.value, isTrue);
expect(controller.isLoading.value, isTrue);
selfHealthRepository.stressScoreCompleter.complete(
const V2StressScore(state: 2, comprehensiveScore: 72),
);
friendsRepository.friendsCompleter.complete(const []);
await refresh;
expect(controller.selfStressScore.value?.comprehensiveScore, 72);
expect(controller.selfHealthUpdatedAt.value, isNotNull);
expect(controller.isLoading.value, isFalse);
expect(controller.isSelfHealthLoading.value, isFalse);
});
}
class _FakeFriendsRepository implements FriendsRepository {
final friendsCompleter = Completer<List<FriendHealthData>>();
int getFriendsCallCount = 0;
@override
Future<List<FriendHealthData>> getFriends() {
getFriendsCallCount++;
return friendsCompleter.future;
}
@override
Future<void> deleteFriend(int userId) async {}
@override
Future<void> selectWatchFaceFriend(int userId) async {}
@override
Future<void> updateRemark(int userId, String remark) async {}
}
class _FakeSelfHealthRepository implements SelfHealthRepository {
final healthDataCompleter = Completer<V2HealthData>();
final stressScoreCompleter = Completer<V2StressScore>();
int getHealthDataCallCount = 0;
int getStressScoreCallCount = 0;
@override
Future<V2HealthData> getHealthData(DateTime date) {
getHealthDataCallCount++;
return healthDataCompleter.future;
}
@override
Future<V2StressScore> getStressScore(DateTime date) {
getStressScoreCallCount++;
return stressScoreCompleter.future;
}
}