Commit cfad1d5d57cbe7b89e2dee1da66d56176c5d0b28

Authored by 刘宏哲
1 parent 1826580f

feat(app): bug fixed

@@ -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 }
  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) =>
  1 +class FriendsSortReq {
  2 + const FriendsSortReq({
  3 + required this.friendUserId,
  4 + required this.sort,
  5 + });
  6 +
  7 + final int friendUserId;
  8 + final int sort;
  9 +
  10 + Map<String, dynamic> toJson() => {
  11 + 'friend_user_id': friendUserId,
  12 + 'sort': sort,
  13 + };
  14 +}
@@ -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 +}
  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 +}