Showing
46 changed files
with
2288 additions
and
818 deletions
Too many changes to show.
To preserve performance only 46 of 46+ files are displayed.
| @@ -8,6 +8,7 @@ import com.doublefeel.app.pigeon.platform.AppleProductInfo | @@ -8,6 +8,7 @@ import com.doublefeel.app.pigeon.platform.AppleProductInfo | ||
| 8 | import com.doublefeel.app.pigeon.platform.AppleProductPaymentResult | 8 | import com.doublefeel.app.pigeon.platform.AppleProductPaymentResult |
| 9 | import com.doublefeel.app.pigeon.platform.AppleSignInModel | 9 | import com.doublefeel.app.pigeon.platform.AppleSignInModel |
| 10 | import com.doublefeel.app.pigeon.platform.PlatformHostApi | 10 | import com.doublefeel.app.pigeon.platform.PlatformHostApi |
| 11 | +import com.doublefeel.app.pigeon.platform.WatchAppOtherInfo | ||
| 11 | import kotlin.math.max | 12 | import kotlin.math.max |
| 12 | import kotlin.math.min | 13 | import kotlin.math.min |
| 13 | 14 | ||
| @@ -84,4 +85,12 @@ class PlatformHostApiImpl(private val application: android.app.Application) : Pl | @@ -84,4 +85,12 @@ class PlatformHostApiImpl(private val application: android.app.Application) : Pl | ||
| 84 | 85 | ||
| 85 | override fun performRestore(callback: (Result<Boolean>) -> Unit) { | 86 | override fun performRestore(callback: (Result<Boolean>) -> Unit) { |
| 86 | } | 87 | } |
| 88 | + | ||
| 89 | + override fun updateWatchOtherUserInfo(otherInfo: WatchAppOtherInfo?) { | ||
| 90 | + | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + override fun refreshWatchSurface() { | ||
| 94 | + | ||
| 95 | + } | ||
| 87 | } | 96 | } |
| @@ -51,7 +51,7 @@ class WearEngineHostApiImpl( | @@ -51,7 +51,7 @@ class WearEngineHostApiImpl( | ||
| 51 | return sendTextMessage(jsonPayload) | 51 | return sendTextMessage(jsonPayload) |
| 52 | } | 52 | } |
| 53 | 53 | ||
| 54 | - override fun pickImageAndRemoveBackground(): String? { | ||
| 55 | - return null | 54 | + override fun removeBackground(originImagePath: String, callback: (Result<String?>) -> Unit) { |
| 55 | + | ||
| 56 | } | 56 | } |
| 57 | } | 57 | } |
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
| 1 | +import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | ||
| 1 | import 'package:get/get.dart'; | 2 | import 'package:get/get.dart'; |
| 2 | 3 | ||
| 3 | import '../../report_common/controllers/report_period_logic.dart'; | 4 | import '../../report_common/controllers/report_period_logic.dart'; |
| @@ -7,22 +8,33 @@ import '../data/activity_burn_report_repository.dart'; | @@ -7,22 +8,33 @@ import '../data/activity_burn_report_repository.dart'; | ||
| 7 | import '../models/activity_burn_report_models.dart'; | 8 | import '../models/activity_burn_report_models.dart'; |
| 8 | 9 | ||
| 9 | class ActivityBurnReportLogic extends ReportPeriodLogic { | 10 | class ActivityBurnReportLogic extends ReportPeriodLogic { |
| 10 | - ActivityBurnReportLogic({int? initialTargetUserId}) { | 11 | + ActivityBurnReportLogic({ |
| 12 | + int? initialTargetUserId, | ||
| 13 | + ActivityBurnReportRepository? repository, | ||
| 14 | + }) : repository = repository ?? | ||
| 15 | + ActivityBurnReportRepositoryImpl( | ||
| 16 | + dataSource: | ||
| 17 | + ApiActivityBurnReportDataSource(Get.find<HealthApi>()), | ||
| 18 | + ) { | ||
| 11 | targetUserId.value = initialTargetUserId; | 19 | targetUserId.value = initialTargetUserId; |
| 12 | } | 20 | } |
| 13 | 21 | ||
| 14 | final targetUserId = RxnInt(); | 22 | final targetUserId = RxnInt(); |
| 15 | 23 | ||
| 16 | - final ActivityBurnReportRepository repository = | ||
| 17 | - const ActivityBurnReportRepositoryImpl( | ||
| 18 | - dataSource: MockActivityBurnReportDataSource(), | ||
| 19 | - ); | 24 | + final ActivityBurnReportRepository repository; |
| 20 | 25 | ||
| 21 | final report = Rxn<ActivityBurnReport>(); | 26 | final report = Rxn<ActivityBurnReport>(); |
| 22 | final weeklyReport = Rxn<WeeklyActivityBurnReport>(); | 27 | final weeklyReport = Rxn<WeeklyActivityBurnReport>(); |
| 23 | final monthlyReport = Rxn<MonthlyActivityBurnReport>(); | 28 | final monthlyReport = Rxn<MonthlyActivityBurnReport>(); |
| 24 | 29 | ||
| 25 | @override | 30 | @override |
| 31 | + List<ReportPeriod> get supportedPeriods => const [ | ||
| 32 | + ReportPeriod.day, | ||
| 33 | + ReportPeriod.week, | ||
| 34 | + ReportPeriod.month, | ||
| 35 | + ]; | ||
| 36 | + | ||
| 37 | + @override | ||
| 26 | Future<void> loadReport() async { | 38 | Future<void> loadReport() async { |
| 27 | isLoading.value = true; | 39 | isLoading.value = true; |
| 28 | try { | 40 | try { |
| 1 | +import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/core/result/app_result.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart'; | ||
| 4 | + | ||
| 1 | import '../models/activity_burn_report_models.dart'; | 5 | import '../models/activity_burn_report_models.dart'; |
| 2 | 6 | ||
| 3 | abstract class ActivityBurnReportDataSource { | 7 | abstract class ActivityBurnReportDataSource { |
| @@ -17,6 +21,224 @@ abstract class ActivityBurnReportDataSource { | @@ -17,6 +21,224 @@ abstract class ActivityBurnReportDataSource { | ||
| 17 | }); | 21 | }); |
| 18 | } | 22 | } |
| 19 | 23 | ||
| 24 | +class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource { | ||
| 25 | + const ApiActivityBurnReportDataSource(this._healthApi); | ||
| 26 | + | ||
| 27 | + static const _weekDateRangeType = 0; | ||
| 28 | + static const _monthDateRangeType = 1; | ||
| 29 | + static const _dayDateRangeType = 3; | ||
| 30 | + | ||
| 31 | + final HealthApi _healthApi; | ||
| 32 | + | ||
| 33 | + @override | ||
| 34 | + Future<ActivityBurnReport> fetchDailyReport( | ||
| 35 | + DateTime date, { | ||
| 36 | + int? targetUserId, | ||
| 37 | + }) async { | ||
| 38 | + final day = _normalizeDate(date); | ||
| 39 | + final result = await _healthApi.getActivityBurnStatistics( | ||
| 40 | + _dayDateRangeType, | ||
| 41 | + _dateKey(day), | ||
| 42 | + queryUserId: targetUserId, | ||
| 43 | + ); | ||
| 44 | + return switch (result) { | ||
| 45 | + AppSuccess(:final data) => _dailyReport(day, data), | ||
| 46 | + AppFailure() => ActivityBurnReport.empty(day), | ||
| 47 | + }; | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + @override | ||
| 51 | + Future<WeeklyActivityBurnReport> fetchWeeklyReport( | ||
| 52 | + DateTime weekStart, { | ||
| 53 | + int? targetUserId, | ||
| 54 | + }) async { | ||
| 55 | + final start = _normalizeDate(weekStart); | ||
| 56 | + final result = await _healthApi.getActivityBurnStatistics( | ||
| 57 | + _weekDateRangeType, | ||
| 58 | + _dateKey(start), | ||
| 59 | + queryUserId: targetUserId, | ||
| 60 | + ); | ||
| 61 | + return switch (result) { | ||
| 62 | + AppSuccess(:final data) => WeeklyActivityBurnReport( | ||
| 63 | + weekStart: start, | ||
| 64 | + weekEnd: start.add(const Duration(days: 6)), | ||
| 65 | + days: _periodReports(start, 7, data), | ||
| 66 | + totalActiveEnergyOverride: data.totalMove?.round(), | ||
| 67 | + totalExerciseMinutesOverride: data.totalExercise?.round(), | ||
| 68 | + totalStandHoursOverride: data.totalStand?.round(), | ||
| 69 | + averageDailyActiveEnergyOverride: data.avgMove?.round(), | ||
| 70 | + activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(), | ||
| 71 | + ), | ||
| 72 | + AppFailure() => WeeklyActivityBurnReport.empty(start), | ||
| 73 | + }; | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + @override | ||
| 77 | + Future<MonthlyActivityBurnReport> fetchMonthlyReport( | ||
| 78 | + DateTime monthStart, { | ||
| 79 | + int? targetUserId, | ||
| 80 | + }) async { | ||
| 81 | + final start = DateTime(monthStart.year, monthStart.month); | ||
| 82 | + final end = DateTime(start.year, start.month + 1, 0); | ||
| 83 | + final result = await _healthApi.getActivityBurnStatistics( | ||
| 84 | + _monthDateRangeType, | ||
| 85 | + _dateKey(start), | ||
| 86 | + queryUserId: targetUserId, | ||
| 87 | + ); | ||
| 88 | + return switch (result) { | ||
| 89 | + AppSuccess(:final data) => MonthlyActivityBurnReport( | ||
| 90 | + monthStart: start, | ||
| 91 | + monthEnd: end, | ||
| 92 | + days: _periodReports(start, end.day, data), | ||
| 93 | + totalActiveEnergyOverride: data.totalMove?.round(), | ||
| 94 | + totalExerciseMinutesOverride: data.totalExercise?.round(), | ||
| 95 | + totalStandHoursOverride: data.totalStand?.round(), | ||
| 96 | + averageDailyActiveEnergyOverride: data.avgMove?.round(), | ||
| 97 | + activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(), | ||
| 98 | + ), | ||
| 99 | + AppFailure() => MonthlyActivityBurnReport.empty(start), | ||
| 100 | + }; | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + ActivityBurnReport _dailyReport( | ||
| 104 | + DateTime date, | ||
| 105 | + ActivityBurnStatisticsDataV2 data, | ||
| 106 | + ) { | ||
| 107 | + final target = data.activityTargetInfo; | ||
| 108 | + final overallValues = _dailyOverallValues(date, data.overallList); | ||
| 109 | + final points = <ActivityBurnHeartRatePoint>[ | ||
| 110 | + for (final item in data.hrList ?? const <HrList>[]) | ||
| 111 | + if (_parseDateTime(item.dataTime) case final time?) | ||
| 112 | + if (item.value case final bpm?) | ||
| 113 | + if (bpm > 0 && | ||
| 114 | + !time.isBefore(date) && | ||
| 115 | + time.isBefore(date.add(const Duration(days: 1)))) | ||
| 116 | + ActivityBurnHeartRatePoint(time: time, bpm: bpm.toDouble()), | ||
| 117 | + ]..sort((a, b) => a.time.compareTo(b.time)); | ||
| 118 | + final sleepTimes = <DateTime>[ | ||
| 119 | + for (final item in data.sleepTimeList ?? const <SleepTimeList>[]) | ||
| 120 | + if (_parseDateTime(item.fromTime, fallbackDate: date) case final from?) | ||
| 121 | + from, | ||
| 122 | + for (final item in data.sleepTimeList ?? const <SleepTimeList>[]) | ||
| 123 | + if (_parseDateTime(item.toTime, fallbackDate: date) case final to?) to, | ||
| 124 | + ]..sort(); | ||
| 125 | + | ||
| 126 | + return ActivityBurnReport( | ||
| 127 | + date: date, | ||
| 128 | + activeEnergy: | ||
| 129 | + _metric(overallValues?.move ?? data.totalMove, target?.move), | ||
| 130 | + exerciseMinutes: _metric( | ||
| 131 | + overallValues?.exercise ?? data.totalExercise, | ||
| 132 | + target?.exercise, | ||
| 133 | + ), | ||
| 134 | + standHours: _metric( | ||
| 135 | + overallValues?.stand ?? data.totalStand, | ||
| 136 | + target?.stand, | ||
| 137 | + ), | ||
| 138 | + heartRate: ActivityBurnHeartRateSummary( | ||
| 139 | + startTime: date, | ||
| 140 | + endTime: points.isEmpty ? null : points.last.time, | ||
| 141 | + sleepStartTime: sleepTimes.isEmpty ? null : sleepTimes.first, | ||
| 142 | + sleepEndTime: sleepTimes.isEmpty ? null : sleepTimes.last, | ||
| 143 | + points: points, | ||
| 144 | + ), | ||
| 145 | + ); | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + Value? _dailyOverallValues( | ||
| 149 | + DateTime date, | ||
| 150 | + List<OverallList>? overallList, | ||
| 151 | + ) { | ||
| 152 | + final items = overallList ?? const <OverallList>[]; | ||
| 153 | + for (final item in items) { | ||
| 154 | + final time = _parseDateTime(item.timeKey); | ||
| 155 | + if (time != null && _normalizeDate(time) == date) return item.value; | ||
| 156 | + } | ||
| 157 | + if (items.length == 1) return items.first.value; | ||
| 158 | + return null; | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + List<ActivityBurnReport> _periodReports( | ||
| 162 | + DateTime start, | ||
| 163 | + int dayCount, | ||
| 164 | + ActivityBurnStatisticsDataV2 data, | ||
| 165 | + ) { | ||
| 166 | + final target = data.activityTargetInfo; | ||
| 167 | + final valuesByDate = <DateTime, Value>{ | ||
| 168 | + for (final item in data.overallList ?? const <OverallList>[]) | ||
| 169 | + if (_parseDateTime(item.timeKey) case final time?) | ||
| 170 | + if (item.value case final value?) _normalizeDate(time): value, | ||
| 171 | + }; | ||
| 172 | + final moveByDate = <DateTime, num>{ | ||
| 173 | + for (final item in data.moveTrendList ?? const <MoveTrendList>[]) | ||
| 174 | + if (_parseDateTime(item.timeKey) case final time?) | ||
| 175 | + if (item.value case final value?) _normalizeDate(time): value, | ||
| 176 | + }; | ||
| 177 | + | ||
| 178 | + return [ | ||
| 179 | + for (var i = 0; i < dayCount; i++) | ||
| 180 | + _periodDay( | ||
| 181 | + start.add(Duration(days: i)), | ||
| 182 | + valuesByDate, | ||
| 183 | + moveByDate, | ||
| 184 | + target, | ||
| 185 | + ), | ||
| 186 | + ]; | ||
| 187 | + } | ||
| 188 | + | ||
| 189 | + ActivityBurnReport _periodDay( | ||
| 190 | + DateTime date, | ||
| 191 | + Map<DateTime, Value> valuesByDate, | ||
| 192 | + Map<DateTime, num> moveByDate, | ||
| 193 | + ActivityTargetInfo? target, | ||
| 194 | + ) { | ||
| 195 | + final values = valuesByDate[date]; | ||
| 196 | + final move = values?.move ?? moveByDate[date]; | ||
| 197 | + if (values == null && move == null) return ActivityBurnReport.empty(date); | ||
| 198 | + return ActivityBurnReport( | ||
| 199 | + date: date, | ||
| 200 | + activeEnergy: _metric(move, target?.move), | ||
| 201 | + exerciseMinutes: _metric(values?.exercise, target?.exercise), | ||
| 202 | + standHours: _metric(values?.stand, target?.stand), | ||
| 203 | + ); | ||
| 204 | + } | ||
| 205 | + | ||
| 206 | + ActivityBurnMetric? _metric(num? value, num? goal) { | ||
| 207 | + if (value == null) return null; | ||
| 208 | + return ActivityBurnMetric(value: value.round(), goal: goal?.round() ?? 0); | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + DateTime? _parseDateTime(Object? value, {DateTime? fallbackDate}) { | ||
| 212 | + if (value is num) { | ||
| 213 | + if (value >= 1000000000) { | ||
| 214 | + final milliseconds = | ||
| 215 | + value >= 1000000000000 ? value.toInt() : (value * 1000).toInt(); | ||
| 216 | + return DateTime.fromMillisecondsSinceEpoch(milliseconds); | ||
| 217 | + } | ||
| 218 | + if (fallbackDate != null && value >= 0 && value < 86400) { | ||
| 219 | + return fallbackDate.add(Duration(seconds: value.round())); | ||
| 220 | + } | ||
| 221 | + } | ||
| 222 | + final raw = value?.toString(); | ||
| 223 | + if (raw == null || raw.isEmpty) return null; | ||
| 224 | + final parsed = DateTime.tryParse(raw); | ||
| 225 | + if (parsed != null) return parsed; | ||
| 226 | + final digits = raw.replaceAll(RegExp(r'[^0-9]'), ''); | ||
| 227 | + if (digits.length < 8) return null; | ||
| 228 | + final year = int.tryParse(digits.substring(0, 4)); | ||
| 229 | + final month = int.tryParse(digits.substring(4, 6)); | ||
| 230 | + final day = int.tryParse(digits.substring(6, 8)); | ||
| 231 | + if (year == null || month == null || day == null) return null; | ||
| 232 | + return DateTime(year, month, day); | ||
| 233 | + } | ||
| 234 | + | ||
| 235 | + DateTime _normalizeDate(DateTime date) => | ||
| 236 | + DateTime(date.year, date.month, date.day); | ||
| 237 | + | ||
| 238 | + int _dateKey(DateTime date) => | ||
| 239 | + date.year * 10000 + date.month * 100 + date.day; | ||
| 240 | +} | ||
| 241 | + | ||
| 20 | class MockActivityBurnReportDataSource implements ActivityBurnReportDataSource { | 242 | class MockActivityBurnReportDataSource implements ActivityBurnReportDataSource { |
| 21 | const MockActivityBurnReportDataSource(); | 243 | const MockActivityBurnReportDataSource(); |
| 22 | 244 |
| @@ -74,37 +74,64 @@ class WeeklyActivityBurnReport { | @@ -74,37 +74,64 @@ class WeeklyActivityBurnReport { | ||
| 74 | required this.weekStart, | 74 | required this.weekStart, |
| 75 | required this.weekEnd, | 75 | required this.weekEnd, |
| 76 | required this.days, | 76 | required this.days, |
| 77 | + this.totalActiveEnergyOverride, | ||
| 78 | + this.totalExerciseMinutesOverride, | ||
| 79 | + this.totalStandHoursOverride, | ||
| 80 | + this.averageDailyActiveEnergyOverride, | ||
| 81 | + this.activeEnergyGoalOverride, | ||
| 77 | }); | 82 | }); |
| 78 | 83 | ||
| 79 | final DateTime weekStart; | 84 | final DateTime weekStart; |
| 80 | final DateTime weekEnd; | 85 | final DateTime weekEnd; |
| 81 | final List<ActivityBurnReport> days; | 86 | final List<ActivityBurnReport> days; |
| 87 | + final int? totalActiveEnergyOverride; | ||
| 88 | + final int? totalExerciseMinutesOverride; | ||
| 89 | + final int? totalStandHoursOverride; | ||
| 90 | + final int? averageDailyActiveEnergyOverride; | ||
| 91 | + final int? activeEnergyGoalOverride; | ||
| 82 | 92 | ||
| 83 | List<ActivityBurnReport> get daysWithData => | 93 | List<ActivityBurnReport> get daysWithData => |
| 84 | days.where((report) => report.hasData).toList(); | 94 | days.where((report) => report.hasData).toList(); |
| 85 | 95 | ||
| 86 | - bool get hasData => daysWithData.isNotEmpty; | ||
| 87 | - | ||
| 88 | - int get totalActiveEnergy => daysWithData.fold( | 96 | + bool get hasData => |
| 97 | + daysWithData.isNotEmpty || | ||
| 98 | + totalActiveEnergyOverride != null || | ||
| 99 | + totalExerciseMinutesOverride != null || | ||
| 100 | + totalStandHoursOverride != null; | ||
| 101 | + | ||
| 102 | + int get totalActiveEnergy => | ||
| 103 | + totalActiveEnergyOverride ?? | ||
| 104 | + daysWithData.fold( | ||
| 89 | 0, | 105 | 0, |
| 90 | (sum, report) => sum + (report.activeEnergy?.value ?? 0), | 106 | (sum, report) => sum + (report.activeEnergy?.value ?? 0), |
| 91 | ); | 107 | ); |
| 92 | 108 | ||
| 93 | - int get totalExerciseMinutes => daysWithData.fold( | 109 | + int get totalExerciseMinutes => |
| 110 | + totalExerciseMinutesOverride ?? | ||
| 111 | + daysWithData.fold( | ||
| 94 | 0, | 112 | 0, |
| 95 | (sum, report) => sum + (report.exerciseMinutes?.value ?? 0), | 113 | (sum, report) => sum + (report.exerciseMinutes?.value ?? 0), |
| 96 | ); | 114 | ); |
| 97 | 115 | ||
| 98 | - int get totalStandHours => daysWithData.fold( | 116 | + int get totalStandHours => |
| 117 | + totalStandHoursOverride ?? | ||
| 118 | + daysWithData.fold( | ||
| 99 | 0, | 119 | 0, |
| 100 | (sum, report) => sum + (report.standHours?.value ?? 0), | 120 | (sum, report) => sum + (report.standHours?.value ?? 0), |
| 101 | ); | 121 | ); |
| 102 | 122 | ||
| 103 | int get averageDailyActiveEnergy { | 123 | int get averageDailyActiveEnergy { |
| 124 | + if (averageDailyActiveEnergyOverride case final value?) return value; | ||
| 104 | if (daysWithData.isEmpty) return 0; | 125 | if (daysWithData.isEmpty) return 0; |
| 105 | return (totalActiveEnergy / daysWithData.length).round(); | 126 | return (totalActiveEnergy / daysWithData.length).round(); |
| 106 | } | 127 | } |
| 107 | 128 | ||
| 129 | + int get activeEnergyGoal => | ||
| 130 | + activeEnergyGoalOverride ?? | ||
| 131 | + days | ||
| 132 | + .map((report) => report.activeEnergy?.goal ?? 0) | ||
| 133 | + .firstWhere((goal) => goal > 0, orElse: () => 0); | ||
| 134 | + | ||
| 108 | int get perfectRingDays => daysWithData | 135 | int get perfectRingDays => daysWithData |
| 109 | .where((report) => | 136 | .where((report) => |
| 110 | (report.activeEnergy?.progress ?? 0) >= 1 && | 137 | (report.activeEnergy?.progress ?? 0) >= 1 && |
| @@ -141,37 +168,64 @@ class MonthlyActivityBurnReport { | @@ -141,37 +168,64 @@ class MonthlyActivityBurnReport { | ||
| 141 | required this.monthStart, | 168 | required this.monthStart, |
| 142 | required this.monthEnd, | 169 | required this.monthEnd, |
| 143 | required this.days, | 170 | required this.days, |
| 171 | + this.totalActiveEnergyOverride, | ||
| 172 | + this.totalExerciseMinutesOverride, | ||
| 173 | + this.totalStandHoursOverride, | ||
| 174 | + this.averageDailyActiveEnergyOverride, | ||
| 175 | + this.activeEnergyGoalOverride, | ||
| 144 | }); | 176 | }); |
| 145 | 177 | ||
| 146 | final DateTime monthStart; | 178 | final DateTime monthStart; |
| 147 | final DateTime monthEnd; | 179 | final DateTime monthEnd; |
| 148 | final List<ActivityBurnReport> days; | 180 | final List<ActivityBurnReport> days; |
| 181 | + final int? totalActiveEnergyOverride; | ||
| 182 | + final int? totalExerciseMinutesOverride; | ||
| 183 | + final int? totalStandHoursOverride; | ||
| 184 | + final int? averageDailyActiveEnergyOverride; | ||
| 185 | + final int? activeEnergyGoalOverride; | ||
| 149 | 186 | ||
| 150 | List<ActivityBurnReport> get daysWithData => | 187 | List<ActivityBurnReport> get daysWithData => |
| 151 | days.where((report) => report.hasData).toList(); | 188 | days.where((report) => report.hasData).toList(); |
| 152 | 189 | ||
| 153 | - bool get hasData => daysWithData.isNotEmpty; | ||
| 154 | - | ||
| 155 | - int get totalActiveEnergy => daysWithData.fold( | 190 | + bool get hasData => |
| 191 | + daysWithData.isNotEmpty || | ||
| 192 | + totalActiveEnergyOverride != null || | ||
| 193 | + totalExerciseMinutesOverride != null || | ||
| 194 | + totalStandHoursOverride != null; | ||
| 195 | + | ||
| 196 | + int get totalActiveEnergy => | ||
| 197 | + totalActiveEnergyOverride ?? | ||
| 198 | + daysWithData.fold( | ||
| 156 | 0, | 199 | 0, |
| 157 | (sum, report) => sum + (report.activeEnergy?.value ?? 0), | 200 | (sum, report) => sum + (report.activeEnergy?.value ?? 0), |
| 158 | ); | 201 | ); |
| 159 | 202 | ||
| 160 | - int get totalExerciseMinutes => daysWithData.fold( | 203 | + int get totalExerciseMinutes => |
| 204 | + totalExerciseMinutesOverride ?? | ||
| 205 | + daysWithData.fold( | ||
| 161 | 0, | 206 | 0, |
| 162 | (sum, report) => sum + (report.exerciseMinutes?.value ?? 0), | 207 | (sum, report) => sum + (report.exerciseMinutes?.value ?? 0), |
| 163 | ); | 208 | ); |
| 164 | 209 | ||
| 165 | - int get totalStandHours => daysWithData.fold( | 210 | + int get totalStandHours => |
| 211 | + totalStandHoursOverride ?? | ||
| 212 | + daysWithData.fold( | ||
| 166 | 0, | 213 | 0, |
| 167 | (sum, report) => sum + (report.standHours?.value ?? 0), | 214 | (sum, report) => sum + (report.standHours?.value ?? 0), |
| 168 | ); | 215 | ); |
| 169 | 216 | ||
| 170 | int get averageDailyActiveEnergy { | 217 | int get averageDailyActiveEnergy { |
| 218 | + if (averageDailyActiveEnergyOverride case final value?) return value; | ||
| 171 | if (daysWithData.isEmpty) return 0; | 219 | if (daysWithData.isEmpty) return 0; |
| 172 | return (totalActiveEnergy / daysWithData.length).round(); | 220 | return (totalActiveEnergy / daysWithData.length).round(); |
| 173 | } | 221 | } |
| 174 | 222 | ||
| 223 | + int get activeEnergyGoal => | ||
| 224 | + activeEnergyGoalOverride ?? | ||
| 225 | + days | ||
| 226 | + .map((report) => report.activeEnergy?.goal ?? 0) | ||
| 227 | + .firstWhere((goal) => goal > 0, orElse: () => 0); | ||
| 228 | + | ||
| 175 | int get perfectRingDays => daysWithData | 229 | int get perfectRingDays => daysWithData |
| 176 | .where((report) => | 230 | .where((report) => |
| 177 | (report.activeEnergy?.progress ?? 0) >= 1 && | 231 | (report.activeEnergy?.progress ?? 0) >= 1 && |
| @@ -108,7 +108,7 @@ class _DailyReportContent extends StatelessWidget { | @@ -108,7 +108,7 @@ class _DailyReportContent extends StatelessWidget { | ||
| 108 | Widget build(BuildContext context) { | 108 | Widget build(BuildContext context) { |
| 109 | return Column( | 109 | return Column( |
| 110 | children: [ | 110 | children: [ |
| 111 | - ActivityBurnRing(report: report), | 111 | + ActivityBurnRing(report: report, animate: true), |
| 112 | const SizedBox(height: 24), | 112 | const SizedBox(height: 24), |
| 113 | ActivityBurnSummaryCard(report: report), | 113 | ActivityBurnSummaryCard(report: report), |
| 114 | const SizedBox(height: 12), | 114 | const SizedBox(height: 12), |
| @@ -132,6 +132,8 @@ class _DateSwitcher extends StatelessWidget { | @@ -132,6 +132,8 @@ class _DateSwitcher extends StatelessWidget { | ||
| 132 | label: logic.dateLabel, | 132 | label: logic.dateLabel, |
| 133 | onPrevious: logic.previousDay, | 133 | onPrevious: logic.previousDay, |
| 134 | onNext: logic.nextDay, | 134 | onNext: logic.nextDay, |
| 135 | + canPrevious: logic.canGoPrevious, | ||
| 136 | + canNext: logic.canGoNext, | ||
| 135 | onTapLabel: () => _showDatePicker(context), | 137 | onTapLabel: () => _showDatePicker(context), |
| 136 | ), | 138 | ), |
| 137 | ); | 139 | ); |
| @@ -2,11 +2,13 @@ import 'dart:math' as math; | @@ -2,11 +2,13 @@ import 'dart:math' as math; | ||
| 2 | 2 | ||
| 3 | import 'package:fl_chart/fl_chart.dart'; | 3 | import 'package:fl_chart/fl_chart.dart'; |
| 4 | import 'package:flutter/material.dart'; | 4 | import 'package:flutter/material.dart'; |
| 5 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 5 | import 'package:intl/intl.dart'; | 6 | import 'package:intl/intl.dart'; |
| 6 | 7 | ||
| 7 | import '../../../../r.dart'; | 8 | import '../../../../r.dart'; |
| 8 | import '../../report_common/widgets/health_report_subject_scope.dart'; | 9 | import '../../report_common/widgets/health_report_subject_scope.dart'; |
| 9 | import '../models/activity_burn_report_models.dart'; | 10 | import '../models/activity_burn_report_models.dart'; |
| 11 | +import 'activity_burn_trend_lines.dart'; | ||
| 10 | 12 | ||
| 11 | class ActivityBurnHeartRateZoneCard extends StatelessWidget { | 13 | class ActivityBurnHeartRateZoneCard extends StatelessWidget { |
| 12 | const ActivityBurnHeartRateZoneCard({ | 14 | const ActivityBurnHeartRateZoneCard({ |
| @@ -19,11 +21,15 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -19,11 +21,15 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 19 | static const _h1 = Color(0xFF0F0F11); | 21 | static const _h1 = Color(0xFF0F0F11); |
| 20 | static const _h3 = Color(0xFFB0B0B6); | 22 | static const _h3 = Color(0xFFB0B0B6); |
| 21 | static const _brand = Color(0xFF845EEE); | 23 | static const _brand = Color(0xFF845EEE); |
| 22 | - static const _grid = Color(0xFFF3F3F3); | ||
| 23 | static const _active = Color(0xFFFF5279); | 24 | static const _active = Color(0xFFFF5279); |
| 24 | static const _warm = Color(0xFFFF9A6E); | 25 | static const _warm = Color(0xFFFF9A6E); |
| 25 | static const _stand = Color(0xFF7B9BFB); | 26 | static const _stand = Color(0xFF7B9BFB); |
| 26 | static const _exercise = Color(0xFF3BD49D); | 27 | static const _exercise = Color(0xFF3BD49D); |
| 28 | + static const _chartHeight = 197.0; | ||
| 29 | + static const _topAxisLabelHeight = 14.0; | ||
| 30 | + static const _bottomAxisLabelHeight = 20.0; | ||
| 31 | + static const _axisHeight = | ||
| 32 | + _chartHeight - _topAxisLabelHeight - _bottomAxisLabelHeight; | ||
| 27 | 33 | ||
| 28 | @override | 34 | @override |
| 29 | Widget build(BuildContext context) { | 35 | Widget build(BuildContext context) { |
| @@ -36,7 +42,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -36,7 +42,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 36 | padding: const EdgeInsets.fromLTRB(20, 20, 14, 16), | 42 | padding: const EdgeInsets.fromLTRB(20, 20, 14, 16), |
| 37 | decoration: BoxDecoration( | 43 | decoration: BoxDecoration( |
| 38 | color: Colors.white, | 44 | color: Colors.white, |
| 39 | - borderRadius: BorderRadius.circular(12), | 45 | + borderRadius: BorderRadius.circular(16), |
| 40 | ), | 46 | ), |
| 41 | child: Column( | 47 | child: Column( |
| 42 | crossAxisAlignment: CrossAxisAlignment.start, | 48 | crossAxisAlignment: CrossAxisAlignment.start, |
| @@ -44,7 +50,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -44,7 +50,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 44 | Text( | 50 | Text( |
| 45 | HealthReportSubjectScope.titleOf( | 51 | HealthReportSubjectScope.titleOf( |
| 46 | context, | 52 | context, |
| 47 | - summary.hasData ? '心率区间' : '实时心率', | 53 | + summary.hasData |
| 54 | + ? context.l10n.activityHeartRateZone | ||
| 55 | + : context.l10n.activityRealtimeHeartRate, | ||
| 48 | ), | 56 | ), |
| 49 | style: TextStyle( | 57 | style: TextStyle( |
| 50 | color: _h1, | 58 | color: _h1, |
| @@ -54,19 +62,31 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -54,19 +62,31 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 54 | ), | 62 | ), |
| 55 | ), | 63 | ), |
| 56 | const SizedBox(height: 20), | 64 | const SizedBox(height: 20), |
| 57 | - Expanded( | 65 | + SizedBox( |
| 66 | + height: _chartHeight, | ||
| 58 | child: LayoutBuilder( | 67 | child: LayoutBuilder( |
| 59 | builder: (context, constraints) { | 68 | builder: (context, constraints) { |
| 60 | return Stack( | 69 | return Stack( |
| 61 | alignment: Alignment.center, | 70 | alignment: Alignment.center, |
| 62 | children: [ | 71 | children: [ |
| 72 | + Positioned( | ||
| 73 | + left: 0, | ||
| 74 | + right: 6, | ||
| 75 | + top: 0, | ||
| 76 | + bottom: 20, | ||
| 77 | + child: ActivityBurnHorizontalGridLines( | ||
| 78 | + minY: axis.minY, | ||
| 79 | + maxY: axis.chartMaxY, | ||
| 80 | + values: axis.ticks, | ||
| 81 | + ), | ||
| 82 | + ), | ||
| 63 | Positioned.fill( | 83 | Positioned.fill( |
| 64 | - top: 18, | 84 | + top: 0, |
| 65 | child: LineChart(_chartData()), | 85 | child: LineChart(_chartData()), |
| 66 | ), | 86 | ), |
| 67 | if (sleepRange != null) | 87 | if (sleepRange != null) |
| 68 | Positioned( | 88 | Positioned( |
| 69 | - top: 4, | 89 | + top: 0, |
| 70 | left: _sleepIconLeft( | 90 | left: _sleepIconLeft( |
| 71 | sleepRange, | 91 | sleepRange, |
| 72 | axis.maxX, | 92 | axis.maxX, |
| @@ -79,9 +99,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -79,9 +99,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 79 | ), | 99 | ), |
| 80 | ), | 100 | ), |
| 81 | if (!summary.hasData) | 101 | if (!summary.hasData) |
| 82 | - const Text( | ||
| 83 | - '暂无本日数据', | ||
| 84 | - style: TextStyle( | 102 | + Text( |
| 103 | + context.l10n.reportNoDataToday, | ||
| 104 | + style: const TextStyle( | ||
| 85 | color: Color(0xFFA1A0A5), | 105 | color: Color(0xFFA1A0A5), |
| 86 | fontSize: 12, | 106 | fontSize: 12, |
| 87 | fontWeight: FontWeight.w400, | 107 | fontWeight: FontWeight.w400, |
| @@ -121,16 +141,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -121,16 +141,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 121 | minX: 0, | 141 | minX: 0, |
| 122 | maxX: axis.maxX, | 142 | maxX: axis.maxX, |
| 123 | minY: axis.minY, | 143 | minY: axis.minY, |
| 124 | - maxY: axis.maxY, | 144 | + maxY: axis.chartMaxY, |
| 125 | gridData: FlGridData( | 145 | gridData: FlGridData( |
| 126 | - show: true, | ||
| 127 | - drawVerticalLine: false, | ||
| 128 | - horizontalInterval: 20, | ||
| 129 | - getDrawingHorizontalLine: (_) => const FlLine( | ||
| 130 | - color: _grid, | ||
| 131 | - strokeWidth: 1, | ||
| 132 | - dashArray: [2, 2], | ||
| 133 | - ), | 146 | + show: false, |
| 134 | ), | 147 | ), |
| 135 | borderData: FlBorderData(show: false), | 148 | borderData: FlBorderData(show: false), |
| 136 | lineTouchData: const LineTouchData(enabled: false), | 149 | lineTouchData: const LineTouchData(enabled: false), |
| @@ -141,21 +154,24 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -141,21 +154,24 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 141 | sideTitles: SideTitles( | 154 | sideTitles: SideTitles( |
| 142 | showTitles: points.isNotEmpty, | 155 | showTitles: points.isNotEmpty, |
| 143 | reservedSize: 28, | 156 | reservedSize: 28, |
| 144 | - interval: 20, | 157 | + interval: 1, |
| 145 | getTitlesWidget: (value, meta) { | 158 | getTitlesWidget: (value, meta) { |
| 146 | if (!axis.shouldShowYLabel(value)) { | 159 | if (!axis.shouldShowYLabel(value)) { |
| 147 | return const SizedBox.shrink(); | 160 | return const SizedBox.shrink(); |
| 148 | } | 161 | } |
| 149 | return Transform.translate( | 162 | return Transform.translate( |
| 150 | - offset: const Offset(0, -6), | ||
| 151 | - child: Text( | ||
| 152 | - axis.formatYLabel(value), | ||
| 153 | - maxLines: 1, | ||
| 154 | - softWrap: false, | ||
| 155 | - style: const TextStyle( | ||
| 156 | - color: _h3, | ||
| 157 | - fontSize: 10, | ||
| 158 | - fontWeight: FontWeight.w400, | 163 | + offset: const Offset(0, -8), |
| 164 | + child: Padding( | ||
| 165 | + padding: const EdgeInsets.only(left: 8), | ||
| 166 | + child: Text( | ||
| 167 | + axis.formatYLabel(value), | ||
| 168 | + maxLines: 1, | ||
| 169 | + softWrap: false, | ||
| 170 | + style: const TextStyle( | ||
| 171 | + color: _h3, | ||
| 172 | + fontSize: 10, | ||
| 173 | + fontWeight: FontWeight.w400, | ||
| 174 | + ), | ||
| 159 | ), | 175 | ), |
| 160 | ), | 176 | ), |
| 161 | ); | 177 | ); |
| @@ -256,7 +272,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -256,7 +272,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 256 | if (points.length < 2) return const []; | 272 | if (points.length < 2) return const []; |
| 257 | 273 | ||
| 258 | final segments = <_HeartRateSegment>[]; | 274 | final segments = <_HeartRateSegment>[]; |
| 259 | - final maxHr = _maxHeartRate(summary.userAge); | ||
| 260 | Color? currentColor; | 275 | Color? currentColor; |
| 261 | var currentSpots = <FlSpot>[]; | 276 | var currentSpots = <FlSpot>[]; |
| 262 | var previousPoint = points.first; | 277 | var previousPoint = points.first; |
| @@ -265,13 +280,12 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -265,13 +280,12 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 265 | final splitSpots = _splitByZoneBoundaries( | 280 | final splitSpots = _splitByZoneBoundaries( |
| 266 | _spot(previousPoint, start), | 281 | _spot(previousPoint, start), |
| 267 | _spot(point, start), | 282 | _spot(point, start), |
| 268 | - maxHr, | ||
| 269 | ); | 283 | ); |
| 270 | 284 | ||
| 271 | for (var i = 0; i < splitSpots.length - 1; i++) { | 285 | for (var i = 0; i < splitSpots.length - 1; i++) { |
| 272 | final from = splitSpots[i]; | 286 | final from = splitSpots[i]; |
| 273 | final to = splitSpots[i + 1]; | 287 | final to = splitSpots[i + 1]; |
| 274 | - final color = _zoneColor(_zoneLevel((from.y + to.y) / 2, maxHr)); | 288 | + final color = _zoneColor(_zoneLevel((from.y + to.y) / 2)); |
| 275 | 289 | ||
| 276 | if (currentColor == color && currentSpots.isNotEmpty) { | 290 | if (currentColor == color && currentSpots.isNotEmpty) { |
| 277 | currentSpots.add(to); | 291 | currentSpots.add(to); |
| @@ -312,7 +326,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -312,7 +326,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 312 | List<FlSpot> _splitByZoneBoundaries( | 326 | List<FlSpot> _splitByZoneBoundaries( |
| 313 | FlSpot start, | 327 | FlSpot start, |
| 314 | FlSpot end, | 328 | FlSpot end, |
| 315 | - double maxHr, | ||
| 316 | ) { | 329 | ) { |
| 317 | if (start.y == end.y) return [start, end]; | 330 | if (start.y == end.y) return [start, end]; |
| 318 | 331 | ||
| @@ -320,7 +333,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -320,7 +333,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 320 | final maxY = math.max(start.y, end.y); | 333 | final maxY = math.max(start.y, end.y); |
| 321 | final crossings = <FlSpot>[]; | 334 | final crossings = <FlSpot>[]; |
| 322 | 335 | ||
| 323 | - for (final boundary in _zoneBoundaries(maxHr)) { | 336 | + for (final boundary in _zoneBoundaries) { |
| 324 | if (boundary <= minY || boundary >= maxY) continue; | 337 | if (boundary <= minY || boundary >= maxY) continue; |
| 325 | final t = (boundary - start.y) / (end.y - start.y); | 338 | final t = (boundary - start.y) / (end.y - start.y); |
| 326 | crossings.add( | 339 | crossings.add( |
| @@ -335,28 +348,14 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -335,28 +348,14 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 335 | return [start, ...crossings, end]; | 348 | return [start, ...crossings, end]; |
| 336 | } | 349 | } |
| 337 | 350 | ||
| 338 | - int _zoneLevel(double bpm, double maxHr) { | ||
| 339 | - final zoneRatio = maxHr <= 0 ? 0 : bpm / maxHr; | ||
| 340 | - if (bpm >= 160 || zoneRatio >= 0.9) return 3; | ||
| 341 | - if (bpm >= 140 || zoneRatio >= 0.8) return 2; | ||
| 342 | - if (zoneRatio >= 0.6) return 1; | 351 | + int _zoneLevel(double bpm) { |
| 352 | + if (bpm >= 170) return 3; | ||
| 353 | + if (bpm >= 151) return 2; | ||
| 354 | + if (bpm >= 108) return 1; | ||
| 343 | return 0; | 355 | return 0; |
| 344 | } | 356 | } |
| 345 | 357 | ||
| 346 | - List<double> _zoneBoundaries(double maxHr) { | ||
| 347 | - final boundaries = [ | ||
| 348 | - 160.0, | ||
| 349 | - 140.0, | ||
| 350 | - maxHr * 0.9, | ||
| 351 | - maxHr * 0.8, | ||
| 352 | - maxHr * 0.6, | ||
| 353 | - ]..sort(); | ||
| 354 | - return [ | ||
| 355 | - for (var i = 0; i < boundaries.length; i++) | ||
| 356 | - if (i == 0 || (boundaries[i] - boundaries[i - 1]).abs() > 0.001) | ||
| 357 | - boundaries[i], | ||
| 358 | - ]; | ||
| 359 | - } | 358 | + static const _zoneBoundaries = [108.0, 151.0, 170.0]; |
| 360 | 359 | ||
| 361 | Color _zoneColor(int zone) { | 360 | Color _zoneColor(int zone) { |
| 362 | switch (zone) { | 361 | switch (zone) { |
| @@ -378,11 +377,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -378,11 +377,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 378 | ); | 377 | ); |
| 379 | } | 378 | } |
| 380 | 379 | ||
| 381 | - double _maxHeartRate(int? userAge) { | ||
| 382 | - final age = userAge == null || userAge <= 0 ? 30 : userAge; | ||
| 383 | - return 208 - 0.7 * age; | ||
| 384 | - } | ||
| 385 | - | ||
| 386 | String? _bottomLabel(double value, DateTime start, double maxX) { | 380 | String? _bottomLabel(double value, DateTime start, double maxX) { |
| 387 | final rounded = value.round(); | 381 | final rounded = value.round(); |
| 388 | if (rounded % 360 != 0 || rounded < 0 || rounded > maxX.round()) { | 382 | if (rounded % 360 != 0 || rounded < 0 || rounded > maxX.round()) { |
| @@ -407,12 +401,38 @@ class _HeartRateAxis { | @@ -407,12 +401,38 @@ class _HeartRateAxis { | ||
| 407 | final double maxX; | 401 | final double maxX; |
| 408 | final double minY; | 402 | final double minY; |
| 409 | final double maxY; | 403 | final double maxY; |
| 404 | + double get chartMaxY { | ||
| 405 | + final range = maxY - minY; | ||
| 406 | + if (range <= 0) return maxY; | ||
| 407 | + return maxY + | ||
| 408 | + range * | ||
| 409 | + ActivityBurnHeartRateZoneCard._topAxisLabelHeight / | ||
| 410 | + ActivityBurnHeartRateZoneCard._axisHeight; | ||
| 411 | + } | ||
| 412 | + | ||
| 413 | + List<double> get ticks { | ||
| 414 | + final values = <double>[minY]; | ||
| 415 | + for (var value = minY + 20; value < maxY; value += 20) { | ||
| 416 | + values.add(value); | ||
| 417 | + } | ||
| 418 | + if ((values.last - maxY).abs() > 0.001) values.add(maxY); | ||
| 419 | + return values; | ||
| 420 | + } | ||
| 410 | 421 | ||
| 411 | factory _HeartRateAxis.fromSummary( | 422 | factory _HeartRateAxis.fromSummary( |
| 412 | ActivityBurnHeartRateSummary summary, | 423 | ActivityBurnHeartRateSummary summary, |
| 413 | DateTime start, | 424 | DateTime start, |
| 414 | ) { | 425 | ) { |
| 415 | - if (!summary.hasData) { | 426 | + final dayEnd = start.add(const Duration(days: 1)); |
| 427 | + final points = summary.points | ||
| 428 | + .where( | ||
| 429 | + (point) => | ||
| 430 | + point.bpm > 0 && | ||
| 431 | + !point.time.isBefore(start) && | ||
| 432 | + point.time.isBefore(dayEnd), | ||
| 433 | + ) | ||
| 434 | + .toList(); | ||
| 435 | + if (points.isEmpty) { | ||
| 416 | return _HeartRateAxis( | 436 | return _HeartRateAxis( |
| 417 | endTime: start.add(const Duration(hours: 18)), | 437 | endTime: start.add(const Duration(hours: 18)), |
| 418 | maxX: 1080, | 438 | maxX: 1080, |
| @@ -421,30 +441,24 @@ class _HeartRateAxis { | @@ -421,30 +441,24 @@ class _HeartRateAxis { | ||
| 421 | ); | 441 | ); |
| 422 | } | 442 | } |
| 423 | 443 | ||
| 424 | - final values = summary.points.map((point) => point.bpm).toList(); | ||
| 425 | - final minBpm = values.reduce(math.min); | ||
| 426 | - final maxBpm = values.reduce(math.max); | ||
| 427 | - final coveredEnd = summary.endTime ?? summary.points.last.time; | 444 | + final coveredEnd = points.last.time; |
| 428 | final coveredMinutes = coveredEnd.difference(start).inMinutes; | 445 | final coveredMinutes = coveredEnd.difference(start).inMinutes; |
| 429 | final maxMinutes = _nextSixHourTick(coveredMinutes); | 446 | final maxMinutes = _nextSixHourTick(coveredMinutes); |
| 430 | - | ||
| 431 | - var maxY = _roundUpToFiveOrTen(maxBpm); | ||
| 432 | - if (maxY <= minBpm) { | ||
| 433 | - maxY = minBpm + 20; | ||
| 434 | - } | 447 | + final heartRates = points.map((point) => point.bpm); |
| 448 | + final minY = heartRates.reduce(math.min); | ||
| 449 | + var maxY = _roundUpToFiveOrTen(heartRates.reduce(math.max)); | ||
| 450 | + if (maxY <= minY) maxY = minY + 5; | ||
| 435 | 451 | ||
| 436 | return _HeartRateAxis( | 452 | return _HeartRateAxis( |
| 437 | endTime: start.add(Duration(minutes: maxMinutes)), | 453 | endTime: start.add(Duration(minutes: maxMinutes)), |
| 438 | maxX: maxMinutes.toDouble(), | 454 | maxX: maxMinutes.toDouble(), |
| 439 | - minY: minBpm, | 455 | + minY: minY, |
| 440 | maxY: maxY, | 456 | maxY: maxY, |
| 441 | ); | 457 | ); |
| 442 | } | 458 | } |
| 443 | 459 | ||
| 444 | bool shouldShowYLabel(double value) { | 460 | bool shouldShowYLabel(double value) { |
| 445 | - if (value < minY || value > maxY) return false; | ||
| 446 | - final offset = value - minY; | ||
| 447 | - return (offset / 20 - (offset / 20).round()).abs() < 0.001; | 461 | + return ticks.any((tick) => (tick - value).abs() < 0.001); |
| 448 | } | 462 | } |
| 449 | 463 | ||
| 450 | String formatYLabel(double value) { | 464 | String formatYLabel(double value) { |
| @@ -456,12 +470,10 @@ class _HeartRateAxis { | @@ -456,12 +470,10 @@ class _HeartRateAxis { | ||
| 456 | const tickMinutes = 360; | 470 | const tickMinutes = 360; |
| 457 | final clamped = coveredMinutes.clamp(0, 1440); | 471 | final clamped = coveredMinutes.clamp(0, 1440); |
| 458 | if (clamped == 0) return tickMinutes; | 472 | if (clamped == 0) return tickMinutes; |
| 459 | - return (clamped / tickMinutes).ceil().clamp(1, 4) * tickMinutes; | 473 | + return (clamped ~/ tickMinutes + 1).clamp(1, 4) * tickMinutes; |
| 460 | } | 474 | } |
| 461 | 475 | ||
| 462 | - static double _roundUpToFiveOrTen(double value) { | ||
| 463 | - return (value / 5).ceil() * 5; | ||
| 464 | - } | 476 | + static double _roundUpToFiveOrTen(double value) => (value / 5).ceil() * 5.0; |
| 465 | } | 477 | } |
| 466 | 478 | ||
| 467 | class _HeartRateSegment { | 479 | class _HeartRateSegment { |
| 1 | import 'package:fl_chart/fl_chart.dart'; | 1 | import 'package:fl_chart/fl_chart.dart'; |
| 2 | import 'package:flutter/material.dart'; | 2 | import 'package:flutter/material.dart'; |
| 3 | -import 'package:intl/intl.dart'; | 3 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 4 | 4 | ||
| 5 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; | 5 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; |
| 6 | import '../../report_common/widgets/health_report_subject_scope.dart'; | 6 | import '../../report_common/widgets/health_report_subject_scope.dart'; |
| 7 | +import '../../report_common/utils/report_localization.dart'; | ||
| 7 | import '../../report_common/widgets/report_month_calendar_grid.dart'; | 8 | import '../../report_common/widgets/report_month_calendar_grid.dart'; |
| 8 | import '../models/activity_burn_report_models.dart'; | 9 | import '../models/activity_burn_report_models.dart'; |
| 9 | import 'activity_burn_ring.dart'; | 10 | import 'activity_burn_ring.dart'; |
| 11 | +import 'activity_burn_trend_lines.dart'; | ||
| 10 | 12 | ||
| 11 | class ActivityBurnMonthReportView extends StatelessWidget { | 13 | class ActivityBurnMonthReportView extends StatelessWidget { |
| 12 | const ActivityBurnMonthReportView({ | 14 | const ActivityBurnMonthReportView({ |
| @@ -54,7 +56,8 @@ class _MonthlySummary extends StatelessWidget { | @@ -54,7 +56,8 @@ class _MonthlySummary extends StatelessWidget { | ||
| 54 | crossAxisAlignment: CrossAxisAlignment.start, | 56 | crossAxisAlignment: CrossAxisAlignment.start, |
| 55 | children: [ | 57 | children: [ |
| 56 | Text( | 58 | Text( |
| 57 | - HealthReportSubjectScope.titleOf(context, '活动总消耗'), | 59 | + HealthReportSubjectScope.titleOf( |
| 60 | + context, context.l10n.activityTotalBurn), | ||
| 58 | style: const TextStyle( | 61 | style: const TextStyle( |
| 59 | color: ActivityBurnMonthReportView._active, | 62 | color: ActivityBurnMonthReportView._active, |
| 60 | fontSize: 12, | 63 | fontSize: 12, |
| @@ -76,11 +79,11 @@ class _MonthlySummary extends StatelessWidget { | @@ -76,11 +79,11 @@ class _MonthlySummary extends StatelessWidget { | ||
| 76 | ), | 79 | ), |
| 77 | ), | 80 | ), |
| 78 | const SizedBox(width: 4), | 81 | const SizedBox(width: 4), |
| 79 | - const Padding( | ||
| 80 | - padding: EdgeInsets.only(bottom: 5), | 82 | + Padding( |
| 83 | + padding: const EdgeInsets.only(bottom: 5), | ||
| 81 | child: Text( | 84 | child: Text( |
| 82 | - '千卡', | ||
| 83 | - style: TextStyle( | 85 | + context.l10n.reportUnitKcal, |
| 86 | + style: const TextStyle( | ||
| 84 | color: ActivityBurnMonthReportView._h1, | 87 | color: ActivityBurnMonthReportView._h1, |
| 85 | fontSize: 12, | 88 | fontSize: 12, |
| 86 | fontWeight: FontWeight.w500, | 89 | fontWeight: FontWeight.w500, |
| @@ -90,9 +93,9 @@ class _MonthlySummary extends StatelessWidget { | @@ -90,9 +93,9 @@ class _MonthlySummary extends StatelessWidget { | ||
| 90 | ], | 93 | ], |
| 91 | ) | 94 | ) |
| 92 | else | 95 | else |
| 93 | - const Text( | ||
| 94 | - '等待数据', | ||
| 95 | - style: TextStyle( | 96 | + Text( |
| 97 | + context.l10n.reportWaitingForData, | ||
| 98 | + style: const TextStyle( | ||
| 96 | color: ActivityBurnMonthReportView._h1, | 99 | color: ActivityBurnMonthReportView._h1, |
| 97 | fontSize: 24, | 100 | fontSize: 24, |
| 98 | fontWeight: FontWeight.w600, | 101 | fontWeight: FontWeight.w600, |
| @@ -102,16 +105,18 @@ class _MonthlySummary extends StatelessWidget { | @@ -102,16 +105,18 @@ class _MonthlySummary extends StatelessWidget { | ||
| 102 | Row( | 105 | Row( |
| 103 | children: [ | 106 | children: [ |
| 104 | _SummaryMetric( | 107 | _SummaryMetric( |
| 105 | - title: HealthReportSubjectScope.titleOf(context, '锻炼总时长'), | 108 | + title: HealthReportSubjectScope.titleOf( |
| 109 | + context, context.l10n.activityExerciseTotalDuration), | ||
| 106 | value: hasData ? report.totalExerciseMinutes.toString() : '-', | 110 | value: hasData ? report.totalExerciseMinutes.toString() : '-', |
| 107 | - unit: '分钟', | 111 | + unit: context.l10n.reportUnitMinute, |
| 108 | color: ActivityBurnMonthReportView._exercise, | 112 | color: ActivityBurnMonthReportView._exercise, |
| 109 | ), | 113 | ), |
| 110 | const SizedBox(width: 34), | 114 | const SizedBox(width: 34), |
| 111 | _SummaryMetric( | 115 | _SummaryMetric( |
| 112 | - title: HealthReportSubjectScope.titleOf(context, '站立总时长'), | 116 | + title: HealthReportSubjectScope.titleOf( |
| 117 | + context, context.l10n.activityStandTotalDuration), | ||
| 113 | value: hasData ? report.totalStandHours.toString() : '-', | 118 | value: hasData ? report.totalStandHours.toString() : '-', |
| 114 | - unit: '小时', | 119 | + unit: context.l10n.reportUnitHour, |
| 115 | color: ActivityBurnMonthReportView._stand, | 120 | color: ActivityBurnMonthReportView._stand, |
| 116 | ), | 121 | ), |
| 117 | ], | 122 | ], |
| @@ -199,7 +204,7 @@ class _MonthRingCard extends StatelessWidget { | @@ -199,7 +204,7 @@ class _MonthRingCard extends StatelessWidget { | ||
| 199 | children: [ | 204 | children: [ |
| 200 | _RingStat( | 205 | _RingStat( |
| 201 | color: ActivityBurnMonthReportView._active, | 206 | color: ActivityBurnMonthReportView._active, |
| 202 | - label: '完美合环', | 207 | + label: context.l10n.activityPerfectRings, |
| 203 | value: report.perfectRingDays, | 208 | value: report.perfectRingDays, |
| 204 | hasData: hasData, | 209 | hasData: hasData, |
| 205 | report: ActivityBurnReport( | 210 | report: ActivityBurnReport( |
| @@ -212,7 +217,7 @@ class _MonthRingCard extends StatelessWidget { | @@ -212,7 +217,7 @@ class _MonthRingCard extends StatelessWidget { | ||
| 212 | const SizedBox(width: 34), | 217 | const SizedBox(width: 34), |
| 213 | _RingStat( | 218 | _RingStat( |
| 214 | color: ActivityBurnMonthReportView._active, | 219 | color: ActivityBurnMonthReportView._active, |
| 215 | - label: '合上活动圆环', | 220 | + label: context.l10n.activityCloseMoveRing, |
| 216 | value: report.activeRingDays, | 221 | value: report.activeRingDays, |
| 217 | hasData: hasData, | 222 | hasData: hasData, |
| 218 | report: ActivityBurnReport( | 223 | report: ActivityBurnReport( |
| @@ -227,7 +232,7 @@ class _MonthRingCard extends StatelessWidget { | @@ -227,7 +232,7 @@ class _MonthRingCard extends StatelessWidget { | ||
| 227 | children: [ | 232 | children: [ |
| 228 | _RingStat( | 233 | _RingStat( |
| 229 | color: ActivityBurnMonthReportView._exercise, | 234 | color: ActivityBurnMonthReportView._exercise, |
| 230 | - label: '合上锻炼圆环', | 235 | + label: context.l10n.activityCloseExerciseRing, |
| 231 | value: report.exerciseRingDays, | 236 | value: report.exerciseRingDays, |
| 232 | hasData: hasData, | 237 | hasData: hasData, |
| 233 | report: ActivityBurnReport( | 238 | report: ActivityBurnReport( |
| @@ -238,7 +243,7 @@ class _MonthRingCard extends StatelessWidget { | @@ -238,7 +243,7 @@ class _MonthRingCard extends StatelessWidget { | ||
| 238 | const SizedBox(width: 34), | 243 | const SizedBox(width: 34), |
| 239 | _RingStat( | 244 | _RingStat( |
| 240 | color: ActivityBurnMonthReportView._stand, | 245 | color: ActivityBurnMonthReportView._stand, |
| 241 | - label: '合上站立圆环', | 246 | + label: context.l10n.activityCloseStandRing, |
| 242 | value: report.standRingDays, | 247 | value: report.standRingDays, |
| 243 | hasData: hasData, | 248 | hasData: hasData, |
| 244 | report: ActivityBurnReport( | 249 | report: ActivityBurnReport( |
| @@ -249,16 +254,11 @@ class _MonthRingCard extends StatelessWidget { | @@ -249,16 +254,11 @@ class _MonthRingCard extends StatelessWidget { | ||
| 249 | ], | 254 | ], |
| 250 | ), | 255 | ), |
| 251 | const SizedBox(height: 22), | 256 | const SizedBox(height: 22), |
| 252 | - const Row( | 257 | + Row( |
| 253 | mainAxisAlignment: MainAxisAlignment.spaceBetween, | 258 | mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| 254 | children: [ | 259 | children: [ |
| 255 | - _WeekdayLabel('一'), | ||
| 256 | - _WeekdayLabel('二'), | ||
| 257 | - _WeekdayLabel('三'), | ||
| 258 | - _WeekdayLabel('四'), | ||
| 259 | - _WeekdayLabel('五'), | ||
| 260 | - _WeekdayLabel('六'), | ||
| 261 | - _WeekdayLabel('日'), | 260 | + for (var weekday = 1; weekday <= 7; weekday++) |
| 261 | + _WeekdayLabel(reportWeekdayLabel(weekday)), | ||
| 262 | ], | 262 | ], |
| 263 | ), | 263 | ), |
| 264 | const SizedBox(height: 10), | 264 | const SizedBox(height: 10), |
| @@ -318,9 +318,9 @@ class _RingStat extends StatelessWidget { | @@ -318,9 +318,9 @@ class _RingStat extends StatelessWidget { | ||
| 318 | fontWeight: FontWeight.w600, | 318 | fontWeight: FontWeight.w600, |
| 319 | ), | 319 | ), |
| 320 | ), | 320 | ), |
| 321 | - const TextSpan( | ||
| 322 | - text: ' 天', | ||
| 323 | - style: TextStyle( | 321 | + TextSpan( |
| 322 | + text: ' ${context.l10n.reportUnitDay}', | ||
| 323 | + style: const TextStyle( | ||
| 324 | color: ActivityBurnMonthReportView._h1, | 324 | color: ActivityBurnMonthReportView._h1, |
| 325 | fontSize: 14, | 325 | fontSize: 14, |
| 326 | fontWeight: FontWeight.w500, | 326 | fontWeight: FontWeight.w500, |
| @@ -407,25 +407,27 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -407,25 +407,27 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 407 | @override | 407 | @override |
| 408 | Widget build(BuildContext context) { | 408 | Widget build(BuildContext context) { |
| 409 | final hasData = widget.report.hasData; | 409 | final hasData = widget.report.hasData; |
| 410 | + final maxY = _maxY; | ||
| 410 | return Container( | 411 | return Container( |
| 411 | - height: 294, | ||
| 412 | - padding: const EdgeInsets.fromLTRB(20, 18, 14, 14), | 412 | + height: 344, |
| 413 | + padding: const EdgeInsets.fromLTRB(20, 20, 14, 40), | ||
| 413 | decoration: BoxDecoration( | 414 | decoration: BoxDecoration( |
| 414 | color: Colors.white, | 415 | color: Colors.white, |
| 415 | - borderRadius: BorderRadius.circular(12), | 416 | + borderRadius: BorderRadius.circular(16), |
| 416 | ), | 417 | ), |
| 417 | child: Column( | 418 | child: Column( |
| 418 | crossAxisAlignment: CrossAxisAlignment.start, | 419 | crossAxisAlignment: CrossAxisAlignment.start, |
| 419 | children: [ | 420 | children: [ |
| 420 | Text( | 421 | Text( |
| 421 | - HealthReportSubjectScope.titleOf(context, '热量消耗趋势'), | 422 | + HealthReportSubjectScope.titleOf( |
| 423 | + context, context.l10n.activityCalorieTrend), | ||
| 422 | style: const TextStyle( | 424 | style: const TextStyle( |
| 423 | color: ActivityBurnMonthReportView._h1, | 425 | color: ActivityBurnMonthReportView._h1, |
| 424 | fontSize: 16, | 426 | fontSize: 16, |
| 425 | fontWeight: FontWeight.w600, | 427 | fontWeight: FontWeight.w600, |
| 426 | ), | 428 | ), |
| 427 | ), | 429 | ), |
| 428 | - const SizedBox(height: 14), | 430 | + const SizedBox(height: 20), |
| 429 | Row( | 431 | Row( |
| 430 | crossAxisAlignment: CrossAxisAlignment.end, | 432 | crossAxisAlignment: CrossAxisAlignment.end, |
| 431 | children: [ | 433 | children: [ |
| @@ -440,9 +442,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -440,9 +442,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 440 | ), | 442 | ), |
| 441 | ), | 443 | ), |
| 442 | const SizedBox(width: 4), | 444 | const SizedBox(width: 4), |
| 443 | - const Text( | ||
| 444 | - '千卡/平均每日', | ||
| 445 | - style: TextStyle( | 445 | + Text( |
| 446 | + context.l10n.activityKcalDailyAverage, | ||
| 447 | + style: const TextStyle( | ||
| 446 | color: ActivityBurnMonthReportView._h1, | 448 | color: ActivityBurnMonthReportView._h1, |
| 447 | fontSize: 12, | 449 | fontSize: 12, |
| 448 | ), | 450 | ), |
| @@ -450,19 +452,56 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -450,19 +452,56 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 450 | ], | 452 | ], |
| 451 | ), | 453 | ), |
| 452 | const SizedBox(height: 2), | 454 | const SizedBox(height: 2), |
| 453 | - Text( | ||
| 454 | - hasData ? '比上月少34%' : '比上周-', | ||
| 455 | - style: TextStyle( | ||
| 456 | - color: ActivityBurnMonthReportView._h2, | ||
| 457 | - fontSize: 12, | ||
| 458 | - ), | 455 | + Row( |
| 456 | + children: [ | ||
| 457 | + Text( | ||
| 458 | + hasData | ||
| 459 | + ? context.l10n.activityComparedLastMonth | ||
| 460 | + : context.l10n.activityComparedUnavailable, | ||
| 461 | + style: TextStyle( | ||
| 462 | + color: ActivityBurnMonthReportView._h2, | ||
| 463 | + fontSize: 12, | ||
| 464 | + ), | ||
| 465 | + ), | ||
| 466 | + const Spacer(), | ||
| 467 | + _TrendLegend( | ||
| 468 | + color: const Color(0xFFFFC0CE), | ||
| 469 | + label: context.l10n.sleepAverage, | ||
| 470 | + ), | ||
| 471 | + const SizedBox(width: 12), | ||
| 472 | + _TrendLegend( | ||
| 473 | + color: const Color(0xFF96E9CB), | ||
| 474 | + label: context.l10n.sleepTarget, | ||
| 475 | + dashed: true, | ||
| 476 | + ), | ||
| 477 | + ], | ||
| 459 | ), | 478 | ), |
| 460 | const SizedBox(height: 8), | 479 | const SizedBox(height: 8), |
| 461 | Expanded( | 480 | Expanded( |
| 462 | child: Stack( | 481 | child: Stack( |
| 463 | alignment: Alignment.center, | 482 | alignment: Alignment.center, |
| 464 | children: [ | 483 | children: [ |
| 465 | - BarChart(_chartData()), | 484 | + Positioned( |
| 485 | + left: 0, | ||
| 486 | + right: 6, | ||
| 487 | + top: 0, | ||
| 488 | + bottom: 32, | ||
| 489 | + child: ActivityBurnTrendLines( | ||
| 490 | + maxY: maxY, | ||
| 491 | + average: widget.report.averageDailyActiveEnergy.toDouble(), | ||
| 492 | + target: widget.report.activeEnergyGoal.toDouble(), | ||
| 493 | + ), | ||
| 494 | + ), | ||
| 495 | + BarChart(_chartData(maxY)), | ||
| 496 | + const Positioned( | ||
| 497 | + left: 0, | ||
| 498 | + right: 6, | ||
| 499 | + bottom: 24, | ||
| 500 | + child: SizedBox( | ||
| 501 | + height: 1, | ||
| 502 | + child: ActivityBurnAxisDashedLine(), | ||
| 503 | + ), | ||
| 504 | + ), | ||
| 466 | if (_touchedOffset != null) | 505 | if (_touchedOffset != null) |
| 467 | Positioned.fill( | 506 | Positioned.fill( |
| 468 | child: ChartSelectionLineOverlay( | 507 | child: ChartSelectionLineOverlay( |
| @@ -473,9 +512,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -473,9 +512,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 473 | ), | 512 | ), |
| 474 | ), | 513 | ), |
| 475 | if (!hasData) | 514 | if (!hasData) |
| 476 | - const Text( | ||
| 477 | - '等待数据', | ||
| 478 | - style: TextStyle( | 515 | + Text( |
| 516 | + context.l10n.reportWaitingForData, | ||
| 517 | + style: const TextStyle( | ||
| 479 | color: ActivityBurnMonthReportView._h3, | 518 | color: ActivityBurnMonthReportView._h3, |
| 480 | fontSize: 12, | 519 | fontSize: 12, |
| 481 | ), | 520 | ), |
| @@ -488,20 +527,25 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -488,20 +527,25 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 488 | ); | 527 | ); |
| 489 | } | 528 | } |
| 490 | 529 | ||
| 491 | - BarChartData _chartData() { | 530 | + double get _maxY { |
| 531 | + final maxValue = widget.report.days | ||
| 532 | + .map((day) => day.activeEnergy?.value ?? 0) | ||
| 533 | + .fold<int>(0, (max, value) => value > max ? value : max); | ||
| 534 | + final referenceMax = [ | ||
| 535 | + maxValue, | ||
| 536 | + widget.report.averageDailyActiveEnergy, | ||
| 537 | + widget.report.activeEnergyGoal, | ||
| 538 | + ].reduce((max, value) => value > max ? value : max); | ||
| 539 | + return ((referenceMax / 100).ceil().clamp(4, 9) * 100).toDouble(); | ||
| 540 | + } | ||
| 541 | + | ||
| 542 | + BarChartData _chartData(double maxY) { | ||
| 492 | final hasData = widget.report.hasData; | 543 | final hasData = widget.report.hasData; |
| 493 | return BarChartData( | 544 | return BarChartData( |
| 494 | minY: 0, | 545 | minY: 0, |
| 495 | - maxY: 400, | 546 | + maxY: maxY, |
| 496 | gridData: FlGridData( | 547 | gridData: FlGridData( |
| 497 | - show: true, | ||
| 498 | - drawVerticalLine: false, | ||
| 499 | - horizontalInterval: 100, | ||
| 500 | - getDrawingHorizontalLine: (_) => const FlLine( | ||
| 501 | - color: Color(0xFFF3F3F3), | ||
| 502 | - strokeWidth: 1, | ||
| 503 | - dashArray: [2, 2], | ||
| 504 | - ), | 548 | + show: false, |
| 505 | ), | 549 | ), |
| 506 | borderData: FlBorderData(show: false), | 550 | borderData: FlBorderData(show: false), |
| 507 | barTouchData: BarTouchData( | 551 | barTouchData: BarTouchData( |
| @@ -537,7 +581,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -537,7 +581,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 537 | getTooltipItem: (group, groupIndex, rod, rodIndex) { | 581 | getTooltipItem: (group, groupIndex, rod, rodIndex) { |
| 538 | final report = widget.report.days[groupIndex]; | 582 | final report = widget.report.days[groupIndex]; |
| 539 | return BarTooltipItem( | 583 | return BarTooltipItem( |
| 540 | - '${report.activeEnergy?.value ?? 0}千卡\n', | 584 | + '${report.activeEnergy?.value ?? 0}${context.l10n.reportUnitKcal}\n', |
| 541 | const TextStyle( | 585 | const TextStyle( |
| 542 | color: ActivityBurnMonthReportView._active, | 586 | color: ActivityBurnMonthReportView._active, |
| 543 | fontSize: 16, | 587 | fontSize: 16, |
| @@ -563,16 +607,19 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -563,16 +607,19 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 563 | rightTitles: AxisTitles( | 607 | rightTitles: AxisTitles( |
| 564 | sideTitles: SideTitles( | 608 | sideTitles: SideTitles( |
| 565 | showTitles: true, | 609 | showTitles: true, |
| 566 | - reservedSize: 28, | 610 | + reservedSize: 36, |
| 567 | interval: 100, | 611 | interval: 100, |
| 568 | getTitlesWidget: (value, meta) { | 612 | getTitlesWidget: (value, meta) { |
| 569 | return Transform.translate( | 613 | return Transform.translate( |
| 570 | - offset: const Offset(0, -5), | ||
| 571 | - child: Text( | ||
| 572 | - value.toInt().toString(), | ||
| 573 | - style: const TextStyle( | ||
| 574 | - color: ActivityBurnMonthReportView._h3, | ||
| 575 | - fontSize: 10, | 614 | + offset: const Offset(0, -8), |
| 615 | + child: Padding( | ||
| 616 | + padding: const EdgeInsets.only(left: 8), | ||
| 617 | + child: Text( | ||
| 618 | + value.toInt().toString(), | ||
| 619 | + style: const TextStyle( | ||
| 620 | + color: ActivityBurnMonthReportView._h3, | ||
| 621 | + fontSize: 10, | ||
| 622 | + ), | ||
| 576 | ), | 623 | ), |
| 577 | ), | 624 | ), |
| 578 | ); | 625 | ); |
| @@ -582,7 +629,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -582,7 +629,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 582 | bottomTitles: AxisTitles( | 629 | bottomTitles: AxisTitles( |
| 583 | sideTitles: SideTitles( | 630 | sideTitles: SideTitles( |
| 584 | showTitles: true, | 631 | showTitles: true, |
| 585 | - reservedSize: 20, | 632 | + reservedSize: 32, |
| 586 | interval: 5, | 633 | interval: 5, |
| 587 | getTitlesWidget: (value, meta) { | 634 | getTitlesWidget: (value, meta) { |
| 588 | final day = value.toInt() + 1; | 635 | final day = value.toInt() + 1; |
| @@ -592,7 +639,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -592,7 +639,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 592 | return const SizedBox.shrink(); | 639 | return const SizedBox.shrink(); |
| 593 | } | 640 | } |
| 594 | return Padding( | 641 | return Padding( |
| 595 | - padding: const EdgeInsets.only(top: 4), | 642 | + padding: const EdgeInsets.only(top: 14), |
| 596 | child: Text( | 643 | child: Text( |
| 597 | day.toString(), | 644 | day.toString(), |
| 598 | style: const TextStyle( | 645 | style: const TextStyle( |
| @@ -605,22 +652,14 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -605,22 +652,14 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 605 | ), | 652 | ), |
| 606 | ), | 653 | ), |
| 607 | ), | 654 | ), |
| 608 | - extraLinesData: ExtraLinesData( | ||
| 609 | - horizontalLines: [ | ||
| 610 | - HorizontalLine( | ||
| 611 | - y: 320, | ||
| 612 | - color: ActivityBurnMonthReportView._exercise, | ||
| 613 | - strokeWidth: 1, | ||
| 614 | - dashArray: [2, 2], | ||
| 615 | - ), | ||
| 616 | - ], | ||
| 617 | - ), | 655 | + baselineY: 0, |
| 618 | barGroups: [ | 656 | barGroups: [ |
| 619 | for (var i = 0; i < widget.report.days.length; i++) | 657 | for (var i = 0; i < widget.report.days.length; i++) |
| 620 | BarChartGroupData( | 658 | BarChartGroupData( |
| 621 | x: i, | 659 | x: i, |
| 622 | barRods: [ | 660 | barRods: [ |
| 623 | BarChartRodData( | 661 | BarChartRodData( |
| 662 | + fromY: 0, | ||
| 624 | toY: | 663 | toY: |
| 625 | (widget.report.days[i].activeEnergy?.value ?? 0).toDouble(), | 664 | (widget.report.days[i].activeEnergy?.value ?? 0).toDouble(), |
| 626 | width: 4, | 665 | width: 4, |
| @@ -635,7 +674,50 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -635,7 +674,50 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 635 | } | 674 | } |
| 636 | 675 | ||
| 637 | String _tooltipDate(DateTime date) { | 676 | String _tooltipDate(DateTime date) { |
| 638 | - const weekdays = ['一', '二', '三', '四', '五', '六', '日']; | ||
| 639 | - return '${DateFormat('M月d日').format(date)} 星期${weekdays[date.weekday - 1]}'; | 677 | + return l10n.reportDateWithWeekday( |
| 678 | + reportMonthDay(date), | ||
| 679 | + reportWeekdayLabel(date.weekday), | ||
| 680 | + ); | ||
| 681 | + } | ||
| 682 | +} | ||
| 683 | + | ||
| 684 | +class _TrendLegend extends StatelessWidget { | ||
| 685 | + const _TrendLegend({ | ||
| 686 | + required this.color, | ||
| 687 | + required this.label, | ||
| 688 | + this.dashed = false, | ||
| 689 | + }); | ||
| 690 | + | ||
| 691 | + final Color color; | ||
| 692 | + final String label; | ||
| 693 | + final bool dashed; | ||
| 694 | + | ||
| 695 | + @override | ||
| 696 | + Widget build(BuildContext context) { | ||
| 697 | + return Row( | ||
| 698 | + mainAxisSize: MainAxisSize.min, | ||
| 699 | + children: [ | ||
| 700 | + SizedBox( | ||
| 701 | + width: 19, | ||
| 702 | + child: Row( | ||
| 703 | + mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||
| 704 | + children: dashed | ||
| 705 | + ? List.generate( | ||
| 706 | + 5, | ||
| 707 | + (_) => Container(width: 2, height: 2, color: color), | ||
| 708 | + ) | ||
| 709 | + : [Container(width: 19, height: 2, color: color)], | ||
| 710 | + ), | ||
| 711 | + ), | ||
| 712 | + const SizedBox(width: 4), | ||
| 713 | + Text( | ||
| 714 | + label, | ||
| 715 | + style: const TextStyle( | ||
| 716 | + color: ActivityBurnMonthReportView._h2, | ||
| 717 | + fontSize: 12, | ||
| 718 | + ), | ||
| 719 | + ), | ||
| 720 | + ], | ||
| 721 | + ); | ||
| 640 | } | 722 | } |
| 641 | } | 723 | } |
| @@ -4,32 +4,134 @@ import 'package:flutter/material.dart'; | @@ -4,32 +4,134 @@ import 'package:flutter/material.dart'; | ||
| 4 | 4 | ||
| 5 | import '../models/activity_burn_report_models.dart'; | 5 | import '../models/activity_burn_report_models.dart'; |
| 6 | 6 | ||
| 7 | -class ActivityBurnRing extends StatelessWidget { | 7 | +class ActivityBurnRing extends StatefulWidget { |
| 8 | const ActivityBurnRing({ | 8 | const ActivityBurnRing({ |
| 9 | super.key, | 9 | super.key, |
| 10 | required this.report, | 10 | required this.report, |
| 11 | this.size = 200, | 11 | this.size = 200, |
| 12 | + this.animate = false, | ||
| 12 | }); | 13 | }); |
| 13 | 14 | ||
| 14 | final ActivityBurnReport? report; | 15 | final ActivityBurnReport? report; |
| 15 | final double size; | 16 | final double size; |
| 17 | + final bool animate; | ||
| 18 | + | ||
| 19 | + @override | ||
| 20 | + State<ActivityBurnRing> createState() => _ActivityBurnRingState(); | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +class _ActivityBurnRingState extends State<ActivityBurnRing> | ||
| 24 | + with SingleTickerProviderStateMixin { | ||
| 25 | + late final AnimationController _controller; | ||
| 26 | + late Animation<_RingProgress> _animation; | ||
| 27 | + | ||
| 28 | + _RingProgress get _target => _RingProgress.fromReport(widget.report); | ||
| 29 | + | ||
| 30 | + @override | ||
| 31 | + void initState() { | ||
| 32 | + super.initState(); | ||
| 33 | + _controller = AnimationController( | ||
| 34 | + vsync: this, | ||
| 35 | + duration: const Duration(seconds: 1), | ||
| 36 | + ); | ||
| 37 | + _animateTo(_target, from: const _RingProgress.zero()); | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + @override | ||
| 41 | + void didUpdateWidget(covariant ActivityBurnRing oldWidget) { | ||
| 42 | + super.didUpdateWidget(oldWidget); | ||
| 43 | + final target = _target; | ||
| 44 | + if (_RingProgress.fromReport(oldWidget.report) != target || | ||
| 45 | + oldWidget.animate != widget.animate) { | ||
| 46 | + _animateTo(target, from: _animation.value); | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + void _animateTo(_RingProgress target, {required _RingProgress from}) { | ||
| 51 | + _animation = _RingProgressTween(begin: from, end: target).animate( | ||
| 52 | + CurvedAnimation(parent: _controller, curve: Curves.easeInOutQuad), | ||
| 53 | + ); | ||
| 54 | + _controller.duration = | ||
| 55 | + widget.animate ? const Duration(seconds: 1) : Duration.zero; | ||
| 56 | + _controller.forward(from: 0); | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + @override | ||
| 60 | + void dispose() { | ||
| 61 | + _controller.dispose(); | ||
| 62 | + super.dispose(); | ||
| 63 | + } | ||
| 16 | 64 | ||
| 17 | @override | 65 | @override |
| 18 | Widget build(BuildContext context) { | 66 | Widget build(BuildContext context) { |
| 19 | return SizedBox( | 67 | return SizedBox( |
| 20 | - width: size, | ||
| 21 | - height: size, | ||
| 22 | - child: CustomPaint( | ||
| 23 | - painter: _ActivityBurnRingPainter(report: report), | 68 | + width: widget.size, |
| 69 | + height: widget.size, | ||
| 70 | + child: AnimatedBuilder( | ||
| 71 | + animation: _animation, | ||
| 72 | + builder: (context, child) => CustomPaint( | ||
| 73 | + painter: _ActivityBurnRingPainter( | ||
| 74 | + progress: _animation.value, | ||
| 75 | + hasData: widget.report?.hasData == true, | ||
| 76 | + ), | ||
| 77 | + ), | ||
| 24 | ), | 78 | ), |
| 25 | ); | 79 | ); |
| 26 | } | 80 | } |
| 27 | } | 81 | } |
| 28 | 82 | ||
| 83 | +class _RingProgress { | ||
| 84 | + const _RingProgress({ | ||
| 85 | + required this.active, | ||
| 86 | + required this.exercise, | ||
| 87 | + required this.stand, | ||
| 88 | + }); | ||
| 89 | + | ||
| 90 | + const _RingProgress.zero() | ||
| 91 | + : active = 0, | ||
| 92 | + exercise = 0, | ||
| 93 | + stand = 0; | ||
| 94 | + | ||
| 95 | + factory _RingProgress.fromReport(ActivityBurnReport? report) => _RingProgress( | ||
| 96 | + active: report?.activeEnergy?.progress ?? 0, | ||
| 97 | + exercise: report?.exerciseMinutes?.progress ?? 0, | ||
| 98 | + stand: report?.standHours?.progress ?? 0, | ||
| 99 | + ); | ||
| 100 | + | ||
| 101 | + final double active; | ||
| 102 | + final double exercise; | ||
| 103 | + final double stand; | ||
| 104 | + | ||
| 105 | + @override | ||
| 106 | + bool operator ==(Object other) => | ||
| 107 | + other is _RingProgress && | ||
| 108 | + active == other.active && | ||
| 109 | + exercise == other.exercise && | ||
| 110 | + stand == other.stand; | ||
| 111 | + | ||
| 112 | + @override | ||
| 113 | + int get hashCode => Object.hash(active, exercise, stand); | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +class _RingProgressTween extends Tween<_RingProgress> { | ||
| 117 | + _RingProgressTween({required super.begin, required super.end}); | ||
| 118 | + | ||
| 119 | + @override | ||
| 120 | + _RingProgress lerp(double t) => _RingProgress( | ||
| 121 | + active: begin!.active + (end!.active - begin!.active) * t, | ||
| 122 | + exercise: begin!.exercise + (end!.exercise - begin!.exercise) * t, | ||
| 123 | + stand: begin!.stand + (end!.stand - begin!.stand) * t, | ||
| 124 | + ); | ||
| 125 | +} | ||
| 126 | + | ||
| 29 | class _ActivityBurnRingPainter extends CustomPainter { | 127 | class _ActivityBurnRingPainter extends CustomPainter { |
| 30 | - const _ActivityBurnRingPainter({required this.report}); | 128 | + const _ActivityBurnRingPainter({ |
| 129 | + required this.progress, | ||
| 130 | + required this.hasData, | ||
| 131 | + }); | ||
| 31 | 132 | ||
| 32 | - final ActivityBurnReport? report; | 133 | + final _RingProgress progress; |
| 134 | + final bool hasData; | ||
| 33 | 135 | ||
| 34 | static const _active = Color(0xFFFF5279); | 136 | static const _active = Color(0xFFFF5279); |
| 35 | static const _exercise = Color(0xFF3BD49D); | 137 | static const _exercise = Color(0xFF3BD49D); |
| @@ -50,7 +152,7 @@ class _ActivityBurnRingPainter extends CustomPainter { | @@ -50,7 +152,7 @@ class _ActivityBurnRingPainter extends CustomPainter { | ||
| 50 | 152 | ||
| 51 | _drawEmptyRing(canvas, center: center, scale: scale); | 153 | _drawEmptyRing(canvas, center: center, scale: scale); |
| 52 | 154 | ||
| 53 | - if (report?.hasData != true) { | 155 | + if (!hasData) { |
| 54 | return; | 156 | return; |
| 55 | } | 157 | } |
| 56 | 158 | ||
| @@ -61,7 +163,7 @@ class _ActivityBurnRingPainter extends CustomPainter { | @@ -61,7 +163,7 @@ class _ActivityBurnRingPainter extends CustomPainter { | ||
| 61 | width: _ringWidth * scale, | 163 | width: _ringWidth * scale, |
| 62 | trackColor: _track, | 164 | trackColor: _track, |
| 63 | progressColor: _active, | 165 | progressColor: _active, |
| 64 | - progress: report?.activeEnergy?.progress ?? 0, | 166 | + progress: progress.active, |
| 65 | startAngle: startAngle, | 167 | startAngle: startAngle, |
| 66 | ); | 168 | ); |
| 67 | _drawRing( | 169 | _drawRing( |
| @@ -71,7 +173,7 @@ class _ActivityBurnRingPainter extends CustomPainter { | @@ -71,7 +173,7 @@ class _ActivityBurnRingPainter extends CustomPainter { | ||
| 71 | width: _ringWidth * scale, | 173 | width: _ringWidth * scale, |
| 72 | trackColor: _innerTrack, | 174 | trackColor: _innerTrack, |
| 73 | progressColor: _exercise, | 175 | progressColor: _exercise, |
| 74 | - progress: report?.exerciseMinutes?.progress ?? 0, | 176 | + progress: progress.exercise, |
| 75 | startAngle: startAngle, | 177 | startAngle: startAngle, |
| 76 | ); | 178 | ); |
| 77 | _drawRing( | 179 | _drawRing( |
| @@ -81,7 +183,7 @@ class _ActivityBurnRingPainter extends CustomPainter { | @@ -81,7 +183,7 @@ class _ActivityBurnRingPainter extends CustomPainter { | ||
| 81 | width: _ringWidth * scale, | 183 | width: _ringWidth * scale, |
| 82 | trackColor: _center, | 184 | trackColor: _center, |
| 83 | progressColor: _stand, | 185 | progressColor: _stand, |
| 84 | - progress: report?.standHours?.progress ?? 0, | 186 | + progress: progress.stand, |
| 85 | startAngle: startAngle, | 187 | startAngle: startAngle, |
| 86 | ); | 188 | ); |
| 87 | } | 189 | } |
| @@ -169,6 +271,6 @@ class _ActivityBurnRingPainter extends CustomPainter { | @@ -169,6 +271,6 @@ class _ActivityBurnRingPainter extends CustomPainter { | ||
| 169 | 271 | ||
| 170 | @override | 272 | @override |
| 171 | bool shouldRepaint(covariant _ActivityBurnRingPainter oldDelegate) { | 273 | bool shouldRepaint(covariant _ActivityBurnRingPainter oldDelegate) { |
| 172 | - return oldDelegate.report != report; | 274 | + return oldDelegate.progress != progress || oldDelegate.hasData != hasData; |
| 173 | } | 275 | } |
| 174 | } | 276 | } |
| 1 | import 'package:flutter/material.dart'; | 1 | import 'package:flutter/material.dart'; |
| 2 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 2 | 3 | ||
| 3 | import '../models/activity_burn_report_models.dart'; | 4 | import '../models/activity_burn_report_models.dart'; |
| 4 | 5 | ||
| @@ -27,26 +28,26 @@ class ActivityBurnSummaryCard extends StatelessWidget { | @@ -27,26 +28,26 @@ class ActivityBurnSummaryCard extends StatelessWidget { | ||
| 27 | children: [ | 28 | children: [ |
| 28 | Expanded( | 29 | Expanded( |
| 29 | child: _MetricColumn( | 30 | child: _MetricColumn( |
| 30 | - title: '活动', | 31 | + title: context.l10n.activityMove, |
| 31 | color: _active, | 32 | color: _active, |
| 32 | value: report?.activeEnergy?.value, | 33 | value: report?.activeEnergy?.value, |
| 33 | - unit: '千卡', | 34 | + unit: context.l10n.reportUnitKcal, |
| 34 | ), | 35 | ), |
| 35 | ), | 36 | ), |
| 36 | Expanded( | 37 | Expanded( |
| 37 | child: _MetricColumn( | 38 | child: _MetricColumn( |
| 38 | - title: '锻炼', | 39 | + title: context.l10n.activityExercise, |
| 39 | color: _exercise, | 40 | color: _exercise, |
| 40 | value: report?.exerciseMinutes?.value, | 41 | value: report?.exerciseMinutes?.value, |
| 41 | - unit: '分钟', | 42 | + unit: context.l10n.reportUnitMinute, |
| 42 | ), | 43 | ), |
| 43 | ), | 44 | ), |
| 44 | Expanded( | 45 | Expanded( |
| 45 | child: _MetricColumn( | 46 | child: _MetricColumn( |
| 46 | - title: '站立', | 47 | + title: context.l10n.activityStand, |
| 47 | color: _stand, | 48 | color: _stand, |
| 48 | value: report?.standHours?.value, | 49 | value: report?.standHours?.value, |
| 49 | - unit: '小时', | 50 | + unit: context.l10n.reportUnitHour, |
| 50 | ), | 51 | ), |
| 51 | ), | 52 | ), |
| 52 | ], | 53 | ], |
| 1 | +import 'package:flutter/material.dart'; | ||
| 2 | + | ||
| 3 | +class ActivityBurnTrendLines extends StatelessWidget { | ||
| 4 | + const ActivityBurnTrendLines({ | ||
| 5 | + super.key, | ||
| 6 | + required this.maxY, | ||
| 7 | + required this.average, | ||
| 8 | + required this.target, | ||
| 9 | + }); | ||
| 10 | + | ||
| 11 | + final double maxY; | ||
| 12 | + final double average; | ||
| 13 | + final double target; | ||
| 14 | + | ||
| 15 | + @override | ||
| 16 | + Widget build(BuildContext context) { | ||
| 17 | + return IgnorePointer( | ||
| 18 | + child: CustomPaint( | ||
| 19 | + painter: _TrendLinesPainter( | ||
| 20 | + maxY: maxY, | ||
| 21 | + average: average, | ||
| 22 | + target: target, | ||
| 23 | + ), | ||
| 24 | + ), | ||
| 25 | + ); | ||
| 26 | + } | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +class ActivityBurnAxisDashedLine extends StatelessWidget { | ||
| 30 | + const ActivityBurnAxisDashedLine({super.key}); | ||
| 31 | + | ||
| 32 | + @override | ||
| 33 | + Widget build(BuildContext context) { | ||
| 34 | + return const IgnorePointer( | ||
| 35 | + child: CustomPaint(painter: _AxisDashedLinePainter()), | ||
| 36 | + ); | ||
| 37 | + } | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +class ActivityBurnHorizontalGridLines extends StatelessWidget { | ||
| 41 | + const ActivityBurnHorizontalGridLines({ | ||
| 42 | + super.key, | ||
| 43 | + required this.minY, | ||
| 44 | + required this.maxY, | ||
| 45 | + required this.values, | ||
| 46 | + }); | ||
| 47 | + | ||
| 48 | + final double minY; | ||
| 49 | + final double maxY; | ||
| 50 | + final List<double> values; | ||
| 51 | + | ||
| 52 | + @override | ||
| 53 | + Widget build(BuildContext context) { | ||
| 54 | + return IgnorePointer( | ||
| 55 | + child: CustomPaint( | ||
| 56 | + painter: _HorizontalGridLinesPainter( | ||
| 57 | + minY: minY, | ||
| 58 | + maxY: maxY, | ||
| 59 | + values: values, | ||
| 60 | + ), | ||
| 61 | + ), | ||
| 62 | + ); | ||
| 63 | + } | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +class _TrendLinesPainter extends CustomPainter { | ||
| 67 | + const _TrendLinesPainter({ | ||
| 68 | + required this.maxY, | ||
| 69 | + required this.average, | ||
| 70 | + required this.target, | ||
| 71 | + }); | ||
| 72 | + | ||
| 73 | + final double maxY; | ||
| 74 | + final double average; | ||
| 75 | + final double target; | ||
| 76 | + | ||
| 77 | + static const _gridColor = Color(0xFFF3F3F3); | ||
| 78 | + static const _averageColor = Color(0xFFFFC0CE); | ||
| 79 | + static const _targetColor = Color(0xFF96E9CB); | ||
| 80 | + | ||
| 81 | + @override | ||
| 82 | + void paint(Canvas canvas, Size size) { | ||
| 83 | + if (maxY <= 0 || size.isEmpty) return; | ||
| 84 | + | ||
| 85 | + for (var value = 100.0; value <= maxY; value += 100) { | ||
| 86 | + _drawDashedLine( | ||
| 87 | + canvas, | ||
| 88 | + size.width, | ||
| 89 | + _yFor(value, size.height), | ||
| 90 | + color: _gridColor, | ||
| 91 | + strokeWidth: 1, | ||
| 92 | + ); | ||
| 93 | + } | ||
| 94 | + if (average > 0) { | ||
| 95 | + _drawSolidLine( | ||
| 96 | + canvas, | ||
| 97 | + size.width, | ||
| 98 | + _yFor(average, size.height), | ||
| 99 | + color: _averageColor, | ||
| 100 | + ); | ||
| 101 | + } | ||
| 102 | + if (target > 0) { | ||
| 103 | + _drawDashedLine( | ||
| 104 | + canvas, | ||
| 105 | + size.width, | ||
| 106 | + _yFor(target, size.height), | ||
| 107 | + color: _targetColor, | ||
| 108 | + strokeWidth: 2, | ||
| 109 | + ); | ||
| 110 | + } | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + double _yFor(double value, double height) => | ||
| 114 | + height * (1 - (value / maxY).clamp(0, 1)); | ||
| 115 | + | ||
| 116 | + void _drawSolidLine( | ||
| 117 | + Canvas canvas, | ||
| 118 | + double width, | ||
| 119 | + double y, { | ||
| 120 | + required Color color, | ||
| 121 | + }) { | ||
| 122 | + canvas.drawLine( | ||
| 123 | + Offset(0, y), | ||
| 124 | + Offset(width, y), | ||
| 125 | + Paint() | ||
| 126 | + ..color = color | ||
| 127 | + ..strokeWidth = 2, | ||
| 128 | + ); | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + void _drawDashedLine( | ||
| 132 | + Canvas canvas, | ||
| 133 | + double width, | ||
| 134 | + double y, { | ||
| 135 | + required Color color, | ||
| 136 | + required double strokeWidth, | ||
| 137 | + }) { | ||
| 138 | + final paint = Paint() | ||
| 139 | + ..color = color | ||
| 140 | + ..strokeWidth = strokeWidth; | ||
| 141 | + for (var x = 0.0; x < width; x += 4) { | ||
| 142 | + canvas.drawLine(Offset(x, y), Offset((x + 2).clamp(0, width), y), paint); | ||
| 143 | + } | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + @override | ||
| 147 | + bool shouldRepaint(_TrendLinesPainter oldDelegate) => | ||
| 148 | + maxY != oldDelegate.maxY || | ||
| 149 | + average != oldDelegate.average || | ||
| 150 | + target != oldDelegate.target; | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +class _AxisDashedLinePainter extends CustomPainter { | ||
| 154 | + const _AxisDashedLinePainter(); | ||
| 155 | + | ||
| 156 | + @override | ||
| 157 | + void paint(Canvas canvas, Size size) { | ||
| 158 | + final paint = Paint() | ||
| 159 | + ..color = const Color(0xFFF3F3F3) | ||
| 160 | + ..strokeWidth = 1; | ||
| 161 | + for (var x = 0.0; x < size.width; x += 4) { | ||
| 162 | + canvas.drawLine( | ||
| 163 | + Offset(x, 0), | ||
| 164 | + Offset((x + 2).clamp(0, size.width), 0), | ||
| 165 | + paint, | ||
| 166 | + ); | ||
| 167 | + } | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + @override | ||
| 171 | + bool shouldRepaint(_AxisDashedLinePainter oldDelegate) => false; | ||
| 172 | +} | ||
| 173 | + | ||
| 174 | +class _HorizontalGridLinesPainter extends CustomPainter { | ||
| 175 | + const _HorizontalGridLinesPainter({ | ||
| 176 | + required this.minY, | ||
| 177 | + required this.maxY, | ||
| 178 | + required this.values, | ||
| 179 | + }); | ||
| 180 | + | ||
| 181 | + final double minY; | ||
| 182 | + final double maxY; | ||
| 183 | + final List<double> values; | ||
| 184 | + | ||
| 185 | + @override | ||
| 186 | + void paint(Canvas canvas, Size size) { | ||
| 187 | + if (maxY <= minY || size.isEmpty) return; | ||
| 188 | + final paint = Paint() | ||
| 189 | + ..color = const Color(0xFFF3F3F3) | ||
| 190 | + ..strokeWidth = 1; | ||
| 191 | + for (final value in values) { | ||
| 192 | + if (value < minY || value > maxY) continue; | ||
| 193 | + final y = size.height * (1 - (value - minY) / (maxY - minY)); | ||
| 194 | + for (var x = 0.0; x < size.width; x += 4) { | ||
| 195 | + canvas.drawLine( | ||
| 196 | + Offset(x, y), | ||
| 197 | + Offset((x + 2).clamp(0, size.width), y), | ||
| 198 | + paint, | ||
| 199 | + ); | ||
| 200 | + } | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | + @override | ||
| 205 | + bool shouldRepaint(_HorizontalGridLinesPainter oldDelegate) => | ||
| 206 | + minY != oldDelegate.minY || | ||
| 207 | + maxY != oldDelegate.maxY || | ||
| 208 | + values != oldDelegate.values; | ||
| 209 | +} |
| 1 | import 'package:fl_chart/fl_chart.dart'; | 1 | import 'package:fl_chart/fl_chart.dart'; |
| 2 | import 'package:flutter/material.dart'; | 2 | import 'package:flutter/material.dart'; |
| 3 | -import 'package:intl/intl.dart'; | 3 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 4 | 4 | ||
| 5 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; | 5 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; |
| 6 | import '../../report_common/widgets/health_report_subject_scope.dart'; | 6 | import '../../report_common/widgets/health_report_subject_scope.dart'; |
| 7 | +import '../../report_common/utils/report_localization.dart'; | ||
| 7 | import '../models/activity_burn_report_models.dart'; | 8 | import '../models/activity_burn_report_models.dart'; |
| 8 | import 'activity_burn_ring.dart'; | 9 | import 'activity_burn_ring.dart'; |
| 10 | +import 'activity_burn_trend_lines.dart'; | ||
| 9 | 11 | ||
| 10 | class ActivityBurnWeekReportView extends StatelessWidget { | 12 | class ActivityBurnWeekReportView extends StatelessWidget { |
| 11 | const ActivityBurnWeekReportView({ | 13 | const ActivityBurnWeekReportView({ |
| @@ -53,7 +55,8 @@ class _WeeklySummary extends StatelessWidget { | @@ -53,7 +55,8 @@ class _WeeklySummary extends StatelessWidget { | ||
| 53 | crossAxisAlignment: CrossAxisAlignment.start, | 55 | crossAxisAlignment: CrossAxisAlignment.start, |
| 54 | children: [ | 56 | children: [ |
| 55 | Text( | 57 | Text( |
| 56 | - HealthReportSubjectScope.titleOf(context, '活动总消耗'), | 58 | + HealthReportSubjectScope.titleOf( |
| 59 | + context, context.l10n.activityTotalBurn), | ||
| 57 | style: const TextStyle( | 60 | style: const TextStyle( |
| 58 | color: ActivityBurnWeekReportView._active, | 61 | color: ActivityBurnWeekReportView._active, |
| 59 | fontSize: 12, | 62 | fontSize: 12, |
| @@ -76,11 +79,11 @@ class _WeeklySummary extends StatelessWidget { | @@ -76,11 +79,11 @@ class _WeeklySummary extends StatelessWidget { | ||
| 76 | ), | 79 | ), |
| 77 | ), | 80 | ), |
| 78 | const SizedBox(width: 4), | 81 | const SizedBox(width: 4), |
| 79 | - const Padding( | ||
| 80 | - padding: EdgeInsets.only(bottom: 5), | 82 | + Padding( |
| 83 | + padding: const EdgeInsets.only(bottom: 5), | ||
| 81 | child: Text( | 84 | child: Text( |
| 82 | - '千卡', | ||
| 83 | - style: TextStyle( | 85 | + context.l10n.reportUnitKcal, |
| 86 | + style: const TextStyle( | ||
| 84 | color: ActivityBurnWeekReportView._h1, | 87 | color: ActivityBurnWeekReportView._h1, |
| 85 | fontSize: 12, | 88 | fontSize: 12, |
| 86 | fontWeight: FontWeight.w500, | 89 | fontWeight: FontWeight.w500, |
| @@ -90,9 +93,9 @@ class _WeeklySummary extends StatelessWidget { | @@ -90,9 +93,9 @@ class _WeeklySummary extends StatelessWidget { | ||
| 90 | ], | 93 | ], |
| 91 | ) | 94 | ) |
| 92 | else | 95 | else |
| 93 | - const Text( | ||
| 94 | - '等待数据', | ||
| 95 | - style: TextStyle( | 96 | + Text( |
| 97 | + context.l10n.reportWaitingForData, | ||
| 98 | + style: const TextStyle( | ||
| 96 | color: ActivityBurnWeekReportView._h1, | 99 | color: ActivityBurnWeekReportView._h1, |
| 97 | fontSize: 24, | 100 | fontSize: 24, |
| 98 | fontWeight: FontWeight.w600, | 101 | fontWeight: FontWeight.w600, |
| @@ -103,18 +106,20 @@ class _WeeklySummary extends StatelessWidget { | @@ -103,18 +106,20 @@ class _WeeklySummary extends StatelessWidget { | ||
| 103 | Row( | 106 | Row( |
| 104 | children: [ | 107 | children: [ |
| 105 | _SummaryMetric( | 108 | _SummaryMetric( |
| 106 | - title: HealthReportSubjectScope.titleOf(context, '锻炼总时长'), | 109 | + title: HealthReportSubjectScope.titleOf( |
| 110 | + context, context.l10n.activityExerciseTotalDuration), | ||
| 107 | value: report.hasData | 111 | value: report.hasData |
| 108 | ? report.totalExerciseMinutes.toString() | 112 | ? report.totalExerciseMinutes.toString() |
| 109 | : '-', | 113 | : '-', |
| 110 | - unit: '分钟', | 114 | + unit: context.l10n.reportUnitMinute, |
| 111 | color: ActivityBurnWeekReportView._exercise, | 115 | color: ActivityBurnWeekReportView._exercise, |
| 112 | ), | 116 | ), |
| 113 | const SizedBox(width: 34), | 117 | const SizedBox(width: 34), |
| 114 | _SummaryMetric( | 118 | _SummaryMetric( |
| 115 | - title: HealthReportSubjectScope.titleOf(context, '站立总时长'), | 119 | + title: HealthReportSubjectScope.titleOf( |
| 120 | + context, context.l10n.activityStandTotalDuration), | ||
| 116 | value: report.hasData ? report.totalStandHours.toString() : '-', | 121 | value: report.hasData ? report.totalStandHours.toString() : '-', |
| 117 | - unit: '小时', | 122 | + unit: context.l10n.reportUnitHour, |
| 118 | color: ActivityBurnWeekReportView._stand, | 123 | color: ActivityBurnWeekReportView._stand, |
| 119 | ), | 124 | ), |
| 120 | ], | 125 | ], |
| @@ -202,7 +207,7 @@ class _RingOverviewCard extends StatelessWidget { | @@ -202,7 +207,7 @@ class _RingOverviewCard extends StatelessWidget { | ||
| 202 | children: [ | 207 | children: [ |
| 203 | _RingStat( | 208 | _RingStat( |
| 204 | color: ActivityBurnWeekReportView._active, | 209 | color: ActivityBurnWeekReportView._active, |
| 205 | - label: '完美合环', | 210 | + label: context.l10n.activityPerfectRings, |
| 206 | value: report.perfectRingDays, | 211 | value: report.perfectRingDays, |
| 207 | hasData: report.hasData, | 212 | hasData: report.hasData, |
| 208 | report: ActivityBurnReport( | 213 | report: ActivityBurnReport( |
| @@ -215,7 +220,7 @@ class _RingOverviewCard extends StatelessWidget { | @@ -215,7 +220,7 @@ class _RingOverviewCard extends StatelessWidget { | ||
| 215 | const SizedBox(width: 34), | 220 | const SizedBox(width: 34), |
| 216 | _RingStat( | 221 | _RingStat( |
| 217 | color: ActivityBurnWeekReportView._active, | 222 | color: ActivityBurnWeekReportView._active, |
| 218 | - label: '合上活动圆环', | 223 | + label: context.l10n.activityCloseMoveRing, |
| 219 | value: report.activeRingDays, | 224 | value: report.activeRingDays, |
| 220 | hasData: report.hasData, | 225 | hasData: report.hasData, |
| 221 | report: ActivityBurnReport( | 226 | report: ActivityBurnReport( |
| @@ -230,7 +235,7 @@ class _RingOverviewCard extends StatelessWidget { | @@ -230,7 +235,7 @@ class _RingOverviewCard extends StatelessWidget { | ||
| 230 | children: [ | 235 | children: [ |
| 231 | _RingStat( | 236 | _RingStat( |
| 232 | color: ActivityBurnWeekReportView._exercise, | 237 | color: ActivityBurnWeekReportView._exercise, |
| 233 | - label: '合上锻炼圆环', | 238 | + label: context.l10n.activityCloseExerciseRing, |
| 234 | value: report.exerciseRingDays, | 239 | value: report.exerciseRingDays, |
| 235 | hasData: report.hasData, | 240 | hasData: report.hasData, |
| 236 | report: ActivityBurnReport( | 241 | report: ActivityBurnReport( |
| @@ -241,7 +246,7 @@ class _RingOverviewCard extends StatelessWidget { | @@ -241,7 +246,7 @@ class _RingOverviewCard extends StatelessWidget { | ||
| 241 | const SizedBox(width: 34), | 246 | const SizedBox(width: 34), |
| 242 | _RingStat( | 247 | _RingStat( |
| 243 | color: ActivityBurnWeekReportView._stand, | 248 | color: ActivityBurnWeekReportView._stand, |
| 244 | - label: '合上站立圆环', | 249 | + label: context.l10n.activityCloseStandRing, |
| 245 | value: report.standRingDays, | 250 | value: report.standRingDays, |
| 246 | hasData: report.hasData, | 251 | hasData: report.hasData, |
| 247 | report: ActivityBurnReport( | 252 | report: ActivityBurnReport( |
| @@ -313,9 +318,9 @@ class _RingStat extends StatelessWidget { | @@ -313,9 +318,9 @@ class _RingStat extends StatelessWidget { | ||
| 313 | fontWeight: FontWeight.w600, | 318 | fontWeight: FontWeight.w600, |
| 314 | ), | 319 | ), |
| 315 | ), | 320 | ), |
| 316 | - const TextSpan( | ||
| 317 | - text: ' 天', | ||
| 318 | - style: TextStyle( | 321 | + TextSpan( |
| 322 | + text: ' ${context.l10n.reportUnitDay}', | ||
| 323 | + style: const TextStyle( | ||
| 319 | color: ActivityBurnWeekReportView._h1, | 324 | color: ActivityBurnWeekReportView._h1, |
| 320 | fontSize: 14, | 325 | fontSize: 14, |
| 321 | fontWeight: FontWeight.w500, | 326 | fontWeight: FontWeight.w500, |
| @@ -335,8 +340,6 @@ class _DayRing extends StatelessWidget { | @@ -335,8 +340,6 @@ class _DayRing extends StatelessWidget { | ||
| 335 | 340 | ||
| 336 | final ActivityBurnReport report; | 341 | final ActivityBurnReport report; |
| 337 | 342 | ||
| 338 | - static const _weekdays = ['一', '二', '三', '四', '五', '六', '日']; | ||
| 339 | - | ||
| 340 | @override | 343 | @override |
| 341 | Widget build(BuildContext context) { | 344 | Widget build(BuildContext context) { |
| 342 | return SizedBox( | 345 | return SizedBox( |
| @@ -344,7 +347,7 @@ class _DayRing extends StatelessWidget { | @@ -344,7 +347,7 @@ class _DayRing extends StatelessWidget { | ||
| 344 | child: Column( | 347 | child: Column( |
| 345 | children: [ | 348 | children: [ |
| 346 | Text( | 349 | Text( |
| 347 | - _weekdays[report.date.weekday - 1], | 350 | + reportWeekdayLabel(report.date.weekday), |
| 348 | style: const TextStyle( | 351 | style: const TextStyle( |
| 349 | color: ActivityBurnWeekReportView._h3, | 352 | color: ActivityBurnWeekReportView._h3, |
| 350 | fontSize: 12, | 353 | fontSize: 12, |
| @@ -384,25 +387,27 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -384,25 +387,27 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 384 | 387 | ||
| 385 | @override | 388 | @override |
| 386 | Widget build(BuildContext context) { | 389 | Widget build(BuildContext context) { |
| 390 | + final maxY = _maxY; | ||
| 387 | return Container( | 391 | return Container( |
| 388 | - height: 294, | ||
| 389 | - padding: const EdgeInsets.fromLTRB(20, 18, 14, 14), | 392 | + height: 344, |
| 393 | + padding: const EdgeInsets.fromLTRB(20, 20, 14, 32), | ||
| 390 | decoration: BoxDecoration( | 394 | decoration: BoxDecoration( |
| 391 | color: Colors.white, | 395 | color: Colors.white, |
| 392 | - borderRadius: BorderRadius.circular(12), | 396 | + borderRadius: BorderRadius.circular(16), |
| 393 | ), | 397 | ), |
| 394 | child: Column( | 398 | child: Column( |
| 395 | crossAxisAlignment: CrossAxisAlignment.start, | 399 | crossAxisAlignment: CrossAxisAlignment.start, |
| 396 | children: [ | 400 | children: [ |
| 397 | Text( | 401 | Text( |
| 398 | - HealthReportSubjectScope.titleOf(context, '热量消耗趋势'), | 402 | + HealthReportSubjectScope.titleOf( |
| 403 | + context, context.l10n.activityCalorieTrend), | ||
| 399 | style: const TextStyle( | 404 | style: const TextStyle( |
| 400 | color: ActivityBurnWeekReportView._h1, | 405 | color: ActivityBurnWeekReportView._h1, |
| 401 | fontSize: 16, | 406 | fontSize: 16, |
| 402 | fontWeight: FontWeight.w600, | 407 | fontWeight: FontWeight.w600, |
| 403 | ), | 408 | ), |
| 404 | ), | 409 | ), |
| 405 | - const SizedBox(height: 14), | 410 | + const SizedBox(height: 20), |
| 406 | Row( | 411 | Row( |
| 407 | crossAxisAlignment: CrossAxisAlignment.end, | 412 | crossAxisAlignment: CrossAxisAlignment.end, |
| 408 | children: [ | 413 | children: [ |
| @@ -417,9 +422,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -417,9 +422,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 417 | ), | 422 | ), |
| 418 | ), | 423 | ), |
| 419 | const SizedBox(width: 4), | 424 | const SizedBox(width: 4), |
| 420 | - const Text( | ||
| 421 | - '千卡/平均每日', | ||
| 422 | - style: TextStyle( | 425 | + Text( |
| 426 | + context.l10n.activityKcalDailyAverage, | ||
| 427 | + style: const TextStyle( | ||
| 423 | color: ActivityBurnWeekReportView._h1, | 428 | color: ActivityBurnWeekReportView._h1, |
| 424 | fontSize: 12, | 429 | fontSize: 12, |
| 425 | ), | 430 | ), |
| @@ -427,19 +432,56 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -427,19 +432,56 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 427 | ], | 432 | ], |
| 428 | ), | 433 | ), |
| 429 | const SizedBox(height: 2), | 434 | const SizedBox(height: 2), |
| 430 | - Text( | ||
| 431 | - widget.report.hasData ? '比上周少34%' : '比上周-', | ||
| 432 | - style: const TextStyle( | ||
| 433 | - color: ActivityBurnWeekReportView._h2, | ||
| 434 | - fontSize: 12, | ||
| 435 | - ), | 435 | + Row( |
| 436 | + children: [ | ||
| 437 | + Text( | ||
| 438 | + widget.report.hasData | ||
| 439 | + ? context.l10n.activityComparedLastWeek | ||
| 440 | + : context.l10n.activityComparedUnavailable, | ||
| 441 | + style: const TextStyle( | ||
| 442 | + color: ActivityBurnWeekReportView._h2, | ||
| 443 | + fontSize: 12, | ||
| 444 | + ), | ||
| 445 | + ), | ||
| 446 | + const Spacer(), | ||
| 447 | + _TrendLegend( | ||
| 448 | + color: const Color(0xFFFFC0CE), | ||
| 449 | + label: context.l10n.sleepAverage, | ||
| 450 | + ), | ||
| 451 | + const SizedBox(width: 12), | ||
| 452 | + _TrendLegend( | ||
| 453 | + color: const Color(0xFF96E9CB), | ||
| 454 | + label: context.l10n.sleepTarget, | ||
| 455 | + dashed: true, | ||
| 456 | + ), | ||
| 457 | + ], | ||
| 436 | ), | 458 | ), |
| 437 | const SizedBox(height: 8), | 459 | const SizedBox(height: 8), |
| 438 | Expanded( | 460 | Expanded( |
| 439 | child: Stack( | 461 | child: Stack( |
| 440 | alignment: Alignment.center, | 462 | alignment: Alignment.center, |
| 441 | children: [ | 463 | children: [ |
| 442 | - BarChart(_chartData()), | 464 | + Positioned( |
| 465 | + left: 0, | ||
| 466 | + right: 6, | ||
| 467 | + top: 0, | ||
| 468 | + bottom: 42, | ||
| 469 | + child: ActivityBurnTrendLines( | ||
| 470 | + maxY: maxY, | ||
| 471 | + average: widget.report.averageDailyActiveEnergy.toDouble(), | ||
| 472 | + target: widget.report.activeEnergyGoal.toDouble(), | ||
| 473 | + ), | ||
| 474 | + ), | ||
| 475 | + BarChart(_chartData(maxY)), | ||
| 476 | + const Positioned( | ||
| 477 | + left: 0, | ||
| 478 | + right: 6, | ||
| 479 | + bottom: 34, | ||
| 480 | + child: SizedBox( | ||
| 481 | + height: 1, | ||
| 482 | + child: ActivityBurnAxisDashedLine(), | ||
| 483 | + ), | ||
| 484 | + ), | ||
| 443 | if (_touchedOffset != null) | 485 | if (_touchedOffset != null) |
| 444 | Positioned.fill( | 486 | Positioned.fill( |
| 445 | child: ChartSelectionLineOverlay( | 487 | child: ChartSelectionLineOverlay( |
| @@ -450,9 +492,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -450,9 +492,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 450 | ), | 492 | ), |
| 451 | ), | 493 | ), |
| 452 | if (!widget.report.hasData) | 494 | if (!widget.report.hasData) |
| 453 | - const Text( | ||
| 454 | - '等待数据', | ||
| 455 | - style: TextStyle( | 495 | + Text( |
| 496 | + context.l10n.reportWaitingForData, | ||
| 497 | + style: const TextStyle( | ||
| 456 | color: Color(0xFFA1A0A5), | 498 | color: Color(0xFFA1A0A5), |
| 457 | fontSize: 12, | 499 | fontSize: 12, |
| 458 | ), | 500 | ), |
| @@ -465,25 +507,25 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -465,25 +507,25 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 465 | ); | 507 | ); |
| 466 | } | 508 | } |
| 467 | 509 | ||
| 468 | - BarChartData _chartData() { | ||
| 469 | - final hasData = widget.report.hasData; | 510 | + double get _maxY { |
| 470 | final maxValue = widget.report.days | 511 | final maxValue = widget.report.days |
| 471 | .map((day) => day.activeEnergy?.value ?? 0) | 512 | .map((day) => day.activeEnergy?.value ?? 0) |
| 472 | .fold<int>(0, (max, value) => value > max ? value : max); | 513 | .fold<int>(0, (max, value) => value > max ? value : max); |
| 473 | - final maxY = ((maxValue / 100).ceil().clamp(4, 9) * 100).toDouble(); | 514 | + final referenceMax = [ |
| 515 | + maxValue, | ||
| 516 | + widget.report.averageDailyActiveEnergy, | ||
| 517 | + widget.report.activeEnergyGoal, | ||
| 518 | + ].reduce((max, value) => value > max ? value : max); | ||
| 519 | + return ((referenceMax / 100).ceil().clamp(4, 9) * 100).toDouble(); | ||
| 520 | + } | ||
| 474 | 521 | ||
| 522 | + BarChartData _chartData(double maxY) { | ||
| 523 | + final hasData = widget.report.hasData; | ||
| 475 | return BarChartData( | 524 | return BarChartData( |
| 476 | minY: 0, | 525 | minY: 0, |
| 477 | maxY: maxY, | 526 | maxY: maxY, |
| 478 | gridData: FlGridData( | 527 | gridData: FlGridData( |
| 479 | - show: true, | ||
| 480 | - drawVerticalLine: false, | ||
| 481 | - horizontalInterval: 100, | ||
| 482 | - getDrawingHorizontalLine: (_) => const FlLine( | ||
| 483 | - color: Color(0xFFF3F3F3), | ||
| 484 | - strokeWidth: 1, | ||
| 485 | - dashArray: [2, 2], | ||
| 486 | - ), | 528 | + show: false, |
| 487 | ), | 529 | ), |
| 488 | borderData: FlBorderData(show: false), | 530 | borderData: FlBorderData(show: false), |
| 489 | barTouchData: BarTouchData( | 531 | barTouchData: BarTouchData( |
| @@ -519,7 +561,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -519,7 +561,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 519 | getTooltipItem: (group, groupIndex, rod, rodIndex) { | 561 | getTooltipItem: (group, groupIndex, rod, rodIndex) { |
| 520 | final report = widget.report.days[groupIndex]; | 562 | final report = widget.report.days[groupIndex]; |
| 521 | return BarTooltipItem( | 563 | return BarTooltipItem( |
| 522 | - '${report.activeEnergy?.value ?? 0}千卡\n', | 564 | + '${report.activeEnergy?.value ?? 0}${context.l10n.reportUnitKcal}\n', |
| 523 | const TextStyle( | 565 | const TextStyle( |
| 524 | color: ActivityBurnWeekReportView._active, | 566 | color: ActivityBurnWeekReportView._active, |
| 525 | fontSize: 16, | 567 | fontSize: 16, |
| @@ -545,16 +587,19 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -545,16 +587,19 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 545 | rightTitles: AxisTitles( | 587 | rightTitles: AxisTitles( |
| 546 | sideTitles: SideTitles( | 588 | sideTitles: SideTitles( |
| 547 | showTitles: true, | 589 | showTitles: true, |
| 548 | - reservedSize: 28, | 590 | + reservedSize: 36, |
| 549 | interval: 100, | 591 | interval: 100, |
| 550 | getTitlesWidget: (value, meta) { | 592 | getTitlesWidget: (value, meta) { |
| 551 | return Transform.translate( | 593 | return Transform.translate( |
| 552 | - offset: const Offset(0, -5), | ||
| 553 | - child: Text( | ||
| 554 | - value.toInt().toString(), | ||
| 555 | - style: const TextStyle( | ||
| 556 | - color: ActivityBurnWeekReportView._h3, | ||
| 557 | - fontSize: 10, | 594 | + offset: const Offset(0, -8), |
| 595 | + child: Padding( | ||
| 596 | + padding: const EdgeInsets.only(left: 8), | ||
| 597 | + child: Text( | ||
| 598 | + value.toInt().toString(), | ||
| 599 | + style: const TextStyle( | ||
| 600 | + color: ActivityBurnWeekReportView._h3, | ||
| 601 | + fontSize: 10, | ||
| 602 | + ), | ||
| 558 | ), | 603 | ), |
| 559 | ), | 604 | ), |
| 560 | ); | 605 | ); |
| @@ -564,16 +609,15 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -564,16 +609,15 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 564 | bottomTitles: AxisTitles( | 609 | bottomTitles: AxisTitles( |
| 565 | sideTitles: SideTitles( | 610 | sideTitles: SideTitles( |
| 566 | showTitles: true, | 611 | showTitles: true, |
| 567 | - reservedSize: 32, | 612 | + reservedSize: 42, |
| 568 | getTitlesWidget: (value, meta) { | 613 | getTitlesWidget: (value, meta) { |
| 569 | final index = value.toInt(); | 614 | final index = value.toInt(); |
| 570 | if (index < 0 || index >= widget.report.days.length) { | 615 | if (index < 0 || index >= widget.report.days.length) { |
| 571 | return const SizedBox.shrink(); | 616 | return const SizedBox.shrink(); |
| 572 | } | 617 | } |
| 573 | final day = widget.report.days[index]; | 618 | final day = widget.report.days[index]; |
| 574 | - const weekdays = ['一', '二', '三', '四', '五', '六', '日']; | ||
| 575 | return Padding( | 619 | return Padding( |
| 576 | - padding: const EdgeInsets.only(top: 4), | 620 | + padding: const EdgeInsets.only(top: 14), |
| 577 | child: Column( | 621 | child: Column( |
| 578 | children: [ | 622 | children: [ |
| 579 | Text( | 623 | Text( |
| @@ -584,7 +628,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -584,7 +628,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 584 | ), | 628 | ), |
| 585 | ), | 629 | ), |
| 586 | Text( | 630 | Text( |
| 587 | - weekdays[day.date.weekday - 1], | 631 | + reportWeekdayLabel(day.date.weekday), |
| 588 | style: const TextStyle( | 632 | style: const TextStyle( |
| 589 | color: ActivityBurnWeekReportView._h3, | 633 | color: ActivityBurnWeekReportView._h3, |
| 590 | fontSize: 10, | 634 | fontSize: 10, |
| @@ -597,22 +641,14 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -597,22 +641,14 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 597 | ), | 641 | ), |
| 598 | ), | 642 | ), |
| 599 | ), | 643 | ), |
| 600 | - extraLinesData: ExtraLinesData( | ||
| 601 | - horizontalLines: [ | ||
| 602 | - HorizontalLine( | ||
| 603 | - y: 320, | ||
| 604 | - color: ActivityBurnWeekReportView._exercise, | ||
| 605 | - strokeWidth: 1, | ||
| 606 | - dashArray: [2, 2], | ||
| 607 | - ), | ||
| 608 | - ], | ||
| 609 | - ), | 644 | + baselineY: 0, |
| 610 | barGroups: [ | 645 | barGroups: [ |
| 611 | for (var i = 0; i < widget.report.days.length; i++) | 646 | for (var i = 0; i < widget.report.days.length; i++) |
| 612 | BarChartGroupData( | 647 | BarChartGroupData( |
| 613 | x: i, | 648 | x: i, |
| 614 | barRods: [ | 649 | barRods: [ |
| 615 | BarChartRodData( | 650 | BarChartRodData( |
| 651 | + fromY: 0, | ||
| 616 | toY: | 652 | toY: |
| 617 | (widget.report.days[i].activeEnergy?.value ?? 0).toDouble(), | 653 | (widget.report.days[i].activeEnergy?.value ?? 0).toDouble(), |
| 618 | width: 16, | 654 | width: 16, |
| @@ -628,7 +664,50 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -628,7 +664,50 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 628 | } | 664 | } |
| 629 | 665 | ||
| 630 | String _tooltipDate(DateTime date) { | 666 | String _tooltipDate(DateTime date) { |
| 631 | - const weekdays = ['一', '二', '三', '四', '五', '六', '日']; | ||
| 632 | - return '${DateFormat('M月d日').format(date)} 星期${weekdays[date.weekday - 1]}'; | 667 | + return l10n.reportDateWithWeekday( |
| 668 | + reportMonthDay(date), | ||
| 669 | + reportWeekdayLabel(date.weekday), | ||
| 670 | + ); | ||
| 671 | + } | ||
| 672 | +} | ||
| 673 | + | ||
| 674 | +class _TrendLegend extends StatelessWidget { | ||
| 675 | + const _TrendLegend({ | ||
| 676 | + required this.color, | ||
| 677 | + required this.label, | ||
| 678 | + this.dashed = false, | ||
| 679 | + }); | ||
| 680 | + | ||
| 681 | + final Color color; | ||
| 682 | + final String label; | ||
| 683 | + final bool dashed; | ||
| 684 | + | ||
| 685 | + @override | ||
| 686 | + Widget build(BuildContext context) { | ||
| 687 | + return Row( | ||
| 688 | + mainAxisSize: MainAxisSize.min, | ||
| 689 | + children: [ | ||
| 690 | + SizedBox( | ||
| 691 | + width: 19, | ||
| 692 | + child: Row( | ||
| 693 | + mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||
| 694 | + children: dashed | ||
| 695 | + ? List.generate( | ||
| 696 | + 5, | ||
| 697 | + (_) => Container(width: 2, height: 2, color: color), | ||
| 698 | + ) | ||
| 699 | + : [Container(width: 19, height: 2, color: color)], | ||
| 700 | + ), | ||
| 701 | + ), | ||
| 702 | + const SizedBox(width: 4), | ||
| 703 | + Text( | ||
| 704 | + label, | ||
| 705 | + style: const TextStyle( | ||
| 706 | + color: ActivityBurnWeekReportView._h2, | ||
| 707 | + fontSize: 12, | ||
| 708 | + ), | ||
| 709 | + ), | ||
| 710 | + ], | ||
| 711 | + ); | ||
| 633 | } | 712 | } |
| 634 | } | 713 | } |
| 1 | +import 'package:doublefeel_flutter/app/routes/app_pages.dart'; | ||
| 1 | import 'package:doublefeel_flutter/data/local/local_storage.dart'; | 2 | import 'package:doublefeel_flutter/data/local/local_storage.dart'; |
| 2 | import 'package:flutter/material.dart'; | 3 | import 'package:flutter/material.dart'; |
| 3 | -import 'package:get/get.dart'; | ||
| 4 | 4 | ||
| 5 | import '../widgets/app_review_prompt_dialog.dart'; | 5 | import '../widgets/app_review_prompt_dialog.dart'; |
| 6 | 6 | ||
| 7 | -class AppReviewPromptController extends GetxController { | ||
| 8 | - AppReviewPromptController({LocalStorage? storage}) | ||
| 9 | - : _storage = storage ?? Get.find<LocalStorage>(); | 7 | +class AppReviewPromptLogic { |
| 8 | + AppReviewPromptLogic({required LocalStorage storage}) : _storage = storage; | ||
| 10 | 9 | ||
| 11 | final LocalStorage _storage; | 10 | final LocalStorage _storage; |
| 12 | 11 | ||
| @@ -15,39 +14,49 @@ class AppReviewPromptController extends GetxController { | @@ -15,39 +14,49 @@ class AppReviewPromptController extends GetxController { | ||
| 15 | static const _likedCooldown = Duration(days: 90); | 14 | static const _likedCooldown = Duration(days: 90); |
| 16 | static const _feedbackCooldown = Duration(days: 30); | 15 | static const _feedbackCooldown = Duration(days: 30); |
| 17 | 16 | ||
| 18 | - Future<void> maybeShowPrompt() async { | ||
| 19 | - final context = Get.context; | ||
| 20 | - if (_isShowing || context == null || !context.mounted) return; | 17 | + Future<void> maybeShowPrompt(BuildContext context) async { |
| 18 | + if (_isShowing || !context.mounted) return; | ||
| 21 | 19 | ||
| 22 | final now = DateTime.now(); | 20 | final now = DateTime.now(); |
| 23 | final nextShowAt = _storage.appReviewPromptNextShowAt; | 21 | final nextShowAt = _storage.appReviewPromptNextShowAt; |
| 24 | if (nextShowAt != null && now.isBefore(nextShowAt)) return; | 22 | if (nextShowAt != null && now.isBefore(nextShowAt)) return; |
| 25 | 23 | ||
| 26 | _isShowing = true; | 24 | _isShowing = true; |
| 27 | - final result = await Get.dialog<AppReviewPromptResult>( | ||
| 28 | - const AppReviewPromptDialog(), | ||
| 29 | - barrierColor: Colors.black.withValues(alpha: 0.7), | ||
| 30 | - barrierDismissible: true, | ||
| 31 | - ); | 25 | + try { |
| 26 | + final result = await showDialog<AppReviewPromptResult>( | ||
| 27 | + context: context, | ||
| 28 | + barrierColor: Colors.black.withValues(alpha: 0.7), | ||
| 29 | + barrierDismissible: false, | ||
| 30 | + builder: (_) => const AppReviewPromptDialog(), | ||
| 31 | + ); | ||
| 32 | 32 | ||
| 33 | - switch (result) { | ||
| 34 | - case AppReviewPromptResult.liked: | ||
| 35 | - await _coolDown(_likedCooldown); | ||
| 36 | - case AppReviewPromptResult.feedback: | ||
| 37 | - await _coolDown(_feedbackCooldown); | ||
| 38 | - await _showFeedbackPrompt(); | ||
| 39 | - case null: | ||
| 40 | - await _coolDown(_feedbackCooldown); | 33 | + switch (result) { |
| 34 | + case AppReviewPromptResult.liked: | ||
| 35 | + await _coolDown(_likedCooldown); | ||
| 36 | + case AppReviewPromptResult.feedback: | ||
| 37 | + await _coolDown(_feedbackCooldown); | ||
| 38 | + if (context.mounted) { | ||
| 39 | + await _showFeedbackPrompt(context); | ||
| 40 | + } | ||
| 41 | + case null: | ||
| 42 | + await _coolDown(_feedbackCooldown); | ||
| 43 | + } | ||
| 44 | + } finally { | ||
| 45 | + _isShowing = false; | ||
| 41 | } | 46 | } |
| 42 | - _isShowing = false; | ||
| 43 | } | 47 | } |
| 44 | 48 | ||
| 45 | - Future<void> _showFeedbackPrompt() async { | ||
| 46 | - await Get.dialog<AppReviewPromptResult>( | ||
| 47 | - const AppReviewFeedbackDialog(), | 49 | + Future<void> _showFeedbackPrompt(BuildContext context) async { |
| 50 | + final result = await showDialog<AppReviewPromptResult>( | ||
| 51 | + context: context, | ||
| 48 | barrierColor: Colors.black.withValues(alpha: 0.7), | 52 | barrierColor: Colors.black.withValues(alpha: 0.7), |
| 49 | - barrierDismissible: true, | 53 | + barrierDismissible: false, |
| 54 | + builder: (_) => const AppReviewFeedbackDialog(), | ||
| 50 | ); | 55 | ); |
| 56 | + | ||
| 57 | + if (result == AppReviewPromptResult.feedback && context.mounted) { | ||
| 58 | + await Navigator.of(context).pushNamed(Routes.SUBMIT_FEEDBACK); | ||
| 59 | + } | ||
| 51 | } | 60 | } |
| 52 | 61 | ||
| 53 | Future<void> _coolDown(Duration duration) { | 62 | Future<void> _coolDown(Duration duration) { |
| 1 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 1 | import 'package:flutter/material.dart'; | 2 | import 'package:flutter/material.dart'; |
| 2 | -import 'package:get/get.dart'; | ||
| 3 | 3 | ||
| 4 | enum AppReviewPromptResult { | 4 | enum AppReviewPromptResult { |
| 5 | liked, | 5 | liked, |
| @@ -13,12 +13,13 @@ class AppReviewPromptDialog extends StatelessWidget { | @@ -13,12 +13,13 @@ class AppReviewPromptDialog extends StatelessWidget { | ||
| 13 | Widget build(BuildContext context) { | 13 | Widget build(BuildContext context) { |
| 14 | return _ReviewPromptCard( | 14 | return _ReviewPromptCard( |
| 15 | height: 380, | 15 | height: 380, |
| 16 | - title: '喜欢 DoubleFeel 吗?', | ||
| 17 | - message: '嗨~想知道 DoubleFeel 是否正在帮助你\n更了解自己的压力与睡眠状态 💜', | ||
| 18 | - primaryText: '😍 很喜欢', | ||
| 19 | - secondaryText: '我有意见', | ||
| 20 | - onPrimaryTap: () => Get.back(result: AppReviewPromptResult.liked), | ||
| 21 | - onSecondaryTap: () => Get.back(result: AppReviewPromptResult.feedback), | 16 | + title: context.l10n.appReviewPromptTitle, |
| 17 | + message: context.l10n.appReviewPromptMessage, | ||
| 18 | + primaryText: context.l10n.appReviewPromptLikeAction, | ||
| 19 | + secondaryText: context.l10n.appReviewPromptFeedbackAction, | ||
| 20 | + onPrimaryTap: () => Navigator.of(context).pop(AppReviewPromptResult.liked), | ||
| 21 | + onSecondaryTap: () => | ||
| 22 | + Navigator.of(context).pop(AppReviewPromptResult.feedback), | ||
| 22 | ); | 23 | ); |
| 23 | } | 24 | } |
| 24 | } | 25 | } |
| @@ -30,12 +31,13 @@ class AppReviewFeedbackDialog extends StatelessWidget { | @@ -30,12 +31,13 @@ class AppReviewFeedbackDialog extends StatelessWidget { | ||
| 30 | Widget build(BuildContext context) { | 31 | Widget build(BuildContext context) { |
| 31 | return _ReviewPromptCard( | 32 | return _ReviewPromptCard( |
| 32 | height: 425, | 33 | height: 425, |
| 33 | - title: '很抱歉 DoubleFeel 没有带\n给你好的体验', | ||
| 34 | - message: '愿意告诉我们遇到了什么问题吗?\n你的反馈可以帮助我们持续改进压力与健康体验 💜', | ||
| 35 | - primaryText: '发送反馈', | ||
| 36 | - secondaryText: '稍后再说', | ||
| 37 | - onPrimaryTap: () => Get.back(result: AppReviewPromptResult.feedback), | ||
| 38 | - onSecondaryTap: () => Get.back(), | 34 | + title: context.l10n.appReviewFeedbackTitle, |
| 35 | + message: context.l10n.appReviewFeedbackMessage, | ||
| 36 | + primaryText: context.l10n.appReviewFeedbackSendAction, | ||
| 37 | + secondaryText: context.l10n.appReviewFeedbackLaterAction, | ||
| 38 | + onPrimaryTap: () => | ||
| 39 | + Navigator.of(context).pop(AppReviewPromptResult.feedback), | ||
| 40 | + onSecondaryTap: () => Navigator.of(context).pop(), | ||
| 39 | ); | 41 | ); |
| 40 | } | 42 | } |
| 41 | } | 43 | } |
| @@ -68,102 +70,105 @@ class _ReviewPromptCard extends StatelessWidget { | @@ -68,102 +70,105 @@ class _ReviewPromptCard extends StatelessWidget { | ||
| 68 | @override | 70 | @override |
| 69 | Widget build(BuildContext context) { | 71 | Widget build(BuildContext context) { |
| 70 | final isFeedback = height > 400; | 72 | final isFeedback = height > 400; |
| 71 | - return Center( | ||
| 72 | - child: Material( | ||
| 73 | - color: Colors.transparent, | ||
| 74 | - child: Container( | ||
| 75 | - width: _cardWidth, | ||
| 76 | - height: height, | ||
| 77 | - clipBehavior: Clip.antiAlias, | ||
| 78 | - decoration: BoxDecoration( | ||
| 79 | - borderRadius: BorderRadius.circular(16), | ||
| 80 | - gradient: const LinearGradient( | ||
| 81 | - begin: Alignment.topCenter, | ||
| 82 | - end: Alignment.bottomCenter, | ||
| 83 | - colors: [ | ||
| 84 | - Color(0xFFE6DBFF), | ||
| 85 | - Color(0xFFEDE4FF), | ||
| 86 | - Color(0xFFEEE6FF), | ||
| 87 | - Color(0xFFF9F6FF), | ||
| 88 | - ], | ||
| 89 | - stops: [0, 0.25, 0.75, 1], | 73 | + return PopScope( |
| 74 | + canPop: false, | ||
| 75 | + child: Center( | ||
| 76 | + child: Material( | ||
| 77 | + color: Colors.transparent, | ||
| 78 | + child: Container( | ||
| 79 | + width: _cardWidth, | ||
| 80 | + height: height, | ||
| 81 | + clipBehavior: Clip.antiAlias, | ||
| 82 | + decoration: BoxDecoration( | ||
| 83 | + borderRadius: BorderRadius.circular(16), | ||
| 84 | + gradient: const LinearGradient( | ||
| 85 | + begin: Alignment.topCenter, | ||
| 86 | + end: Alignment.bottomCenter, | ||
| 87 | + colors: [ | ||
| 88 | + Color(0xFFE6DBFF), | ||
| 89 | + Color(0xFFEDE4FF), | ||
| 90 | + Color(0xFFEEE6FF), | ||
| 91 | + Color(0xFFF9F6FF), | ||
| 92 | + ], | ||
| 93 | + stops: [0, 0.25, 0.75, 1], | ||
| 94 | + ), | ||
| 90 | ), | 95 | ), |
| 91 | - ), | ||
| 92 | - child: Stack( | ||
| 93 | - children: [ | ||
| 94 | - const Positioned( | ||
| 95 | - top: 0, | ||
| 96 | - left: 0, | ||
| 97 | - right: 0, | ||
| 98 | - height: _illustrationHeight, | ||
| 99 | - child: ColoredBox( | ||
| 100 | - color: Color(0xFFF09AB8), | ||
| 101 | - child: Center( | ||
| 102 | - child: Text( | ||
| 103 | - '插图占位', | ||
| 104 | - style: TextStyle( | ||
| 105 | - color: Colors.red, | ||
| 106 | - fontSize: 14, | ||
| 107 | - fontWeight: FontWeight.w400, | 96 | + child: Stack( |
| 97 | + children: [ | ||
| 98 | + Positioned( | ||
| 99 | + top: 0, | ||
| 100 | + left: 0, | ||
| 101 | + right: 0, | ||
| 102 | + height: _illustrationHeight, | ||
| 103 | + child: ColoredBox( | ||
| 104 | + color: const Color(0xFFF09AB8), | ||
| 105 | + child: Center( | ||
| 106 | + child: Text( | ||
| 107 | + context.l10n.appReviewIllustrationPlaceholder, | ||
| 108 | + style: const TextStyle( | ||
| 109 | + color: Colors.red, | ||
| 110 | + fontSize: 14, | ||
| 111 | + fontWeight: FontWeight.w400, | ||
| 112 | + ), | ||
| 108 | ), | 113 | ), |
| 109 | ), | 114 | ), |
| 110 | ), | 115 | ), |
| 111 | ), | 116 | ), |
| 112 | - ), | ||
| 113 | - Positioned( | ||
| 114 | - top: isFeedback ? 162 : 163, | ||
| 115 | - left: 38, | ||
| 116 | - right: 38, | ||
| 117 | - child: Text( | ||
| 118 | - title, | ||
| 119 | - textAlign: TextAlign.center, | ||
| 120 | - style: const TextStyle( | ||
| 121 | - color: _h1, | ||
| 122 | - fontSize: 18, | ||
| 123 | - height: 1.35, | ||
| 124 | - fontWeight: FontWeight.w600, | 117 | + Positioned( |
| 118 | + top: isFeedback ? 162 : 163, | ||
| 119 | + left: 38, | ||
| 120 | + right: 38, | ||
| 121 | + child: Text( | ||
| 122 | + title, | ||
| 123 | + textAlign: TextAlign.center, | ||
| 124 | + style: const TextStyle( | ||
| 125 | + color: _h1, | ||
| 126 | + fontSize: 18, | ||
| 127 | + height: 1.35, | ||
| 128 | + fontWeight: FontWeight.w600, | ||
| 129 | + ), | ||
| 125 | ), | 130 | ), |
| 126 | ), | 131 | ), |
| 127 | - ), | ||
| 128 | - Positioned( | ||
| 129 | - top: isFeedback ? 222 : 195, | ||
| 130 | - left: 20, | ||
| 131 | - right: 20, | ||
| 132 | - child: Text( | ||
| 133 | - message, | ||
| 134 | - textAlign: TextAlign.center, | ||
| 135 | - style: const TextStyle( | ||
| 136 | - color: _h2, | ||
| 137 | - fontSize: 14, | ||
| 138 | - height: 1.35, | ||
| 139 | - fontWeight: FontWeight.w400, | 132 | + Positioned( |
| 133 | + top: isFeedback ? 222 : 195, | ||
| 134 | + left: 20, | ||
| 135 | + right: 20, | ||
| 136 | + child: Text( | ||
| 137 | + message, | ||
| 138 | + textAlign: TextAlign.center, | ||
| 139 | + style: const TextStyle( | ||
| 140 | + color: _h2, | ||
| 141 | + fontSize: 14, | ||
| 142 | + height: 1.35, | ||
| 143 | + fontWeight: FontWeight.w400, | ||
| 144 | + ), | ||
| 140 | ), | 145 | ), |
| 141 | ), | 146 | ), |
| 142 | - ), | ||
| 143 | - Positioned( | ||
| 144 | - top: isFeedback ? 305 : 260, | ||
| 145 | - left: 40, | ||
| 146 | - right: 40, | ||
| 147 | - child: _PromptButton( | ||
| 148 | - text: primaryText, | ||
| 149 | - textColor: Colors.white, | ||
| 150 | - fontWeight: FontWeight.w600, | ||
| 151 | - backgroundColor: _brandColor, | ||
| 152 | - onTap: onPrimaryTap, | 147 | + Positioned( |
| 148 | + top: isFeedback ? 305 : 260, | ||
| 149 | + left: 40, | ||
| 150 | + right: 40, | ||
| 151 | + child: _PromptButton( | ||
| 152 | + text: primaryText, | ||
| 153 | + textColor: Colors.white, | ||
| 154 | + fontWeight: FontWeight.w600, | ||
| 155 | + backgroundColor: _brandColor, | ||
| 156 | + onTap: onPrimaryTap, | ||
| 157 | + ), | ||
| 153 | ), | 158 | ), |
| 154 | - ), | ||
| 155 | - Positioned( | ||
| 156 | - top: isFeedback ? 357 : 312, | ||
| 157 | - left: 40, | ||
| 158 | - right: 40, | ||
| 159 | - child: _PromptButton( | ||
| 160 | - text: secondaryText, | ||
| 161 | - textColor: _h2, | ||
| 162 | - fontWeight: FontWeight.w400, | ||
| 163 | - onTap: onSecondaryTap, | 159 | + Positioned( |
| 160 | + top: isFeedback ? 357 : 312, | ||
| 161 | + left: 40, | ||
| 162 | + right: 40, | ||
| 163 | + child: _PromptButton( | ||
| 164 | + text: secondaryText, | ||
| 165 | + textColor: _h2, | ||
| 166 | + fontWeight: FontWeight.w400, | ||
| 167 | + onTap: onSecondaryTap, | ||
| 168 | + ), | ||
| 164 | ), | 169 | ), |
| 165 | - ), | ||
| 166 | - ], | 170 | + ], |
| 171 | + ), | ||
| 167 | ), | 172 | ), |
| 168 | ), | 173 | ), |
| 169 | ), | 174 | ), |
| 1 | +import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; | ||
| 1 | import 'package:get/get.dart'; | 2 | import 'package:get/get.dart'; |
| 2 | 3 | ||
| 3 | import '../controllers/add_friend_controller.dart'; | 4 | import '../controllers/add_friend_controller.dart'; |
| @@ -5,6 +6,6 @@ import '../controllers/add_friend_controller.dart'; | @@ -5,6 +6,6 @@ import '../controllers/add_friend_controller.dart'; | ||
| 5 | class AddFriendBinding extends Bindings { | 6 | class AddFriendBinding extends Bindings { |
| 6 | @override | 7 | @override |
| 7 | void dependencies() { | 8 | void dependencies() { |
| 8 | - Get.put(AddFriendController()); | 9 | + Get.put(AddFriendController(Get.find<FriendApi>())); |
| 9 | } | 10 | } |
| 10 | } | 11 | } |
| @@ -22,7 +22,7 @@ class FriendHomeBinding extends Bindings { | @@ -22,7 +22,7 @@ class FriendHomeBinding extends Bindings { | ||
| 22 | Get.find<AppleHealthUploadTool>(), | 22 | Get.find<AppleHealthUploadTool>(), |
| 23 | friendInfo: args, | 23 | friendInfo: args, |
| 24 | ), | 24 | ), |
| 25 | - tag: 'friend_${args.userId}', // 动态 tag,支持多个好友同时在路由栈中 | 25 | + tag: 'friend_${args.friendUserId}', // 动态 tag,支持多个好友同时在路由栈中 |
| 26 | ); | 26 | ); |
| 27 | } | 27 | } |
| 28 | } | 28 | } |
| 1 | +import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'; | ||
| 1 | import 'package:get/get.dart'; | 2 | import 'package:get/get.dart'; |
| 2 | 3 | ||
| 3 | import '../controllers/friend_trend_controller.dart'; | 4 | import '../controllers/friend_trend_controller.dart'; |
| @@ -6,15 +7,18 @@ class FriendTrendBinding extends Bindings { | @@ -6,15 +7,18 @@ class FriendTrendBinding extends Bindings { | ||
| 6 | @override | 7 | @override |
| 7 | void dependencies() { | 8 | void dependencies() { |
| 8 | final arguments = Get.arguments; | 9 | final arguments = Get.arguments; |
| 9 | - if (arguments is! FriendTrendArguments) { | ||
| 10 | - throw ArgumentError('FriendTrendView requires FriendTrendArguments'); | ||
| 11 | - } | 10 | + final trendArguments = arguments is FriendTrendArguments |
| 11 | + ? arguments | ||
| 12 | + : FriendTrendArguments( | ||
| 13 | + friendItem: arguments is FriendItem ? arguments : null, | ||
| 14 | + ); | ||
| 12 | 15 | ||
| 13 | Get.lazyPut<FriendTrendController>( | 16 | Get.lazyPut<FriendTrendController>( |
| 14 | () => FriendTrendController( | 17 | () => FriendTrendController( |
| 15 | - userId: arguments.userId, | ||
| 16 | - friendName: arguments.name, | ||
| 17 | - avatarUrl: arguments.avatarUrl, | 18 | + friendItem: trendArguments.friendItem, |
| 19 | + initialDate: trendArguments.initialDate, | ||
| 20 | + initialPeriod: trendArguments.initialPeriod, | ||
| 21 | + initialTypeIndex: trendArguments.initialTypeIndex, | ||
| 18 | ), | 22 | ), |
| 19 | ); | 23 | ); |
| 20 | } | 24 | } |
| 1 | +import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/core/result/app_result.dart'; | ||
| 1 | import 'package:flutter/services.dart'; | 3 | import 'package:flutter/services.dart'; |
| 2 | import 'package:flutter/widgets.dart'; | 4 | import 'package:flutter/widgets.dart'; |
| 3 | import 'package:get/get.dart'; | 5 | import 'package:get/get.dart'; |
| 4 | 6 | ||
| 5 | -import '../models/add_friend_prompt_type.dart'; | ||
| 6 | - | ||
| 7 | class AddFriendController extends GetxController { | 7 | class AddFriendController extends GetxController { |
| 8 | + AddFriendController(this._friendApi); | ||
| 9 | + | ||
| 10 | + final FriendApi _friendApi; | ||
| 11 | + | ||
| 8 | final TextEditingController friendCodeController = TextEditingController(); | 12 | final TextEditingController friendCodeController = TextEditingController(); |
| 9 | final FocusNode friendCodeFocusNode = FocusNode(); | 13 | final FocusNode friendCodeFocusNode = FocusNode(); |
| 10 | 14 | ||
| @@ -33,16 +37,16 @@ class AddFriendController extends GetxController { | @@ -33,16 +37,16 @@ class AddFriendController extends GetxController { | ||
| 33 | Clipboard.setData(ClipboardData(text: myInviteCode.value)); | 37 | Clipboard.setData(ClipboardData(text: myInviteCode.value)); |
| 34 | } | 38 | } |
| 35 | 39 | ||
| 36 | - Future<AddFriendPromptType?> submitFriendCode() async { | ||
| 37 | - if (!canSubmit || isSubmitting.value) return null; | 40 | + Future<bool> submitFriendCode() async { |
| 41 | + if (!canSubmit || isSubmitting.value) return false; | ||
| 38 | isSubmitting.value = true; | 42 | isSubmitting.value = true; |
| 39 | try { | 43 | try { |
| 40 | - final code = friendCodeInput.value; | ||
| 41 | - if (code == myInviteCode.value) { | ||
| 42 | - return AddFriendPromptType.selfId; | 44 | + final result = await _friendApi.addFriend(friendCodeInput.value); |
| 45 | + if (result is AppSuccess<void>) { | ||
| 46 | + Get.back(result: true); | ||
| 47 | + return true; | ||
| 43 | } | 48 | } |
| 44 | - | ||
| 45 | - return AddFriendPromptType.idNotFound; | 49 | + return false; |
| 46 | } finally { | 50 | } finally { |
| 47 | isSubmitting.value = false; | 51 | isSubmitting.value = false; |
| 48 | } | 52 | } |
| 1 | +import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'; | ||
| 1 | import 'package:get/get.dart'; | 2 | import 'package:get/get.dart'; |
| 2 | 3 | ||
| 4 | +import '../../health_trend/controllers/health_trend_control.dart'; | ||
| 3 | import '../../report_common/models/health_report_query.dart'; | 5 | import '../../report_common/models/health_report_query.dart'; |
| 6 | +import '../../report_common/models/report_period.dart'; | ||
| 4 | 7 | ||
| 5 | class FriendTrendArguments { | 8 | class FriendTrendArguments { |
| 6 | const FriendTrendArguments({ | 9 | const FriendTrendArguments({ |
| 7 | - required this.userId, | ||
| 8 | - required this.name, | ||
| 9 | - this.avatarUrl, | 10 | + required this.friendItem, |
| 11 | + this.initialDate, | ||
| 12 | + this.initialPeriod, | ||
| 13 | + this.initialTypeIndex = 0, | ||
| 10 | }); | 14 | }); |
| 11 | 15 | ||
| 12 | - final int userId; | ||
| 13 | - final String name; | ||
| 14 | - final String? avatarUrl; | 16 | + final FriendItem? friendItem; |
| 17 | + final DateTime? initialDate; | ||
| 18 | + final ReportPeriod? initialPeriod; | ||
| 19 | + final int initialTypeIndex; | ||
| 15 | } | 20 | } |
| 16 | 21 | ||
| 17 | -class FriendTrendController extends GetxController { | 22 | +class FriendTrendController extends GetxController with HealthTrendControl { |
| 18 | FriendTrendController({ | 23 | FriendTrendController({ |
| 19 | - required this.userId, | ||
| 20 | - required this.friendName, | ||
| 21 | - this.avatarUrl, | ||
| 22 | - }); | ||
| 23 | - | ||
| 24 | - final int userId; | ||
| 25 | - final String friendName; | ||
| 26 | - final String? avatarUrl; | ||
| 27 | - final selectedTypeIndex = 0.obs; | 24 | + required this.friendItem, |
| 25 | + DateTime? initialDate, | ||
| 26 | + ReportPeriod? initialPeriod, | ||
| 27 | + int initialTypeIndex = 0, | ||
| 28 | + }) { | ||
| 29 | + changeType(initialTypeIndex); | ||
| 30 | + if (initialPeriod != null) changePeriod(initialPeriod); | ||
| 31 | + if (initialDate != null) changeDate(initialDate); | ||
| 32 | + } | ||
| 28 | 33 | ||
| 29 | - HealthReportQuery get query => HealthReportQuery(targetUserId: userId); | 34 | + final FriendItem? friendItem; |
| 30 | 35 | ||
| 31 | - void changeType(int index) { | ||
| 32 | - if (index == selectedTypeIndex.value) return; | ||
| 33 | - selectedTypeIndex.value = index; | ||
| 34 | - } | 36 | + HealthReportQuery get query => HealthReportQuery( |
| 37 | + targetUserId: friendItem?.id, | ||
| 38 | + period: selectedPeriod.value, | ||
| 39 | + date: selectedDate.value, | ||
| 40 | + ); | ||
| 35 | } | 41 | } |
| @@ -4,64 +4,125 @@ import 'package:doublefeel_flutter/app/actions/dialog_action.dart'; | @@ -4,64 +4,125 @@ import 'package:doublefeel_flutter/app/actions/dialog_action.dart'; | ||
| 4 | import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart'; | 4 | import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart'; |
| 5 | import 'package:doublefeel_flutter/app/models/input_dialog_meta_data.dart'; | 5 | import 'package:doublefeel_flutter/app/models/input_dialog_meta_data.dart'; |
| 6 | import 'package:doublefeel_flutter/app/utils/dialog_utils.dart'; | 6 | import 'package:doublefeel_flutter/app/utils/dialog_utils.dart'; |
| 7 | +import 'package:doublefeel_flutter/core/error/app_error.dart'; | ||
| 7 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; | 8 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; |
| 9 | +import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; | ||
| 10 | +import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | ||
| 11 | +import 'package:doublefeel_flutter/core/util/app_toast.dart'; | ||
| 12 | +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; | ||
| 13 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 14 | +import 'package:doublefeel_flutter/pigeon/platform_api.g.dart'; | ||
| 8 | import 'package:get/get.dart'; | 15 | import 'package:get/get.dart'; |
| 9 | 16 | ||
| 17 | +import '../data/friends_repository.dart'; | ||
| 18 | +import '../data/self_health_repository.dart'; | ||
| 19 | +import '../models/friend_health_data.dart'; | ||
| 20 | + | ||
| 10 | class FriendsController extends GetxController { | 21 | class FriendsController extends GetxController { |
| 22 | + FriendsController({ | ||
| 23 | + FriendsRepository? repository, | ||
| 24 | + SelfHealthRepository? selfHealthRepository, | ||
| 25 | + Future<void> Function(WatchAppOtherInfo info)? updateWatchOtherUserInfo, | ||
| 26 | + }) : _repository = | ||
| 27 | + repository ?? FriendsRepositoryImpl(Get.find<FriendApi>()), | ||
| 28 | + _selfHealthRepository = selfHealthRepository ?? | ||
| 29 | + SelfHealthRepositoryImpl(Get.find<HealthApi>()), | ||
| 30 | + _updateWatchOtherUserInfo = updateWatchOtherUserInfo ?? | ||
| 31 | + ((info) => PlatformHostApi().updateWatchOtherUserInfo(info)); | ||
| 32 | + | ||
| 11 | static const maxFriends = 10; | 33 | static const maxFriends = 10; |
| 12 | 34 | ||
| 35 | + final FriendsRepository _repository; | ||
| 36 | + final SelfHealthRepository _selfHealthRepository; | ||
| 37 | + final Future<void> Function(WatchAppOtherInfo info) _updateWatchOtherUserInfo; | ||
| 13 | final friends = <FriendHealthData>[].obs; | 38 | final friends = <FriendHealthData>[].obs; |
| 14 | final isLoading = false.obs; | 39 | final isLoading = false.obs; |
| 40 | + final selfHealthData = Rxn<V2HealthData>(); | ||
| 41 | + final selfStressScore = Rxn<V2StressScore>(); | ||
| 42 | + final selfHealthUpdatedAt = Rxn<DateTime>(); | ||
| 43 | + final isSelfHealthLoading = false.obs; | ||
| 44 | + Future<void>? _friendsRequest; | ||
| 45 | + Future<void>? _selfHealthRequest; | ||
| 15 | 46 | ||
| 16 | bool get isFull => friends.length >= maxFriends; | 47 | bool get isFull => friends.length >= maxFriends; |
| 17 | 48 | ||
| 18 | @override | 49 | @override |
| 19 | void onInit() { | 50 | void onInit() { |
| 20 | super.onInit(); | 51 | super.onInit(); |
| 21 | - unawaited(loadFriends()); | 52 | + unawaited(refreshData()); |
| 53 | + } | ||
| 54 | + | ||
| 55 | + Future<void> refreshData() { | ||
| 56 | + return Future.wait([loadFriends(), loadSelfHealth()]); | ||
| 22 | } | 57 | } |
| 23 | 58 | ||
| 24 | - Future<void> loadFriends() async { | ||
| 25 | - if (isLoading.value) return; | 59 | + Future<void> loadFriends() { |
| 60 | + return _friendsRequest ??= _loadFriends(); | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + Future<void> _loadFriends() async { | ||
| 26 | isLoading.value = true; | 64 | isLoading.value = true; |
| 27 | try { | 65 | try { |
| 28 | - // TODO: Replace mock data with the friend list API when it is available. | ||
| 29 | - await Future<void>.delayed(const Duration(milliseconds: 150)); | ||
| 30 | - friends.assignAll(const [ | ||
| 31 | - FriendHealthData( | ||
| 32 | - userId: 10001, | ||
| 33 | - name: '女朋友(不爱吃热干面)', | ||
| 34 | - updatedAt: '更新于15:12', | ||
| 35 | - sleepQualityScore: 92, | ||
| 36 | - steps: '10254步', | ||
| 37 | - statusText: '注意压力', | ||
| 38 | - stressValue: 72, | ||
| 39 | - isOnWatchFace: true, | ||
| 40 | - ), | ||
| 41 | - FriendHealthData( | ||
| 42 | - userId: 10002, | ||
| 43 | - name: 'John', | ||
| 44 | - updatedAt: '更新于15:12', | ||
| 45 | - sleepQualityScore: null, | ||
| 46 | - steps: null, | ||
| 47 | - statusText: '等待数据', | ||
| 48 | - stressValue: null, | ||
| 49 | - ), | ||
| 50 | - ]); | 66 | + friends.assignAll(await _repository.getFriends()); |
| 51 | } catch (error, stackTrace) { | 67 | } catch (error, stackTrace) { |
| 52 | AppLogger.e('FriendsController.loadFriends failed', error, stackTrace); | 68 | AppLogger.e('FriendsController.loadFriends failed', error, stackTrace); |
| 53 | } finally { | 69 | } finally { |
| 54 | isLoading.value = false; | 70 | isLoading.value = false; |
| 71 | + _friendsRequest = null; | ||
| 72 | + } | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + Future<void> loadSelfHealth() { | ||
| 76 | + return _selfHealthRequest ??= _loadSelfHealth(); | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + Future<void> _loadSelfHealth() async { | ||
| 80 | + isSelfHealthLoading.value = true; | ||
| 81 | + try { | ||
| 82 | + final today = DateTime.now(); | ||
| 83 | + await Future.wait([ | ||
| 84 | + _loadSelfHealthData(today), | ||
| 85 | + _loadSelfStressScore(today), | ||
| 86 | + ]); | ||
| 87 | + } finally { | ||
| 88 | + isSelfHealthLoading.value = false; | ||
| 89 | + _selfHealthRequest = null; | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + Future<void> _loadSelfHealthData(DateTime date) async { | ||
| 94 | + try { | ||
| 95 | + selfHealthData.value = await _selfHealthRepository.getHealthData(date); | ||
| 96 | + selfHealthUpdatedAt.value = DateTime.now(); | ||
| 97 | + } catch (error, stackTrace) { | ||
| 98 | + AppLogger.e( | ||
| 99 | + 'FriendsController.loadSelfHealthData failed', | ||
| 100 | + error, | ||
| 101 | + stackTrace, | ||
| 102 | + ); | ||
| 103 | + } | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + Future<void> _loadSelfStressScore(DateTime date) async { | ||
| 107 | + try { | ||
| 108 | + selfStressScore.value = await _selfHealthRepository.getStressScore(date); | ||
| 109 | + selfHealthUpdatedAt.value = DateTime.now(); | ||
| 110 | + } catch (error, stackTrace) { | ||
| 111 | + AppLogger.e( | ||
| 112 | + 'FriendsController.loadSelfStressScore failed', | ||
| 113 | + error, | ||
| 114 | + stackTrace, | ||
| 115 | + ); | ||
| 55 | } | 116 | } |
| 56 | } | 117 | } |
| 57 | 118 | ||
| 58 | Future<void> showEditRemarkDialog(FriendHealthData friend) async { | 119 | Future<void> showEditRemarkDialog(FriendHealthData friend) async { |
| 59 | final result = await DialogUtils.showInputDialog( | 120 | final result = await DialogUtils.showInputDialog( |
| 60 | InputDialogMetaData( | 121 | InputDialogMetaData( |
| 61 | - title: '修改好友备注', | ||
| 62 | - initialValue: friend.name, | ||
| 63 | - hintText: '请输入昵称', | ||
| 64 | - confirmText: '保存', | 122 | + title: l10n.friendsEditRemarkTitle, |
| 123 | + initialValue: friend.remark ?? '', | ||
| 124 | + hintText: l10n.friendsEditRemarkHint, | ||
| 125 | + confirmText: l10n.friendsSave, | ||
| 65 | maxLength: 6, | 126 | maxLength: 6, |
| 66 | ), | 127 | ), |
| 67 | ); | 128 | ); |
| @@ -73,77 +134,87 @@ class FriendsController extends GetxController { | @@ -73,77 +134,87 @@ class FriendsController extends GetxController { | ||
| 73 | return; | 134 | return; |
| 74 | } | 135 | } |
| 75 | 136 | ||
| 76 | - final index = friends.indexOf(friend); | ||
| 77 | - if (index < 0) return; | ||
| 78 | - friends[index] = friend.copyWith(name: remark); | 137 | + final userId = friend.userId; |
| 138 | + if (userId == null) return; | ||
| 139 | + try { | ||
| 140 | + await _repository.updateRemark(userId, remark); | ||
| 141 | + final index = friends.indexOf(friend); | ||
| 142 | + if (index >= 0) friends[index] = friend.copyWith(remark: remark); | ||
| 143 | + } catch (error, stackTrace) { | ||
| 144 | + if (error is AppError) { | ||
| 145 | + AppToast.show(error.displayMessage); | ||
| 146 | + } else { | ||
| 147 | + AppLogger.e( | ||
| 148 | + 'FriendsController.showEditRemarkDialog failed', | ||
| 149 | + error, | ||
| 150 | + stackTrace, | ||
| 151 | + ); | ||
| 152 | + } | ||
| 153 | + } | ||
| 154 | + } | ||
| 79 | 155 | ||
| 80 | - // TODO: Submit the remark to the friend API when it is available. | 156 | + Future<void> applyWatchFaceSelection(FriendHealthData selectedFriend) async { |
| 157 | + final friendUserId = selectedFriend.userId; | ||
| 158 | + if (friendUserId == null) { | ||
| 159 | + return; | ||
| 160 | + } | ||
| 161 | + try { | ||
| 162 | + await _repository.selectWatchFaceFriend(friendUserId); | ||
| 163 | + final updatedFriends = friends | ||
| 164 | + .map( | ||
| 165 | + (friend) => friend.userId == friendUserId | ||
| 166 | + ? friend.copyWith(isOnWatchFace: true) | ||
| 167 | + : friend, | ||
| 168 | + ) | ||
| 169 | + .toList(growable: false); | ||
| 170 | + friends.assignAll(updatedFriends); | ||
| 171 | + await _updateWatchOtherUserInfo( | ||
| 172 | + WatchAppOtherInfo( | ||
| 173 | + userId: friendUserId, | ||
| 174 | + nickname: selectedFriend.name, | ||
| 175 | + markName: selectedFriend.remark, | ||
| 176 | + ), | ||
| 177 | + ); | ||
| 178 | + } catch (error, stackTrace) { | ||
| 179 | + await loadFriends(); | ||
| 180 | + if (error is AppError) { | ||
| 181 | + AppToast.show(error.displayMessage); | ||
| 182 | + } else { | ||
| 183 | + AppLogger.e( | ||
| 184 | + 'FriendsController.applyWatchFaceSelection failed', | ||
| 185 | + error, | ||
| 186 | + stackTrace, | ||
| 187 | + ); | ||
| 188 | + } | ||
| 189 | + } | ||
| 81 | } | 190 | } |
| 82 | 191 | ||
| 83 | Future<void> showDeleteConfirmDialog(FriendHealthData friend) async { | 192 | Future<void> showDeleteConfirmDialog(FriendHealthData friend) async { |
| 84 | try { | 193 | try { |
| 85 | final dialogMetaData = DialogMetaData( | 194 | final dialogMetaData = DialogMetaData( |
| 86 | iconAsset: null, | 195 | iconAsset: null, |
| 87 | - title: '确认要和${friend.name}解除好友关系吗?', | ||
| 88 | - message: '解除后你将无法查看对方的情绪、健康状态', | ||
| 89 | - confirmText: '确认解除', | ||
| 90 | - cancelText: '取消', | 196 | + title: l10n.friendsDeleteConfirmTitle(friend.displayName), |
| 197 | + message: l10n.friendsDeleteConfirmMessage, | ||
| 198 | + confirmText: l10n.friendsDeleteConfirmAction, | ||
| 199 | + cancelText: l10n.cancel, | ||
| 91 | ); | 200 | ); |
| 92 | - await DialogUtils.showCommonDialog(dialogMetaData); | 201 | + final result = await DialogUtils.showCommonDialog(dialogMetaData); |
| 202 | + if (result.action != DialogAction.confirm) return; | ||
| 203 | + | ||
| 204 | + final userId = friend.userId; | ||
| 205 | + if (userId == null) return; | ||
| 206 | + await _repository.deleteFriend(userId); | ||
| 207 | + await loadFriends(); | ||
| 93 | } catch (error, stackTrace) { | 208 | } catch (error, stackTrace) { |
| 94 | - AppLogger.e( | ||
| 95 | - 'FriendsController.showDeleteConfirmDialog failed', | ||
| 96 | - error, | ||
| 97 | - stackTrace, | ||
| 98 | - ); | 209 | + if (error is AppError) { |
| 210 | + AppToast.show(error.displayMessage); | ||
| 211 | + } else { | ||
| 212 | + AppLogger.e( | ||
| 213 | + 'FriendsController.showDeleteConfirmDialog failed', | ||
| 214 | + error, | ||
| 215 | + stackTrace, | ||
| 216 | + ); | ||
| 217 | + } | ||
| 99 | } | 218 | } |
| 100 | } | 219 | } |
| 101 | } | 220 | } |
| 102 | - | ||
| 103 | -class FriendHealthData { | ||
| 104 | - const FriendHealthData({ | ||
| 105 | - this.userId, | ||
| 106 | - this.avatarUrl, | ||
| 107 | - required this.name, | ||
| 108 | - required this.updatedAt, | ||
| 109 | - required this.sleepQualityScore, | ||
| 110 | - required this.steps, | ||
| 111 | - required this.statusText, | ||
| 112 | - required this.stressValue, | ||
| 113 | - this.isOnWatchFace = false, | ||
| 114 | - }); | ||
| 115 | - | ||
| 116 | - final int? userId; | ||
| 117 | - final String? avatarUrl; | ||
| 118 | - final String name; | ||
| 119 | - final String updatedAt; | ||
| 120 | - final int? sleepQualityScore; | ||
| 121 | - final String? steps; | ||
| 122 | - final String statusText; | ||
| 123 | - final double? stressValue; | ||
| 124 | - final bool isOnWatchFace; | ||
| 125 | - | ||
| 126 | - FriendHealthData copyWith({ | ||
| 127 | - int? userId, | ||
| 128 | - String? avatarUrl, | ||
| 129 | - String? name, | ||
| 130 | - String? updatedAt, | ||
| 131 | - int? sleepQualityScore, | ||
| 132 | - String? steps, | ||
| 133 | - String? statusText, | ||
| 134 | - double? stressValue, | ||
| 135 | - bool? isOnWatchFace, | ||
| 136 | - }) { | ||
| 137 | - return FriendHealthData( | ||
| 138 | - userId: userId ?? this.userId, | ||
| 139 | - avatarUrl: avatarUrl ?? this.avatarUrl, | ||
| 140 | - name: name ?? this.name, | ||
| 141 | - updatedAt: updatedAt ?? this.updatedAt, | ||
| 142 | - sleepQualityScore: sleepQualityScore ?? this.sleepQualityScore, | ||
| 143 | - steps: steps ?? this.steps, | ||
| 144 | - statusText: statusText ?? this.statusText, | ||
| 145 | - stressValue: stressValue ?? this.stressValue, | ||
| 146 | - isOnWatchFace: isOnWatchFace ?? this.isOnWatchFace, | ||
| 147 | - ); | ||
| 148 | - } | ||
| 149 | -} |
| 1 | +import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; | ||
| 1 | import 'package:get/get.dart'; | 2 | import 'package:get/get.dart'; |
| 2 | 3 | ||
| 3 | -import 'friends_controller.dart'; | 4 | +import '../data/friends_repository.dart'; |
| 5 | +import '../models/friend_health_data.dart'; | ||
| 4 | 6 | ||
| 5 | class SelectFriendLogic { | 7 | class SelectFriendLogic { |
| 6 | - SelectFriendLogic(this._friendsController); | 8 | + SelectFriendLogic({FriendsRepository? repository}) |
| 9 | + : _repository = | ||
| 10 | + repository ?? FriendsRepositoryImpl(Get.find<FriendApi>()); | ||
| 7 | 11 | ||
| 8 | - final FriendsController _friendsController; | 12 | + final FriendsRepository _repository; |
| 13 | + final friends = <FriendHealthData>[].obs; | ||
| 14 | + final isLoading = false.obs; | ||
| 9 | final selectedIndex = (-1).obs; | 15 | final selectedIndex = (-1).obs; |
| 10 | 16 | ||
| 11 | - List<FriendHealthData> get friends => _friendsController.friends; | ||
| 12 | - | ||
| 13 | Future<void> initialize() async { | 17 | Future<void> initialize() async { |
| 14 | - if (friends.isEmpty) { | ||
| 15 | - await _friendsController.loadFriends(); | 18 | + if (isLoading.value) return; |
| 19 | + isLoading.value = true; | ||
| 20 | + try { | ||
| 21 | + friends.assignAll(await _repository.getFriends()); | ||
| 22 | + final watchFaceIndex = friends.indexWhere( | ||
| 23 | + (friend) => friend.isOnWatchFace, | ||
| 24 | + ); | ||
| 25 | + selectedIndex.value = friends.isEmpty | ||
| 26 | + ? -1 | ||
| 27 | + : watchFaceIndex < 0 | ||
| 28 | + ? 0 | ||
| 29 | + : watchFaceIndex; | ||
| 30 | + } finally { | ||
| 31 | + isLoading.value = false; | ||
| 16 | } | 32 | } |
| 17 | - final items = friends; | ||
| 18 | - if (items.isEmpty) return; | ||
| 19 | - final watchFaceIndex = items.indexWhere((friend) => friend.isOnWatchFace); | ||
| 20 | - selectedIndex.value = watchFaceIndex < 0 ? 0 : watchFaceIndex; | ||
| 21 | } | 33 | } |
| 22 | 34 | ||
| 23 | void selectFriendAt(int index) { | 35 | void selectFriendAt(int index) { |
| @@ -25,21 +37,16 @@ class SelectFriendLogic { | @@ -25,21 +37,16 @@ class SelectFriendLogic { | ||
| 25 | selectedIndex.value = index; | 37 | selectedIndex.value = index; |
| 26 | } | 38 | } |
| 27 | 39 | ||
| 28 | - bool syncSelectedFriendToWatchFace() { | 40 | + Future<FriendHealthData?> syncSelectedFriendToWatchFace() async { |
| 29 | final index = selectedIndex.value; | 41 | final index = selectedIndex.value; |
| 30 | - if (index < 0 || index >= friends.length) return false; | ||
| 31 | - | ||
| 32 | - final updated = List.generate( | ||
| 33 | - friends.length, | ||
| 34 | - (itemIndex) => friends[itemIndex].copyWith( | ||
| 35 | - isOnWatchFace: itemIndex == index, | ||
| 36 | - ), | ||
| 37 | - growable: false, | ||
| 38 | - ); | ||
| 39 | - _friendsController.friends.assignAll(updated); | ||
| 40 | - | ||
| 41 | - // TODO: Submit selected watch-face friend to the friend API when available. | ||
| 42 | - return true; | 42 | + if (index < 0 || index >= friends.length) return null; |
| 43 | + | ||
| 44 | + final selectedFriend = friends[index]; | ||
| 45 | + final userId = selectedFriend.userId; | ||
| 46 | + if (userId == null) return null; | ||
| 47 | + | ||
| 48 | + await _repository.selectWatchFaceFriend(userId); | ||
| 49 | + return selectedFriend; | ||
| 43 | } | 50 | } |
| 44 | 51 | ||
| 45 | void dispose() {} | 52 | void dispose() {} |
| 1 | +import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/core/result/app_result.dart'; | ||
| 4 | +import 'package:doublefeel_flutter/data/models/friend/friend_models.dart' | ||
| 5 | + as api_models; | ||
| 6 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 7 | +import 'package:intl/intl.dart'; | ||
| 8 | + | ||
| 9 | +import '../models/friend_health_data.dart'; | ||
| 10 | + | ||
| 11 | +abstract class FriendsRepository { | ||
| 12 | + Future<List<FriendHealthData>> getFriends(); | ||
| 13 | + | ||
| 14 | + Future<void> updateRemark(int userId, String remark); | ||
| 15 | + | ||
| 16 | + Future<void> selectWatchFaceFriend(int userId); | ||
| 17 | + | ||
| 18 | + Future<void> deleteFriend(int userId); | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +class FriendsRepositoryImpl implements FriendsRepository { | ||
| 22 | + const FriendsRepositoryImpl(this._friendApi); | ||
| 23 | + | ||
| 24 | + final FriendApi _friendApi; | ||
| 25 | + | ||
| 26 | + @override | ||
| 27 | + Future<List<FriendHealthData>> getFriends() async { | ||
| 28 | + return switch (await _friendApi.friendList(true)) { | ||
| 29 | + AppSuccess(:final data) => data.list.map(_mapFriend).toList(), | ||
| 30 | + AppFailure(:final error) => throw error, | ||
| 31 | + }; | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + @override | ||
| 35 | + Future<void> deleteFriend(int userId) async { | ||
| 36 | + switch (await _friendApi.deleteFriend(userId)) { | ||
| 37 | + case AppSuccess(): | ||
| 38 | + return; | ||
| 39 | + case AppFailure(:final error): | ||
| 40 | + throw error; | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + @override | ||
| 45 | + Future<void> selectWatchFaceFriend(int userId) async { | ||
| 46 | + switch (await _friendApi.updateFriend(userId, showInDial: 1)) { | ||
| 47 | + case AppSuccess(): | ||
| 48 | + return; | ||
| 49 | + case AppFailure(:final error): | ||
| 50 | + throw error; | ||
| 51 | + } | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + @override | ||
| 55 | + Future<void> updateRemark(int userId, String remark) async { | ||
| 56 | + switch (await _friendApi.updateFriend(userId, remarkName: remark)) { | ||
| 57 | + case AppSuccess(): | ||
| 58 | + return; | ||
| 59 | + case AppFailure(:final error): | ||
| 60 | + throw error; | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + FriendHealthData _mapFriend(api_models.FriendItem friend) { | ||
| 65 | + final healthData = friend.healthData; | ||
| 66 | + final stressValue = healthData?.latestHrv; | ||
| 67 | + final name = friend.remarkName?.trim(); | ||
| 68 | + | ||
| 69 | + return FriendHealthData( | ||
| 70 | + friendItem: friend, | ||
| 71 | + userId: friend.friendUserId, | ||
| 72 | + avatarUrl: friend.avatar, | ||
| 73 | + name: name == null || name.isEmpty ? '未知好友' : name, | ||
| 74 | + updatedAt: _updatedAt(friend.updateTime), | ||
| 75 | + sleepQualityScore: healthData?.sleepEvaluate, | ||
| 76 | + steps: healthData?.totalSteps == null | ||
| 77 | + ? null | ||
| 78 | + : l10n.friendsStepCount(healthData!.totalSteps!), | ||
| 79 | + statusText: stressValue == null | ||
| 80 | + ? l10n.friendsWaitingForData | ||
| 81 | + : HrvStressLevel.fromRealtimeStress(stressValue).label, | ||
| 82 | + stressValue: stressValue, | ||
| 83 | + isOnWatchFace: friend.isShowInDial, | ||
| 84 | + ); | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + String _updatedAt(int? timestamp) { | ||
| 88 | + if (timestamp == null) return l10n.friendsWaitingForData; | ||
| 89 | + final milliseconds = | ||
| 90 | + timestamp < 1000000000000 ? timestamp * 1000 : timestamp; | ||
| 91 | + final time = DateTime.fromMillisecondsSinceEpoch(milliseconds); | ||
| 92 | + return l10n.friendsUpdatedAt(DateFormat('HH:mm').format(time)); | ||
| 93 | + } | ||
| 94 | +} |
| 1 | +import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/core/result/app_result.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; | ||
| 4 | +import 'package:intl/intl.dart'; | ||
| 5 | + | ||
| 6 | +abstract class SelfHealthRepository { | ||
| 7 | + Future<V2HealthData> getHealthData(DateTime date); | ||
| 8 | + | ||
| 9 | + Future<V2StressScore> getStressScore(DateTime date); | ||
| 10 | +} | ||
| 11 | + | ||
| 12 | +class SelfHealthRepositoryImpl implements SelfHealthRepository { | ||
| 13 | + const SelfHealthRepositoryImpl(this._healthApi); | ||
| 14 | + | ||
| 15 | + final HealthApi _healthApi; | ||
| 16 | + | ||
| 17 | + @override | ||
| 18 | + Future<V2HealthData> getHealthData(DateTime date) async { | ||
| 19 | + final intDate = int.parse(DateFormat('yyyyMMdd').format(date)); | ||
| 20 | + return switch (await _healthApi.getV2HealthData(null, intDate)) { | ||
| 21 | + AppSuccess(:final data) => data, | ||
| 22 | + AppFailure(:final error) => throw error, | ||
| 23 | + }; | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + @override | ||
| 27 | + Future<V2StressScore> getStressScore(DateTime date) async { | ||
| 28 | + final intDate = int.parse(DateFormat('yyyyMMdd').format(date)); | ||
| 29 | + return switch (await _healthApi.getV2StressScore(null, intDate)) { | ||
| 30 | + AppSuccess(:final data) => data, | ||
| 31 | + AppFailure(:final error) => throw error, | ||
| 32 | + }; | ||
| 33 | + } | ||
| 34 | +} |
| @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart'; | @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart'; | ||
| 2 | import 'package:doublefeel_flutter/app/widget/common_dialog_view.dart'; | 2 | import 'package:doublefeel_flutter/app/widget/common_dialog_view.dart'; |
| 3 | import 'package:flutter/material.dart'; | 3 | import 'package:flutter/material.dart'; |
| 4 | import 'package:get/get.dart'; | 4 | import 'package:get/get.dart'; |
| 5 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 5 | 6 | ||
| 6 | import '../models/add_friend_prompt_type.dart'; | 7 | import '../models/add_friend_prompt_type.dart'; |
| 7 | 8 | ||
| @@ -15,29 +16,29 @@ class AddFriendPromptDialog extends StatelessWidget { | @@ -15,29 +16,29 @@ class AddFriendPromptDialog extends StatelessWidget { | ||
| 15 | iconAsset: null, | 16 | iconAsset: null, |
| 16 | title: _titleFor(type), | 17 | title: _titleFor(type), |
| 17 | message: _messageFor(type), | 18 | message: _messageFor(type), |
| 18 | - confirmText: '我知道了', | 19 | + confirmText: l10n.friendsPromptGotIt, |
| 19 | ); | 20 | ); |
| 20 | } | 21 | } |
| 21 | 22 | ||
| 22 | static String _titleFor(AddFriendPromptType type) { | 23 | static String _titleFor(AddFriendPromptType type) { |
| 23 | switch (type) { | 24 | switch (type) { |
| 24 | case AddFriendPromptType.idNotFound: | 25 | case AddFriendPromptType.idNotFound: |
| 25 | - return '该ID不存在'; | 26 | + return l10n.friendsPromptIdNotFoundTitle; |
| 26 | case AddFriendPromptType.alreadyFriend: | 27 | case AddFriendPromptType.alreadyFriend: |
| 27 | - return '已经是亲密联系人'; | 28 | + return l10n.friendsPromptAlreadyFriendTitle; |
| 28 | case AddFriendPromptType.selfId: | 29 | case AddFriendPromptType.selfId: |
| 29 | - return '不能添加自己'; | 30 | + return l10n.friendsPromptSelfIdTitle; |
| 30 | } | 31 | } |
| 31 | } | 32 | } |
| 32 | 33 | ||
| 33 | static String _messageFor(AddFriendPromptType type) { | 34 | static String _messageFor(AddFriendPromptType type) { |
| 34 | switch (type) { | 35 | switch (type) { |
| 35 | case AddFriendPromptType.idNotFound: | 36 | case AddFriendPromptType.idNotFound: |
| 36 | - return '这个ID不存在哦,请检查后重新输入'; | 37 | + return l10n.friendsPromptIdNotFoundMessage; |
| 37 | case AddFriendPromptType.alreadyFriend: | 38 | case AddFriendPromptType.alreadyFriend: |
| 38 | - return '你们已经是亲密联系人啦'; | 39 | + return l10n.friendsPromptAlreadyFriendMessage; |
| 39 | case AddFriendPromptType.selfId: | 40 | case AddFriendPromptType.selfId: |
| 40 | - return '请输入亲密联系人的ID'; | 41 | + return l10n.friendsPromptSelfIdMessage; |
| 41 | } | 42 | } |
| 42 | } | 43 | } |
| 43 | 44 |
| 1 | +import 'package:doublefeel_flutter/data/models/friend/friend_models.dart' | ||
| 2 | + as api_models; | ||
| 3 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 4 | + | ||
| 5 | +class FriendHealthData { | ||
| 6 | + const FriendHealthData({ | ||
| 7 | + required this.friendItem, | ||
| 8 | + this.userId, | ||
| 9 | + this.avatarUrl, | ||
| 10 | + required this.name, | ||
| 11 | + this.remark, | ||
| 12 | + required this.updatedAt, | ||
| 13 | + required this.sleepQualityScore, | ||
| 14 | + required this.steps, | ||
| 15 | + required this.statusText, | ||
| 16 | + required this.stressValue, | ||
| 17 | + this.isOnWatchFace = false, | ||
| 18 | + }); | ||
| 19 | + | ||
| 20 | + final api_models.FriendItem friendItem; | ||
| 21 | + final int? userId; | ||
| 22 | + final String? avatarUrl; | ||
| 23 | + final String name; | ||
| 24 | + final String? remark; | ||
| 25 | + final String updatedAt; | ||
| 26 | + final int? sleepQualityScore; | ||
| 27 | + final String? steps; | ||
| 28 | + final String statusText; | ||
| 29 | + final double? stressValue; | ||
| 30 | + final bool isOnWatchFace; | ||
| 31 | + | ||
| 32 | + String get displayName { | ||
| 33 | + final value = remark?.trim(); | ||
| 34 | + if (value == null || value.isEmpty) return name; | ||
| 35 | + return l10n.friendsRemarkedDisplayName(value, name); | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + FriendHealthData copyWith({ | ||
| 39 | + api_models.FriendItem? friendItem, | ||
| 40 | + int? userId, | ||
| 41 | + String? avatarUrl, | ||
| 42 | + String? name, | ||
| 43 | + String? remark, | ||
| 44 | + String? updatedAt, | ||
| 45 | + int? sleepQualityScore, | ||
| 46 | + String? steps, | ||
| 47 | + String? statusText, | ||
| 48 | + double? stressValue, | ||
| 49 | + bool? isOnWatchFace, | ||
| 50 | + }) { | ||
| 51 | + return FriendHealthData( | ||
| 52 | + friendItem: friendItem ?? this.friendItem, | ||
| 53 | + userId: userId ?? this.userId, | ||
| 54 | + avatarUrl: avatarUrl ?? this.avatarUrl, | ||
| 55 | + name: name ?? this.name, | ||
| 56 | + remark: remark ?? this.remark, | ||
| 57 | + updatedAt: updatedAt ?? this.updatedAt, | ||
| 58 | + sleepQualityScore: sleepQualityScore ?? this.sleepQualityScore, | ||
| 59 | + steps: steps ?? this.steps, | ||
| 60 | + statusText: statusText ?? this.statusText, | ||
| 61 | + stressValue: stressValue ?? this.stressValue, | ||
| 62 | + isOnWatchFace: isOnWatchFace ?? this.isOnWatchFace, | ||
| 63 | + ); | ||
| 64 | + } | ||
| 65 | +} |
| 1 | import 'package:doublefeel_flutter/app/ext/font_ext.dart'; | 1 | import 'package:doublefeel_flutter/app/ext/font_ext.dart'; |
| 2 | -import 'package:doublefeel_flutter/app/utils/dialog_utils.dart'; | ||
| 3 | import 'package:doublefeel_flutter/core/theme/app_colors.dart'; | 2 | import 'package:doublefeel_flutter/core/theme/app_colors.dart'; |
| 4 | import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | 3 | import 'package:doublefeel_flutter/core/util/size_extensions.dart'; |
| 4 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 5 | import 'package:doublefeel_flutter/r.dart'; | 5 | import 'package:doublefeel_flutter/r.dart'; |
| 6 | import 'package:flutter/material.dart'; | 6 | import 'package:flutter/material.dart'; |
| 7 | import 'package:get/get.dart'; | 7 | import 'package:get/get.dart'; |
| 8 | 8 | ||
| 9 | import '../controllers/add_friend_controller.dart'; | 9 | import '../controllers/add_friend_controller.dart'; |
| 10 | -import '../dialog/add_friend_prompt_dialog.dart'; | ||
| 11 | import '../widgets/primary_button.dart'; | 10 | import '../widgets/primary_button.dart'; |
| 12 | 11 | ||
| 13 | class AddFriendView extends GetView<AddFriendController> { | 12 | class AddFriendView extends GetView<AddFriendController> { |
| @@ -91,8 +90,8 @@ class AddFriendView extends GetView<AddFriendController> { | @@ -91,8 +90,8 @@ class AddFriendView extends GetView<AddFriendController> { | ||
| 91 | child: Column( | 90 | child: Column( |
| 92 | children: [ | 91 | children: [ |
| 93 | SizedBox(height: 4), | 92 | SizedBox(height: 4), |
| 94 | - const Text( | ||
| 95 | - '添加亲密联系人\n多一个人关注你的健康', | 93 | + Text( |
| 94 | + context.l10n.bindPartnerTitle, | ||
| 96 | textAlign: TextAlign.center, | 95 | textAlign: TextAlign.center, |
| 97 | style: TextStyle( | 96 | style: TextStyle( |
| 98 | color: Color(0xFF141414), | 97 | color: Color(0xFF141414), |
| @@ -183,8 +182,8 @@ class AddFriendView extends GetView<AddFriendController> { | @@ -183,8 +182,8 @@ class AddFriendView extends GetView<AddFriendController> { | ||
| 183 | padding: EdgeInsets.fromLTRB(32.dp, 28, 32.dp, 0), | 182 | padding: EdgeInsets.fromLTRB(32.dp, 28, 32.dp, 0), |
| 184 | child: Column( | 183 | child: Column( |
| 185 | children: [ | 184 | children: [ |
| 186 | - const Text( | ||
| 187 | - '我的ID', | 185 | + Text( |
| 186 | + l10n.bindPartnerMyId, | ||
| 188 | textAlign: TextAlign.center, | 187 | textAlign: TextAlign.center, |
| 189 | style: TextStyle( | 188 | style: TextStyle( |
| 190 | color: AppColors.textPrimary, | 189 | color: AppColors.textPrimary, |
| @@ -196,7 +195,7 @@ class AddFriendView extends GetView<AddFriendController> { | @@ -196,7 +195,7 @@ class AddFriendView extends GetView<AddFriendController> { | ||
| 196 | _MyInviteCode(controller: controller), | 195 | _MyInviteCode(controller: controller), |
| 197 | SizedBox(height: 26), | 196 | SizedBox(height: 26), |
| 198 | PrimaryButton( | 197 | PrimaryButton( |
| 199 | - label: '分享我的码', | 198 | + label: l10n.bindPartnerShareMyCode, |
| 200 | enabled: true, | 199 | enabled: true, |
| 201 | loading: false, | 200 | loading: false, |
| 202 | onTap: controller.copyMyInviteCode, | 201 | onTap: controller.copyMyInviteCode, |
| @@ -211,8 +210,8 @@ class AddFriendView extends GetView<AddFriendController> { | @@ -211,8 +210,8 @@ class AddFriendView extends GetView<AddFriendController> { | ||
| 211 | padding: EdgeInsets.fromLTRB(32.dp, 46, 32.dp, 0), | 210 | padding: EdgeInsets.fromLTRB(32.dp, 46, 32.dp, 0), |
| 212 | child: Column( | 211 | child: Column( |
| 213 | children: [ | 212 | children: [ |
| 214 | - const Text( | ||
| 215 | - '亲密联系人的ID', | 213 | + Text( |
| 214 | + l10n.bindPartnerContactId, | ||
| 216 | textAlign: TextAlign.center, | 215 | textAlign: TextAlign.center, |
| 217 | style: TextStyle( | 216 | style: TextStyle( |
| 218 | color: Color(0xFF141414), | 217 | color: Color(0xFF141414), |
| @@ -226,7 +225,7 @@ class AddFriendView extends GetView<AddFriendController> { | @@ -226,7 +225,7 @@ class AddFriendView extends GetView<AddFriendController> { | ||
| 226 | SizedBox(height: 16), | 225 | SizedBox(height: 16), |
| 227 | Obx( | 226 | Obx( |
| 228 | () => PrimaryButton( | 227 | () => PrimaryButton( |
| 229 | - label: '添加', | 228 | + label: l10n.friendsAddAction, |
| 230 | enabled: controller.canSubmit && !controller.isSubmitting.value, | 229 | enabled: controller.canSubmit && !controller.isSubmitting.value, |
| 231 | loading: controller.isSubmitting.value, | 230 | loading: controller.isSubmitting.value, |
| 232 | backgroundColor: const Color(0xFFFF916A), | 231 | backgroundColor: const Color(0xFFFF916A), |
| @@ -265,8 +264,8 @@ class AddFriendView extends GetView<AddFriendController> { | @@ -265,8 +264,8 @@ class AddFriendView extends GetView<AddFriendController> { | ||
| 265 | color: Color(0xFFE8DDFF), | 264 | color: Color(0xFFE8DDFF), |
| 266 | shape: BoxShape.circle, | 265 | shape: BoxShape.circle, |
| 267 | ), | 266 | ), |
| 268 | - child: const Text( | ||
| 269 | - '或', | 267 | + child: Text( |
| 268 | + l10n.bindPartnerOr, | ||
| 270 | style: TextStyle( | 269 | style: TextStyle( |
| 271 | color: AppColors.primary, | 270 | color: AppColors.primary, |
| 272 | fontSize: 14, | 271 | fontSize: 14, |
| @@ -292,20 +291,9 @@ class AddFriendView extends GetView<AddFriendController> { | @@ -292,20 +291,9 @@ class AddFriendView extends GetView<AddFriendController> { | ||
| 292 | await Future.delayed(const Duration(milliseconds: 120)); | 291 | await Future.delayed(const Duration(milliseconds: 120)); |
| 293 | } | 292 | } |
| 294 | 293 | ||
| 295 | - final promptType = await controller.submitFriendCode(); | ||
| 296 | - if (promptType == null) { | ||
| 297 | - controller.friendCodeFocusNode.canRequestFocus = true; | ||
| 298 | - return; | ||
| 299 | - } | ||
| 300 | - | ||
| 301 | - try { | ||
| 302 | - await DialogUtils.showCommonDialog( | ||
| 303 | - AddFriendPromptDialog.metaDataFor(promptType), | ||
| 304 | - barrierColor: Colors.black.withValues(alpha: 0.45), | ||
| 305 | - ); | ||
| 306 | - } finally { | 294 | + final didAddFriend = await controller.submitFriendCode(); |
| 295 | + if (!didAddFriend) { | ||
| 307 | controller.friendCodeFocusNode.canRequestFocus = true; | 296 | controller.friendCodeFocusNode.canRequestFocus = true; |
| 308 | - _dismissKeyboard(); | ||
| 309 | } | 297 | } |
| 310 | } | 298 | } |
| 311 | 299 | ||
| @@ -384,10 +372,10 @@ class _FriendIdInput extends StatelessWidget { | @@ -384,10 +372,10 @@ class _FriendIdInput extends StatelessWidget { | ||
| 384 | textAlign: TextAlign.center, | 372 | textAlign: TextAlign.center, |
| 385 | minLines: 1, | 373 | minLines: 1, |
| 386 | maxLines: 1, | 374 | maxLines: 1, |
| 387 | - decoration: const InputDecoration( | 375 | + decoration: InputDecoration( |
| 388 | border: InputBorder.none, | 376 | border: InputBorder.none, |
| 389 | - hintText: '输入ID', | ||
| 390 | - hintStyle: TextStyle( | 377 | + hintText: context.l10n.friendsEnterId, |
| 378 | + hintStyle: const TextStyle( | ||
| 391 | color: AppColors.disabled, | 379 | color: AppColors.disabled, |
| 392 | fontSize: 26, | 380 | fontSize: 26, |
| 393 | fontWeight: FontWeightExt.medium, | 381 | fontWeight: FontWeightExt.medium, |
| @@ -11,7 +11,7 @@ class FriendHomePage extends GetView<TodayController> { | @@ -11,7 +11,7 @@ class FriendHomePage extends GetView<TodayController> { | ||
| 11 | 11 | ||
| 12 | /// 与 FriendHomeBinding 中注册的 tag 保持一致 | 12 | /// 与 FriendHomeBinding 中注册的 tag 保持一致 |
| 13 | @override | 13 | @override |
| 14 | - String? get tag => 'friend_${_args.userId}'; | 14 | + String? get tag => 'friend_${_args.friendUserId}'; |
| 15 | 15 | ||
| 16 | @override | 16 | @override |
| 17 | Widget build(BuildContext context) { | 17 | Widget build(BuildContext context) { |
| 1 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 1 | import 'package:flutter/material.dart'; | 2 | import 'package:flutter/material.dart'; |
| 2 | import 'package:flutter/services.dart'; | 3 | import 'package:flutter/services.dart'; |
| 3 | import 'package:get/get.dart'; | 4 | import 'package:get/get.dart'; |
| @@ -29,12 +30,13 @@ class FriendTrendView extends GetView<FriendTrendController> { | @@ -29,12 +30,13 @@ class FriendTrendView extends GetView<FriendTrendController> { | ||
| 29 | icon: const Icon(Icons.arrow_back_ios_new_rounded), | 30 | icon: const Icon(Icons.arrow_back_ios_new_rounded), |
| 30 | color: const Color(0xFF0F0F11), | 31 | color: const Color(0xFF0F0F11), |
| 31 | iconSize: 20, | 32 | iconSize: 20, |
| 32 | - tooltip: '返回', | 33 | + tooltip: context.l10n.friendsBack, |
| 33 | ), | 34 | ), |
| 34 | titleSpacing: 0, | 35 | titleSpacing: 0, |
| 35 | title: _FriendTrendTitle( | 36 | title: _FriendTrendTitle( |
| 36 | - title: '${controller.friendName}的趋势', | ||
| 37 | - avatarUrl: controller.avatarUrl, | 37 | + title: context.l10n |
| 38 | + .friendsTrendTitle(controller.friendItem?.remarkName ?? ""), | ||
| 39 | + avatarUrl: controller.friendItem?.avatar ?? "", | ||
| 38 | ), | 40 | ), |
| 39 | ), | 41 | ), |
| 40 | body: Stack( | 42 | body: Stack( |
| @@ -58,6 +60,8 @@ class FriendTrendView extends GetView<FriendTrendController> { | @@ -58,6 +60,8 @@ class FriendTrendView extends GetView<FriendTrendController> { | ||
| 58 | query: controller.query, | 60 | query: controller.query, |
| 59 | selectedTypeIndex: controller.selectedTypeIndex.value, | 61 | selectedTypeIndex: controller.selectedTypeIndex.value, |
| 60 | onTypeChanged: controller.changeType, | 62 | onTypeChanged: controller.changeType, |
| 63 | + onPeriodChanged: controller.changePeriod, | ||
| 64 | + onDateChanged: controller.changeDate, | ||
| 61 | ), | 65 | ), |
| 62 | ), | 66 | ), |
| 63 | ), | 67 | ), |
| 1 | import 'package:doublefeel_flutter/app/routes/app_pages.dart'; | 1 | import 'package:doublefeel_flutter/app/routes/app_pages.dart'; |
| 2 | +import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | ||
| 2 | import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | 4 | import 'package:doublefeel_flutter/core/util/size_extensions.dart'; |
| 3 | import 'package:doublefeel_flutter/r.dart'; | 5 | import 'package:doublefeel_flutter/r.dart'; |
| 6 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 4 | import 'package:flutter/material.dart'; | 7 | import 'package:flutter/material.dart'; |
| 5 | import 'package:get/get.dart'; | 8 | import 'package:get/get.dart'; |
| 9 | +import 'package:intl/intl.dart'; | ||
| 6 | 10 | ||
| 7 | import '../controllers/friends_controller.dart'; | 11 | import '../controllers/friends_controller.dart'; |
| 8 | -import '../controllers/friend_trend_controller.dart'; | 12 | +import '../models/friend_health_data.dart'; |
| 9 | import '../widgets/friend_health_card.dart'; | 13 | import '../widgets/friend_health_card.dart'; |
| 10 | -import 'select_friend_view.dart'; | ||
| 11 | 14 | ||
| 12 | class FriendsTab extends GetView<FriendsController> { | 15 | class FriendsTab extends GetView<FriendsController> { |
| 13 | const FriendsTab({super.key}); | 16 | const FriendsTab({super.key}); |
| 14 | 17 | ||
| 15 | @override | 18 | @override |
| 16 | Widget build(BuildContext context) { | 19 | Widget build(BuildContext context) { |
| 20 | + final userPreferences = Get.find<UserPreferencesStorage>(); | ||
| 17 | return Scaffold( | 21 | return Scaffold( |
| 18 | - backgroundColor: const Color(0xFFF2F2F7), | 22 | + backgroundColor: Colors.white, |
| 19 | body: SafeArea( | 23 | body: SafeArea( |
| 20 | bottom: false, | 24 | bottom: false, |
| 21 | child: Column( | 25 | child: Column( |
| @@ -23,87 +27,133 @@ class FriendsTab extends GetView<FriendsController> { | @@ -23,87 +27,133 @@ class FriendsTab extends GetView<FriendsController> { | ||
| 23 | children: [ | 27 | children: [ |
| 24 | const _FriendsHeader(), | 28 | const _FriendsHeader(), |
| 25 | Expanded( | 29 | Expanded( |
| 26 | - child: Obx( | ||
| 27 | - () { | ||
| 28 | - final friends = controller.friends; | ||
| 29 | - final hasFriends = friends.isNotEmpty; | ||
| 30 | - final isFull = controller.isFull; | 30 | + child: ColoredBox( |
| 31 | + color: const Color(0xFFF5F2FF), | ||
| 32 | + child: Obx( | ||
| 33 | + () { | ||
| 34 | + final friends = controller.friends; | ||
| 35 | + final hasFriends = friends.isNotEmpty; | ||
| 36 | + final isFull = controller.isFull; | ||
| 37 | + final healthData = controller.selfHealthData.value; | ||
| 38 | + final stressScore = controller.selfStressScore.value; | ||
| 39 | + final updatedAt = controller.selfHealthUpdatedAt.value; | ||
| 40 | + final currentUser = | ||
| 41 | + userPreferences.preferences.value.meUserInfo; | ||
| 42 | + final isSelfInitialLoading = | ||
| 43 | + controller.isSelfHealthLoading.value && | ||
| 44 | + healthData == null && | ||
| 45 | + stressScore == null; | ||
| 46 | + final isFriendsInitialLoading = | ||
| 47 | + controller.isLoading.value && !hasFriends; | ||
| 48 | + final stressValue = stressScore?.state == 0 | ||
| 49 | + ? null | ||
| 50 | + : stressScore?.comprehensiveScore?.toDouble(); | ||
| 31 | 51 | ||
| 32 | - return Stack( | ||
| 33 | - children: [ | ||
| 34 | - RefreshIndicator( | ||
| 35 | - onRefresh: controller.loadFriends, | ||
| 36 | - child: ListView( | ||
| 37 | - padding: EdgeInsets.fromLTRB( | ||
| 38 | - 16, | ||
| 39 | - 0, | ||
| 40 | - 16, | ||
| 41 | - hasFriends ? 188.dp : 172, | ||
| 42 | - ), | ||
| 43 | - children: [ | ||
| 44 | - const FriendHealthCard( | ||
| 45 | - name: '蓝胖子(我)', | ||
| 46 | - updatedAt: '更新于15:12', | ||
| 47 | - sleepQualityScore: 92, | ||
| 48 | - steps: '8424步', | ||
| 49 | - statusText: '注意压力', | ||
| 50 | - stressValue: 72, | ||
| 51 | - isSelf: true, | 52 | + return Stack( |
| 53 | + children: [ | ||
| 54 | + RefreshIndicator( | ||
| 55 | + onRefresh: controller.refreshData, | ||
| 56 | + child: ListView( | ||
| 57 | + physics: const AlwaysScrollableScrollPhysics(), | ||
| 58 | + padding: EdgeInsets.fromLTRB( | ||
| 59 | + 0, | ||
| 60 | + 0, | ||
| 61 | + 0, | ||
| 62 | + hasFriends ? 188.dp : 172, | ||
| 52 | ), | 63 | ), |
| 53 | - if (!hasFriends) | ||
| 54 | - const _EmptyFriendsView() | ||
| 55 | - else ...[ | ||
| 56 | - const SizedBox(height: 12), | ||
| 57 | - ...friends.map( | ||
| 58 | - (friend) => Padding( | ||
| 59 | - padding: const EdgeInsets.only(bottom: 12), | ||
| 60 | - child: FriendHealthCard( | ||
| 61 | - name: friend.name, | ||
| 62 | - updatedAt: friend.updatedAt, | ||
| 63 | - sleepQualityScore: friend.sleepQualityScore, | ||
| 64 | - steps: friend.steps, | ||
| 65 | - statusText: friend.statusText, | ||
| 66 | - stressValue: friend.stressValue, | ||
| 67 | - isOnWatchFace: friend.isOnWatchFace, | ||
| 68 | - onTap: () => _openFriendTrend(friend), | ||
| 69 | - onMoreSelected: (action) { | ||
| 70 | - _handleFriendAction(action, friend); | ||
| 71 | - }, | ||
| 72 | - ), | ||
| 73 | - ), | ||
| 74 | - ), | ||
| 75 | - ], | ||
| 76 | - if (!hasFriends) ...[ | ||
| 77 | - const SizedBox(height: 8), | ||
| 78 | - _AddFriendButton( | ||
| 79 | - enabled: !isFull, | ||
| 80 | - onTap: isFull ? null : _handleAddFriend, | ||
| 81 | - ), | ||
| 82 | - if (isFull) const _FullFriendsTip(), | ||
| 83 | - ], | ||
| 84 | - ], | ||
| 85 | - ), | ||
| 86 | - ), | ||
| 87 | - if (hasFriends) | ||
| 88 | - Positioned( | ||
| 89 | - left: 0, | ||
| 90 | - right: 0, | ||
| 91 | - bottom: _floatingAddButtonBottom(context), | ||
| 92 | - child: Column( | ||
| 93 | children: [ | 64 | children: [ |
| 94 | - _AddFriendButton( | ||
| 95 | - enabled: !isFull, | ||
| 96 | - friendCount: friends.length, | ||
| 97 | - maxFriends: FriendsController.maxFriends, | ||
| 98 | - onTap: isFull ? null : _handleAddFriend, | 65 | + _SelfHealthCard( |
| 66 | + name: | ||
| 67 | + currentUser?.nickname?.trim().isNotEmpty == | ||
| 68 | + true | ||
| 69 | + ? currentUser!.nickname!.trim() | ||
| 70 | + : '-', | ||
| 71 | + avatarUrl: currentUser?.avatar, | ||
| 72 | + updatedAt: updatedAt == null | ||
| 73 | + ? context.l10n.friendsWaitingForData | ||
| 74 | + : context.l10n.friendsUpdatedAt( | ||
| 75 | + DateFormat('HH:mm').format(updatedAt), | ||
| 76 | + ), | ||
| 77 | + sleepQualityScore: | ||
| 78 | + _normalizedScore(healthData?.sleepScore), | ||
| 79 | + steps: healthData?.steps == null | ||
| 80 | + ? null | ||
| 81 | + : context.l10n.friendsStepCount( | ||
| 82 | + healthData!.steps!, | ||
| 83 | + ), | ||
| 84 | + statusText: stressValue == null | ||
| 85 | + ? context.l10n.friendsWaitingForData | ||
| 86 | + : HrvStressLevel.fromRealtimeStress( | ||
| 87 | + stressValue, | ||
| 88 | + ).label, | ||
| 89 | + stressValue: stressValue, | ||
| 90 | + isLoading: isSelfInitialLoading, | ||
| 99 | ), | 91 | ), |
| 100 | - if (isFull) const _FullFriendsTip(), | 92 | + if (isFriendsInitialLoading) |
| 93 | + const _FriendsLoadingIndicator() | ||
| 94 | + else if (!hasFriends) | ||
| 95 | + const _EmptyFriendsView() | ||
| 96 | + else ...[ | ||
| 97 | + const SizedBox(height: 12), | ||
| 98 | + ...friends.map( | ||
| 99 | + (friend) => Padding( | ||
| 100 | + padding: const EdgeInsets.fromLTRB( | ||
| 101 | + 16, | ||
| 102 | + 0, | ||
| 103 | + 16, | ||
| 104 | + 12, | ||
| 105 | + ), | ||
| 106 | + child: FriendHealthCard( | ||
| 107 | + name: friend.name, | ||
| 108 | + remark: friend.remark, | ||
| 109 | + avatarUrl: friend.avatarUrl, | ||
| 110 | + updatedAt: friend.updatedAt, | ||
| 111 | + sleepQualityScore: | ||
| 112 | + friend.sleepQualityScore, | ||
| 113 | + steps: friend.steps, | ||
| 114 | + statusText: friend.statusText, | ||
| 115 | + stressValue: friend.stressValue, | ||
| 116 | + isOnWatchFace: friend.isOnWatchFace, | ||
| 117 | + onTap: () => _openFriendHome(friend), | ||
| 118 | + onMoreSelected: (action) { | ||
| 119 | + _handleFriendAction(action, friend); | ||
| 120 | + }, | ||
| 121 | + ), | ||
| 122 | + ), | ||
| 123 | + ), | ||
| 124 | + ], | ||
| 125 | + if (!hasFriends && !isFriendsInitialLoading) ...[ | ||
| 126 | + const SizedBox(height: 8), | ||
| 127 | + _AddFriendButton( | ||
| 128 | + enabled: !isFull, | ||
| 129 | + onTap: isFull ? null : _handleAddFriend, | ||
| 130 | + ), | ||
| 131 | + if (isFull) const _FullFriendsTip(), | ||
| 132 | + ], | ||
| 101 | ], | 133 | ], |
| 102 | ), | 134 | ), |
| 103 | ), | 135 | ), |
| 104 | - ], | ||
| 105 | - ); | ||
| 106 | - }, | 136 | + if (hasFriends) |
| 137 | + Positioned( | ||
| 138 | + left: 0, | ||
| 139 | + right: 0, | ||
| 140 | + bottom: _floatingAddButtonBottom(context), | ||
| 141 | + child: Column( | ||
| 142 | + children: [ | ||
| 143 | + _AddFriendButton( | ||
| 144 | + enabled: !isFull, | ||
| 145 | + friendCount: friends.length, | ||
| 146 | + maxFriends: FriendsController.maxFriends, | ||
| 147 | + onTap: isFull ? null : _handleAddFriend, | ||
| 148 | + ), | ||
| 149 | + if (isFull) const _FullFriendsTip(), | ||
| 150 | + ], | ||
| 151 | + ), | ||
| 152 | + ), | ||
| 153 | + ], | ||
| 154 | + ); | ||
| 155 | + }, | ||
| 156 | + ), | ||
| 107 | ), | 157 | ), |
| 108 | ), | 158 | ), |
| 109 | ], | 159 | ], |
| @@ -122,20 +172,14 @@ class FriendsTab extends GetView<FriendsController> { | @@ -122,20 +172,14 @@ class FriendsTab extends GetView<FriendsController> { | ||
| 122 | } | 172 | } |
| 123 | 173 | ||
| 124 | Future<void> _handleAddFriend() async { | 174 | Future<void> _handleAddFriend() async { |
| 125 | - await Get.toNamed(Routes.ADD_FRIEND); | ||
| 126 | - await controller.loadFriends(); | 175 | + final didAddFriend = await Get.toNamed(Routes.ADD_FRIEND); |
| 176 | + if (didAddFriend == true) await controller.loadFriends(); | ||
| 127 | } | 177 | } |
| 128 | 178 | ||
| 129 | - void _openFriendTrend(FriendHealthData friend) { | ||
| 130 | - final userId = friend.userId; | ||
| 131 | - if (userId == null) return; | 179 | + void _openFriendHome(FriendHealthData friend) { |
| 132 | Get.toNamed( | 180 | Get.toNamed( |
| 133 | - Routes.FRIEND_TREND, | ||
| 134 | - arguments: FriendTrendArguments( | ||
| 135 | - userId: userId, | ||
| 136 | - name: friend.name, | ||
| 137 | - avatarUrl: friend.avatarUrl, | ||
| 138 | - ), | 181 | + Routes.FRIEND_HOME, |
| 182 | + arguments: friend.friendItem, | ||
| 139 | ); | 183 | ); |
| 140 | } | 184 | } |
| 141 | 185 | ||
| @@ -146,24 +190,87 @@ class FriendsTab extends GetView<FriendsController> { | @@ -146,24 +190,87 @@ class FriendsTab extends GetView<FriendsController> { | ||
| 146 | case FriendCardAction.remove: | 190 | case FriendCardAction.remove: |
| 147 | controller.showDeleteConfirmDialog(friend); | 191 | controller.showDeleteConfirmDialog(friend); |
| 148 | case FriendCardAction.watchFace: | 192 | case FriendCardAction.watchFace: |
| 149 | - _showSelectFriendSheet(); | 193 | + controller.applyWatchFaceSelection(friend); |
| 150 | } | 194 | } |
| 151 | } | 195 | } |
| 152 | 196 | ||
| 153 | - void _showSelectFriendSheet() { | ||
| 154 | - final context = Get.context; | ||
| 155 | - if (context == null) return; | 197 | + int? _normalizedScore(double? value) { |
| 198 | + if (value == null || !value.isFinite) return null; | ||
| 199 | + return value.round().clamp(0, 100); | ||
| 200 | + } | ||
| 201 | +} | ||
| 202 | + | ||
| 203 | +class _SelfHealthCard extends StatelessWidget { | ||
| 204 | + const _SelfHealthCard({ | ||
| 205 | + required this.name, | ||
| 206 | + required this.avatarUrl, | ||
| 207 | + required this.updatedAt, | ||
| 208 | + required this.sleepQualityScore, | ||
| 209 | + required this.steps, | ||
| 210 | + required this.statusText, | ||
| 211 | + required this.stressValue, | ||
| 212 | + required this.isLoading, | ||
| 213 | + }); | ||
| 214 | + | ||
| 215 | + final String name; | ||
| 216 | + final String? avatarUrl; | ||
| 217 | + final String updatedAt; | ||
| 218 | + final int? sleepQualityScore; | ||
| 219 | + final String? steps; | ||
| 220 | + final String statusText; | ||
| 221 | + final double? stressValue; | ||
| 222 | + final bool isLoading; | ||
| 223 | + | ||
| 224 | + @override | ||
| 225 | + Widget build(BuildContext context) { | ||
| 226 | + const borderRadius = BorderRadius.vertical(bottom: Radius.circular(12)); | ||
| 227 | + return Stack( | ||
| 228 | + children: [ | ||
| 229 | + FriendHealthCard( | ||
| 230 | + name: name, | ||
| 231 | + avatarUrl: avatarUrl, | ||
| 232 | + updatedAt: updatedAt, | ||
| 233 | + sleepQualityScore: sleepQualityScore, | ||
| 234 | + steps: steps, | ||
| 235 | + statusText: statusText, | ||
| 236 | + stressValue: stressValue, | ||
| 237 | + isSelf: true, | ||
| 238 | + borderRadius: borderRadius, | ||
| 239 | + contentPadding: const EdgeInsets.fromLTRB(36, 20, 35, 20), | ||
| 240 | + ), | ||
| 241 | + if (isLoading) | ||
| 242 | + const Positioned.fill( | ||
| 243 | + child: DecoratedBox( | ||
| 244 | + decoration: BoxDecoration( | ||
| 245 | + color: Color(0xCCFFFFFF), | ||
| 246 | + borderRadius: borderRadius, | ||
| 247 | + ), | ||
| 248 | + child: Center( | ||
| 249 | + child: SizedBox.square( | ||
| 250 | + dimension: 28, | ||
| 251 | + child: CircularProgressIndicator(strokeWidth: 2.5), | ||
| 252 | + ), | ||
| 253 | + ), | ||
| 254 | + ), | ||
| 255 | + ), | ||
| 256 | + ], | ||
| 257 | + ); | ||
| 258 | + } | ||
| 259 | +} | ||
| 260 | + | ||
| 261 | +class _FriendsLoadingIndicator extends StatelessWidget { | ||
| 262 | + const _FriendsLoadingIndicator(); | ||
| 156 | 263 | ||
| 157 | - final mediaQuery = MediaQuery.of(context); | ||
| 158 | - Get.bottomSheet<void>( | ||
| 159 | - SizedBox( | ||
| 160 | - height: mediaQuery.size.height - mediaQuery.viewPadding.top, | ||
| 161 | - child: SelectFriendView(friendsController: controller), | 264 | + @override |
| 265 | + Widget build(BuildContext context) { | ||
| 266 | + return const Padding( | ||
| 267 | + padding: EdgeInsets.symmetric(vertical: 48), | ||
| 268 | + child: Center( | ||
| 269 | + child: SizedBox.square( | ||
| 270 | + dimension: 24, | ||
| 271 | + child: CircularProgressIndicator(strokeWidth: 2.5), | ||
| 272 | + ), | ||
| 162 | ), | 273 | ), |
| 163 | - barrierColor: Colors.black.withValues(alpha: 0.7), | ||
| 164 | - enableDrag: true, | ||
| 165 | - isScrollControlled: true, | ||
| 166 | - persistent: false, | ||
| 167 | ); | 274 | ); |
| 168 | } | 275 | } |
| 169 | } | 276 | } |
| @@ -179,9 +286,9 @@ class _FriendsHeader extends StatelessWidget { | @@ -179,9 +286,9 @@ class _FriendsHeader extends StatelessWidget { | ||
| 179 | padding: const EdgeInsets.symmetric(horizontal: 16), | 286 | padding: const EdgeInsets.symmetric(horizontal: 16), |
| 180 | child: Row( | 287 | child: Row( |
| 181 | children: [ | 288 | children: [ |
| 182 | - const Text( | ||
| 183 | - '好友', | ||
| 184 | - style: TextStyle( | 289 | + Text( |
| 290 | + context.l10n.tabFriends, | ||
| 291 | + style: const TextStyle( | ||
| 185 | color: Color(0xFF0F0F11), | 292 | color: Color(0xFF0F0F11), |
| 186 | fontSize: 24, | 293 | fontSize: 24, |
| 187 | fontWeight: FontWeight.w600, | 294 | fontWeight: FontWeight.w600, |
| @@ -237,9 +344,9 @@ class _EmptyFriendsView extends StatelessWidget { | @@ -237,9 +344,9 @@ class _EmptyFriendsView extends StatelessWidget { | ||
| 237 | ), | 344 | ), |
| 238 | ), | 345 | ), |
| 239 | const SizedBox(height: 16), | 346 | const SizedBox(height: 16), |
| 240 | - const Text( | ||
| 241 | - '添加亲密联系人,多一个人关注你的健康', | ||
| 242 | - style: TextStyle( | 347 | + Text( |
| 348 | + context.l10n.friendsAddCloseContactDescription, | ||
| 349 | + style: const TextStyle( | ||
| 243 | color: Color(0xFF78787D), | 350 | color: Color(0xFF78787D), |
| 244 | fontSize: 12, | 351 | fontSize: 12, |
| 245 | fontWeight: FontWeight.w400, | 352 | fontWeight: FontWeight.w400, |
| @@ -256,12 +363,12 @@ class _FullFriendsTip extends StatelessWidget { | @@ -256,12 +363,12 @@ class _FullFriendsTip extends StatelessWidget { | ||
| 256 | 363 | ||
| 257 | @override | 364 | @override |
| 258 | Widget build(BuildContext context) { | 365 | Widget build(BuildContext context) { |
| 259 | - return const Padding( | ||
| 260 | - padding: EdgeInsets.only(top: 10), | 366 | + return Padding( |
| 367 | + padding: const EdgeInsets.only(top: 10), | ||
| 261 | child: Center( | 368 | child: Center( |
| 262 | child: Text( | 369 | child: Text( |
| 263 | - '好友数量已达上限', | ||
| 264 | - style: TextStyle( | 370 | + context.l10n.friendsLimitReached, |
| 371 | + style: const TextStyle( | ||
| 265 | color: Color(0xFFB0B0B6), | 372 | color: Color(0xFFB0B0B6), |
| 266 | fontSize: 12, | 373 | fontSize: 12, |
| 267 | fontWeight: FontWeight.w400, | 374 | fontWeight: FontWeight.w400, |
| @@ -312,7 +419,7 @@ class _AddFriendButton extends StatelessWidget { | @@ -312,7 +419,7 @@ class _AddFriendButton extends StatelessWidget { | ||
| 312 | ), | 419 | ), |
| 313 | const SizedBox(width: 6), | 420 | const SizedBox(width: 6), |
| 314 | Text( | 421 | Text( |
| 315 | - _buttonText, | 422 | + _buttonText(context), |
| 316 | style: const TextStyle( | 423 | style: const TextStyle( |
| 317 | color: Colors.white, | 424 | color: Colors.white, |
| 318 | fontSize: 16, | 425 | fontSize: 16, |
| @@ -328,10 +435,12 @@ class _AddFriendButton extends StatelessWidget { | @@ -328,10 +435,12 @@ class _AddFriendButton extends StatelessWidget { | ||
| 328 | ); | 435 | ); |
| 329 | } | 436 | } |
| 330 | 437 | ||
| 331 | - String get _buttonText { | 438 | + String _buttonText(BuildContext context) { |
| 332 | final count = friendCount; | 439 | final count = friendCount; |
| 333 | final max = maxFriends; | 440 | final max = maxFriends; |
| 334 | - if (count == null || max == null) return '添加亲密联系人'; | ||
| 335 | - return '添加亲密联系人($count/$max)'; | 441 | + if (count == null || max == null) { |
| 442 | + return context.l10n.friendsAddCloseContact; | ||
| 443 | + } | ||
| 444 | + return context.l10n.friendsAddCloseContactWithCount(count, max); | ||
| 336 | } | 445 | } |
| 337 | } | 446 | } |
| 1 | import 'package:doublefeel_flutter/core/theme/app_colors.dart'; | 1 | import 'package:doublefeel_flutter/core/theme/app_colors.dart'; |
| 2 | import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | 2 | import 'package:doublefeel_flutter/core/util/size_extensions.dart'; |
| 3 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 3 | import 'package:flutter/material.dart'; | 4 | import 'package:flutter/material.dart'; |
| 4 | import 'package:get/get.dart'; | 5 | import 'package:get/get.dart'; |
| 5 | 6 | ||
| 6 | -import '../controllers/friends_controller.dart'; | ||
| 7 | import '../controllers/select_friend_logic.dart'; | 7 | import '../controllers/select_friend_logic.dart'; |
| 8 | import '../widgets/friend_health_card.dart'; | 8 | import '../widgets/friend_health_card.dart'; |
| 9 | 9 | ||
| 10 | class SelectFriendView extends StatefulWidget { | 10 | class SelectFriendView extends StatefulWidget { |
| 11 | - const SelectFriendView({super.key, required this.friendsController}); | ||
| 12 | - | ||
| 13 | - final FriendsController friendsController; | 11 | + const SelectFriendView({super.key}); |
| 14 | 12 | ||
| 15 | @override | 13 | @override |
| 16 | State<SelectFriendView> createState() => _SelectFriendViewState(); | 14 | State<SelectFriendView> createState() => _SelectFriendViewState(); |
| @@ -22,7 +20,7 @@ class _SelectFriendViewState extends State<SelectFriendView> { | @@ -22,7 +20,7 @@ class _SelectFriendViewState extends State<SelectFriendView> { | ||
| 22 | @override | 20 | @override |
| 23 | void initState() { | 21 | void initState() { |
| 24 | super.initState(); | 22 | super.initState(); |
| 25 | - _logic = SelectFriendLogic(widget.friendsController); | 23 | + _logic = SelectFriendLogic(); |
| 26 | _logic.initialize(); | 24 | _logic.initialize(); |
| 27 | } | 25 | } |
| 28 | 26 | ||
| @@ -47,8 +45,7 @@ class _SelectFriendViewState extends State<SelectFriendView> { | @@ -47,8 +45,7 @@ class _SelectFriendViewState extends State<SelectFriendView> { | ||
| 47 | _SelectFriendHeader(onClose: () => Get.back<void>()), | 45 | _SelectFriendHeader(onClose: () => Get.back<void>()), |
| 48 | Expanded( | 46 | Expanded( |
| 49 | child: Obx(() { | 47 | child: Obx(() { |
| 50 | - if (widget.friendsController.isLoading.value && | ||
| 51 | - _logic.friends.isEmpty) { | 48 | + if (_logic.isLoading.value && _logic.friends.isEmpty) { |
| 52 | return const Center(child: CircularProgressIndicator()); | 49 | return const Center(child: CircularProgressIndicator()); |
| 53 | } | 50 | } |
| 54 | 51 | ||
| @@ -65,6 +62,8 @@ class _SelectFriendViewState extends State<SelectFriendView> { | @@ -65,6 +62,8 @@ class _SelectFriendViewState extends State<SelectFriendView> { | ||
| 65 | final isSelected = selectedIndex == index; | 62 | final isSelected = selectedIndex == index; |
| 66 | return FriendHealthCard( | 63 | return FriendHealthCard( |
| 67 | name: friend.name, | 64 | name: friend.name, |
| 65 | + remark: friend.remark, | ||
| 66 | + avatarUrl: friend.avatarUrl, | ||
| 68 | updatedAt: friend.updatedAt, | 67 | updatedAt: friend.updatedAt, |
| 69 | sleepQualityScore: friend.sleepQualityScore, | 68 | sleepQualityScore: friend.sleepQualityScore, |
| 70 | steps: friend.steps, | 69 | steps: friend.steps, |
| @@ -94,10 +93,9 @@ class _SelectFriendViewState extends State<SelectFriendView> { | @@ -94,10 +93,9 @@ class _SelectFriendViewState extends State<SelectFriendView> { | ||
| 94 | ); | 93 | ); |
| 95 | } | 94 | } |
| 96 | 95 | ||
| 97 | - void _syncSelectedFriend() { | ||
| 98 | - if (_logic.syncSelectedFriendToWatchFace()) { | ||
| 99 | - Get.back<void>(); | ||
| 100 | - } | 96 | + Future<void> _syncSelectedFriend() async { |
| 97 | + final selectedFriend = await _logic.syncSelectedFriendToWatchFace(); | ||
| 98 | + if (selectedFriend != null) Get.back(result: selectedFriend); | ||
| 101 | } | 99 | } |
| 102 | } | 100 | } |
| 103 | 101 | ||
| @@ -130,11 +128,11 @@ class _SelectFriendHeader extends StatelessWidget { | @@ -130,11 +128,11 @@ class _SelectFriendHeader extends StatelessWidget { | ||
| 130 | ), | 128 | ), |
| 131 | ), | 129 | ), |
| 132 | ), | 130 | ), |
| 133 | - const Expanded( | 131 | + Expanded( |
| 134 | child: Center( | 132 | child: Center( |
| 135 | child: Text( | 133 | child: Text( |
| 136 | - '选择好友', | ||
| 137 | - style: TextStyle( | 134 | + context.l10n.friendsSelect, |
| 135 | + style: const TextStyle( | ||
| 138 | color: AppColors.textPrimary, | 136 | color: AppColors.textPrimary, |
| 139 | fontSize: 16, | 137 | fontSize: 16, |
| 140 | fontWeight: FontWeight.w600, | 138 | fontWeight: FontWeight.w600, |
| @@ -167,9 +165,9 @@ class _SyncButton extends StatelessWidget { | @@ -167,9 +165,9 @@ class _SyncButton extends StatelessWidget { | ||
| 167 | color: AppColors.primary, | 165 | color: AppColors.primary, |
| 168 | borderRadius: BorderRadius.circular(24.dp), | 166 | borderRadius: BorderRadius.circular(24.dp), |
| 169 | ), | 167 | ), |
| 170 | - child: const Text( | ||
| 171 | - '选择并同步至表盘', | ||
| 172 | - style: TextStyle( | 168 | + child: Text( |
| 169 | + context.l10n.friendsSelectAndSync, | ||
| 170 | + style: const TextStyle( | ||
| 173 | color: Colors.white, | 171 | color: Colors.white, |
| 174 | fontSize: 16, | 172 | fontSize: 16, |
| 175 | fontWeight: FontWeight.w600, | 173 | fontWeight: FontWeight.w600, |
| 1 | import 'dart:math' as math; | 1 | import 'dart:math' as math; |
| 2 | 2 | ||
| 3 | +import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart'; | ||
| 3 | import 'package:doublefeel_flutter/app/modules/sleep_report/models/sleep_report_models.dart'; | 4 | import 'package:doublefeel_flutter/app/modules/sleep_report/models/sleep_report_models.dart'; |
| 5 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 4 | import 'package:doublefeel_flutter/r.dart'; | 6 | import 'package:doublefeel_flutter/r.dart'; |
| 5 | import 'package:flutter/material.dart'; | 7 | import 'package:flutter/material.dart'; |
| 6 | 8 | ||
| @@ -14,6 +16,8 @@ class FriendHealthCard extends StatelessWidget { | @@ -14,6 +16,8 @@ class FriendHealthCard extends StatelessWidget { | ||
| 14 | const FriendHealthCard({ | 16 | const FriendHealthCard({ |
| 15 | super.key, | 17 | super.key, |
| 16 | required this.name, | 18 | required this.name, |
| 19 | + this.remark, | ||
| 20 | + this.avatarUrl, | ||
| 17 | required this.updatedAt, | 21 | required this.updatedAt, |
| 18 | required this.sleepQualityScore, | 22 | required this.sleepQualityScore, |
| 19 | required this.steps, | 23 | required this.steps, |
| @@ -23,11 +27,15 @@ class FriendHealthCard extends StatelessWidget { | @@ -23,11 +27,15 @@ class FriendHealthCard extends StatelessWidget { | ||
| 23 | this.isOnWatchFace = false, | 27 | this.isOnWatchFace = false, |
| 24 | this.isSelected = false, | 28 | this.isSelected = false, |
| 25 | this.showCheckbox = false, | 29 | this.showCheckbox = false, |
| 30 | + this.borderRadius, | ||
| 31 | + this.contentPadding, | ||
| 26 | this.onTap, | 32 | this.onTap, |
| 27 | this.onMoreSelected, | 33 | this.onMoreSelected, |
| 28 | }); | 34 | }); |
| 29 | 35 | ||
| 30 | final String name; | 36 | final String name; |
| 37 | + final String? remark; | ||
| 38 | + final String? avatarUrl; | ||
| 31 | final String updatedAt; | 39 | final String updatedAt; |
| 32 | final int? sleepQualityScore; | 40 | final int? sleepQualityScore; |
| 33 | final String? steps; | 41 | final String? steps; |
| @@ -37,13 +45,14 @@ class FriendHealthCard extends StatelessWidget { | @@ -37,13 +45,14 @@ class FriendHealthCard extends StatelessWidget { | ||
| 37 | final bool isOnWatchFace; | 45 | final bool isOnWatchFace; |
| 38 | final bool isSelected; | 46 | final bool isSelected; |
| 39 | final bool showCheckbox; | 47 | final bool showCheckbox; |
| 48 | + final BorderRadius? borderRadius; | ||
| 49 | + final EdgeInsetsGeometry? contentPadding; | ||
| 40 | final VoidCallback? onTap; | 50 | final VoidCallback? onTap; |
| 41 | final ValueChanged<FriendCardAction>? onMoreSelected; | 51 | final ValueChanged<FriendCardAction>? onMoreSelected; |
| 42 | 52 | ||
| 43 | @override | 53 | @override |
| 44 | Widget build(BuildContext context) { | 54 | Widget build(BuildContext context) { |
| 45 | final sleepQualityLevel = sleepQualityLevelFromScore(sleepQualityScore); | 55 | final sleepQualityLevel = sleepQualityLevelFromScore(sleepQualityScore); |
| 46 | - final hasData = sleepQualityScore != null || steps != null; | ||
| 47 | 56 | ||
| 48 | return GestureDetector( | 57 | return GestureDetector( |
| 49 | onTap: onTap, | 58 | onTap: onTap, |
| @@ -54,10 +63,11 @@ class FriendHealthCard extends StatelessWidget { | @@ -54,10 +63,11 @@ class FriendHealthCard extends StatelessWidget { | ||
| 54 | AnimatedContainer( | 63 | AnimatedContainer( |
| 55 | duration: const Duration(milliseconds: 180), | 64 | duration: const Duration(milliseconds: 180), |
| 56 | curve: Curves.easeOut, | 65 | curve: Curves.easeOut, |
| 57 | - padding: const EdgeInsets.fromLTRB(20, 20, 16, 20), | 66 | + padding: |
| 67 | + contentPadding ?? const EdgeInsets.fromLTRB(20, 20, 16, 20), | ||
| 58 | decoration: BoxDecoration( | 68 | decoration: BoxDecoration( |
| 59 | color: Colors.white, | 69 | color: Colors.white, |
| 60 | - borderRadius: BorderRadius.circular(20), | 70 | + borderRadius: borderRadius ?? BorderRadius.circular(20), |
| 61 | border: Border.all( | 71 | border: Border.all( |
| 62 | color: | 72 | color: |
| 63 | isSelected ? const Color(0xFF845EEE) : Colors.transparent, | 73 | isSelected ? const Color(0xFF845EEE) : Colors.transparent, |
| @@ -68,9 +78,12 @@ class FriendHealthCard extends StatelessWidget { | @@ -68,9 +78,12 @@ class FriendHealthCard extends StatelessWidget { | ||
| 68 | children: [ | 78 | children: [ |
| 69 | _FriendCardHeader( | 79 | _FriendCardHeader( |
| 70 | name: name, | 80 | name: name, |
| 81 | + remark: remark, | ||
| 82 | + avatarUrl: avatarUrl, | ||
| 71 | updatedAt: updatedAt, | 83 | updatedAt: updatedAt, |
| 72 | isSelf: isSelf, | 84 | isSelf: isSelf, |
| 73 | - showMoreButton: !showCheckbox, | 85 | + isOnWatchFace: isOnWatchFace, |
| 86 | + showMoreButton: !showCheckbox && !isSelf, | ||
| 74 | onMoreSelected: onMoreSelected, | 87 | onMoreSelected: onMoreSelected, |
| 75 | ), | 88 | ), |
| 76 | const SizedBox(height: 17), | 89 | const SizedBox(height: 17), |
| @@ -81,7 +94,7 @@ class FriendHealthCard extends StatelessWidget { | @@ -81,7 +94,7 @@ class FriendHealthCard extends StatelessWidget { | ||
| 81 | child: _StatusFigure( | 94 | child: _StatusFigure( |
| 82 | statusText: statusText, | 95 | statusText: statusText, |
| 83 | stressValue: stressValue, | 96 | stressValue: stressValue, |
| 84 | - waiting: !hasData, | 97 | + waiting: stressValue == null, |
| 85 | ), | 98 | ), |
| 86 | ), | 99 | ), |
| 87 | const SizedBox(width: 18), | 100 | const SizedBox(width: 18), |
| @@ -91,8 +104,9 @@ class FriendHealthCard extends StatelessWidget { | @@ -91,8 +104,9 @@ class FriendHealthCard extends StatelessWidget { | ||
| 91 | children: [ | 104 | children: [ |
| 92 | _MetricTile( | 105 | _MetricTile( |
| 93 | iconPath: R.assetsImagesHealthFriendSleepQuality, | 106 | iconPath: R.assetsImagesHealthFriendSleepQuality, |
| 94 | - title: '睡眠质量', | ||
| 95 | - value: sleepQualityLevel.shortText, | 107 | + title: context.l10n.friendsSleepQuality, |
| 108 | + value: | ||
| 109 | + _sleepQualityText(context, sleepQualityLevel), | ||
| 96 | valueColor: sleepQualityScore == null | 110 | valueColor: sleepQualityScore == null |
| 97 | ? null | 111 | ? null |
| 98 | : Color(sleepQualityLevel.qualityColorValue), | 112 | : Color(sleepQualityLevel.qualityColorValue), |
| @@ -100,7 +114,7 @@ class FriendHealthCard extends StatelessWidget { | @@ -100,7 +114,7 @@ class FriendHealthCard extends StatelessWidget { | ||
| 100 | const SizedBox(height: 4), | 114 | const SizedBox(height: 4), |
| 101 | _MetricTile( | 115 | _MetricTile( |
| 102 | iconPath: R.assetsImagesHealthFriendSteps, | 116 | iconPath: R.assetsImagesHealthFriendSteps, |
| 103 | - title: '今日步数', | 117 | + title: context.l10n.friendsTodaySteps, |
| 104 | value: steps ?? '-', | 118 | value: steps ?? '-', |
| 105 | ), | 119 | ), |
| 106 | ], | 120 | ], |
| @@ -132,35 +146,75 @@ class FriendHealthCard extends StatelessWidget { | @@ -132,35 +146,75 @@ class FriendHealthCard extends StatelessWidget { | ||
| 132 | ), | 146 | ), |
| 133 | ); | 147 | ); |
| 134 | } | 148 | } |
| 149 | + | ||
| 150 | + String _sleepQualityText( | ||
| 151 | + BuildContext context, | ||
| 152 | + SleepQualityLevel level, | ||
| 153 | + ) { | ||
| 154 | + switch (level) { | ||
| 155 | + case SleepQualityLevel.excellent: | ||
| 156 | + return context.l10n.friendsSleepQualityExcellent; | ||
| 157 | + case SleepQualityLevel.good: | ||
| 158 | + return context.l10n.friendsSleepQualityNormal; | ||
| 159 | + case SleepQualityLevel.poor: | ||
| 160 | + return context.l10n.friendsSleepQualityAttention; | ||
| 161 | + case SleepQualityLevel.unknown: | ||
| 162 | + return '-'; | ||
| 163 | + } | ||
| 164 | + } | ||
| 135 | } | 165 | } |
| 136 | 166 | ||
| 137 | class _FriendCardHeader extends StatelessWidget { | 167 | class _FriendCardHeader extends StatelessWidget { |
| 138 | const _FriendCardHeader({ | 168 | const _FriendCardHeader({ |
| 139 | required this.name, | 169 | required this.name, |
| 170 | + required this.remark, | ||
| 171 | + required this.avatarUrl, | ||
| 140 | required this.updatedAt, | 172 | required this.updatedAt, |
| 141 | required this.isSelf, | 173 | required this.isSelf, |
| 174 | + required this.isOnWatchFace, | ||
| 142 | required this.showMoreButton, | 175 | required this.showMoreButton, |
| 143 | this.onMoreSelected, | 176 | this.onMoreSelected, |
| 144 | }); | 177 | }); |
| 145 | 178 | ||
| 146 | final String name; | 179 | final String name; |
| 180 | + final String? remark; | ||
| 181 | + final String? avatarUrl; | ||
| 147 | final String updatedAt; | 182 | final String updatedAt; |
| 148 | final bool isSelf; | 183 | final bool isSelf; |
| 184 | + final bool isOnWatchFace; | ||
| 149 | final bool showMoreButton; | 185 | final bool showMoreButton; |
| 150 | final ValueChanged<FriendCardAction>? onMoreSelected; | 186 | final ValueChanged<FriendCardAction>? onMoreSelected; |
| 151 | 187 | ||
| 152 | @override | 188 | @override |
| 153 | Widget build(BuildContext context) { | 189 | Widget build(BuildContext context) { |
| 190 | + final friendRemark = remark?.trim(); | ||
| 191 | + final hasRemark = friendRemark?.isNotEmpty == true; | ||
| 192 | + final primaryName = !isSelf && hasRemark ? friendRemark! : name; | ||
| 193 | + final suffix = isSelf | ||
| 194 | + ? context.l10n.friendsMe | ||
| 195 | + : hasRemark | ||
| 196 | + ? name | ||
| 197 | + : null; | ||
| 198 | + | ||
| 154 | return Row( | 199 | return Row( |
| 155 | children: [ | 200 | children: [ |
| 156 | - const _Avatar(), | 201 | + _Avatar(avatarUrl: avatarUrl), |
| 157 | const SizedBox(width: 12), | 202 | const SizedBox(width: 12), |
| 158 | Expanded( | 203 | Expanded( |
| 159 | child: Column( | 204 | child: Column( |
| 160 | crossAxisAlignment: CrossAxisAlignment.start, | 205 | crossAxisAlignment: CrossAxisAlignment.start, |
| 161 | children: [ | 206 | children: [ |
| 162 | - Text( | ||
| 163 | - name, | 207 | + Text.rich( |
| 208 | + TextSpan( | ||
| 209 | + children: [ | ||
| 210 | + TextSpan(text: primaryName), | ||
| 211 | + if (suffix != null) | ||
| 212 | + TextSpan( | ||
| 213 | + text: context.l10n.friendsRemarkSuffix(suffix), | ||
| 214 | + style: const TextStyle(color: Color(0xFF78787D)), | ||
| 215 | + ), | ||
| 216 | + ], | ||
| 217 | + ), | ||
| 164 | maxLines: 1, | 218 | maxLines: 1, |
| 165 | overflow: TextOverflow.ellipsis, | 219 | overflow: TextOverflow.ellipsis, |
| 166 | style: const TextStyle( | 220 | style: const TextStyle( |
| @@ -183,7 +237,7 @@ class _FriendCardHeader extends StatelessWidget { | @@ -183,7 +237,7 @@ class _FriendCardHeader extends StatelessWidget { | ||
| 183 | ], | 237 | ], |
| 184 | ), | 238 | ), |
| 185 | ), | 239 | ), |
| 186 | - if (showMoreButton && !isSelf) | 240 | + if (showMoreButton) |
| 187 | PopupMenuButton<FriendCardAction>( | 241 | PopupMenuButton<FriendCardAction>( |
| 188 | onSelected: onMoreSelected, | 242 | onSelected: onMoreSelected, |
| 189 | elevation: 10, | 243 | elevation: 10, |
| @@ -193,23 +247,26 @@ class _FriendCardHeader extends StatelessWidget { | @@ -193,23 +247,26 @@ class _FriendCardHeader extends StatelessWidget { | ||
| 193 | shape: RoundedRectangleBorder( | 247 | shape: RoundedRectangleBorder( |
| 194 | borderRadius: BorderRadius.circular(12), | 248 | borderRadius: BorderRadius.circular(12), |
| 195 | ), | 249 | ), |
| 196 | - itemBuilder: (context) => const [ | 250 | + itemBuilder: (context) => [ |
| 197 | PopupMenuItem( | 251 | PopupMenuItem( |
| 198 | height: 42, | 252 | height: 42, |
| 199 | value: FriendCardAction.remove, | 253 | value: FriendCardAction.remove, |
| 200 | - child: Center(child: Text('删除Ta')), | 254 | + child: Center(child: Text(context.l10n.friendsRemove)), |
| 201 | ), | 255 | ), |
| 202 | PopupMenuItem( | 256 | PopupMenuItem( |
| 203 | height: 42, | 257 | height: 42, |
| 204 | padding: EdgeInsets.symmetric(vertical: 0), | 258 | padding: EdgeInsets.symmetric(vertical: 0), |
| 205 | value: FriendCardAction.editRemark, | 259 | value: FriendCardAction.editRemark, |
| 206 | - child: Center(child: Text('修改备注')), | ||
| 207 | - ), | ||
| 208 | - PopupMenuItem( | ||
| 209 | - height: 42, | ||
| 210 | - value: FriendCardAction.watchFace, | ||
| 211 | - child: Center(child: Text('在表盘显示')), | 260 | + child: Center(child: Text(context.l10n.friendsEditRemark)), |
| 212 | ), | 261 | ), |
| 262 | + if (!isOnWatchFace) | ||
| 263 | + PopupMenuItem( | ||
| 264 | + height: 42, | ||
| 265 | + value: FriendCardAction.watchFace, | ||
| 266 | + child: Center( | ||
| 267 | + child: Text(context.l10n.friendsShowOnWatchFace), | ||
| 268 | + ), | ||
| 269 | + ), | ||
| 213 | ], | 270 | ], |
| 214 | child: Padding( | 271 | child: Padding( |
| 215 | padding: const EdgeInsets.all(6), | 272 | padding: const EdgeInsets.all(6), |
| @@ -226,13 +283,17 @@ class _FriendCardHeader extends StatelessWidget { | @@ -226,13 +283,17 @@ class _FriendCardHeader extends StatelessWidget { | ||
| 226 | } | 283 | } |
| 227 | 284 | ||
| 228 | class _Avatar extends StatelessWidget { | 285 | class _Avatar extends StatelessWidget { |
| 229 | - const _Avatar(); | 286 | + const _Avatar({this.avatarUrl}); |
| 287 | + | ||
| 288 | + final String? avatarUrl; | ||
| 230 | 289 | ||
| 231 | @override | 290 | @override |
| 232 | Widget build(BuildContext context) { | 291 | Widget build(BuildContext context) { |
| 292 | + final imageUrl = avatarUrl?.trim(); | ||
| 233 | return Container( | 293 | return Container( |
| 234 | width: 36, | 294 | width: 36, |
| 235 | height: 36, | 295 | height: 36, |
| 296 | + clipBehavior: Clip.antiAlias, | ||
| 236 | decoration: const BoxDecoration( | 297 | decoration: const BoxDecoration( |
| 237 | shape: BoxShape.circle, | 298 | shape: BoxShape.circle, |
| 238 | gradient: LinearGradient( | 299 | gradient: LinearGradient( |
| @@ -244,11 +305,26 @@ class _Avatar extends StatelessWidget { | @@ -244,11 +305,26 @@ class _Avatar extends StatelessWidget { | ||
| 244 | ], | 305 | ], |
| 245 | ), | 306 | ), |
| 246 | ), | 307 | ), |
| 247 | - child: const Icon( | ||
| 248 | - Icons.person_rounded, | ||
| 249 | - size: 22, | ||
| 250 | - color: Colors.white, | ||
| 251 | - ), | 308 | + child: imageUrl?.isNotEmpty == true |
| 309 | + ? Image.network( | ||
| 310 | + imageUrl!, | ||
| 311 | + fit: BoxFit.cover, | ||
| 312 | + errorBuilder: (_, __, ___) => const _AvatarFallback(), | ||
| 313 | + ) | ||
| 314 | + : const _AvatarFallback(), | ||
| 315 | + ); | ||
| 316 | + } | ||
| 317 | +} | ||
| 318 | + | ||
| 319 | +class _AvatarFallback extends StatelessWidget { | ||
| 320 | + const _AvatarFallback(); | ||
| 321 | + | ||
| 322 | + @override | ||
| 323 | + Widget build(BuildContext context) { | ||
| 324 | + return const Icon( | ||
| 325 | + Icons.person_rounded, | ||
| 326 | + size: 22, | ||
| 327 | + color: Colors.white, | ||
| 252 | ); | 328 | ); |
| 253 | } | 329 | } |
| 254 | } | 330 | } |
| @@ -266,6 +342,10 @@ class _StatusFigure extends StatelessWidget { | @@ -266,6 +342,10 @@ class _StatusFigure extends StatelessWidget { | ||
| 266 | 342 | ||
| 267 | @override | 343 | @override |
| 268 | Widget build(BuildContext context) { | 344 | Widget build(BuildContext context) { |
| 345 | + final stressLevel = stressValue == null | ||
| 346 | + ? null | ||
| 347 | + : HrvStressLevel.fromRealtimeStress(stressValue!); | ||
| 348 | + | ||
| 269 | return SizedBox( | 349 | return SizedBox( |
| 270 | height: 144, | 350 | height: 144, |
| 271 | child: Column( | 351 | child: Column( |
| @@ -288,27 +368,12 @@ class _StatusFigure extends StatelessWidget { | @@ -288,27 +368,12 @@ class _StatusFigure extends StatelessWidget { | ||
| 288 | Positioned( | 368 | Positioned( |
| 289 | left: 22, | 369 | left: 22, |
| 290 | top: 24, | 370 | top: 24, |
| 291 | - child: Container( | 371 | + child: Image.asset( |
| 372 | + stressLevel?.realtimeStressIconPath ?? | ||
| 373 | + R.assetsImagesRealtimeStressNoDataIcon, | ||
| 292 | width: 80, | 374 | width: 80, |
| 293 | height: 80, | 375 | height: 80, |
| 294 | - decoration: BoxDecoration( | ||
| 295 | - color: waiting | ||
| 296 | - ? const Color(0xFFF3F3F3) | ||
| 297 | - : const Color(0xFF5AE1A8), | ||
| 298 | - shape: BoxShape.circle, | ||
| 299 | - ), | ||
| 300 | - child: Center( | ||
| 301 | - child: Container( | ||
| 302 | - width: 6, | ||
| 303 | - height: 6, | ||
| 304 | - decoration: BoxDecoration( | ||
| 305 | - color: waiting | ||
| 306 | - ? const Color(0xFFD0D0D2) | ||
| 307 | - : const Color(0xFF0F0F11), | ||
| 308 | - shape: BoxShape.circle, | ||
| 309 | - ), | ||
| 310 | - ), | ||
| 311 | - ), | 376 | + fit: BoxFit.contain, |
| 312 | ), | 377 | ), |
| 313 | ), | 378 | ), |
| 314 | ], | 379 | ], |
| @@ -545,10 +610,10 @@ class _WatchFaceBadge extends StatelessWidget { | @@ -545,10 +610,10 @@ class _WatchFaceBadge extends StatelessWidget { | ||
| 545 | bottomLeft: Radius.circular(12), | 610 | bottomLeft: Radius.circular(12), |
| 546 | ), | 611 | ), |
| 547 | ), | 612 | ), |
| 548 | - child: const Center( | 613 | + child: Center( |
| 549 | child: Text( | 614 | child: Text( |
| 550 | - '已在表盘显示Ta', | ||
| 551 | - style: TextStyle( | 615 | + context.l10n.friendsShownOnWatchFace, |
| 616 | + style: const TextStyle( | ||
| 552 | color: Colors.white, | 617 | color: Colors.white, |
| 553 | fontSize: 10, | 618 | fontSize: 10, |
| 554 | fontWeight: FontWeight.w500, | 619 | fontWeight: FontWeight.w500, |
| 1 | +import 'package:get/get.dart'; | ||
| 2 | + | ||
| 3 | +import '../../report_common/config/report_date_range_config.dart'; | ||
| 4 | +import '../../report_common/models/report_period.dart'; | ||
| 5 | + | ||
| 6 | +mixin HealthTrendControl on GetxController { | ||
| 7 | + final selectedTypeIndex = 0.obs; | ||
| 8 | + final selectedPeriod = ReportPeriod.week.obs; | ||
| 9 | + final selectedDate = ReportDateRangeConfig.clampDate(DateTime.now()).obs; | ||
| 10 | + | ||
| 11 | + void changeType(int index) { | ||
| 12 | + final nextIndex = normalizeTypeIndex(index); | ||
| 13 | + selectedTypeIndex.value = nextIndex; | ||
| 14 | + selectedPeriod.value = normalizePeriod(nextIndex, selectedPeriod.value); | ||
| 15 | + } | ||
| 16 | + | ||
| 17 | + void changePeriod(ReportPeriod period) { | ||
| 18 | + selectedPeriod.value = normalizePeriod(selectedTypeIndex.value, period); | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + void changeDate(DateTime date) { | ||
| 22 | + selectedDate.value = ReportDateRangeConfig.clampDate(date); | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + int normalizeTypeIndex(int index) => index >= 0 && index < 3 ? index : 0; | ||
| 26 | + | ||
| 27 | + ReportPeriod normalizePeriod(int typeIndex, ReportPeriod period) { | ||
| 28 | + final periods = supportedPeriods(normalizeTypeIndex(typeIndex)); | ||
| 29 | + return periods.contains(period) ? period : periods.first; | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + List<ReportPeriod> supportedPeriods(int typeIndex) { | ||
| 33 | + return normalizeTypeIndex(typeIndex) == 0 | ||
| 34 | + ? const [ReportPeriod.week, ReportPeriod.month, ReportPeriod.year] | ||
| 35 | + : const [ReportPeriod.day, ReportPeriod.week, ReportPeriod.month]; | ||
| 36 | + } | ||
| 37 | +} |
| @@ -9,6 +9,7 @@ import '../../home/widgets/trend/trend_type_tab_bar.dart'; | @@ -9,6 +9,7 @@ import '../../home/widgets/trend/trend_type_tab_bar.dart'; | ||
| 9 | import '../../hrv_report/controllers/hrv_report_logic.dart'; | 9 | import '../../hrv_report/controllers/hrv_report_logic.dart'; |
| 10 | import '../../hrv_report/views/hrv_report_view.dart'; | 10 | import '../../hrv_report/views/hrv_report_view.dart'; |
| 11 | import '../../report_common/models/health_report_query.dart'; | 11 | import '../../report_common/models/health_report_query.dart'; |
| 12 | +import '../../report_common/models/report_period.dart'; | ||
| 12 | import '../../report_common/widgets/health_report_subject_scope.dart'; | 13 | import '../../report_common/widgets/health_report_subject_scope.dart'; |
| 13 | import '../../sleep_report/controllers/sleep_report_logic.dart'; | 14 | import '../../sleep_report/controllers/sleep_report_logic.dart'; |
| 14 | import '../../sleep_report/views/sleep_report_view.dart'; | 15 | import '../../sleep_report/views/sleep_report_view.dart'; |
| @@ -19,11 +20,15 @@ class HealthTrendContent extends StatefulWidget { | @@ -19,11 +20,15 @@ class HealthTrendContent extends StatefulWidget { | ||
| 19 | required this.query, | 20 | required this.query, |
| 20 | required this.selectedTypeIndex, | 21 | required this.selectedTypeIndex, |
| 21 | required this.onTypeChanged, | 22 | required this.onTypeChanged, |
| 23 | + required this.onPeriodChanged, | ||
| 24 | + required this.onDateChanged, | ||
| 22 | }); | 25 | }); |
| 23 | 26 | ||
| 24 | final HealthReportQuery query; | 27 | final HealthReportQuery query; |
| 25 | final int selectedTypeIndex; | 28 | final int selectedTypeIndex; |
| 26 | final ValueChanged<int> onTypeChanged; | 29 | final ValueChanged<int> onTypeChanged; |
| 30 | + final ValueChanged<ReportPeriod> onPeriodChanged; | ||
| 31 | + final ValueChanged<DateTime> onDateChanged; | ||
| 27 | 32 | ||
| 28 | @override | 33 | @override |
| 29 | State<HealthTrendContent> createState() => _HealthTrendContentState(); | 34 | State<HealthTrendContent> createState() => _HealthTrendContentState(); |
| @@ -31,32 +36,50 @@ class HealthTrendContent extends StatefulWidget { | @@ -31,32 +36,50 @@ class HealthTrendContent extends StatefulWidget { | ||
| 31 | 36 | ||
| 32 | class _HealthTrendContentState extends State<HealthTrendContent> | 37 | class _HealthTrendContentState extends State<HealthTrendContent> |
| 33 | with SingleTickerProviderStateMixin { | 38 | with SingleTickerProviderStateMixin { |
| 39 | + static const _typeCount = 3; | ||
| 40 | + | ||
| 34 | late final TabController _tabController; | 41 | late final TabController _tabController; |
| 35 | 42 | ||
| 36 | @override | 43 | @override |
| 37 | void initState() { | 44 | void initState() { |
| 38 | super.initState(); | 45 | super.initState(); |
| 46 | + final initialIndex = _normalizedTypeIndex(widget.selectedTypeIndex); | ||
| 39 | _tabController = TabController( | 47 | _tabController = TabController( |
| 40 | - length: 3, | 48 | + length: _typeCount, |
| 41 | vsync: this, | 49 | vsync: this, |
| 42 | - initialIndex: widget.selectedTypeIndex, | 50 | + initialIndex: initialIndex, |
| 43 | ); | 51 | ); |
| 44 | _tabController.addListener(() { | 52 | _tabController.addListener(() { |
| 45 | if (!_tabController.indexIsChanging) { | 53 | if (!_tabController.indexIsChanging) { |
| 46 | widget.onTypeChanged(_tabController.index); | 54 | widget.onTypeChanged(_tabController.index); |
| 47 | } | 55 | } |
| 48 | }); | 56 | }); |
| 57 | + _correctInvalidTypeIndex(widget.selectedTypeIndex, initialIndex); | ||
| 49 | } | 58 | } |
| 50 | 59 | ||
| 51 | @override | 60 | @override |
| 52 | void didUpdateWidget(covariant HealthTrendContent oldWidget) { | 61 | void didUpdateWidget(covariant HealthTrendContent oldWidget) { |
| 53 | super.didUpdateWidget(oldWidget); | 62 | super.didUpdateWidget(oldWidget); |
| 54 | - if (oldWidget.selectedTypeIndex != widget.selectedTypeIndex && | ||
| 55 | - _tabController.index != widget.selectedTypeIndex) { | ||
| 56 | - _tabController.animateTo(widget.selectedTypeIndex); | 63 | + if (oldWidget.selectedTypeIndex != widget.selectedTypeIndex) { |
| 64 | + final nextIndex = _normalizedTypeIndex(widget.selectedTypeIndex); | ||
| 65 | + if (_tabController.index != nextIndex) { | ||
| 66 | + _tabController.animateTo(nextIndex); | ||
| 67 | + } | ||
| 68 | + _correctInvalidTypeIndex(widget.selectedTypeIndex, nextIndex); | ||
| 57 | } | 69 | } |
| 58 | } | 70 | } |
| 59 | 71 | ||
| 72 | + int _normalizedTypeIndex(int index) { | ||
| 73 | + return index >= 0 && index < _typeCount ? index : 0; | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + void _correctInvalidTypeIndex(int requestedIndex, int normalizedIndex) { | ||
| 77 | + if (requestedIndex == normalizedIndex) return; | ||
| 78 | + WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| 79 | + if (mounted) widget.onTypeChanged(normalizedIndex); | ||
| 80 | + }); | ||
| 81 | + } | ||
| 82 | + | ||
| 60 | @override | 83 | @override |
| 61 | void dispose() { | 84 | void dispose() { |
| 62 | _tabController.dispose(); | 85 | _tabController.dispose(); |
| @@ -86,6 +109,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> | @@ -86,6 +109,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> | ||
| 86 | query: widget.query, | 109 | query: widget.query, |
| 87 | isVip: isVip, | 110 | isVip: isVip, |
| 88 | onSubscribe: openPurchase, | 111 | onSubscribe: openPurchase, |
| 112 | + onPeriodChanged: widget.onPeriodChanged, | ||
| 113 | + onDateChanged: widget.onDateChanged, | ||
| 89 | ), | 114 | ), |
| 90 | ), | 115 | ), |
| 91 | _KeepAliveWrapper( | 116 | _KeepAliveWrapper( |
| @@ -93,6 +118,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> | @@ -93,6 +118,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> | ||
| 93 | query: widget.query, | 118 | query: widget.query, |
| 94 | isVip: isVip, | 119 | isVip: isVip, |
| 95 | onSubscribe: openPurchase, | 120 | onSubscribe: openPurchase, |
| 121 | + onPeriodChanged: widget.onPeriodChanged, | ||
| 122 | + onDateChanged: widget.onDateChanged, | ||
| 96 | ), | 123 | ), |
| 97 | ), | 124 | ), |
| 98 | _KeepAliveWrapper( | 125 | _KeepAliveWrapper( |
| @@ -100,6 +127,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> | @@ -100,6 +127,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> | ||
| 100 | query: widget.query, | 127 | query: widget.query, |
| 101 | isVip: isVip, | 128 | isVip: isVip, |
| 102 | onSubscribe: openPurchase, | 129 | onSubscribe: openPurchase, |
| 130 | + onPeriodChanged: widget.onPeriodChanged, | ||
| 131 | + onDateChanged: widget.onDateChanged, | ||
| 103 | ), | 132 | ), |
| 104 | ), | 133 | ), |
| 105 | ], | 134 | ], |
| @@ -117,11 +146,15 @@ class _HrvTrendSection extends StatefulWidget { | @@ -117,11 +146,15 @@ class _HrvTrendSection extends StatefulWidget { | ||
| 117 | required this.query, | 146 | required this.query, |
| 118 | required this.isVip, | 147 | required this.isVip, |
| 119 | required this.onSubscribe, | 148 | required this.onSubscribe, |
| 149 | + required this.onPeriodChanged, | ||
| 150 | + required this.onDateChanged, | ||
| 120 | }); | 151 | }); |
| 121 | 152 | ||
| 122 | final HealthReportQuery query; | 153 | final HealthReportQuery query; |
| 123 | final bool isVip; | 154 | final bool isVip; |
| 124 | final VoidCallback onSubscribe; | 155 | final VoidCallback onSubscribe; |
| 156 | + final ValueChanged<ReportPeriod> onPeriodChanged; | ||
| 157 | + final ValueChanged<DateTime> onDateChanged; | ||
| 125 | 158 | ||
| 126 | @override | 159 | @override |
| 127 | State<_HrvTrendSection> createState() => _HrvTrendSectionState(); | 160 | State<_HrvTrendSection> createState() => _HrvTrendSectionState(); |
| @@ -129,11 +162,21 @@ class _HrvTrendSection extends StatefulWidget { | @@ -129,11 +162,21 @@ class _HrvTrendSection extends StatefulWidget { | ||
| 129 | 162 | ||
| 130 | class _HrvTrendSectionState extends State<_HrvTrendSection> { | 163 | class _HrvTrendSectionState extends State<_HrvTrendSection> { |
| 131 | late final HrvReportLogic _logic; | 164 | late final HrvReportLogic _logic; |
| 165 | + late final Worker _periodWorker; | ||
| 166 | + late final Worker _dateWorker; | ||
| 167 | + var _syncingExternalQuery = false; | ||
| 132 | 168 | ||
| 133 | @override | 169 | @override |
| 134 | void initState() { | 170 | void initState() { |
| 135 | super.initState(); | 171 | super.initState(); |
| 136 | _logic = HrvReportLogic(initialTargetUserId: widget.query.targetUserId); | 172 | _logic = HrvReportLogic(initialTargetUserId: widget.query.targetUserId); |
| 173 | + _logic.initializeQuery(widget.query.period, widget.query.date); | ||
| 174 | + _periodWorker = ever(_logic.selectedPeriod, (period) { | ||
| 175 | + if (!_syncingExternalQuery) widget.onPeriodChanged(period); | ||
| 176 | + }); | ||
| 177 | + _dateWorker = ever(_logic.selectedDate, (date) { | ||
| 178 | + if (!_syncingExternalQuery) widget.onDateChanged(date); | ||
| 179 | + }); | ||
| 137 | _logic.loadReport(); | 180 | _logic.loadReport(); |
| 138 | } | 181 | } |
| 139 | 182 | ||
| @@ -141,12 +184,24 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { | @@ -141,12 +184,24 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { | ||
| 141 | void didUpdateWidget(covariant _HrvTrendSection oldWidget) { | 184 | void didUpdateWidget(covariant _HrvTrendSection oldWidget) { |
| 142 | super.didUpdateWidget(oldWidget); | 185 | super.didUpdateWidget(oldWidget); |
| 143 | if (oldWidget.query != widget.query) { | 186 | if (oldWidget.query != widget.query) { |
| 144 | - _logic.updateTargetUserId(widget.query.targetUserId); | 187 | + _syncExternalQuery(); |
| 188 | + } | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + Future<void> _syncExternalQuery() async { | ||
| 192 | + _syncingExternalQuery = true; | ||
| 193 | + _logic.targetUserId.value = widget.query.targetUserId; | ||
| 194 | + try { | ||
| 195 | + await _logic.selectQuery(widget.query.period, widget.query.date); | ||
| 196 | + } finally { | ||
| 197 | + _syncingExternalQuery = false; | ||
| 145 | } | 198 | } |
| 146 | } | 199 | } |
| 147 | 200 | ||
| 148 | @override | 201 | @override |
| 149 | void dispose() { | 202 | void dispose() { |
| 203 | + _periodWorker.dispose(); | ||
| 204 | + _dateWorker.dispose(); | ||
| 150 | _logic.dispose(); | 205 | _logic.dispose(); |
| 151 | super.dispose(); | 206 | super.dispose(); |
| 152 | } | 207 | } |
| @@ -164,11 +219,15 @@ class _ActivityBurnTrendSection extends StatefulWidget { | @@ -164,11 +219,15 @@ class _ActivityBurnTrendSection extends StatefulWidget { | ||
| 164 | required this.query, | 219 | required this.query, |
| 165 | required this.isVip, | 220 | required this.isVip, |
| 166 | required this.onSubscribe, | 221 | required this.onSubscribe, |
| 222 | + required this.onPeriodChanged, | ||
| 223 | + required this.onDateChanged, | ||
| 167 | }); | 224 | }); |
| 168 | 225 | ||
| 169 | final HealthReportQuery query; | 226 | final HealthReportQuery query; |
| 170 | final bool isVip; | 227 | final bool isVip; |
| 171 | final VoidCallback onSubscribe; | 228 | final VoidCallback onSubscribe; |
| 229 | + final ValueChanged<ReportPeriod> onPeriodChanged; | ||
| 230 | + final ValueChanged<DateTime> onDateChanged; | ||
| 172 | 231 | ||
| 173 | @override | 232 | @override |
| 174 | State<_ActivityBurnTrendSection> createState() => | 233 | State<_ActivityBurnTrendSection> createState() => |
| @@ -177,6 +236,9 @@ class _ActivityBurnTrendSection extends StatefulWidget { | @@ -177,6 +236,9 @@ class _ActivityBurnTrendSection extends StatefulWidget { | ||
| 177 | 236 | ||
| 178 | class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { | 237 | class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { |
| 179 | late final ActivityBurnReportLogic _logic; | 238 | late final ActivityBurnReportLogic _logic; |
| 239 | + late final Worker _periodWorker; | ||
| 240 | + late final Worker _dateWorker; | ||
| 241 | + var _syncingExternalQuery = false; | ||
| 180 | 242 | ||
| 181 | @override | 243 | @override |
| 182 | void initState() { | 244 | void initState() { |
| @@ -184,6 +246,13 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { | @@ -184,6 +246,13 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { | ||
| 184 | _logic = ActivityBurnReportLogic( | 246 | _logic = ActivityBurnReportLogic( |
| 185 | initialTargetUserId: widget.query.targetUserId, | 247 | initialTargetUserId: widget.query.targetUserId, |
| 186 | ); | 248 | ); |
| 249 | + _logic.initializeQuery(widget.query.period, widget.query.date); | ||
| 250 | + _periodWorker = ever(_logic.selectedPeriod, (period) { | ||
| 251 | + if (!_syncingExternalQuery) widget.onPeriodChanged(period); | ||
| 252 | + }); | ||
| 253 | + _dateWorker = ever(_logic.selectedDate, (date) { | ||
| 254 | + if (!_syncingExternalQuery) widget.onDateChanged(date); | ||
| 255 | + }); | ||
| 187 | _logic.loadReport(); | 256 | _logic.loadReport(); |
| 188 | } | 257 | } |
| 189 | 258 | ||
| @@ -191,12 +260,24 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { | @@ -191,12 +260,24 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { | ||
| 191 | void didUpdateWidget(covariant _ActivityBurnTrendSection oldWidget) { | 260 | void didUpdateWidget(covariant _ActivityBurnTrendSection oldWidget) { |
| 192 | super.didUpdateWidget(oldWidget); | 261 | super.didUpdateWidget(oldWidget); |
| 193 | if (oldWidget.query != widget.query) { | 262 | if (oldWidget.query != widget.query) { |
| 194 | - _logic.updateTargetUserId(widget.query.targetUserId); | 263 | + _syncExternalQuery(); |
| 264 | + } | ||
| 265 | + } | ||
| 266 | + | ||
| 267 | + Future<void> _syncExternalQuery() async { | ||
| 268 | + _syncingExternalQuery = true; | ||
| 269 | + _logic.targetUserId.value = widget.query.targetUserId; | ||
| 270 | + try { | ||
| 271 | + await _logic.selectQuery(widget.query.period, widget.query.date); | ||
| 272 | + } finally { | ||
| 273 | + _syncingExternalQuery = false; | ||
| 195 | } | 274 | } |
| 196 | } | 275 | } |
| 197 | 276 | ||
| 198 | @override | 277 | @override |
| 199 | void dispose() { | 278 | void dispose() { |
| 279 | + _periodWorker.dispose(); | ||
| 280 | + _dateWorker.dispose(); | ||
| 200 | _logic.dispose(); | 281 | _logic.dispose(); |
| 201 | super.dispose(); | 282 | super.dispose(); |
| 202 | } | 283 | } |
| @@ -214,11 +295,15 @@ class _SleepTrendSection extends StatefulWidget { | @@ -214,11 +295,15 @@ class _SleepTrendSection extends StatefulWidget { | ||
| 214 | required this.query, | 295 | required this.query, |
| 215 | required this.isVip, | 296 | required this.isVip, |
| 216 | required this.onSubscribe, | 297 | required this.onSubscribe, |
| 298 | + required this.onPeriodChanged, | ||
| 299 | + required this.onDateChanged, | ||
| 217 | }); | 300 | }); |
| 218 | 301 | ||
| 219 | final HealthReportQuery query; | 302 | final HealthReportQuery query; |
| 220 | final bool isVip; | 303 | final bool isVip; |
| 221 | final VoidCallback onSubscribe; | 304 | final VoidCallback onSubscribe; |
| 305 | + final ValueChanged<ReportPeriod> onPeriodChanged; | ||
| 306 | + final ValueChanged<DateTime> onDateChanged; | ||
| 222 | 307 | ||
| 223 | @override | 308 | @override |
| 224 | State<_SleepTrendSection> createState() => _SleepTrendSectionState(); | 309 | State<_SleepTrendSection> createState() => _SleepTrendSectionState(); |
| @@ -226,6 +311,9 @@ class _SleepTrendSection extends StatefulWidget { | @@ -226,6 +311,9 @@ class _SleepTrendSection extends StatefulWidget { | ||
| 226 | 311 | ||
| 227 | class _SleepTrendSectionState extends State<_SleepTrendSection> { | 312 | class _SleepTrendSectionState extends State<_SleepTrendSection> { |
| 228 | late final SleepReportLogic _logic; | 313 | late final SleepReportLogic _logic; |
| 314 | + late final Worker _periodWorker; | ||
| 315 | + late final Worker _dateWorker; | ||
| 316 | + var _syncingExternalQuery = false; | ||
| 229 | 317 | ||
| 230 | @override | 318 | @override |
| 231 | void initState() { | 319 | void initState() { |
| @@ -233,6 +321,13 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | @@ -233,6 +321,13 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | ||
| 233 | _logic = SleepReportLogic( | 321 | _logic = SleepReportLogic( |
| 234 | initialTargetUserId: widget.query.targetUserId, | 322 | initialTargetUserId: widget.query.targetUserId, |
| 235 | ); | 323 | ); |
| 324 | + _logic.initializeQuery(widget.query.period, widget.query.date); | ||
| 325 | + _periodWorker = ever(_logic.selectedPeriod, (period) { | ||
| 326 | + if (!_syncingExternalQuery) widget.onPeriodChanged(period); | ||
| 327 | + }); | ||
| 328 | + _dateWorker = ever(_logic.selectedDate, (date) { | ||
| 329 | + if (!_syncingExternalQuery) widget.onDateChanged(date); | ||
| 330 | + }); | ||
| 236 | _logic.loadReport(); | 331 | _logic.loadReport(); |
| 237 | } | 332 | } |
| 238 | 333 | ||
| @@ -240,12 +335,24 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | @@ -240,12 +335,24 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | ||
| 240 | void didUpdateWidget(covariant _SleepTrendSection oldWidget) { | 335 | void didUpdateWidget(covariant _SleepTrendSection oldWidget) { |
| 241 | super.didUpdateWidget(oldWidget); | 336 | super.didUpdateWidget(oldWidget); |
| 242 | if (oldWidget.query != widget.query) { | 337 | if (oldWidget.query != widget.query) { |
| 243 | - _logic.updateTargetUserId(widget.query.targetUserId); | 338 | + _syncExternalQuery(); |
| 339 | + } | ||
| 340 | + } | ||
| 341 | + | ||
| 342 | + Future<void> _syncExternalQuery() async { | ||
| 343 | + _syncingExternalQuery = true; | ||
| 344 | + _logic.targetUserId.value = widget.query.targetUserId; | ||
| 345 | + try { | ||
| 346 | + await _logic.selectQuery(widget.query.period, widget.query.date); | ||
| 347 | + } finally { | ||
| 348 | + _syncingExternalQuery = false; | ||
| 244 | } | 349 | } |
| 245 | } | 350 | } |
| 246 | 351 | ||
| 247 | @override | 352 | @override |
| 248 | void dispose() { | 353 | void dispose() { |
| 354 | + _periodWorker.dispose(); | ||
| 355 | + _dateWorker.dispose(); | ||
| 249 | _logic.dispose(); | 356 | _logic.dispose(); |
| 250 | super.dispose(); | 357 | super.dispose(); |
| 251 | } | 358 | } |
| 1 | import 'package:doublefeel_flutter/core/services/user_state_service.dart'; | 1 | import 'package:doublefeel_flutter/core/services/user_state_service.dart'; |
| 2 | +import 'package:doublefeel_flutter/data/local/local_storage.dart'; | ||
| 3 | +import 'package:flutter/material.dart'; | ||
| 2 | import 'package:get/get.dart'; | 4 | import 'package:get/get.dart'; |
| 3 | 5 | ||
| 6 | +import '../../app_review_prompt/logic/app_review_prompt_logic.dart'; | ||
| 7 | +import '../../report_common/models/report_period.dart'; | ||
| 4 | import 'trend/trend_controller.dart'; | 8 | import 'trend/trend_controller.dart'; |
| 5 | 9 | ||
| 6 | class HomeController extends GetxController { | 10 | class HomeController extends GetxController { |
| 7 | static const trendTabIndex = 1; | 11 | static const trendTabIndex = 1; |
| 8 | 12 | ||
| 13 | + final AppReviewPromptLogic _appReviewPromptLogic = AppReviewPromptLogic( | ||
| 14 | + storage: Get.find<LocalStorage>(), | ||
| 15 | + ); | ||
| 9 | final UserStateService userStateService = Get.find<UserStateService>(); | 16 | final UserStateService userStateService = Get.find<UserStateService>(); |
| 10 | 17 | ||
| 11 | /// 当前选中的底部 tab 索引 | 18 | /// 当前选中的底部 tab 索引 |
| 12 | final selectedIndex = 0.obs; | 19 | final selectedIndex = 0.obs; |
| 13 | 20 | ||
| 21 | + Future<void> maybeShowAppReviewPrompt(BuildContext context) { | ||
| 22 | + return _appReviewPromptLogic.maybeShowPrompt(context); | ||
| 23 | + } | ||
| 24 | + | ||
| 14 | void changeTab(int index) { | 25 | void changeTab(int index) { |
| 15 | selectedIndex.value = index; | 26 | selectedIndex.value = index; |
| 16 | } | 27 | } |
| 17 | 28 | ||
| 18 | - void openTrend(TrendType type) { | ||
| 19 | - Get.find<TrendController>().selectType(type); | 29 | + void openTrend(TrendType type, {DateTime? date, ReportPeriod? period}) { |
| 30 | + Get.find<TrendController>().selectType(type, date: date, period: period); | ||
| 20 | changeTab(trendTabIndex); | 31 | changeTab(trendTabIndex); |
| 21 | } | 32 | } |
| 22 | } | 33 | } |
| 1 | import 'dart:async'; | 1 | import 'dart:async'; |
| 2 | -import 'dart:math'; | ||
| 3 | 2 | ||
| 4 | import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart'; | 3 | import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart'; |
| 5 | import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart'; | 4 | import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart'; |
| @@ -379,7 +378,7 @@ class TodayController extends GetxController { | @@ -379,7 +378,7 @@ class TodayController extends GetxController { | ||
| 379 | if (isFriend) { | 378 | if (isFriend) { |
| 380 | Get.toNamed( | 379 | Get.toNamed( |
| 381 | Routes.FRIEND_TREND, | 380 | Routes.FRIEND_TREND, |
| 382 | - arguments: getFriendTrendArguments(), | 381 | + arguments: getFriendTrendArguments(TrendType.hrv), |
| 383 | ); | 382 | ); |
| 384 | } else { | 383 | } else { |
| 385 | Get.find<HomeController>().openTrend(TrendType.hrv); | 384 | Get.find<HomeController>().openTrend(TrendType.hrv); |
| @@ -388,7 +387,10 @@ class TodayController extends GetxController { | @@ -388,7 +387,10 @@ class TodayController extends GetxController { | ||
| 388 | 387 | ||
| 389 | toTrendSleepPage() { | 388 | toTrendSleepPage() { |
| 390 | if (isFriend) { | 389 | if (isFriend) { |
| 391 | - Get.toNamed(Routes.FRIEND_TREND, arguments: getFriendTrendArguments()); | 390 | + Get.toNamed( |
| 391 | + Routes.FRIEND_TREND, | ||
| 392 | + arguments: getFriendTrendArguments(TrendType.sleep), | ||
| 393 | + ); | ||
| 392 | } else { | 394 | } else { |
| 393 | Get.find<HomeController>().openTrend(TrendType.sleep); | 395 | Get.find<HomeController>().openTrend(TrendType.sleep); |
| 394 | } | 396 | } |
| @@ -396,23 +398,20 @@ class TodayController extends GetxController { | @@ -396,23 +398,20 @@ class TodayController extends GetxController { | ||
| 396 | 398 | ||
| 397 | toTrendActivityPage() { | 399 | toTrendActivityPage() { |
| 398 | if (isFriend) { | 400 | if (isFriend) { |
| 399 | - Get.toNamed(Routes.FRIEND_TREND, arguments: getFriendTrendArguments()); | 401 | + Get.toNamed( |
| 402 | + Routes.FRIEND_TREND, | ||
| 403 | + arguments: getFriendTrendArguments(TrendType.activity), | ||
| 404 | + ); | ||
| 400 | } else { | 405 | } else { |
| 401 | Get.find<HomeController>().openTrend(TrendType.activity); | 406 | Get.find<HomeController>().openTrend(TrendType.activity); |
| 402 | } | 407 | } |
| 403 | } | 408 | } |
| 404 | 409 | ||
| 405 | - getFriendTrendArguments() { | ||
| 406 | - var value = targetFriendInfo.value; | ||
| 407 | - if (value != null && value.friendUserId != null) { | ||
| 408 | - return FriendTrendArguments( | ||
| 409 | - userId: value.friendUserId!, | ||
| 410 | - name: value.remarkName ?? '', | ||
| 411 | - avatarUrl: value.avatar, | ||
| 412 | - ); | ||
| 413 | - } else { | ||
| 414 | - return null; | ||
| 415 | - } | 410 | + FriendTrendArguments getFriendTrendArguments(TrendType type) { |
| 411 | + return FriendTrendArguments( | ||
| 412 | + friendItem: targetFriendInfo.value, | ||
| 413 | + initialTypeIndex: type.tabIndex, | ||
| 414 | + ); | ||
| 416 | } | 415 | } |
| 417 | 416 | ||
| 418 | void selectFriend(FriendItem friendInfo) { | 417 | void selectFriend(FriendItem friendInfo) { |
| 1 | import 'package:get/get.dart'; | 1 | import 'package:get/get.dart'; |
| 2 | 2 | ||
| 3 | +import '../../../health_trend/controllers/health_trend_control.dart'; | ||
| 3 | import '../../../report_common/models/health_report_query.dart'; | 4 | import '../../../report_common/models/health_report_query.dart'; |
| 5 | +import '../../../report_common/models/report_period.dart'; | ||
| 4 | 6 | ||
| 5 | enum TrendType { | 7 | enum TrendType { |
| 6 | hrv, | 8 | hrv, |
| @@ -12,24 +14,26 @@ enum TrendType { | @@ -12,24 +14,26 @@ enum TrendType { | ||
| 12 | 14 | ||
| 13 | /// 趋势页顶层 Controller,仅负责: | 15 | /// 趋势页顶层 Controller,仅负责: |
| 14 | /// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex) | 16 | /// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex) |
| 15 | -class TrendController extends GetxController { | ||
| 16 | - // 第1层类型 Tab(0 = HRV, 1 = 活动, 2 = 睡眠) | ||
| 17 | - final selectedTypeIndex = 0.obs; | ||
| 18 | - | 17 | +class TrendController extends GetxController with HealthTrendControl { |
| 19 | // 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。 | 18 | // 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。 |
| 20 | final targetUserId = RxnInt(); | 19 | final targetUserId = RxnInt(); |
| 21 | 20 | ||
| 22 | HealthReportQuery get query => HealthReportQuery( | 21 | HealthReportQuery get query => HealthReportQuery( |
| 23 | targetUserId: targetUserId.value, | 22 | targetUserId: targetUserId.value, |
| 23 | + period: selectedPeriod.value, | ||
| 24 | + date: selectedDate.value, | ||
| 24 | ); | 25 | ); |
| 25 | 26 | ||
| 26 | - void changeType(int index) { | ||
| 27 | - if (index == selectedTypeIndex.value) return; | ||
| 28 | - selectedTypeIndex.value = index; | 27 | + void selectType(TrendType type, {DateTime? date, ReportPeriod? period}) { |
| 28 | + changeType(type.tabIndex); | ||
| 29 | + if (period != null) { | ||
| 30 | + changePeriod(period); | ||
| 31 | + } | ||
| 32 | + if (date != null) { | ||
| 33 | + changeDate(date); | ||
| 34 | + } | ||
| 29 | } | 35 | } |
| 30 | 36 | ||
| 31 | - void selectType(TrendType type) => changeType(type.tabIndex); | ||
| 32 | - | ||
| 33 | void changeTargetUser(int? userId) { | 37 | void changeTargetUser(int? userId) { |
| 34 | if (userId == targetUserId.value) return; | 38 | if (userId == targetUserId.value) return; |
| 35 | targetUserId.value = userId; | 39 | targetUserId.value = userId; |
| @@ -20,23 +20,54 @@ class HomePage extends GetView<HomeController> { | @@ -20,23 +20,54 @@ class HomePage extends GetView<HomeController> { | ||
| 20 | 20 | ||
| 21 | @override | 21 | @override |
| 22 | Widget build(BuildContext context) { | 22 | Widget build(BuildContext context) { |
| 23 | - return Scaffold( | ||
| 24 | - // 不使用系统 AppBar,Today 页自带状态栏适配 | ||
| 25 | - extendBody: true, // 让内容延伸到 bottomNavigationBar 下方 | ||
| 26 | - extendBodyBehindAppBar: true, | ||
| 27 | - | ||
| 28 | - body: Obx( | ||
| 29 | - () => IndexedStack( | ||
| 30 | - index: controller.selectedIndex.value, | ||
| 31 | - children: _tabs, | 23 | + return _HomeReviewPromptGate( |
| 24 | + controller: controller, | ||
| 25 | + child: Scaffold( | ||
| 26 | + // 不使用系统 AppBar,Today 页自带状态栏适配 | ||
| 27 | + extendBody: true, // 让内容延伸到 bottomNavigationBar 下方 | ||
| 28 | + extendBodyBehindAppBar: true, | ||
| 29 | + | ||
| 30 | + body: Obx( | ||
| 31 | + () => IndexedStack( | ||
| 32 | + index: controller.selectedIndex.value, | ||
| 33 | + children: _tabs, | ||
| 34 | + ), | ||
| 32 | ), | 35 | ), |
| 33 | - ), | ||
| 34 | - bottomNavigationBar: Obx( | ||
| 35 | - () => DfTabBar( | ||
| 36 | - selectedIndex: controller.selectedIndex.value, | ||
| 37 | - onTap: controller.changeTab, | 36 | + bottomNavigationBar: Obx( |
| 37 | + () => DfTabBar( | ||
| 38 | + selectedIndex: controller.selectedIndex.value, | ||
| 39 | + onTap: controller.changeTab, | ||
| 40 | + ), | ||
| 38 | ), | 41 | ), |
| 39 | ), | 42 | ), |
| 40 | ); | 43 | ); |
| 41 | } | 44 | } |
| 42 | } | 45 | } |
| 46 | + | ||
| 47 | +class _HomeReviewPromptGate extends StatefulWidget { | ||
| 48 | + const _HomeReviewPromptGate({ | ||
| 49 | + required this.controller, | ||
| 50 | + required this.child, | ||
| 51 | + }); | ||
| 52 | + | ||
| 53 | + final HomeController controller; | ||
| 54 | + final Widget child; | ||
| 55 | + | ||
| 56 | + @override | ||
| 57 | + State<_HomeReviewPromptGate> createState() => _HomeReviewPromptGateState(); | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +class _HomeReviewPromptGateState extends State<_HomeReviewPromptGate> { | ||
| 61 | + @override | ||
| 62 | + void initState() { | ||
| 63 | + super.initState(); | ||
| 64 | + | ||
| 65 | + WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| 66 | + if (!mounted) return; | ||
| 67 | + widget.controller.maybeShowAppReviewPrompt(context); | ||
| 68 | + }); | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + @override | ||
| 72 | + Widget build(BuildContext context) => widget.child; | ||
| 73 | +} |
| @@ -33,6 +33,8 @@ class _HomeTrendBody extends GetView<TrendController> { | @@ -33,6 +33,8 @@ class _HomeTrendBody extends GetView<TrendController> { | ||
| 33 | query: controller.query, | 33 | query: controller.query, |
| 34 | selectedTypeIndex: controller.selectedTypeIndex.value, | 34 | selectedTypeIndex: controller.selectedTypeIndex.value, |
| 35 | onTypeChanged: controller.changeType, | 35 | onTypeChanged: controller.changeType, |
| 36 | + onPeriodChanged: controller.changePeriod, | ||
| 37 | + onDateChanged: controller.changeDate, | ||
| 36 | ), | 38 | ), |
| 37 | ); | 39 | ); |
| 38 | } | 40 | } |
| 1 | import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | 1 | import 'package:doublefeel_flutter/core/util/size_extensions.dart'; |
| 2 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 2 | import 'package:flutter/material.dart'; | 3 | import 'package:flutter/material.dart'; |
| 3 | 4 | ||
| 4 | /// 第1层:HRV心率 / 活动消耗 / 睡眠报告 Tab | 5 | /// 第1层:HRV心率 / 活动消耗 / 睡眠报告 Tab |
| @@ -40,10 +41,10 @@ class TrendTypeTabBar extends StatelessWidget { | @@ -40,10 +41,10 @@ class TrendTypeTabBar extends StatelessWidget { | ||
| 40 | fontSize: 18, | 41 | fontSize: 18, |
| 41 | fontWeight: FontWeight.w500, | 42 | fontWeight: FontWeight.w500, |
| 42 | ), | 43 | ), |
| 43 | - tabs: const [ | ||
| 44 | - Tab(text: 'HRV心率'), | ||
| 45 | - Tab(text: '活动消耗'), | ||
| 46 | - Tab(text: '睡眠报告'), | 44 | + tabs: [ |
| 45 | + Tab(text: context.l10n.trendHrvHeartRate), | ||
| 46 | + Tab(text: context.l10n.trendActivityBurn), | ||
| 47 | + Tab(text: context.l10n.trendSleepReport), | ||
| 47 | ], | 48 | ], |
| 48 | ); | 49 | ); |
| 49 | }, | 50 | }, |
| 1 | +import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | ||
| 1 | import 'package:get/get.dart'; | 2 | import 'package:get/get.dart'; |
| 2 | 3 | ||
| 3 | import '../../report_common/controllers/report_period_logic.dart'; | 4 | import '../../report_common/controllers/report_period_logic.dart'; |
| @@ -7,7 +8,13 @@ import '../data/hrv_report_repository.dart'; | @@ -7,7 +8,13 @@ import '../data/hrv_report_repository.dart'; | ||
| 7 | import '../models/hrv_report_models.dart'; | 8 | import '../models/hrv_report_models.dart'; |
| 8 | 9 | ||
| 9 | class HrvReportLogic extends ReportPeriodLogic { | 10 | class HrvReportLogic extends ReportPeriodLogic { |
| 10 | - HrvReportLogic({int? initialTargetUserId}) { | 11 | + HrvReportLogic({ |
| 12 | + int? initialTargetUserId, | ||
| 13 | + HrvReportRepository? repository, | ||
| 14 | + }) : repository = repository ?? | ||
| 15 | + HrvReportRepositoryImpl( | ||
| 16 | + dataSource: ApiHrvReportDataSource(Get.find<HealthApi>()), | ||
| 17 | + ) { | ||
| 11 | targetUserId.value = initialTargetUserId; | 18 | targetUserId.value = initialTargetUserId; |
| 12 | selectedPeriod.value = ReportPeriod.week; | 19 | selectedPeriod.value = ReportPeriod.week; |
| 13 | } | 20 | } |
| @@ -16,9 +23,31 @@ class HrvReportLogic extends ReportPeriodLogic { | @@ -16,9 +23,31 @@ class HrvReportLogic extends ReportPeriodLogic { | ||
| 16 | final weeklyReport = Rxn<WeeklyHrvReport>(); | 23 | final weeklyReport = Rxn<WeeklyHrvReport>(); |
| 17 | final monthlyReport = Rxn<MonthlyHrvReport>(); | 24 | final monthlyReport = Rxn<MonthlyHrvReport>(); |
| 18 | final yearlyReport = Rxn<YearlyHrvReport>(); | 25 | final yearlyReport = Rxn<YearlyHrvReport>(); |
| 19 | - final HrvReportRepository repository = const HrvReportRepositoryImpl( | ||
| 20 | - dataSource: MockHrvReportDataSource(), | ||
| 21 | - ); | 26 | + final HrvReportRepository repository; |
| 27 | + | ||
| 28 | + @override | ||
| 29 | + List<ReportPeriod> get supportedPeriods => const [ | ||
| 30 | + ReportPeriod.week, | ||
| 31 | + ReportPeriod.month, | ||
| 32 | + ReportPeriod.year, | ||
| 33 | + ]; | ||
| 34 | + | ||
| 35 | + @override | ||
| 36 | + Future<void> selectPeriod( | ||
| 37 | + ReportPeriod period, { | ||
| 38 | + bool? forceRefresh, | ||
| 39 | + }) { | ||
| 40 | + final nextPeriod = normalizePeriod(period); | ||
| 41 | + if (selectedPeriod.value == ReportPeriod.year && | ||
| 42 | + (nextPeriod == ReportPeriod.week || nextPeriod == ReportPeriod.month)) { | ||
| 43 | + final now = DateTime.now(); | ||
| 44 | + final selectedYear = selectedDate.value.year; | ||
| 45 | + selectedDate.value = selectedYear == now.year | ||
| 46 | + ? DateTime(now.year, now.month, now.day) | ||
| 47 | + : DateTime(selectedYear); | ||
| 48 | + } | ||
| 49 | + return super.selectPeriod(nextPeriod, forceRefresh: forceRefresh); | ||
| 50 | + } | ||
| 22 | 51 | ||
| 23 | @override | 52 | @override |
| 24 | Future<void> loadReport() async { | 53 | Future<void> loadReport() async { |
-
Please register or login to post a comment