Showing
22 changed files
with
598 additions
and
195 deletions
| @@ -4,7 +4,7 @@ import 'dart:ui' as ui; | @@ -4,7 +4,7 @@ import 'dart:ui' as ui; | ||
| 4 | import 'package:fl_chart/fl_chart.dart'; | 4 | import 'package:fl_chart/fl_chart.dart'; |
| 5 | import 'package:flutter/material.dart'; | 5 | import 'package:flutter/material.dart'; |
| 6 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 6 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 7 | -import 'package:intl/intl.dart'; | 7 | +import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart'; |
| 8 | 8 | ||
| 9 | import '../../report_common/widgets/health_report_subject_scope.dart'; | 9 | import '../../report_common/widgets/health_report_subject_scope.dart'; |
| 10 | import '../models/activity_burn_report_models.dart'; | 10 | import '../models/activity_burn_report_models.dart'; |
| @@ -83,7 +83,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -83,7 +83,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 83 | ), | 83 | ), |
| 84 | Positioned.fill( | 84 | Positioned.fill( |
| 85 | top: 0, | 85 | top: 0, |
| 86 | - child: LineChart(_chartData()), | 86 | + child: LineChart(_chartData(context)), |
| 87 | ), | 87 | ), |
| 88 | if (!summary.hasData) | 88 | if (!summary.hasData) |
| 89 | Positioned( | 89 | Positioned( |
| @@ -112,7 +112,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -112,7 +112,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 112 | ); | 112 | ); |
| 113 | } | 113 | } |
| 114 | 114 | ||
| 115 | - LineChartData _chartData() { | 115 | + LineChartData _chartData(BuildContext context) { |
| 116 | final start = _startTime; | 116 | final start = _startTime; |
| 117 | final axis = _HeartRateAxis.fromSummary(summary, start); | 117 | final axis = _HeartRateAxis.fromSummary(summary, start); |
| 118 | final points = _points(start, axis.endTime); | 118 | final points = _points(start, axis.endTime); |
| @@ -164,7 +164,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -164,7 +164,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 164 | reservedSize: 20, | 164 | reservedSize: 20, |
| 165 | interval: 360, | 165 | interval: 360, |
| 166 | getTitlesWidget: (value, meta) { | 166 | getTitlesWidget: (value, meta) { |
| 167 | - final label = _bottomLabel(value, start, axis.maxX); | 167 | + final label = _bottomLabel(context, value, start, axis.maxX); |
| 168 | if (label == null) return const SizedBox.shrink(); | 168 | if (label == null) return const SizedBox.shrink(); |
| 169 | final isFirstLabel = value == 0; | 169 | final isFirstLabel = value == 0; |
| 170 | return Padding( | 170 | return Padding( |
| @@ -209,7 +209,8 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -209,7 +209,8 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 209 | return DateTime(0); | 209 | return DateTime(0); |
| 210 | } | 210 | } |
| 211 | 211 | ||
| 212 | - DateTime _dayStart(DateTime time) => DateTime(time.year, time.month, time.day); | 212 | + DateTime _dayStart(DateTime time) => |
| 213 | + DateTime(time.year, time.month, time.day); | ||
| 213 | 214 | ||
| 214 | List<ActivityBurnHeartRatePoint> _points(DateTime start, DateTime end) { | 215 | List<ActivityBurnHeartRatePoint> _points(DateTime start, DateTime end) { |
| 215 | return summary.points | 216 | return summary.points |
| @@ -330,15 +331,26 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -330,15 +331,26 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 330 | ); | 331 | ); |
| 331 | } | 332 | } |
| 332 | 333 | ||
| 333 | - String? _bottomLabel(double value, DateTime start, double maxX) { | 334 | + String? _bottomLabel( |
| 335 | + BuildContext context, | ||
| 336 | + double value, | ||
| 337 | + DateTime start, | ||
| 338 | + double maxX, | ||
| 339 | + ) { | ||
| 334 | final minutes = value.toInt(); | 340 | final minutes = value.toInt(); |
| 335 | if (minutes % 360 != 0 || minutes < 0 || minutes > maxX.toInt()) { | 341 | if (minutes % 360 != 0 || minutes < 0 || minutes > maxX.toInt()) { |
| 336 | return null; | 342 | return null; |
| 337 | } | 343 | } |
| 338 | - if (minutes == 1440) return '24:00'; | 344 | + if (minutes == 1440) { |
| 345 | + return AppTimeFormatter.hourAxis( | ||
| 346 | + 24, | ||
| 347 | + context: context, | ||
| 348 | + showEndOfDayAs24: true, | ||
| 349 | + ); | ||
| 350 | + } | ||
| 339 | 351 | ||
| 340 | final time = start.add(Duration(minutes: minutes)); | 352 | final time = start.add(Duration(minutes: minutes)); |
| 341 | - return DateFormat('HH:mm').format(time); | 353 | + return AppTimeFormatter.time(time, context: context); |
| 342 | } | 354 | } |
| 343 | 355 | ||
| 344 | double _textWidth(String text, TextStyle style) { | 356 | double _textWidth(String text, TextStyle style) { |
| @@ -505,7 +505,8 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -505,7 +505,8 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 505 | ), | 505 | ), |
| 506 | const SizedBox(height: 10), | 506 | const SizedBox(height: 10), |
| 507 | Row( | 507 | Row( |
| 508 | - crossAxisAlignment: CrossAxisAlignment.end, | 508 | + crossAxisAlignment: CrossAxisAlignment.baseline, |
| 509 | + textBaseline: TextBaseline.alphabetic, | ||
| 509 | children: [ | 510 | children: [ |
| 510 | Text( | 511 | Text( |
| 511 | hasData | 512 | hasData |
| @@ -517,12 +518,14 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -517,12 +518,14 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 517 | fontWeight: FontWeight.w700, | 518 | fontWeight: FontWeight.w700, |
| 518 | ), | 519 | ), |
| 519 | ), | 520 | ), |
| 520 | - const SizedBox(width: 4), | ||
| 521 | - Text( | ||
| 522 | - context.l10n.activityKcalDailyAverage, | ||
| 523 | - style: const TextStyle( | ||
| 524 | - color: ActivityBurnMonthReportView._h1, | ||
| 525 | - fontSize: 12, | 521 | + Padding( |
| 522 | + padding: const EdgeInsets.only(left: 4), | ||
| 523 | + child: Text( | ||
| 524 | + context.l10n.activityKcalDailyAverage, | ||
| 525 | + style: const TextStyle( | ||
| 526 | + color: ActivityBurnMonthReportView._h1, | ||
| 527 | + fontSize: 12, | ||
| 528 | + ), | ||
| 526 | ), | 529 | ), |
| 527 | ), | 530 | ), |
| 528 | ], | 531 | ], |
| @@ -526,7 +526,8 @@ class _ActivityBurnEnergyTrendCardState | @@ -526,7 +526,8 @@ class _ActivityBurnEnergyTrendCardState | ||
| 526 | ), | 526 | ), |
| 527 | const SizedBox(height: 10), | 527 | const SizedBox(height: 10), |
| 528 | Row( | 528 | Row( |
| 529 | - crossAxisAlignment: CrossAxisAlignment.end, | 529 | + crossAxisAlignment: CrossAxisAlignment.baseline, |
| 530 | + textBaseline: TextBaseline.alphabetic, | ||
| 530 | children: [ | 531 | children: [ |
| 531 | Text( | 532 | Text( |
| 532 | hasData | 533 | hasData |
| @@ -538,12 +539,14 @@ class _ActivityBurnEnergyTrendCardState | @@ -538,12 +539,14 @@ class _ActivityBurnEnergyTrendCardState | ||
| 538 | fontWeight: FontWeight.w700, | 539 | fontWeight: FontWeight.w700, |
| 539 | ), | 540 | ), |
| 540 | ), | 541 | ), |
| 541 | - const SizedBox(width: 4), | ||
| 542 | - Text( | ||
| 543 | - context.l10n.activityKcalDailyAverage, | ||
| 544 | - style: const TextStyle( | ||
| 545 | - color: ActivityBurnWeekReportView._h1, | ||
| 546 | - fontSize: 12, | 542 | + Padding( |
| 543 | + padding: const EdgeInsets.only(left: 4), | ||
| 544 | + child: Text( | ||
| 545 | + context.l10n.activityKcalDailyAverage, | ||
| 546 | + style: const TextStyle( | ||
| 547 | + color: ActivityBurnWeekReportView._h1, | ||
| 548 | + fontSize: 12, | ||
| 549 | + ), | ||
| 547 | ), | 550 | ), |
| 548 | ), | 551 | ), |
| 549 | ], | 552 | ], |
| @@ -49,6 +49,9 @@ class FriendsController extends GetxController { | @@ -49,6 +49,9 @@ class FriendsController extends GetxController { | ||
| 49 | final selfHealthData = Rxn<SelfFriendHealthData>(); | 49 | final selfHealthData = Rxn<SelfFriendHealthData>(); |
| 50 | Future<void>? _friendsRequest; | 50 | Future<void>? _friendsRequest; |
| 51 | bool _isPageVisible = false; | 51 | bool _isPageVisible = false; |
| 52 | + bool _isUploadingFriendOrder = false; | ||
| 53 | + _PendingFriendOrder? _pendingFriendOrder; | ||
| 54 | + int _friendListVersion = 0; | ||
| 52 | late final Worker _preferencesWorker; | 55 | late final Worker _preferencesWorker; |
| 53 | late final Worker _realtimeStressSettingsWorker; | 56 | late final Worker _realtimeStressSettingsWorker; |
| 54 | bool _showRealtimeStress = false; | 57 | bool _showRealtimeStress = false; |
| @@ -121,9 +124,8 @@ class FriendsController extends GetxController { | @@ -121,9 +124,8 @@ class FriendsController extends GetxController { | ||
| 121 | isLoading.value = true; | 124 | isLoading.value = true; |
| 122 | try { | 125 | try { |
| 123 | final data = await _repository.getFriendList(withSelfHealthData: true); | 126 | final data = await _repository.getFriendList(withSelfHealthData: true); |
| 124 | - final sortedFriends = _sortFriends(data.friends); | ||
| 125 | - await _syncFriendOrder(sortedFriends); | ||
| 126 | - friends.assignAll(sortedFriends); | 127 | + _friendListVersion++; |
| 128 | + friends.assignAll(data.friends); | ||
| 127 | selfHealthData.value = data.selfHealthData; | 129 | selfHealthData.value = data.selfHealthData; |
| 128 | } catch (error, stackTrace) { | 130 | } catch (error, stackTrace) { |
| 129 | AppLogger.e('FriendsController.loadFriends failed', error, stackTrace); | 131 | AppLogger.e('FriendsController.loadFriends failed', error, stackTrace); |
| @@ -133,7 +135,7 @@ class FriendsController extends GetxController { | @@ -133,7 +135,7 @@ class FriendsController extends GetxController { | ||
| 133 | } | 135 | } |
| 134 | } | 136 | } |
| 135 | 137 | ||
| 136 | - /// Moves a friend immediately and persists the order for the current account. | 138 | + /// Moves a friend immediately and uploads the complete order to the server. |
| 137 | Future<void> reorderFriends(int oldIndex, int newIndex) async { | 139 | Future<void> reorderFriends(int oldIndex, int newIndex) async { |
| 138 | if (oldIndex < 0 || oldIndex >= friends.length || newIndex < 0) return; | 140 | if (oldIndex < 0 || oldIndex >= friends.length || newIndex < 0) return; |
| 139 | 141 | ||
| @@ -143,74 +145,59 @@ class FriendsController extends GetxController { | @@ -143,74 +145,59 @@ class FriendsController extends GetxController { | ||
| 143 | 145 | ||
| 144 | final friend = friends.removeAt(oldIndex); | 146 | final friend = friends.removeAt(oldIndex); |
| 145 | friends.insert(targetIndex, friend); | 147 | friends.insert(targetIndex, friend); |
| 148 | + final listVersion = ++_friendListVersion; | ||
| 146 | 149 | ||
| 147 | - final userId = _userPreferencesStorage.preferences.value.meUserInfo?.id; | ||
| 148 | - if (userId == null) return; | ||
| 149 | - try { | ||
| 150 | - await _userAccountStorage.saveFriendOrder( | ||
| 151 | - userId, | ||
| 152 | - friends.map((friend) => friend.userId).whereType<int>().toList(), | ||
| 153 | - ); | ||
| 154 | - } catch (error, stackTrace) { | ||
| 155 | - AppLogger.e( | ||
| 156 | - 'FriendsController.reorderFriends failed', | ||
| 157 | - error, | ||
| 158 | - stackTrace, | ||
| 159 | - ); | ||
| 160 | - } | 150 | + _pendingFriendOrder = _PendingFriendOrder( |
| 151 | + friends: List<FriendHealthData>.from(friends), | ||
| 152 | + listVersion: listVersion, | ||
| 153 | + ); | ||
| 154 | + await _flushPendingFriendOrder(); | ||
| 161 | } | 155 | } |
| 162 | 156 | ||
| 163 | - List<FriendHealthData> _sortFriends(List<FriendHealthData> items) { | ||
| 164 | - final userId = _userPreferencesStorage.preferences.value.meUserInfo?.id; | ||
| 165 | - final savedOrder = | ||
| 166 | - userId == null ? null : _userAccountStorage.friendOrder(userId); | ||
| 167 | - if (savedOrder == null || savedOrder.isEmpty) return items; | 157 | + /// Uploads the latest pending order. New drags replace a queued order. |
| 158 | + Future<void> _flushPendingFriendOrder() async { | ||
| 159 | + if (_isUploadingFriendOrder) return; | ||
| 168 | 160 | ||
| 169 | - final positions = <int, int>{ | ||
| 170 | - for (final (index, friendId) in savedOrder.indexed) | ||
| 171 | - if (int.tryParse(friendId) case final id?) id: index, | ||
| 172 | - }; | ||
| 173 | - final originalPositions = <FriendHealthData, int>{ | ||
| 174 | - for (final (index, friend) in items.indexed) friend: index, | ||
| 175 | - }; | ||
| 176 | - final sorted = [...items]; | ||
| 177 | - sorted.sort((a, b) { | ||
| 178 | - final aPosition = a.userId == null ? null : positions[a.userId!]; | ||
| 179 | - final bPosition = b.userId == null ? null : positions[b.userId!]; | ||
| 180 | - if (aPosition == null && bPosition == null) { | ||
| 181 | - return originalPositions[a]!.compareTo(originalPositions[b]!); | 161 | + _isUploadingFriendOrder = true; |
| 162 | + try { | ||
| 163 | + while (_pendingFriendOrder != null) { | ||
| 164 | + final pendingOrder = _pendingFriendOrder!; | ||
| 165 | + _pendingFriendOrder = null; | ||
| 166 | + try { | ||
| 167 | + await _uploadFriendOrder(pendingOrder.friends); | ||
| 168 | + if (_pendingFriendOrder == null && | ||
| 169 | + _friendListVersion != pendingOrder.listVersion) { | ||
| 170 | + unawaited(refreshData()); | ||
| 171 | + } | ||
| 172 | + } catch (error, stackTrace) { | ||
| 173 | + AppLogger.e( | ||
| 174 | + 'FriendsController.reorderFriends failed', | ||
| 175 | + error, | ||
| 176 | + stackTrace, | ||
| 177 | + ); | ||
| 178 | + if (_pendingFriendOrder == null && | ||
| 179 | + _friendListVersion == pendingOrder.listVersion) { | ||
| 180 | + unawaited(refreshData()); | ||
| 181 | + } | ||
| 182 | + } | ||
| 183 | + } | ||
| 184 | + } finally { | ||
| 185 | + _isUploadingFriendOrder = false; | ||
| 186 | + if (_pendingFriendOrder != null) { | ||
| 187 | + unawaited(_flushPendingFriendOrder()); | ||
| 182 | } | 188 | } |
| 183 | - if (aPosition == null) return 1; | ||
| 184 | - if (bPosition == null) return -1; | ||
| 185 | - return aPosition.compareTo(bPosition); | ||
| 186 | - }); | ||
| 187 | - return sorted; | 189 | + } |
| 188 | } | 190 | } |
| 189 | 191 | ||
| 190 | - /// Removes deleted friends from the saved order and appends newly added ones. | ||
| 191 | - Future<void> _syncFriendOrder(List<FriendHealthData> sortedFriends) async { | ||
| 192 | - final userId = _userPreferencesStorage.preferences.value.meUserInfo?.id; | ||
| 193 | - if (userId == null) return; | ||
| 194 | - | ||
| 195 | - final currentOrder = | ||
| 196 | - sortedFriends.map((friend) => friend.userId).whereType<int>().toList(); | ||
| 197 | - final savedOrder = _userAccountStorage.friendOrder(userId); | ||
| 198 | - final matchesSavedOrder = savedOrder != null && | ||
| 199 | - savedOrder.length == currentOrder.length && | ||
| 200 | - savedOrder.indexed.every( | ||
| 201 | - (entry) => entry.$2 == currentOrder[entry.$1].toString(), | ||
| 202 | - ); | ||
| 203 | - if (matchesSavedOrder) return; | ||
| 204 | - | ||
| 205 | - try { | ||
| 206 | - await _userAccountStorage.saveFriendOrder(userId, currentOrder); | ||
| 207 | - } catch (error, stackTrace) { | ||
| 208 | - AppLogger.e( | ||
| 209 | - 'FriendsController.syncFriendOrder failed', | ||
| 210 | - error, | ||
| 211 | - stackTrace, | 192 | + Future<void> _uploadFriendOrder(List<FriendHealthData> orderedFriends) { |
| 193 | + final friendUserIds = | ||
| 194 | + orderedFriends.map((friend) => friend.userId).whereType<int>().toList(); | ||
| 195 | + if (friendUserIds.length != orderedFriends.length) { | ||
| 196 | + return Future.error( | ||
| 197 | + StateError('Cannot upload a friend order containing a null user ID.'), | ||
| 212 | ); | 198 | ); |
| 213 | } | 199 | } |
| 200 | + return _repository.sortFriends(friendUserIds); | ||
| 214 | } | 201 | } |
| 215 | 202 | ||
| 216 | Future<void> showEditRemarkDialog(FriendHealthData friend) async { | 203 | Future<void> showEditRemarkDialog(FriendHealthData friend) async { |
| @@ -319,3 +306,13 @@ class FriendsController extends GetxController { | @@ -319,3 +306,13 @@ class FriendsController extends GetxController { | ||
| 319 | } | 306 | } |
| 320 | } | 307 | } |
| 321 | } | 308 | } |
| 309 | + | ||
| 310 | +class _PendingFriendOrder { | ||
| 311 | + const _PendingFriendOrder({ | ||
| 312 | + required this.friends, | ||
| 313 | + required this.listVersion, | ||
| 314 | + }); | ||
| 315 | + | ||
| 316 | + final List<FriendHealthData> friends; | ||
| 317 | + final int listVersion; | ||
| 318 | +} |
| 1 | import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; | 1 | import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; |
| 2 | import 'package:doublefeel_flutter/core/result/app_result.dart'; | 2 | import 'package:doublefeel_flutter/core/result/app_result.dart'; |
| 3 | +import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart'; | ||
| 3 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | 4 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; |
| 4 | import 'package:doublefeel_flutter/data/local/user_account_storage.dart'; | 5 | import 'package:doublefeel_flutter/data/local/user_account_storage.dart'; |
| 5 | import 'package:doublefeel_flutter/data/models/friend/friend_models.dart' | 6 | import 'package:doublefeel_flutter/data/models/friend/friend_models.dart' |
| 6 | as api_models; | 7 | as api_models; |
| 8 | +import 'package:doublefeel_flutter/data/models/friend/friends_sort_models.dart'; | ||
| 7 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 9 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 8 | import 'package:get/get.dart'; | 10 | import 'package:get/get.dart'; |
| 9 | -import 'package:intl/intl.dart'; | ||
| 10 | 11 | ||
| 11 | import '../models/friend_health_data.dart'; | 12 | import '../models/friend_health_data.dart'; |
| 12 | import '../models/friend_stress_state.dart'; | 13 | import '../models/friend_stress_state.dart'; |
| @@ -24,6 +25,8 @@ abstract class FriendsRepository { | @@ -24,6 +25,8 @@ abstract class FriendsRepository { | ||
| 24 | Future<void> selectWatchFaceFriend(int userId); | 25 | Future<void> selectWatchFaceFriend(int userId); |
| 25 | 26 | ||
| 26 | Future<void> deleteFriend(int userId); | 27 | Future<void> deleteFriend(int userId); |
| 28 | + | ||
| 29 | + Future<void> sortFriends(List<int> friendUserIds); | ||
| 27 | } | 30 | } |
| 28 | 31 | ||
| 29 | class FriendListData { | 32 | class FriendListData { |
| @@ -101,6 +104,20 @@ class FriendsRepositoryImpl implements FriendsRepository { | @@ -101,6 +104,20 @@ class FriendsRepositoryImpl implements FriendsRepository { | ||
| 101 | } | 104 | } |
| 102 | } | 105 | } |
| 103 | 106 | ||
| 107 | + @override | ||
| 108 | + Future<void> sortFriends(List<int> friendUserIds) async { | ||
| 109 | + final sortList = [ | ||
| 110 | + for (final (index, userId) in friendUserIds.indexed) | ||
| 111 | + FriendsSortReq(friendUserId: userId, sort: index), | ||
| 112 | + ]; | ||
| 113 | + switch (await _friendApi.postFriendsSort(sortList)) { | ||
| 114 | + case AppSuccess(): | ||
| 115 | + return; | ||
| 116 | + case AppFailure(:final error): | ||
| 117 | + throw error; | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | + | ||
| 104 | FriendHealthData _mapFriend(api_models.FriendItem friend) { | 121 | FriendHealthData _mapFriend(api_models.FriendItem friend) { |
| 105 | final healthData = friend.healthData; | 122 | final healthData = friend.healthData; |
| 106 | final nickname = friend.friendNickname?.trim(); | 123 | final nickname = friend.friendNickname?.trim(); |
| @@ -203,7 +220,7 @@ class FriendsRepositoryImpl implements FriendsRepository { | @@ -203,7 +220,7 @@ class FriendsRepositoryImpl implements FriendsRepository { | ||
| 203 | final milliseconds = | 220 | final milliseconds = |
| 204 | timestamp < 1000000000000 ? timestamp * 1000 : timestamp; | 221 | timestamp < 1000000000000 ? timestamp * 1000 : timestamp; |
| 205 | final time = DateTime.fromMillisecondsSinceEpoch(milliseconds.toInt()); | 222 | final time = DateTime.fromMillisecondsSinceEpoch(milliseconds.toInt()); |
| 206 | - final formattedTime = DateFormat('HH:mm').format(time); | 223 | + final formattedTime = AppTimeFormatter.time(time); |
| 207 | return _showRealtimeStress | 224 | return _showRealtimeStress |
| 208 | ? l10n.friendsRealtimeStressUpdatedAt(formattedTime) | 225 | ? l10n.friendsRealtimeStressUpdatedAt(formattedTime) |
| 209 | : l10n.friendsHrvUpdatedAt(formattedTime); | 226 | : l10n.friendsHrvUpdatedAt(formattedTime); |
| @@ -2,16 +2,15 @@ import 'package:doublefeel_flutter/r.dart'; | @@ -2,16 +2,15 @@ import 'package:doublefeel_flutter/r.dart'; | ||
| 2 | import 'package:flutter/material.dart'; | 2 | import 'package:flutter/material.dart'; |
| 3 | 3 | ||
| 4 | enum FriendStressState { | 4 | enum FriendStressState { |
| 5 | - wait(0, '等待数据'), | ||
| 6 | - stressful(1, '压力过载'), | ||
| 7 | - slightStress(2, '注意压力'), | ||
| 8 | - normal(3, '状态正常'), | ||
| 9 | - energetic(4, '状态优秀'); | 5 | + wait(0), |
| 6 | + stressful(1), | ||
| 7 | + slightStress(2), | ||
| 8 | + normal(3), | ||
| 9 | + energetic(4); | ||
| 10 | 10 | ||
| 11 | - const FriendStressState(this.value, this.label); | 11 | + const FriendStressState(this.value); |
| 12 | 12 | ||
| 13 | final int value; | 13 | final int value; |
| 14 | - final String label; | ||
| 15 | 14 | ||
| 16 | static FriendStressState fromValue(int? value) { | 15 | static FriendStressState fromValue(int? value) { |
| 17 | return switch (value) { | 16 | return switch (value) { |
| @@ -396,8 +396,8 @@ class _StatusFigure extends StatelessWidget { | @@ -396,8 +396,8 @@ class _StatusFigure extends StatelessWidget { | ||
| 396 | stressState == FriendStressState.wait | 396 | stressState == FriendStressState.wait |
| 397 | ? context.l10n.friendsWaitingForData | 397 | ? context.l10n.friendsWaitingForData |
| 398 | : stressValueText != null | 398 | : stressValueText != null |
| 399 | - ? '${stressState.label}·$stressValueText' | ||
| 400 | - : stressState.label, | 399 | + ? '${_localizedStressStateLabel(context)}·$stressValueText' |
| 400 | + : _localizedStressStateLabel(context), | ||
| 401 | style: const TextStyle( | 401 | style: const TextStyle( |
| 402 | color: Color(0xFF0F0F11), | 402 | color: Color(0xFF0F0F11), |
| 403 | fontSize: 14, | 403 | fontSize: 14, |
| @@ -409,6 +409,16 @@ class _StatusFigure extends StatelessWidget { | @@ -409,6 +409,16 @@ class _StatusFigure extends StatelessWidget { | ||
| 409 | ), | 409 | ), |
| 410 | ); | 410 | ); |
| 411 | } | 411 | } |
| 412 | + | ||
| 413 | + String _localizedStressStateLabel(BuildContext context) { | ||
| 414 | + return switch (stressState) { | ||
| 415 | + FriendStressState.wait => context.l10n.friendsWaitingForData, | ||
| 416 | + FriendStressState.stressful => context.l10n.pressureOverload, | ||
| 417 | + FriendStressState.slightStress => context.l10n.beMindfulOfStress, | ||
| 418 | + FriendStressState.normal => context.l10n.statusNormal, | ||
| 419 | + FriendStressState.energetic => context.l10n.inExcellentCondition, | ||
| 420 | + }; | ||
| 421 | + } | ||
| 412 | } | 422 | } |
| 413 | 423 | ||
| 414 | class _StressStatusRingPainter extends CustomPainter { | 424 | class _StressStatusRingPainter extends CustomPainter { |
| @@ -59,7 +59,7 @@ class _HomeTrendHeader extends GetView<TrendController> { | @@ -59,7 +59,7 @@ class _HomeTrendHeader extends GetView<TrendController> { | ||
| 59 | Align( | 59 | Align( |
| 60 | alignment: Alignment.centerLeft, | 60 | alignment: Alignment.centerLeft, |
| 61 | child: Text( | 61 | child: Text( |
| 62 | - l10n.tabTrend, | 62 | + l10n.reportTrend, |
| 63 | style: const TextStyle( | 63 | style: const TextStyle( |
| 64 | color: Color(0xFF0F0F11), | 64 | color: Color(0xFF0F0F11), |
| 65 | fontSize: 24, | 65 | fontSize: 24, |
| @@ -245,10 +245,14 @@ class ApiHrvReportDataSource implements HrvReportDataSource { | @@ -245,10 +245,14 @@ class ApiHrvReportDataSource implements HrvReportDataSource { | ||
| 245 | HrvMax(:final timeList) => timeList, | 245 | HrvMax(:final timeList) => timeList, |
| 246 | _ => null, | 246 | _ => null, |
| 247 | }; | 247 | }; |
| 248 | - if (value == null || timeList == null || timeList.isEmpty) return null; | ||
| 249 | - | ||
| 250 | - final recordedAt = _parseApiDateTime(timeList.first); | ||
| 251 | - final date = _parseApiDate(timeList.first); | 248 | + if (value == null) return null; |
| 249 | + | ||
| 250 | + final time = timeList?.isEmpty == false ? timeList!.first : null; | ||
| 251 | + final recordedAt = _parseApiDateTime(time); | ||
| 252 | + // Extreme-HRV responses can omit time_list. In that case, resolve the | ||
| 253 | + // reported extreme value back to its daily trend entry and use time_key. | ||
| 254 | + final date = | ||
| 255 | + _parseApiDate(time) ?? _trendDateForHrvValue(value, trendsByDate); | ||
| 252 | if (date == null) return null; | 256 | if (date == null) return null; |
| 253 | final trend = trendsByDate[date]; | 257 | final trend = trendsByDate[date]; |
| 254 | return HrvDayReport( | 258 | return HrvDayReport( |
| @@ -261,6 +265,17 @@ class ApiHrvReportDataSource implements HrvReportDataSource { | @@ -261,6 +265,17 @@ class ApiHrvReportDataSource implements HrvReportDataSource { | ||
| 261 | ); | 265 | ); |
| 262 | } | 266 | } |
| 263 | 267 | ||
| 268 | + DateTime? _trendDateForHrvValue( | ||
| 269 | + num value, | ||
| 270 | + Map<DateTime, HrvTrendList> trendsByDate, | ||
| 271 | + ) { | ||
| 272 | + final target = value.toDouble(); | ||
| 273 | + for (final entry in trendsByDate.entries) { | ||
| 274 | + if (entry.value.hrvAverage == target) return entry.key; | ||
| 275 | + } | ||
| 276 | + return null; | ||
| 277 | + } | ||
| 278 | + | ||
| 264 | Map<HrvStressLevel, int> _distributionCounts( | 279 | Map<HrvStressLevel, int> _distributionCounts( |
| 265 | List<HrvDistributionList>? distributionList, | 280 | List<HrvDistributionList>? distributionList, |
| 266 | ) { | 281 | ) { |
| @@ -7,6 +7,7 @@ import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | @@ -7,6 +7,7 @@ import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 7 | import 'package:fl_chart/fl_chart.dart'; | 7 | import 'package:fl_chart/fl_chart.dart'; |
| 8 | import 'package:flutter/material.dart'; | 8 | import 'package:flutter/material.dart'; |
| 9 | 9 | ||
| 10 | +import '../../../../core/utils/app_time_formatter.dart'; | ||
| 10 | import '../../../../r.dart'; | 11 | import '../../../../r.dart'; |
| 11 | import '../../report_common/utils/report_localization.dart'; | 12 | import '../../report_common/utils/report_localization.dart'; |
| 12 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; | 13 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; |
| @@ -1014,7 +1015,9 @@ String _extremeDateTime(BuildContext context, HrvDayReport day) { | @@ -1014,7 +1015,9 @@ String _extremeDateTime(BuildContext context, HrvDayReport day) { | ||
| 1014 | final date = day.recordedAt ?? day.date; | 1015 | final date = day.recordedAt ?? day.date; |
| 1015 | final locale = Localizations.localeOf(context).toString(); | 1016 | final locale = Localizations.localeOf(context).toString(); |
| 1016 | final dateText = DateFormat.MMMd(locale).format(date); | 1017 | final dateText = DateFormat.MMMd(locale).format(date); |
| 1017 | - final time = day.recordedAt == null ? null : DateFormat('HH:mm').format(date); | 1018 | + final time = day.recordedAt == null |
| 1019 | + ? null | ||
| 1020 | + : AppTimeFormatter.time(date, context: context); | ||
| 1018 | return time == null ? dateText : '$dateText · $time'; | 1021 | return time == null ? dateText : '$dateText · $time'; |
| 1019 | } | 1022 | } |
| 1020 | 1023 |
| @@ -17,6 +17,12 @@ String reportWeekdayLabel(int weekday) => switch (weekday) { | @@ -17,6 +17,12 @@ String reportWeekdayLabel(int weekday) => switch (weekday) { | ||
| 17 | _ => l10n.reportWeekdaySunday, | 17 | _ => l10n.reportWeekdaySunday, |
| 18 | }; | 18 | }; |
| 19 | 19 | ||
| 20 | +String reportWeekdayNarrowLabel(int weekday) { | ||
| 21 | + final monday = DateTime(2024, 1, 1); | ||
| 22 | + return DateFormat('EEEEE', _localeName) | ||
| 23 | + .format(monday.add(Duration(days: weekday - DateTime.monday))); | ||
| 24 | +} | ||
| 25 | + | ||
| 20 | String reportMonthDay(DateTime date) => | 26 | String reportMonthDay(DateTime date) => |
| 21 | ReportDateFormatter.monthDay(date, _localeName); | 27 | ReportDateFormatter.monthDay(date, _localeName); |
| 22 | 28 |
| 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/core/utils/app_time_formatter.dart'; | ||
| 2 | import 'package:fl_chart/fl_chart.dart'; | 3 | import 'package:fl_chart/fl_chart.dart'; |
| 3 | import 'package:flutter/material.dart'; | 4 | import 'package:flutter/material.dart'; |
| 4 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 5 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 5 | -import 'package:intl/intl.dart'; | ||
| 6 | 6 | ||
| 7 | import '../../report_common/widgets/health_report_subject_scope.dart'; | 7 | import '../../report_common/widgets/health_report_subject_scope.dart'; |
| 8 | import '../models/sleep_report_models.dart'; | 8 | import '../models/sleep_report_models.dart'; |
| @@ -23,8 +23,8 @@ class SleepHeartRateCard extends StatelessWidget { | @@ -23,8 +23,8 @@ class SleepHeartRateCard extends StatelessWidget { | ||
| 23 | static const _h3 = Color(0xFFB0B0B6); | 23 | static const _h3 = Color(0xFFB0B0B6); |
| 24 | static const _grid = Color(0xFFF3F3F3); | 24 | static const _grid = Color(0xFFF3F3F3); |
| 25 | static const _red = Color(0xFFFF5279); | 25 | static const _red = Color(0xFFFF5279); |
| 26 | - static const _chartLeftInset = 0.0; | ||
| 27 | - static const _chartRightInset = 20.0; | 26 | + static const _xAxisLeftInset = 0.0; |
| 27 | + static const _xAxisRightInset = 20.0; | ||
| 28 | static const _chartTopInset = 16.0; | 28 | static const _chartTopInset = 16.0; |
| 29 | 29 | ||
| 30 | @override | 30 | @override |
| @@ -134,7 +134,7 @@ class SleepHeartRateCard extends StatelessWidget { | @@ -134,7 +134,7 @@ class SleepHeartRateCard extends StatelessWidget { | ||
| 134 | bottom: 20, | 134 | bottom: 20, |
| 135 | child: Center( | 135 | child: Center( |
| 136 | child: Text( | 136 | child: Text( |
| 137 | - context.l10n.reportWaitingForData, | 137 | + context.l10n.sleepPeriodAwaitingData, |
| 138 | style: const TextStyle( | 138 | style: const TextStyle( |
| 139 | color: Color(0xFFA1A0A5), | 139 | color: Color(0xFFA1A0A5), |
| 140 | fontSize: 12, | 140 | fontSize: 12, |
| @@ -167,11 +167,11 @@ class SleepHeartRateCard extends StatelessWidget { | @@ -167,11 +167,11 @@ class SleepHeartRateCard extends StatelessWidget { | ||
| 167 | ), | 167 | ), |
| 168 | ) | 168 | ) |
| 169 | .toList(); | 169 | .toList(); |
| 170 | - final maxX = xAxis.end.difference(xAxis.start).inMinutes.clamp(1, 1440); | ||
| 171 | - final dataWidth = chartWidth - _chartLeftInset - _chartRightInset; | ||
| 172 | - final leftXInset = dataWidth > 0 ? _chartLeftInset * maxX / dataWidth : 0.0; | ||
| 173 | - final rightXInset = | ||
| 174 | - dataWidth > 0 ? _chartRightInset * maxX / dataWidth : 0.0; | 170 | + final maxX = |
| 171 | + xAxis.end.difference(xAxis.start).inMinutes.clamp(1, 1440).toDouble(); | ||
| 172 | + final xAxisLayout = _HeartRateXAxisLayout.forWidth(chartWidth); | ||
| 173 | + final leftXInset = xAxisLayout.dataXInset(maxX, left: true); | ||
| 174 | + final rightXInset = xAxisLayout.dataXInset(maxX, left: false); | ||
| 175 | final yRange = _HeartRateYAxisRange.fromPoints(chartPoints); | 175 | final yRange = _HeartRateYAxisRange.fromPoints(chartPoints); |
| 176 | 176 | ||
| 177 | return LineChartData( | 177 | return LineChartData( |
| @@ -383,6 +383,52 @@ class _HeartRateXAxis { | @@ -383,6 +383,52 @@ class _HeartRateXAxis { | ||
| 383 | final DateTime end; | 383 | final DateTime end; |
| 384 | } | 384 | } |
| 385 | 385 | ||
| 386 | +/// Keeps chart data and X-axis labels within the same drawable bounds. | ||
| 387 | +class _HeartRateXAxisLayout { | ||
| 388 | + const _HeartRateXAxisLayout({ | ||
| 389 | + required this.chartWidth, | ||
| 390 | + required this.left, | ||
| 391 | + required this.right, | ||
| 392 | + }); | ||
| 393 | + | ||
| 394 | + factory _HeartRateXAxisLayout.forWidth(double width) { | ||
| 395 | + final right = (width - SleepHeartRateCard._xAxisRightInset) | ||
| 396 | + .clamp(SleepHeartRateCard._xAxisLeftInset, width) | ||
| 397 | + .toDouble(); | ||
| 398 | + return _HeartRateXAxisLayout( | ||
| 399 | + chartWidth: width, | ||
| 400 | + left: SleepHeartRateCard._xAxisLeftInset, | ||
| 401 | + right: right, | ||
| 402 | + ); | ||
| 403 | + } | ||
| 404 | + | ||
| 405 | + final double chartWidth; | ||
| 406 | + final double left; | ||
| 407 | + final double right; | ||
| 408 | + | ||
| 409 | + double get width => (right - left).clamp(0, double.infinity).toDouble(); | ||
| 410 | + | ||
| 411 | + double dataXInset(double maxX, {required bool left}) { | ||
| 412 | + if (width <= 0 || maxX <= 0) return 0; | ||
| 413 | + final inset = left ? this.left : chartWidth - right; | ||
| 414 | + return inset * maxX / width; | ||
| 415 | + } | ||
| 416 | + | ||
| 417 | + double tickX(double progress) => left + width * progress.clamp(0, 1); | ||
| 418 | + | ||
| 419 | + double labelLeft({ | ||
| 420 | + required int index, | ||
| 421 | + required int count, | ||
| 422 | + required double tickX, | ||
| 423 | + required double labelWidth, | ||
| 424 | + }) { | ||
| 425 | + final maxLeft = (right - labelWidth).clamp(left, right).toDouble(); | ||
| 426 | + if (index == 0) return left; | ||
| 427 | + if (index == count - 1) return maxLeft; | ||
| 428 | + return (tickX - labelWidth / 2).clamp(left, maxLeft).toDouble(); | ||
| 429 | + } | ||
| 430 | +} | ||
| 431 | + | ||
| 386 | class _HeartRateAxisLabels extends StatelessWidget { | 432 | class _HeartRateAxisLabels extends StatelessWidget { |
| 387 | const _HeartRateAxisLabels({required this.range}); | 433 | const _HeartRateAxisLabels({required this.range}); |
| 388 | 434 | ||
| @@ -446,13 +492,13 @@ class _HeartRateTimeLabels extends StatelessWidget { | @@ -446,13 +492,13 @@ class _HeartRateTimeLabels extends StatelessWidget { | ||
| 446 | child: LayoutBuilder( | 492 | child: LayoutBuilder( |
| 447 | builder: (context, constraints) { | 493 | builder: (context, constraints) { |
| 448 | final width = constraints.maxWidth; | 494 | final width = constraints.maxWidth; |
| 449 | - const labelWidth = 34.0; | 495 | + final xAxisLayout = _HeartRateXAxisLayout.forWidth(width); |
| 450 | 496 | ||
| 451 | return Stack( | 497 | return Stack( |
| 452 | clipBehavior: Clip.none, | 498 | clipBehavior: Clip.none, |
| 453 | children: [ | 499 | children: [ |
| 454 | for (var index = 0; index < ticks.length; index++) | 500 | for (var index = 0; index < ticks.length; index++) |
| 455 | - _timeLabel(ticks, index, width, labelWidth), | 501 | + _timeLabel(context, ticks, index, xAxisLayout), |
| 456 | ], | 502 | ], |
| 457 | ); | 503 | ); |
| 458 | }, | 504 | }, |
| @@ -461,36 +507,48 @@ class _HeartRateTimeLabels extends StatelessWidget { | @@ -461,36 +507,48 @@ class _HeartRateTimeLabels extends StatelessWidget { | ||
| 461 | } | 507 | } |
| 462 | 508 | ||
| 463 | Widget _timeLabel( | 509 | Widget _timeLabel( |
| 510 | + BuildContext context, | ||
| 464 | List<DateTime> ticks, | 511 | List<DateTime> ticks, |
| 465 | int index, | 512 | int index, |
| 466 | - double width, | ||
| 467 | - double labelWidth, | 513 | + _HeartRateXAxisLayout xAxisLayout, |
| 468 | ) { | 514 | ) { |
| 469 | final tick = ticks[index]; | 515 | final tick = ticks[index]; |
| 516 | + final text = AppTimeFormatter.time(tick, context: context); | ||
| 517 | + final labelWidth = _labelWidth(context, text); | ||
| 470 | final totalMinutes = axis.end.difference(axis.start).inMinutes; | 518 | final totalMinutes = axis.end.difference(axis.start).inMinutes; |
| 471 | final tickMinutes = tick.difference(axis.start).inMinutes; | 519 | final tickMinutes = tick.difference(axis.start).inMinutes; |
| 472 | final progress = totalMinutes <= 0 ? 0.0 : tickMinutes / totalMinutes; | 520 | final progress = totalMinutes <= 0 ? 0.0 : tickMinutes / totalMinutes; |
| 473 | - final plotLeft = SleepHeartRateCard._chartLeftInset; | ||
| 474 | - final plotRight = width - SleepHeartRateCard._chartRightInset; | ||
| 475 | - final plotWidth = | ||
| 476 | - (plotRight - plotLeft).clamp(0, double.infinity).toDouble(); | ||
| 477 | - final tickX = plotLeft + plotWidth * progress; | ||
| 478 | - final isFirstTick = index == 0; | ||
| 479 | - final left = isFirstTick ? tickX : tickX - labelWidth / 2; | 521 | + final tickX = xAxisLayout.tickX(progress); |
| 522 | + final left = xAxisLayout.labelLeft( | ||
| 523 | + index: index, | ||
| 524 | + count: ticks.length, | ||
| 525 | + tickX: tickX, | ||
| 526 | + labelWidth: labelWidth, | ||
| 527 | + ); | ||
| 480 | 528 | ||
| 481 | return Positioned( | 529 | return Positioned( |
| 482 | left: left, | 530 | left: left, |
| 483 | bottom: 0, | 531 | bottom: 0, |
| 484 | width: labelWidth, | 532 | width: labelWidth, |
| 485 | child: _TimeLabel( | 533 | child: _TimeLabel( |
| 486 | - _tickLabel(tick), | ||
| 487 | - textAlign: isFirstTick ? TextAlign.left : TextAlign.center, | 534 | + text, |
| 535 | + textAlign: switch (index) { | ||
| 536 | + 0 => TextAlign.left, | ||
| 537 | + _ when index == ticks.length - 1 => TextAlign.right, | ||
| 538 | + _ => TextAlign.center, | ||
| 539 | + }, | ||
| 488 | ), | 540 | ), |
| 489 | ); | 541 | ); |
| 490 | } | 542 | } |
| 491 | 543 | ||
| 492 | - String _tickLabel(DateTime tick) { | ||
| 493 | - return DateFormat('HH:mm').format(tick); | 544 | + double _labelWidth(BuildContext context, String text) { |
| 545 | + final painter = TextPainter( | ||
| 546 | + text: TextSpan(text: text, style: _TimeLabel.style), | ||
| 547 | + textDirection: Directionality.of(context), | ||
| 548 | + maxLines: 1, | ||
| 549 | + )..layout(); | ||
| 550 | + // Leave room for fractional glyph widths so AM/PM stays on the same line. | ||
| 551 | + return painter.width + 4; | ||
| 494 | } | 552 | } |
| 495 | } | 553 | } |
| 496 | 554 | ||
| @@ -503,17 +561,21 @@ class _TimeLabel extends StatelessWidget { | @@ -503,17 +561,21 @@ class _TimeLabel extends StatelessWidget { | ||
| 503 | final String text; | 561 | final String text; |
| 504 | final TextAlign textAlign; | 562 | final TextAlign textAlign; |
| 505 | 563 | ||
| 564 | + static const style = TextStyle( | ||
| 565 | + color: SleepHeartRateCard._h3, | ||
| 566 | + fontSize: 10, | ||
| 567 | + fontWeight: FontWeight.w400, | ||
| 568 | + height: 1.2, | ||
| 569 | + ); | ||
| 570 | + | ||
| 506 | @override | 571 | @override |
| 507 | Widget build(BuildContext context) { | 572 | Widget build(BuildContext context) { |
| 508 | return Text( | 573 | return Text( |
| 509 | text, | 574 | text, |
| 575 | + maxLines: 1, | ||
| 576 | + softWrap: false, | ||
| 510 | textAlign: textAlign, | 577 | textAlign: textAlign, |
| 511 | - style: const TextStyle( | ||
| 512 | - color: SleepHeartRateCard._h3, | ||
| 513 | - fontSize: 10, | ||
| 514 | - fontWeight: FontWeight.w400, | ||
| 515 | - height: 1.2, | ||
| 516 | - ), | 578 | + style: style, |
| 517 | ); | 579 | ); |
| 518 | } | 580 | } |
| 519 | } | 581 | } |
| @@ -663,7 +725,9 @@ class _HeartRateMetric extends StatelessWidget { | @@ -663,7 +725,9 @@ class _HeartRateMetric extends StatelessWidget { | ||
| 663 | const SizedBox(height: 3), | 725 | const SizedBox(height: 3), |
| 664 | if (showTime) | 726 | if (showTime) |
| 665 | Text( | 727 | Text( |
| 666 | - time == null ? '--:--' : DateFormat('HH:mm').format(time!), | 728 | + time == null |
| 729 | + ? '--:--' | ||
| 730 | + : AppTimeFormatter.time(time!, context: context), | ||
| 667 | style: const TextStyle( | 731 | style: const TextStyle( |
| 668 | color: _h3, | 732 | color: _h3, |
| 669 | fontSize: 12, | 733 | fontSize: 12, |
| 1 | import 'dart:async'; | 1 | import 'dart:async'; |
| 2 | 2 | ||
| 3 | +import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart'; | ||
| 3 | import 'package:doublefeel_flutter/r.dart'; | 4 | import 'package:doublefeel_flutter/r.dart'; |
| 4 | import 'package:fl_chart/fl_chart.dart'; | 5 | import 'package:fl_chart/fl_chart.dart'; |
| 5 | import 'package:flutter/material.dart'; | 6 | import 'package:flutter/material.dart'; |
| @@ -201,12 +202,14 @@ class _SleepPeriodTrendContent extends StatelessWidget { | @@ -201,12 +202,14 @@ class _SleepPeriodTrendContent extends StatelessWidget { | ||
| 201 | tooltipAutoDismissMs: tooltipAutoDismissMs, | 202 | tooltipAutoDismissMs: tooltipAutoDismissMs, |
| 202 | ), | 203 | ), |
| 203 | leftMetric: _ExtremeMetric.time( | 204 | leftMetric: _ExtremeMetric.time( |
| 205 | + context: context, | ||
| 204 | title: context.l10n.sleepEarliestBedtime, | 206 | title: context.l10n.sleepEarliestBedtime, |
| 205 | color: SleepWeekReportView.green, | 207 | color: SleepWeekReportView.green, |
| 206 | report: | 208 | report: |
| 207 | earliestSleepOverride ?? _bedtimeExtreme(days, earliest: true), | 209 | earliestSleepOverride ?? _bedtimeExtreme(days, earliest: true), |
| 208 | ), | 210 | ), |
| 209 | rightMetric: _ExtremeMetric.time( | 211 | rightMetric: _ExtremeMetric.time( |
| 212 | + context: context, | ||
| 210 | title: context.l10n.sleepLatestBedtime, | 213 | title: context.l10n.sleepLatestBedtime, |
| 211 | color: SleepWeekReportView.red, | 214 | color: SleepWeekReportView.red, |
| 212 | report: | 215 | report: |
| @@ -931,7 +934,7 @@ class _BedtimeChart extends StatefulWidget { | @@ -931,7 +934,7 @@ class _BedtimeChart extends StatefulWidget { | ||
| 931 | 934 | ||
| 932 | class _BedtimeChartState extends State<_BedtimeChart> { | 935 | class _BedtimeChartState extends State<_BedtimeChart> { |
| 933 | static const _xAxisLeftInset = 10.0; | 936 | static const _xAxisLeftInset = 10.0; |
| 934 | - static const _xAxisRightInset = 40.0; | 937 | + static const _xAxisRightPadding = 8.0; |
| 935 | 938 | ||
| 936 | int? _selectedDayIndex; | 939 | int? _selectedDayIndex; |
| 937 | Timer? _tooltipTimer; | 940 | Timer? _tooltipTimer; |
| @@ -957,6 +960,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { | @@ -957,6 +960,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { | ||
| 957 | (day) => day.hasSleepData && day.heartRate.sleepStart != null, | 960 | (day) => day.hasSleepData && day.heartRate.sleepStart != null, |
| 958 | ); | 961 | ); |
| 959 | final axis = _BedtimeAxis.fromReports(widget.days); | 962 | final axis = _BedtimeAxis.fromReports(widget.days); |
| 963 | + final rightAxisWidth = _rightAxisWidth(axis); | ||
| 960 | return LayoutBuilder( | 964 | return LayoutBuilder( |
| 961 | builder: (context, constraints) => Stack( | 965 | builder: (context, constraints) => Stack( |
| 962 | alignment: Alignment.center, | 966 | alignment: Alignment.center, |
| @@ -973,6 +977,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { | @@ -973,6 +977,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { | ||
| 973 | lineX: _lineXForDay( | 977 | lineX: _lineXForDay( |
| 974 | constraints.maxWidth, | 978 | constraints.maxWidth, |
| 975 | _selectedDayIndex!, | 979 | _selectedDayIndex!, |
| 980 | + rightAxisWidth, | ||
| 976 | ), | 981 | ), |
| 977 | lineTop: _bedtimeChartTopInset, | 982 | lineTop: _bedtimeChartTopInset, |
| 978 | ), | 983 | ), |
| @@ -983,6 +988,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { | @@ -983,6 +988,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { | ||
| 983 | interval: 1, | 988 | interval: 1, |
| 984 | topInset: _bedtimeChartTopInset, | 989 | topInset: _bedtimeChartTopInset, |
| 985 | insetChildHorizontally: false, | 990 | insetChildHorizontally: false, |
| 991 | + rightAxisWidth: rightAxisWidth, | ||
| 986 | rightLabel: _timeAxisLabel, | 992 | rightLabel: _timeAxisLabel, |
| 987 | child: LayoutBuilder( | 993 | child: LayoutBuilder( |
| 988 | builder: (context, constraints) => Listener( | 994 | builder: (context, constraints) => Listener( |
| @@ -990,10 +996,12 @@ class _BedtimeChartState extends State<_BedtimeChart> { | @@ -990,10 +996,12 @@ class _BedtimeChartState extends State<_BedtimeChart> { | ||
| 990 | onPointerDown: (event) => _selectSpotAt( | 996 | onPointerDown: (event) => _selectSpotAt( |
| 991 | event.localPosition.dx, | 997 | event.localPosition.dx, |
| 992 | constraints.maxWidth, | 998 | constraints.maxWidth, |
| 999 | + rightAxisWidth, | ||
| 993 | ), | 1000 | ), |
| 994 | onPointerMove: (event) => _selectSpotAt( | 1001 | onPointerMove: (event) => _selectSpotAt( |
| 995 | event.localPosition.dx, | 1002 | event.localPosition.dx, |
| 996 | constraints.maxWidth, | 1003 | constraints.maxWidth, |
| 1004 | + rightAxisWidth, | ||
| 997 | ), | 1005 | ), |
| 998 | onPointerUp: (_) => _scheduleTooltipDismissal(), | 1006 | onPointerUp: (_) => _scheduleTooltipDismissal(), |
| 999 | onPointerCancel: (_) => _scheduleTooltipDismissal(), | 1007 | onPointerCancel: (_) => _scheduleTooltipDismissal(), |
| @@ -1001,6 +1009,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { | @@ -1001,6 +1009,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { | ||
| 1001 | _chartData( | 1009 | _chartData( |
| 1002 | axis, | 1010 | axis, |
| 1003 | constraints.maxWidth, | 1011 | constraints.maxWidth, |
| 1012 | + rightAxisWidth, | ||
| 1004 | ), | 1013 | ), |
| 1005 | ), | 1014 | ), |
| 1006 | ), | 1015 | ), |
| @@ -1012,18 +1021,20 @@ class _BedtimeChartState extends State<_BedtimeChart> { | @@ -1012,18 +1021,20 @@ class _BedtimeChartState extends State<_BedtimeChart> { | ||
| 1012 | ); | 1021 | ); |
| 1013 | } | 1022 | } |
| 1014 | 1023 | ||
| 1015 | - double _lineXForDay(double width, int dayIndex) { | 1024 | + double _lineXForDay(double width, int dayIndex, double rightAxisWidth) { |
| 1025 | + final xAxisRightInset = _xAxisRightInset(rightAxisWidth); | ||
| 1016 | final count = widget.days.length; | 1026 | final count = widget.days.length; |
| 1017 | - if (count <= 1 || width <= _xAxisLeftInset + _xAxisRightInset) { | 1027 | + if (count <= 1 || width <= _xAxisLeftInset + xAxisRightInset) { |
| 1018 | return width / 2; | 1028 | return width / 2; |
| 1019 | } | 1029 | } |
| 1020 | - final dataWidth = width - _xAxisLeftInset - _xAxisRightInset; | 1030 | + final dataWidth = width - _xAxisLeftInset - xAxisRightInset; |
| 1021 | return _xAxisLeftInset + dataWidth * dayIndex / (count - 1); | 1031 | return _xAxisLeftInset + dataWidth * dayIndex / (count - 1); |
| 1022 | } | 1032 | } |
| 1023 | 1033 | ||
| 1024 | LineChartData _chartData( | 1034 | LineChartData _chartData( |
| 1025 | _BedtimeAxis axis, | 1035 | _BedtimeAxis axis, |
| 1026 | double chartWidth, | 1036 | double chartWidth, |
| 1037 | + double rightAxisWidth, | ||
| 1027 | ) { | 1038 | ) { |
| 1028 | final points = <FlSpot>[ | 1039 | final points = <FlSpot>[ |
| 1029 | for (var i = 0; i < widget.days.length; i++) | 1040 | for (var i = 0; i < widget.days.length; i++) |
| @@ -1072,7 +1083,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { | @@ -1072,7 +1083,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { | ||
| 1072 | } | 1083 | } |
| 1073 | 1084 | ||
| 1074 | final count = widget.days.length; | 1085 | final count = widget.days.length; |
| 1075 | - final xBounds = _xBounds(chartWidth, count); | 1086 | + final xBounds = _xBounds(chartWidth, count, rightAxisWidth); |
| 1076 | 1087 | ||
| 1077 | return LineChartData( | 1088 | return LineChartData( |
| 1078 | minX: xBounds.minX, | 1089 | minX: xBounds.minX, |
| @@ -1087,7 +1098,10 @@ class _BedtimeChartState extends State<_BedtimeChart> { | @@ -1087,7 +1098,10 @@ class _BedtimeChartState extends State<_BedtimeChart> { | ||
| 1087 | getTouchedSpotIndicator: _lineTouchedIndicators, | 1098 | getTouchedSpotIndicator: _lineTouchedIndicators, |
| 1088 | touchTooltipData: _lineTooltipData( | 1099 | touchTooltipData: _lineTooltipData( |
| 1089 | days: widget.days, | 1100 | days: widget.days, |
| 1090 | - dataBuilder: _ChartSummaryData.bedtime, | 1101 | + dataBuilder: (report) => _ChartSummaryData.bedtime( |
| 1102 | + report, | ||
| 1103 | + context: context, | ||
| 1104 | + ), | ||
| 1091 | ), | 1105 | ), |
| 1092 | ), | 1106 | ), |
| 1093 | titlesData: _SleepTrendXAxis.titlesData( | 1107 | titlesData: _SleepTrendXAxis.titlesData( |
| @@ -1103,9 +1117,13 @@ class _BedtimeChartState extends State<_BedtimeChart> { | @@ -1103,9 +1117,13 @@ class _BedtimeChartState extends State<_BedtimeChart> { | ||
| 1103 | ); | 1117 | ); |
| 1104 | } | 1118 | } |
| 1105 | 1119 | ||
| 1106 | - void _selectSpotAt(double dx, double chartWidth) { | 1120 | + void _selectSpotAt( |
| 1121 | + double dx, | ||
| 1122 | + double chartWidth, | ||
| 1123 | + double rightAxisWidth, | ||
| 1124 | + ) { | ||
| 1107 | _tooltipTimer?.cancel(); | 1125 | _tooltipTimer?.cancel(); |
| 1108 | - final index = _dayIndexForTouch(dx, chartWidth); | 1126 | + final index = _dayIndexForTouch(dx, chartWidth, rightAxisWidth); |
| 1109 | final hasData = index != null && | 1127 | final hasData = index != null && |
| 1110 | widget.days[index].hasSleepData && | 1128 | widget.days[index].hasSleepData && |
| 1111 | widget.days[index].heartRate.sleepStart != null; | 1129 | widget.days[index].heartRate.sleepStart != null; |
| @@ -1114,31 +1132,40 @@ class _BedtimeChartState extends State<_BedtimeChart> { | @@ -1114,31 +1132,40 @@ class _BedtimeChartState extends State<_BedtimeChart> { | ||
| 1114 | setState(() => _selectedDayIndex = nextIndex); | 1132 | setState(() => _selectedDayIndex = nextIndex); |
| 1115 | } | 1133 | } |
| 1116 | 1134 | ||
| 1117 | - ({double minX, double maxX}) _xBounds(double width, int count) { | ||
| 1118 | - if (count <= 1 || width <= _xAxisLeftInset + _xAxisRightInset) { | 1135 | + ({double minX, double maxX}) _xBounds( |
| 1136 | + double width, | ||
| 1137 | + int count, | ||
| 1138 | + double rightAxisWidth, | ||
| 1139 | + ) { | ||
| 1140 | + final xAxisRightInset = _xAxisRightInset(rightAxisWidth); | ||
| 1141 | + if (count <= 1 || width <= _xAxisLeftInset + xAxisRightInset) { | ||
| 1119 | return (minX: -0.5, maxX: 0.5); | 1142 | return (minX: -0.5, maxX: 0.5); |
| 1120 | } | 1143 | } |
| 1121 | - final scale = (count - 1) / (width - _xAxisLeftInset - _xAxisRightInset); | 1144 | + final scale = (count - 1) / (width - _xAxisLeftInset - xAxisRightInset); |
| 1122 | return ( | 1145 | return ( |
| 1123 | minX: -_xAxisLeftInset * scale, | 1146 | minX: -_xAxisLeftInset * scale, |
| 1124 | - maxX: count - 1 + _xAxisRightInset * scale, | 1147 | + maxX: count - 1 + xAxisRightInset * scale, |
| 1125 | ); | 1148 | ); |
| 1126 | } | 1149 | } |
| 1127 | 1150 | ||
| 1128 | - int? _dayIndexForTouch(double? dx, double width) { | 1151 | + int? _dayIndexForTouch( |
| 1152 | + double? dx, | ||
| 1153 | + double width, | ||
| 1154 | + double rightAxisWidth, | ||
| 1155 | + ) { | ||
| 1156 | + final xAxisRightInset = _xAxisRightInset(rightAxisWidth); | ||
| 1129 | final count = widget.days.length; | 1157 | final count = widget.days.length; |
| 1130 | if (dx == null || width <= 0 || count <= 0) return null; | 1158 | if (dx == null || width <= 0 || count <= 0) return null; |
| 1131 | - if (count == 1 || width <= _xAxisLeftInset + _xAxisRightInset) { | ||
| 1132 | - return dx <= width - _xAxisRightInset ? 0 : null; | 1159 | + if (count == 1 || width <= _xAxisLeftInset + xAxisRightInset) { |
| 1160 | + return dx <= width - xAxisRightInset ? 0 : null; | ||
| 1133 | } | 1161 | } |
| 1134 | - final dataWidth = width - _xAxisLeftInset - _xAxisRightInset; | 1162 | + final dataWidth = width - _xAxisLeftInset - xAxisRightInset; |
| 1135 | final firstX = _xAxisLeftInset; | 1163 | final firstX = _xAxisLeftInset; |
| 1136 | - final lastX = width - _xAxisRightInset; | 1164 | + final lastX = width - xAxisRightInset; |
| 1137 | final step = dataWidth / (count - 1); | 1165 | final step = dataWidth / (count - 1); |
| 1138 | final minTouchX = (firstX - step / 2).clamp(0, width).toDouble(); | 1166 | final minTouchX = (firstX - step / 2).clamp(0, width).toDouble(); |
| 1139 | - final maxTouchX = (lastX + step / 2) | ||
| 1140 | - .clamp(0, width - _ChartPlotFrame.rightAxisWidth) | ||
| 1141 | - .toDouble(); | 1167 | + final maxTouchX = |
| 1168 | + (lastX + step / 2).clamp(0, width - rightAxisWidth).toDouble(); | ||
| 1142 | if (dx < minTouchX || dx > maxTouchX) return null; | 1169 | if (dx < minTouchX || dx > maxTouchX) return null; |
| 1143 | return ((dx - _xAxisLeftInset) / dataWidth * (count - 1)) | 1170 | return ((dx - _xAxisLeftInset) / dataWidth * (count - 1)) |
| 1144 | .round() | 1171 | .round() |
| @@ -1169,9 +1196,31 @@ class _BedtimeChartState extends State<_BedtimeChart> { | @@ -1169,9 +1196,31 @@ class _BedtimeChartState extends State<_BedtimeChart> { | ||
| 1169 | } | 1196 | } |
| 1170 | 1197 | ||
| 1171 | String _timeAxisLabel(double value) { | 1198 | String _timeAxisLabel(double value) { |
| 1172 | - final hour = value.toInt() % 24; | ||
| 1173 | - return '${hour.toString().padLeft(2, '0')}:00'; | 1199 | + return AppTimeFormatter.hourAxis(value.toInt(), context: context); |
| 1200 | + } | ||
| 1201 | + | ||
| 1202 | + double _rightAxisWidth(_BedtimeAxis axis) { | ||
| 1203 | + final steps = (axis.maxHour - axis.minHour).ceil(); | ||
| 1204 | + final textPainter = TextPainter( | ||
| 1205 | + textDirection: Directionality.of(context), | ||
| 1206 | + textScaler: MediaQuery.textScalerOf(context), | ||
| 1207 | + maxLines: 1, | ||
| 1208 | + ); | ||
| 1209 | + var maxWidth = 0.0; | ||
| 1210 | + for (var index = 0; index <= steps; index++) { | ||
| 1211 | + textPainter.text = TextSpan( | ||
| 1212 | + text: _timeAxisLabel((axis.minHour + index).toDouble()), | ||
| 1213 | + style: _RightAxisLabels.labelStyle, | ||
| 1214 | + ); | ||
| 1215 | + textPainter.layout(); | ||
| 1216 | + if (textPainter.width > maxWidth) maxWidth = textPainter.width; | ||
| 1217 | + } | ||
| 1218 | + textPainter.dispose(); | ||
| 1219 | + return maxWidth + _xAxisRightPadding; | ||
| 1174 | } | 1220 | } |
| 1221 | + | ||
| 1222 | + double _xAxisRightInset(double rightAxisWidth) => | ||
| 1223 | + rightAxisWidth + _xAxisRightPadding; | ||
| 1175 | } | 1224 | } |
| 1176 | 1225 | ||
| 1177 | double _barWidth(bool compact) => compact ? 4 : 16; | 1226 | double _barWidth(bool compact) => compact ? 4 : 16; |
| @@ -1448,11 +1497,14 @@ class _ChartSummaryData { | @@ -1448,11 +1497,14 @@ class _ChartSummaryData { | ||
| 1448 | ); | 1497 | ); |
| 1449 | } | 1498 | } |
| 1450 | 1499 | ||
| 1451 | - factory _ChartSummaryData.bedtime(SleepReport report) { | 1500 | + factory _ChartSummaryData.bedtime( |
| 1501 | + SleepReport report, { | ||
| 1502 | + required BuildContext context, | ||
| 1503 | + }) { | ||
| 1452 | final start = report.heartRate.sleepStart!; | 1504 | final start = report.heartRate.sleepStart!; |
| 1453 | return _ChartSummaryData( | 1505 | return _ChartSummaryData( |
| 1454 | title: l10n.sleepFellAsleepAt( | 1506 | title: l10n.sleepFellAsleepAt( |
| 1455 | - '${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}', | 1507 | + AppTimeFormatter.time(start, context: context), |
| 1456 | ), | 1508 | ), |
| 1457 | subtitle: _fullDateText(report.date), | 1509 | subtitle: _fullDateText(report.date), |
| 1458 | titleColor: SleepWeekReportView.brand, | 1510 | titleColor: SleepWeekReportView.brand, |
| @@ -1590,11 +1642,12 @@ class _ChartPlotFrame extends StatelessWidget { | @@ -1590,11 +1642,12 @@ class _ChartPlotFrame extends StatelessWidget { | ||
| 1590 | this.minY = 0, | 1642 | this.minY = 0, |
| 1591 | this.topInset = 0, | 1643 | this.topInset = 0, |
| 1592 | this.insetChildHorizontally = true, | 1644 | this.insetChildHorizontally = true, |
| 1645 | + this.rightAxisWidth = defaultRightAxisWidth, | ||
| 1593 | this.referenceLines = const [], | 1646 | this.referenceLines = const [], |
| 1594 | }); | 1647 | }); |
| 1595 | 1648 | ||
| 1596 | static const horizontalInset = 20.0; | 1649 | static const horizontalInset = 20.0; |
| 1597 | - static const rightAxisWidth = 32.0; | 1650 | + static const defaultRightAxisWidth = 32.0; |
| 1598 | static const bottomTitleHeight = 28.0; | 1651 | static const bottomTitleHeight = 28.0; |
| 1599 | 1652 | ||
| 1600 | final Widget child; | 1653 | final Widget child; |
| @@ -1603,6 +1656,7 @@ class _ChartPlotFrame extends StatelessWidget { | @@ -1603,6 +1656,7 @@ class _ChartPlotFrame extends StatelessWidget { | ||
| 1603 | final double interval; | 1656 | final double interval; |
| 1604 | final double topInset; | 1657 | final double topInset; |
| 1605 | final bool insetChildHorizontally; | 1658 | final bool insetChildHorizontally; |
| 1659 | + final double rightAxisWidth; | ||
| 1606 | final String Function(double value) rightLabel; | 1660 | final String Function(double value) rightLabel; |
| 1607 | final List<_ChartReferenceLine> referenceLines; | 1661 | final List<_ChartReferenceLine> referenceLines; |
| 1608 | 1662 | ||
| @@ -1637,9 +1691,7 @@ class _ChartPlotFrame extends StatelessWidget { | @@ -1637,9 +1691,7 @@ class _ChartPlotFrame extends StatelessWidget { | ||
| 1637 | padding: EdgeInsets.only( | 1691 | padding: EdgeInsets.only( |
| 1638 | top: topInset, | 1692 | top: topInset, |
| 1639 | left: insetChildHorizontally ? horizontalInset : 0, | 1693 | left: insetChildHorizontally ? horizontalInset : 0, |
| 1640 | - right: insetChildHorizontally | ||
| 1641 | - ? rightAxisWidth | ||
| 1642 | - : 0, | 1694 | + right: insetChildHorizontally ? rightAxisWidth : 0, |
| 1643 | ), | 1695 | ), |
| 1644 | child: child, | 1696 | child: child, |
| 1645 | ), | 1697 | ), |
| @@ -1650,6 +1702,7 @@ class _ChartPlotFrame extends StatelessWidget { | @@ -1650,6 +1702,7 @@ class _ChartPlotFrame extends StatelessWidget { | ||
| 1650 | minY: minY, | 1702 | minY: minY, |
| 1651 | maxY: maxY, | 1703 | maxY: maxY, |
| 1652 | interval: interval, | 1704 | interval: interval, |
| 1705 | + width: rightAxisWidth, | ||
| 1653 | labelBuilder: rightLabel, | 1706 | labelBuilder: rightLabel, |
| 1654 | ), | 1707 | ), |
| 1655 | ), | 1708 | ), |
| @@ -1741,14 +1794,23 @@ class _RightAxisLabels extends StatelessWidget { | @@ -1741,14 +1794,23 @@ class _RightAxisLabels extends StatelessWidget { | ||
| 1741 | required this.minY, | 1794 | required this.minY, |
| 1742 | required this.maxY, | 1795 | required this.maxY, |
| 1743 | required this.interval, | 1796 | required this.interval, |
| 1797 | + required this.width, | ||
| 1744 | required this.labelBuilder, | 1798 | required this.labelBuilder, |
| 1745 | }); | 1799 | }); |
| 1746 | 1800 | ||
| 1747 | final double minY; | 1801 | final double minY; |
| 1748 | final double maxY; | 1802 | final double maxY; |
| 1749 | final double interval; | 1803 | final double interval; |
| 1804 | + final double width; | ||
| 1750 | final String Function(double value) labelBuilder; | 1805 | final String Function(double value) labelBuilder; |
| 1751 | 1806 | ||
| 1807 | + static const labelStyle = TextStyle( | ||
| 1808 | + color: SleepWeekReportView.h3, | ||
| 1809 | + fontSize: 10, | ||
| 1810 | + fontWeight: FontWeight.w400, | ||
| 1811 | + height: 1.2, | ||
| 1812 | + ); | ||
| 1813 | + | ||
| 1752 | @override | 1814 | @override |
| 1753 | Widget build(BuildContext context) { | 1815 | Widget build(BuildContext context) { |
| 1754 | return IgnorePointer( | 1816 | return IgnorePointer( |
| @@ -1762,19 +1824,14 @@ class _RightAxisLabels extends StatelessWidget { | @@ -1762,19 +1824,14 @@ class _RightAxisLabels extends StatelessWidget { | ||
| 1762 | Positioned( | 1824 | Positioned( |
| 1763 | top: constraints.maxHeight * (1 - index / steps) - 16, | 1825 | top: constraints.maxHeight * (1 - index / steps) - 16, |
| 1764 | right: 0, | 1826 | right: 0, |
| 1765 | - width: _ChartPlotFrame.rightAxisWidth, | 1827 | + width: width, |
| 1766 | child: Align( | 1828 | child: Align( |
| 1767 | alignment: Alignment.centerRight, | 1829 | alignment: Alignment.centerRight, |
| 1768 | child: Text( | 1830 | child: Text( |
| 1769 | labelBuilder(minY + interval * index), | 1831 | labelBuilder(minY + interval * index), |
| 1770 | maxLines: 1, | 1832 | maxLines: 1, |
| 1771 | textAlign: TextAlign.right, | 1833 | textAlign: TextAlign.right, |
| 1772 | - style: const TextStyle( | ||
| 1773 | - color: SleepWeekReportView.h3, | ||
| 1774 | - fontSize: 10, | ||
| 1775 | - fontWeight: FontWeight.w400, | ||
| 1776 | - height: 1.2, | ||
| 1777 | - ), | 1834 | + style: labelStyle, |
| 1778 | ), | 1835 | ), |
| 1779 | ), | 1836 | ), |
| 1780 | ), | 1837 | ), |
| @@ -1837,7 +1894,7 @@ class _SleepTrendXAxis { | @@ -1837,7 +1894,7 @@ class _SleepTrendXAxis { | ||
| 1837 | return Column( | 1894 | return Column( |
| 1838 | children: [ | 1895 | children: [ |
| 1839 | Text('${day.day}', style: labelStyle), | 1896 | Text('${day.day}', style: labelStyle), |
| 1840 | - Text(reportWeekdayLabel(day.weekday), style: labelStyle), | 1897 | + Text(reportWeekdayNarrowLabel(day.weekday), style: labelStyle), |
| 1841 | ], | 1898 | ], |
| 1842 | ); | 1899 | ); |
| 1843 | } | 1900 | } |
| @@ -1901,6 +1958,7 @@ class _ExtremeMetric extends StatelessWidget { | @@ -1901,6 +1958,7 @@ class _ExtremeMetric extends StatelessWidget { | ||
| 1901 | } | 1958 | } |
| 1902 | 1959 | ||
| 1903 | factory _ExtremeMetric.time({ | 1960 | factory _ExtremeMetric.time({ |
| 1961 | + required BuildContext context, | ||
| 1904 | required String title, | 1962 | required String title, |
| 1905 | required Color color, | 1963 | required Color color, |
| 1906 | required SleepReport? report, | 1964 | required SleepReport? report, |
| @@ -1912,7 +1970,7 @@ class _ExtremeMetric extends StatelessWidget { | @@ -1912,7 +1970,7 @@ class _ExtremeMetric extends StatelessWidget { | ||
| 1912 | color: color, | 1970 | color: color, |
| 1913 | value: start == null | 1971 | value: start == null |
| 1914 | ? '--:--' | 1972 | ? '--:--' |
| 1915 | - : '${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}', | 1973 | + : AppTimeFormatter.time(start, context: context), |
| 1916 | unit: '', | 1974 | unit: '', |
| 1917 | date: report?.date, | 1975 | date: report?.date, |
| 1918 | ); | 1976 | ); |
| 1 | import 'package:doublefeel_flutter/core/error/http_error_handling_policy.dart'; | 1 | import 'package:doublefeel_flutter/core/error/http_error_handling_policy.dart'; |
| 2 | +import 'package:doublefeel_flutter/data/models/friend/friends_sort_models.dart'; | ||
| 2 | 3 | ||
| 3 | import '../../../data/models/friend/friend_models.dart'; | 4 | import '../../../data/models/friend/friend_models.dart'; |
| 4 | import '../../result/app_result.dart'; | 5 | import '../../result/app_result.dart'; |
| @@ -109,4 +110,17 @@ class FriendApi { | @@ -109,4 +110,17 @@ class FriendApi { | ||
| 109 | }, | 110 | }, |
| 110 | ); | 111 | ); |
| 111 | } | 112 | } |
| 113 | + | ||
| 114 | + Future<AppResult<void>> postFriendsSort(List<FriendsSortReq> list) { | ||
| 115 | + return safeCall( | ||
| 116 | + call: () async { | ||
| 117 | + await _dioClient.dio.post( | ||
| 118 | + ApiPaths.friendsSort, | ||
| 119 | + data: { | ||
| 120 | + 'sort_list': list.map((item) => item.toJson()).toList(), | ||
| 121 | + }, | ||
| 122 | + ); | ||
| 123 | + }, | ||
| 124 | + ); | ||
| 125 | + } | ||
| 112 | } | 126 | } |
| @@ -89,4 +89,5 @@ abstract final class ApiPaths { | @@ -89,4 +89,5 @@ abstract final class ApiPaths { | ||
| 89 | // friends | 89 | // friends |
| 90 | static const friends = '/client/doublefeel/health/v2/friends/'; | 90 | static const friends = '/client/doublefeel/health/v2/friends/'; |
| 91 | static const friendInfo = '/client/doublefeel/health/v2/friend_info/'; | 91 | static const friendInfo = '/client/doublefeel/health/v2/friend_info/'; |
| 92 | + static const friendsSort = '/client/doublefeel/health/v2/friends/sort/'; | ||
| 92 | } | 93 | } |
lib/core/utils/app_time_formatter.dart
0 → 100644
| 1 | +import 'dart:ui' show PlatformDispatcher; | ||
| 2 | + | ||
| 3 | +import 'package:flutter/material.dart'; | ||
| 4 | +import 'package:intl/intl.dart'; | ||
| 5 | + | ||
| 6 | +class AppTimeFormatter { | ||
| 7 | + const AppTimeFormatter._(); | ||
| 8 | + | ||
| 9 | + static String time( | ||
| 10 | + DateTime value, { | ||
| 11 | + BuildContext? context, | ||
| 12 | + bool? use24HourFormat, | ||
| 13 | + String? localeName, | ||
| 14 | + }) { | ||
| 15 | + final uses24HourFormat = _uses24HourFormat(context, use24HourFormat); | ||
| 16 | + if (context != null) { | ||
| 17 | + return MaterialLocalizations.of(context).formatTimeOfDay( | ||
| 18 | + TimeOfDay.fromDateTime(value), | ||
| 19 | + alwaysUse24HourFormat: uses24HourFormat, | ||
| 20 | + ); | ||
| 21 | + } | ||
| 22 | + return uses24HourFormat | ||
| 23 | + ? DateFormat('HH:mm').format(value) | ||
| 24 | + : DateFormat('h:mm a', localeName).format(value); | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + static String hourAxis( | ||
| 28 | + int hour, { | ||
| 29 | + BuildContext? context, | ||
| 30 | + bool? use24HourFormat, | ||
| 31 | + String? localeName, | ||
| 32 | + bool showEndOfDayAs24 = false, | ||
| 33 | + }) { | ||
| 34 | + final uses24HourFormat = _uses24HourFormat(context, use24HourFormat); | ||
| 35 | + if (showEndOfDayAs24 && hour == 24 && uses24HourFormat) { | ||
| 36 | + return '24:00'; | ||
| 37 | + } | ||
| 38 | + final clockTime = DateTime(2000, 1, 1).add(Duration(hours: hour)); | ||
| 39 | + if (context != null) { | ||
| 40 | + return MaterialLocalizations.of(context).formatTimeOfDay( | ||
| 41 | + TimeOfDay.fromDateTime(clockTime), | ||
| 42 | + alwaysUse24HourFormat: uses24HourFormat, | ||
| 43 | + ); | ||
| 44 | + } | ||
| 45 | + return uses24HourFormat | ||
| 46 | + ? DateFormat('HH:mm').format(clockTime) | ||
| 47 | + : DateFormat('h a', localeName).format(clockTime); | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + static bool _uses24HourFormat(BuildContext? context, bool? override) => | ||
| 51 | + override ?? | ||
| 52 | + (context == null | ||
| 53 | + ? PlatformDispatcher.instance.alwaysUse24HourFormat | ||
| 54 | + : MediaQuery.alwaysUse24HourFormatOf(context)); | ||
| 55 | +} |
| @@ -31,9 +31,6 @@ class UserAccountStorage { | @@ -31,9 +31,6 @@ class UserAccountStorage { | ||
| 31 | static String _showRealtimeStressKey(int userId) => | 31 | static String _showRealtimeStressKey(int userId) => |
| 32 | 'account_show_realtime_stress_$userId'; | 32 | 'account_show_realtime_stress_$userId'; |
| 33 | 33 | ||
| 34 | - /// 好友列表排序,按 userId 隔离。 | ||
| 35 | - static String _friendsOrderKey(int userId) => 'account_friends_order_$userId'; | ||
| 36 | - | ||
| 37 | /// 在实时压力偏好更新后通知依赖它的页面刷新。 | 34 | /// 在实时压力偏好更新后通知依赖它的页面刷新。 |
| 38 | final realtimeStressSettingsVersion = 0.obs; | 35 | final realtimeStressSettingsVersion = 0.obs; |
| 39 | 36 | ||
| @@ -95,17 +92,6 @@ class UserAccountStorage { | @@ -95,17 +92,6 @@ class UserAccountStorage { | ||
| 95 | realtimeStressSettingsVersion.value++; | 92 | realtimeStressSettingsVersion.value++; |
| 96 | } | 93 | } |
| 97 | 94 | ||
| 98 | - // ─── 好友列表排序 ───────────────────────────────────────────────────────── | ||
| 99 | - | ||
| 100 | - List<String>? friendOrder(int userId) => | ||
| 101 | - _prefs.getStringList(_friendsOrderKey(userId)); | ||
| 102 | - | ||
| 103 | - Future<void> saveFriendOrder(int userId, List<int> friendUserIds) => | ||
| 104 | - _prefs.setStringList( | ||
| 105 | - _friendsOrderKey(userId), | ||
| 106 | - friendUserIds.map((id) => id.toString()).toList(), | ||
| 107 | - ); | ||
| 108 | - | ||
| 109 | // ─── Apple Health 上传记录 ──────────────────────────────────────────────── | 95 | // ─── Apple Health 上传记录 ──────────────────────────────────────────────── |
| 110 | 96 | ||
| 111 | String? appleHealthUploadLocalRecordJson(int userId) => | 97 | String? appleHealthUploadLocalRecordJson(int userId) => |
| @@ -15,7 +15,7 @@ | @@ -15,7 +15,7 @@ | ||
| 15 | "passwordEmptyHint": "Password cannot be empty", | 15 | "passwordEmptyHint": "Password cannot be empty", |
| 16 | "homeTitle": "Home", | 16 | "homeTitle": "Home", |
| 17 | "tabToday": "Today", | 17 | "tabToday": "Today", |
| 18 | - "tabTrend": "Trend", | 18 | + "tabTrend": "Trends", |
| 19 | "tabFriends": "Friends", | 19 | "tabFriends": "Friends", |
| 20 | "tabMy": "Me", | 20 | "tabMy": "Me", |
| 21 | "switchLanguage": "Switch Language", | 21 | "switchLanguage": "Switch Language", |
| @@ -371,7 +371,7 @@ | @@ -371,7 +371,7 @@ | ||
| 371 | "hrvTrendDayUnit": "", | 371 | "hrvTrendDayUnit": "", |
| 372 | "hrvTrendComparedLastWeekUnavailable": "vs last week -", | 372 | "hrvTrendComparedLastWeekUnavailable": "vs last week -", |
| 373 | "hrvTrendSameAsLastWeek": "Same as last week", | 373 | "hrvTrendSameAsLastWeek": "Same as last week", |
| 374 | - "hrvTrendMoreDaysThanLastWeek": "{count}d vs last week", | 374 | + "hrvTrendMoreDaysThanLastWeek": "+{count}d vs last week", |
| 375 | "@hrvTrendMoreDaysThanLastWeek": { | 375 | "@hrvTrendMoreDaysThanLastWeek": { |
| 376 | "placeholders": { | 376 | "placeholders": { |
| 377 | "count": { | 377 | "count": { |
| @@ -389,7 +389,7 @@ | @@ -389,7 +389,7 @@ | ||
| 389 | }, | 389 | }, |
| 390 | "hrvTrendComparedLastMonthUnavailable": "vs last month -", | 390 | "hrvTrendComparedLastMonthUnavailable": "vs last month -", |
| 391 | "hrvTrendSameAsLastMonth": "Same as last month", | 391 | "hrvTrendSameAsLastMonth": "Same as last month", |
| 392 | - "hrvTrendMoreDaysThanLastMonth": "{count}d vs last month", | 392 | + "hrvTrendMoreDaysThanLastMonth": "+{count}d vs last month", |
| 393 | "@hrvTrendMoreDaysThanLastMonth": { | 393 | "@hrvTrendMoreDaysThanLastMonth": { |
| 394 | "placeholders": { | 394 | "placeholders": { |
| 395 | "count": { | 395 | "count": { |
| @@ -624,8 +624,8 @@ | @@ -624,8 +624,8 @@ | ||
| 624 | "friendsRemarkSuffix": " ({remark})", | 624 | "friendsRemarkSuffix": " ({remark})", |
| 625 | "friendsUnknownFriend": "Unknown friend", | 625 | "friendsUnknownFriend": "Unknown friend", |
| 626 | "friendsUpdatedAt": "Updated at {time}", | 626 | "friendsUpdatedAt": "Updated at {time}", |
| 627 | - "friendsRealtimeStressUpdatedAt": "Real-time stress updated at {time}", | ||
| 628 | - "friendsHrvUpdatedAt": "HRV updated at {time}", | 627 | + "friendsRealtimeStressUpdatedAt": "Live Stress Last Updated at {time}", |
| 628 | + "friendsHrvUpdatedAt": "HRV Last Updated at {time}", | ||
| 629 | "friendsStepCount": "{count} steps", | 629 | "friendsStepCount": "{count} steps", |
| 630 | "friendsStressAttention": "Stress alert", | 630 | "friendsStressAttention": "Stress alert", |
| 631 | "friendsWaitingForData": "No Data Yet", | 631 | "friendsWaitingForData": "No Data Yet", |
| @@ -1065,4 +1065,4 @@ | @@ -1065,4 +1065,4 @@ | ||
| 1065 | } | 1065 | } |
| 1066 | }, | 1066 | }, |
| 1067 | "updatePassword": "Update password" | 1067 | "updatePassword": "Update password" |
| 1068 | -} | ||
| 1068 | +} |
| @@ -54,7 +54,7 @@ class AppLocalizationsEn extends AppLocalizations { | @@ -54,7 +54,7 @@ class AppLocalizationsEn extends AppLocalizations { | ||
| 54 | String get tabToday => 'Today'; | 54 | String get tabToday => 'Today'; |
| 55 | 55 | ||
| 56 | @override | 56 | @override |
| 57 | - String get tabTrend => 'Trend'; | 57 | + String get tabTrend => 'Trends'; |
| 58 | 58 | ||
| 59 | @override | 59 | @override |
| 60 | String get tabFriends => 'Friends'; | 60 | String get tabFriends => 'Friends'; |
| @@ -1234,7 +1234,7 @@ class AppLocalizationsEn extends AppLocalizations { | @@ -1234,7 +1234,7 @@ class AppLocalizationsEn extends AppLocalizations { | ||
| 1234 | 1234 | ||
| 1235 | @override | 1235 | @override |
| 1236 | String hrvTrendMoreDaysThanLastWeek(int count) { | 1236 | String hrvTrendMoreDaysThanLastWeek(int count) { |
| 1237 | - return '${count}d vs last week'; | 1237 | + return '+${count}d vs last week'; |
| 1238 | } | 1238 | } |
| 1239 | 1239 | ||
| 1240 | @override | 1240 | @override |
| @@ -1250,7 +1250,7 @@ class AppLocalizationsEn extends AppLocalizations { | @@ -1250,7 +1250,7 @@ class AppLocalizationsEn extends AppLocalizations { | ||
| 1250 | 1250 | ||
| 1251 | @override | 1251 | @override |
| 1252 | String hrvTrendMoreDaysThanLastMonth(int count) { | 1252 | String hrvTrendMoreDaysThanLastMonth(int count) { |
| 1253 | - return '${count}d vs last month'; | 1253 | + return '+${count}d vs last month'; |
| 1254 | } | 1254 | } |
| 1255 | 1255 | ||
| 1256 | @override | 1256 | @override |
| @@ -1786,12 +1786,12 @@ class AppLocalizationsEn extends AppLocalizations { | @@ -1786,12 +1786,12 @@ class AppLocalizationsEn extends AppLocalizations { | ||
| 1786 | 1786 | ||
| 1787 | @override | 1787 | @override |
| 1788 | String friendsRealtimeStressUpdatedAt(String time) { | 1788 | String friendsRealtimeStressUpdatedAt(String time) { |
| 1789 | - return 'Real-time stress updated at $time'; | 1789 | + return 'Live Stress Last Updated at $time'; |
| 1790 | } | 1790 | } |
| 1791 | 1791 | ||
| 1792 | @override | 1792 | @override |
| 1793 | String friendsHrvUpdatedAt(String time) { | 1793 | String friendsHrvUpdatedAt(String time) { |
| 1794 | - return 'HRV updated at $time'; | 1794 | + return 'HRV Last Updated at $time'; |
| 1795 | } | 1795 | } |
| 1796 | 1796 | ||
| 1797 | @override | 1797 | @override |
| 1 | +import 'package:doublefeel_flutter/app/modules/sleep_report/models/sleep_report_models.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/app/modules/sleep_report/widgets/sleep_heart_rate_card.dart'; | ||
| 3 | +import 'package:fl_chart/fl_chart.dart'; | ||
| 4 | +import 'package:flutter/material.dart'; | ||
| 5 | +import 'package:flutter_test/flutter_test.dart'; | ||
| 6 | + | ||
| 7 | +void main() { | ||
| 8 | + testWidgets('keeps 12-hour X-axis labels aligned with data bounds', | ||
| 9 | + (tester) async { | ||
| 10 | + final start = DateTime(2026, 1, 1, 22, 30); | ||
| 11 | + final end = DateTime(2026, 1, 2, 6, 30); | ||
| 12 | + await tester.pumpWidget( | ||
| 13 | + MaterialApp( | ||
| 14 | + locale: const Locale('en'), | ||
| 15 | + home: MediaQuery( | ||
| 16 | + data: const MediaQueryData(alwaysUse24HourFormat: false), | ||
| 17 | + child: Scaffold( | ||
| 18 | + body: Center( | ||
| 19 | + child: SizedBox( | ||
| 20 | + width: 240, | ||
| 21 | + child: SleepHeartRateCard( | ||
| 22 | + summary: SleepHeartRateSummary( | ||
| 23 | + averageBpm: 60, | ||
| 24 | + maxBpm: 65, | ||
| 25 | + minBpm: 55, | ||
| 26 | + points: [ | ||
| 27 | + SleepHeartRatePoint(time: start, bpm: 55), | ||
| 28 | + SleepHeartRatePoint(time: end, bpm: 65), | ||
| 29 | + ], | ||
| 30 | + ), | ||
| 31 | + ), | ||
| 32 | + ), | ||
| 33 | + ), | ||
| 34 | + ), | ||
| 35 | + ), | ||
| 36 | + ), | ||
| 37 | + ); | ||
| 38 | + | ||
| 39 | + final cardRect = tester.getRect(find.byType(SleepHeartRateCard)); | ||
| 40 | + final firstLabelRect = tester.getRect(find.text('10:30 PM')); | ||
| 41 | + final lastLabelRect = tester.getRect(find.text('6:30 AM')); | ||
| 42 | + final chartFinder = find.byType(LineChart); | ||
| 43 | + final chartRect = tester.getRect(chartFinder); | ||
| 44 | + final chartData = tester.widget<LineChart>(chartFinder).data; | ||
| 45 | + final endX = end.difference(start).inMinutes.toDouble(); | ||
| 46 | + final endPointX = chartRect.left + | ||
| 47 | + (endX - chartData.minX) / | ||
| 48 | + (chartData.maxX - chartData.minX) * | ||
| 49 | + chartRect.width; | ||
| 50 | + | ||
| 51 | + expect(firstLabelRect.left, greaterThanOrEqualTo(cardRect.left + 20)); | ||
| 52 | + expect(lastLabelRect.right, lessThanOrEqualTo(cardRect.right - 40)); | ||
| 53 | + expect(endPointX, closeTo(lastLabelRect.right, 1)); | ||
| 54 | + }); | ||
| 55 | +} |
test/core/utils/app_time_formatter_test.dart
0 → 100644
| 1 | +import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart'; | ||
| 2 | +import 'package:flutter/material.dart'; | ||
| 3 | +import 'package:flutter_test/flutter_test.dart'; | ||
| 4 | + | ||
| 5 | +void main() { | ||
| 6 | + group('AppTimeFormatter', () { | ||
| 7 | + test('formats 24-hour time when requested', () { | ||
| 8 | + expect( | ||
| 9 | + AppTimeFormatter.time( | ||
| 10 | + DateTime(2026, 1, 1, 13, 5), | ||
| 11 | + use24HourFormat: true, | ||
| 12 | + ), | ||
| 13 | + '13:05', | ||
| 14 | + ); | ||
| 15 | + }); | ||
| 16 | + | ||
| 17 | + test('supports 12-hour time', () { | ||
| 18 | + expect( | ||
| 19 | + AppTimeFormatter.time( | ||
| 20 | + DateTime(2026, 1, 1, 13, 5), | ||
| 21 | + use24HourFormat: false, | ||
| 22 | + localeName: 'en', | ||
| 23 | + ), | ||
| 24 | + '1:05 PM', | ||
| 25 | + ); | ||
| 26 | + }); | ||
| 27 | + | ||
| 28 | + test('normalizes overnight chart hours for their labels', () { | ||
| 29 | + expect( | ||
| 30 | + AppTimeFormatter.hourAxis(26, use24HourFormat: true), | ||
| 31 | + '02:00', | ||
| 32 | + ); | ||
| 33 | + expect( | ||
| 34 | + AppTimeFormatter.hourAxis( | ||
| 35 | + 26, | ||
| 36 | + use24HourFormat: false, | ||
| 37 | + localeName: 'en', | ||
| 38 | + ), | ||
| 39 | + '2 AM', | ||
| 40 | + ); | ||
| 41 | + }); | ||
| 42 | + | ||
| 43 | + test('can retain 24:00 at a day boundary', () { | ||
| 44 | + expect( | ||
| 45 | + AppTimeFormatter.hourAxis( | ||
| 46 | + 24, | ||
| 47 | + use24HourFormat: true, | ||
| 48 | + showEndOfDayAs24: true, | ||
| 49 | + ), | ||
| 50 | + '24:00', | ||
| 51 | + ); | ||
| 52 | + }); | ||
| 53 | + | ||
| 54 | + testWidgets('honors the MediaQuery time-format preference', (tester) async { | ||
| 55 | + await tester.pumpWidget( | ||
| 56 | + MaterialApp( | ||
| 57 | + home: MediaQuery( | ||
| 58 | + data: const MediaQueryData(alwaysUse24HourFormat: false), | ||
| 59 | + child: Builder( | ||
| 60 | + builder: (context) => Text( | ||
| 61 | + AppTimeFormatter.time(DateTime(2026, 1, 1, 13, 5), | ||
| 62 | + context: context), | ||
| 63 | + ), | ||
| 64 | + ), | ||
| 65 | + ), | ||
| 66 | + ), | ||
| 67 | + ); | ||
| 68 | + | ||
| 69 | + expect(find.text('1:05 PM'), findsOneWidget); | ||
| 70 | + }); | ||
| 71 | + | ||
| 72 | + testWidgets('uses localized Material formatting for 24-hour time', | ||
| 73 | + (tester) async { | ||
| 74 | + await tester.pumpWidget( | ||
| 75 | + MaterialApp( | ||
| 76 | + home: MediaQuery( | ||
| 77 | + data: const MediaQueryData(alwaysUse24HourFormat: true), | ||
| 78 | + child: Builder( | ||
| 79 | + builder: (context) => Text( | ||
| 80 | + AppTimeFormatter.time(DateTime(2026, 1, 1, 13, 5), | ||
| 81 | + context: context), | ||
| 82 | + ), | ||
| 83 | + ), | ||
| 84 | + ), | ||
| 85 | + ), | ||
| 86 | + ); | ||
| 87 | + | ||
| 88 | + expect(find.text('13:05'), findsOneWidget); | ||
| 89 | + }); | ||
| 90 | + }); | ||
| 91 | +} |
-
Please register or login to post a comment