Commit cfad1d5d57cbe7b89e2dee1da66d56176c5d0b28

Authored by 刘宏哲
1 parent 1826580f

feat(app): bug fixed

... ... @@ -4,7 +4,7 @@ import 'dart:ui' as ui;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:intl/intl.dart';
import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../models/activity_burn_report_models.dart';
... ... @@ -83,7 +83,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
),
Positioned.fill(
top: 0,
child: LineChart(_chartData()),
child: LineChart(_chartData(context)),
),
if (!summary.hasData)
Positioned(
... ... @@ -112,7 +112,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
);
}
LineChartData _chartData() {
LineChartData _chartData(BuildContext context) {
final start = _startTime;
final axis = _HeartRateAxis.fromSummary(summary, start);
final points = _points(start, axis.endTime);
... ... @@ -164,7 +164,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
reservedSize: 20,
interval: 360,
getTitlesWidget: (value, meta) {
final label = _bottomLabel(value, start, axis.maxX);
final label = _bottomLabel(context, value, start, axis.maxX);
if (label == null) return const SizedBox.shrink();
final isFirstLabel = value == 0;
return Padding(
... ... @@ -209,7 +209,8 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
return DateTime(0);
}
DateTime _dayStart(DateTime time) => DateTime(time.year, time.month, time.day);
DateTime _dayStart(DateTime time) =>
DateTime(time.year, time.month, time.day);
List<ActivityBurnHeartRatePoint> _points(DateTime start, DateTime end) {
return summary.points
... ... @@ -330,15 +331,26 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
);
}
String? _bottomLabel(double value, DateTime start, double maxX) {
String? _bottomLabel(
BuildContext context,
double value,
DateTime start,
double maxX,
) {
final minutes = value.toInt();
if (minutes % 360 != 0 || minutes < 0 || minutes > maxX.toInt()) {
return null;
}
if (minutes == 1440) return '24:00';
if (minutes == 1440) {
return AppTimeFormatter.hourAxis(
24,
context: context,
showEndOfDayAs24: true,
);
}
final time = start.add(Duration(minutes: minutes));
return DateFormat('HH:mm').format(time);
return AppTimeFormatter.time(time, context: context);
}
double _textWidth(String text, TextStyle style) {
... ...
... ... @@ -505,7 +505,8 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
hasData
... ... @@ -517,12 +518,14 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 4),
Text(
context.l10n.activityKcalDailyAverage,
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 12,
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
context.l10n.activityKcalDailyAverage,
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 12,
),
),
),
],
... ...
... ... @@ -526,7 +526,8 @@ class _ActivityBurnEnergyTrendCardState
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
hasData
... ... @@ -538,12 +539,14 @@ class _ActivityBurnEnergyTrendCardState
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 4),
Text(
context.l10n.activityKcalDailyAverage,
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 12,
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
context.l10n.activityKcalDailyAverage,
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 12,
),
),
),
],
... ...
... ... @@ -49,6 +49,9 @@ class FriendsController extends GetxController {
final selfHealthData = Rxn<SelfFriendHealthData>();
Future<void>? _friendsRequest;
bool _isPageVisible = false;
bool _isUploadingFriendOrder = false;
_PendingFriendOrder? _pendingFriendOrder;
int _friendListVersion = 0;
late final Worker _preferencesWorker;
late final Worker _realtimeStressSettingsWorker;
bool _showRealtimeStress = false;
... ... @@ -121,9 +124,8 @@ class FriendsController extends GetxController {
isLoading.value = true;
try {
final data = await _repository.getFriendList(withSelfHealthData: true);
final sortedFriends = _sortFriends(data.friends);
await _syncFriendOrder(sortedFriends);
friends.assignAll(sortedFriends);
_friendListVersion++;
friends.assignAll(data.friends);
selfHealthData.value = data.selfHealthData;
} catch (error, stackTrace) {
AppLogger.e('FriendsController.loadFriends failed', error, stackTrace);
... ... @@ -133,7 +135,7 @@ class FriendsController extends GetxController {
}
}
/// Moves a friend immediately and persists the order for the current account.
/// Moves a friend immediately and uploads the complete order to the server.
Future<void> reorderFriends(int oldIndex, int newIndex) async {
if (oldIndex < 0 || oldIndex >= friends.length || newIndex < 0) return;
... ... @@ -143,74 +145,59 @@ class FriendsController extends GetxController {
final friend = friends.removeAt(oldIndex);
friends.insert(targetIndex, friend);
final listVersion = ++_friendListVersion;
final userId = _userPreferencesStorage.preferences.value.meUserInfo?.id;
if (userId == null) return;
try {
await _userAccountStorage.saveFriendOrder(
userId,
friends.map((friend) => friend.userId).whereType<int>().toList(),
);
} catch (error, stackTrace) {
AppLogger.e(
'FriendsController.reorderFriends failed',
error,
stackTrace,
);
}
_pendingFriendOrder = _PendingFriendOrder(
friends: List<FriendHealthData>.from(friends),
listVersion: listVersion,
);
await _flushPendingFriendOrder();
}
List<FriendHealthData> _sortFriends(List<FriendHealthData> items) {
final userId = _userPreferencesStorage.preferences.value.meUserInfo?.id;
final savedOrder =
userId == null ? null : _userAccountStorage.friendOrder(userId);
if (savedOrder == null || savedOrder.isEmpty) return items;
/// Uploads the latest pending order. New drags replace a queued order.
Future<void> _flushPendingFriendOrder() async {
if (_isUploadingFriendOrder) return;
final positions = <int, int>{
for (final (index, friendId) in savedOrder.indexed)
if (int.tryParse(friendId) case final id?) id: index,
};
final originalPositions = <FriendHealthData, int>{
for (final (index, friend) in items.indexed) friend: index,
};
final sorted = [...items];
sorted.sort((a, b) {
final aPosition = a.userId == null ? null : positions[a.userId!];
final bPosition = b.userId == null ? null : positions[b.userId!];
if (aPosition == null && bPosition == null) {
return originalPositions[a]!.compareTo(originalPositions[b]!);
_isUploadingFriendOrder = true;
try {
while (_pendingFriendOrder != null) {
final pendingOrder = _pendingFriendOrder!;
_pendingFriendOrder = null;
try {
await _uploadFriendOrder(pendingOrder.friends);
if (_pendingFriendOrder == null &&
_friendListVersion != pendingOrder.listVersion) {
unawaited(refreshData());
}
} catch (error, stackTrace) {
AppLogger.e(
'FriendsController.reorderFriends failed',
error,
stackTrace,
);
if (_pendingFriendOrder == null &&
_friendListVersion == pendingOrder.listVersion) {
unawaited(refreshData());
}
}
}
} finally {
_isUploadingFriendOrder = false;
if (_pendingFriendOrder != null) {
unawaited(_flushPendingFriendOrder());
}
if (aPosition == null) return 1;
if (bPosition == null) return -1;
return aPosition.compareTo(bPosition);
});
return sorted;
}
}
/// Removes deleted friends from the saved order and appends newly added ones.
Future<void> _syncFriendOrder(List<FriendHealthData> sortedFriends) async {
final userId = _userPreferencesStorage.preferences.value.meUserInfo?.id;
if (userId == null) return;
final currentOrder =
sortedFriends.map((friend) => friend.userId).whereType<int>().toList();
final savedOrder = _userAccountStorage.friendOrder(userId);
final matchesSavedOrder = savedOrder != null &&
savedOrder.length == currentOrder.length &&
savedOrder.indexed.every(
(entry) => entry.$2 == currentOrder[entry.$1].toString(),
);
if (matchesSavedOrder) return;
try {
await _userAccountStorage.saveFriendOrder(userId, currentOrder);
} catch (error, stackTrace) {
AppLogger.e(
'FriendsController.syncFriendOrder failed',
error,
stackTrace,
Future<void> _uploadFriendOrder(List<FriendHealthData> orderedFriends) {
final friendUserIds =
orderedFriends.map((friend) => friend.userId).whereType<int>().toList();
if (friendUserIds.length != orderedFriends.length) {
return Future.error(
StateError('Cannot upload a friend order containing a null user ID.'),
);
}
return _repository.sortFriends(friendUserIds);
}
Future<void> showEditRemarkDialog(FriendHealthData friend) async {
... ... @@ -319,3 +306,13 @@ class FriendsController extends GetxController {
}
}
}
class _PendingFriendOrder {
const _PendingFriendOrder({
required this.friends,
required this.listVersion,
});
final List<FriendHealthData> friends;
final int listVersion;
}
... ...
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/local/user_account_storage.dart';
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'
as api_models;
import 'package:doublefeel_flutter/data/models/friend/friends_sort_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../models/friend_health_data.dart';
import '../models/friend_stress_state.dart';
... ... @@ -24,6 +25,8 @@ abstract class FriendsRepository {
Future<void> selectWatchFaceFriend(int userId);
Future<void> deleteFriend(int userId);
Future<void> sortFriends(List<int> friendUserIds);
}
class FriendListData {
... ... @@ -101,6 +104,20 @@ class FriendsRepositoryImpl implements FriendsRepository {
}
}
@override
Future<void> sortFriends(List<int> friendUserIds) async {
final sortList = [
for (final (index, userId) in friendUserIds.indexed)
FriendsSortReq(friendUserId: userId, sort: index),
];
switch (await _friendApi.postFriendsSort(sortList)) {
case AppSuccess():
return;
case AppFailure(:final error):
throw error;
}
}
FriendHealthData _mapFriend(api_models.FriendItem friend) {
final healthData = friend.healthData;
final nickname = friend.friendNickname?.trim();
... ... @@ -203,7 +220,7 @@ class FriendsRepositoryImpl implements FriendsRepository {
final milliseconds =
timestamp < 1000000000000 ? timestamp * 1000 : timestamp;
final time = DateTime.fromMillisecondsSinceEpoch(milliseconds.toInt());
final formattedTime = DateFormat('HH:mm').format(time);
final formattedTime = AppTimeFormatter.time(time);
return _showRealtimeStress
? l10n.friendsRealtimeStressUpdatedAt(formattedTime)
: l10n.friendsHrvUpdatedAt(formattedTime);
... ...
... ... @@ -2,16 +2,15 @@ import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
enum FriendStressState {
wait(0, '等待数据'),
stressful(1, '压力过载'),
slightStress(2, '注意压力'),
normal(3, '状态正常'),
energetic(4, '状态优秀');
wait(0),
stressful(1),
slightStress(2),
normal(3),
energetic(4);
const FriendStressState(this.value, this.label);
const FriendStressState(this.value);
final int value;
final String label;
static FriendStressState fromValue(int? value) {
return switch (value) {
... ...
... ... @@ -396,8 +396,8 @@ class _StatusFigure extends StatelessWidget {
stressState == FriendStressState.wait
? context.l10n.friendsWaitingForData
: stressValueText != null
? '${stressState.label}·$stressValueText'
: stressState.label,
? '${_localizedStressStateLabel(context)}·$stressValueText'
: _localizedStressStateLabel(context),
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 14,
... ... @@ -409,6 +409,16 @@ class _StatusFigure extends StatelessWidget {
),
);
}
String _localizedStressStateLabel(BuildContext context) {
return switch (stressState) {
FriendStressState.wait => context.l10n.friendsWaitingForData,
FriendStressState.stressful => context.l10n.pressureOverload,
FriendStressState.slightStress => context.l10n.beMindfulOfStress,
FriendStressState.normal => context.l10n.statusNormal,
FriendStressState.energetic => context.l10n.inExcellentCondition,
};
}
}
class _StressStatusRingPainter extends CustomPainter {
... ...
... ... @@ -59,7 +59,7 @@ class _HomeTrendHeader extends GetView<TrendController> {
Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.tabTrend,
l10n.reportTrend,
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 24,
... ...
... ... @@ -245,10 +245,14 @@ class ApiHrvReportDataSource implements HrvReportDataSource {
HrvMax(:final timeList) => timeList,
_ => null,
};
if (value == null || timeList == null || timeList.isEmpty) return null;
final recordedAt = _parseApiDateTime(timeList.first);
final date = _parseApiDate(timeList.first);
if (value == null) return null;
final time = timeList?.isEmpty == false ? timeList!.first : null;
final recordedAt = _parseApiDateTime(time);
// Extreme-HRV responses can omit time_list. In that case, resolve the
// reported extreme value back to its daily trend entry and use time_key.
final date =
_parseApiDate(time) ?? _trendDateForHrvValue(value, trendsByDate);
if (date == null) return null;
final trend = trendsByDate[date];
return HrvDayReport(
... ... @@ -261,6 +265,17 @@ class ApiHrvReportDataSource implements HrvReportDataSource {
);
}
DateTime? _trendDateForHrvValue(
num value,
Map<DateTime, HrvTrendList> trendsByDate,
) {
final target = value.toDouble();
for (final entry in trendsByDate.entries) {
if (entry.value.hrvAverage == target) return entry.key;
}
return null;
}
Map<HrvStressLevel, int> _distributionCounts(
List<HrvDistributionList>? distributionList,
) {
... ...
... ... @@ -7,6 +7,7 @@ import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../../../core/utils/app_time_formatter.dart';
import '../../../../r.dart';
import '../../report_common/utils/report_localization.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
... ... @@ -1014,7 +1015,9 @@ String _extremeDateTime(BuildContext context, HrvDayReport day) {
final date = day.recordedAt ?? day.date;
final locale = Localizations.localeOf(context).toString();
final dateText = DateFormat.MMMd(locale).format(date);
final time = day.recordedAt == null ? null : DateFormat('HH:mm').format(date);
final time = day.recordedAt == null
? null
: AppTimeFormatter.time(date, context: context);
return time == null ? dateText : '$dateText · $time';
}
... ...
... ... @@ -17,6 +17,12 @@ String reportWeekdayLabel(int weekday) => switch (weekday) {
_ => l10n.reportWeekdaySunday,
};
String reportWeekdayNarrowLabel(int weekday) {
final monday = DateTime(2024, 1, 1);
return DateFormat('EEEEE', _localeName)
.format(monday.add(Duration(days: weekday - DateTime.monday)));
}
String reportMonthDay(DateTime date) =>
ReportDateFormatter.monthDay(date, _localeName);
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:intl/intl.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../models/sleep_report_models.dart';
... ... @@ -23,8 +23,8 @@ class SleepHeartRateCard extends StatelessWidget {
static const _h3 = Color(0xFFB0B0B6);
static const _grid = Color(0xFFF3F3F3);
static const _red = Color(0xFFFF5279);
static const _chartLeftInset = 0.0;
static const _chartRightInset = 20.0;
static const _xAxisLeftInset = 0.0;
static const _xAxisRightInset = 20.0;
static const _chartTopInset = 16.0;
@override
... ... @@ -134,7 +134,7 @@ class SleepHeartRateCard extends StatelessWidget {
bottom: 20,
child: Center(
child: Text(
context.l10n.reportWaitingForData,
context.l10n.sleepPeriodAwaitingData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
... ... @@ -167,11 +167,11 @@ class SleepHeartRateCard extends StatelessWidget {
),
)
.toList();
final maxX = xAxis.end.difference(xAxis.start).inMinutes.clamp(1, 1440);
final dataWidth = chartWidth - _chartLeftInset - _chartRightInset;
final leftXInset = dataWidth > 0 ? _chartLeftInset * maxX / dataWidth : 0.0;
final rightXInset =
dataWidth > 0 ? _chartRightInset * maxX / dataWidth : 0.0;
final maxX =
xAxis.end.difference(xAxis.start).inMinutes.clamp(1, 1440).toDouble();
final xAxisLayout = _HeartRateXAxisLayout.forWidth(chartWidth);
final leftXInset = xAxisLayout.dataXInset(maxX, left: true);
final rightXInset = xAxisLayout.dataXInset(maxX, left: false);
final yRange = _HeartRateYAxisRange.fromPoints(chartPoints);
return LineChartData(
... ... @@ -383,6 +383,52 @@ class _HeartRateXAxis {
final DateTime end;
}
/// Keeps chart data and X-axis labels within the same drawable bounds.
class _HeartRateXAxisLayout {
const _HeartRateXAxisLayout({
required this.chartWidth,
required this.left,
required this.right,
});
factory _HeartRateXAxisLayout.forWidth(double width) {
final right = (width - SleepHeartRateCard._xAxisRightInset)
.clamp(SleepHeartRateCard._xAxisLeftInset, width)
.toDouble();
return _HeartRateXAxisLayout(
chartWidth: width,
left: SleepHeartRateCard._xAxisLeftInset,
right: right,
);
}
final double chartWidth;
final double left;
final double right;
double get width => (right - left).clamp(0, double.infinity).toDouble();
double dataXInset(double maxX, {required bool left}) {
if (width <= 0 || maxX <= 0) return 0;
final inset = left ? this.left : chartWidth - right;
return inset * maxX / width;
}
double tickX(double progress) => left + width * progress.clamp(0, 1);
double labelLeft({
required int index,
required int count,
required double tickX,
required double labelWidth,
}) {
final maxLeft = (right - labelWidth).clamp(left, right).toDouble();
if (index == 0) return left;
if (index == count - 1) return maxLeft;
return (tickX - labelWidth / 2).clamp(left, maxLeft).toDouble();
}
}
class _HeartRateAxisLabels extends StatelessWidget {
const _HeartRateAxisLabels({required this.range});
... ... @@ -446,13 +492,13 @@ class _HeartRateTimeLabels extends StatelessWidget {
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
const labelWidth = 34.0;
final xAxisLayout = _HeartRateXAxisLayout.forWidth(width);
return Stack(
clipBehavior: Clip.none,
children: [
for (var index = 0; index < ticks.length; index++)
_timeLabel(ticks, index, width, labelWidth),
_timeLabel(context, ticks, index, xAxisLayout),
],
);
},
... ... @@ -461,36 +507,48 @@ class _HeartRateTimeLabels extends StatelessWidget {
}
Widget _timeLabel(
BuildContext context,
List<DateTime> ticks,
int index,
double width,
double labelWidth,
_HeartRateXAxisLayout xAxisLayout,
) {
final tick = ticks[index];
final text = AppTimeFormatter.time(tick, context: context);
final labelWidth = _labelWidth(context, text);
final totalMinutes = axis.end.difference(axis.start).inMinutes;
final tickMinutes = tick.difference(axis.start).inMinutes;
final progress = totalMinutes <= 0 ? 0.0 : tickMinutes / totalMinutes;
final plotLeft = SleepHeartRateCard._chartLeftInset;
final plotRight = width - SleepHeartRateCard._chartRightInset;
final plotWidth =
(plotRight - plotLeft).clamp(0, double.infinity).toDouble();
final tickX = plotLeft + plotWidth * progress;
final isFirstTick = index == 0;
final left = isFirstTick ? tickX : tickX - labelWidth / 2;
final tickX = xAxisLayout.tickX(progress);
final left = xAxisLayout.labelLeft(
index: index,
count: ticks.length,
tickX: tickX,
labelWidth: labelWidth,
);
return Positioned(
left: left,
bottom: 0,
width: labelWidth,
child: _TimeLabel(
_tickLabel(tick),
textAlign: isFirstTick ? TextAlign.left : TextAlign.center,
text,
textAlign: switch (index) {
0 => TextAlign.left,
_ when index == ticks.length - 1 => TextAlign.right,
_ => TextAlign.center,
},
),
);
}
String _tickLabel(DateTime tick) {
return DateFormat('HH:mm').format(tick);
double _labelWidth(BuildContext context, String text) {
final painter = TextPainter(
text: TextSpan(text: text, style: _TimeLabel.style),
textDirection: Directionality.of(context),
maxLines: 1,
)..layout();
// Leave room for fractional glyph widths so AM/PM stays on the same line.
return painter.width + 4;
}
}
... ... @@ -503,17 +561,21 @@ class _TimeLabel extends StatelessWidget {
final String text;
final TextAlign textAlign;
static const style = TextStyle(
color: SleepHeartRateCard._h3,
fontSize: 10,
fontWeight: FontWeight.w400,
height: 1.2,
);
@override
Widget build(BuildContext context) {
return Text(
text,
maxLines: 1,
softWrap: false,
textAlign: textAlign,
style: const TextStyle(
color: SleepHeartRateCard._h3,
fontSize: 10,
fontWeight: FontWeight.w400,
height: 1.2,
),
style: style,
);
}
}
... ... @@ -663,7 +725,9 @@ class _HeartRateMetric extends StatelessWidget {
const SizedBox(height: 3),
if (showTime)
Text(
time == null ? '--:--' : DateFormat('HH:mm').format(time!),
time == null
? '--:--'
: AppTimeFormatter.time(time!, context: context),
style: const TextStyle(
color: _h3,
fontSize: 12,
... ...
import 'dart:async';
import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
... ... @@ -201,12 +202,14 @@ class _SleepPeriodTrendContent extends StatelessWidget {
tooltipAutoDismissMs: tooltipAutoDismissMs,
),
leftMetric: _ExtremeMetric.time(
context: context,
title: context.l10n.sleepEarliestBedtime,
color: SleepWeekReportView.green,
report:
earliestSleepOverride ?? _bedtimeExtreme(days, earliest: true),
),
rightMetric: _ExtremeMetric.time(
context: context,
title: context.l10n.sleepLatestBedtime,
color: SleepWeekReportView.red,
report:
... ... @@ -931,7 +934,7 @@ class _BedtimeChart extends StatefulWidget {
class _BedtimeChartState extends State<_BedtimeChart> {
static const _xAxisLeftInset = 10.0;
static const _xAxisRightInset = 40.0;
static const _xAxisRightPadding = 8.0;
int? _selectedDayIndex;
Timer? _tooltipTimer;
... ... @@ -957,6 +960,7 @@ class _BedtimeChartState extends State<_BedtimeChart> {
(day) => day.hasSleepData && day.heartRate.sleepStart != null,
);
final axis = _BedtimeAxis.fromReports(widget.days);
final rightAxisWidth = _rightAxisWidth(axis);
return LayoutBuilder(
builder: (context, constraints) => Stack(
alignment: Alignment.center,
... ... @@ -973,6 +977,7 @@ class _BedtimeChartState extends State<_BedtimeChart> {
lineX: _lineXForDay(
constraints.maxWidth,
_selectedDayIndex!,
rightAxisWidth,
),
lineTop: _bedtimeChartTopInset,
),
... ... @@ -983,6 +988,7 @@ class _BedtimeChartState extends State<_BedtimeChart> {
interval: 1,
topInset: _bedtimeChartTopInset,
insetChildHorizontally: false,
rightAxisWidth: rightAxisWidth,
rightLabel: _timeAxisLabel,
child: LayoutBuilder(
builder: (context, constraints) => Listener(
... ... @@ -990,10 +996,12 @@ class _BedtimeChartState extends State<_BedtimeChart> {
onPointerDown: (event) => _selectSpotAt(
event.localPosition.dx,
constraints.maxWidth,
rightAxisWidth,
),
onPointerMove: (event) => _selectSpotAt(
event.localPosition.dx,
constraints.maxWidth,
rightAxisWidth,
),
onPointerUp: (_) => _scheduleTooltipDismissal(),
onPointerCancel: (_) => _scheduleTooltipDismissal(),
... ... @@ -1001,6 +1009,7 @@ class _BedtimeChartState extends State<_BedtimeChart> {
_chartData(
axis,
constraints.maxWidth,
rightAxisWidth,
),
),
),
... ... @@ -1012,18 +1021,20 @@ class _BedtimeChartState extends State<_BedtimeChart> {
);
}
double _lineXForDay(double width, int dayIndex) {
double _lineXForDay(double width, int dayIndex, double rightAxisWidth) {
final xAxisRightInset = _xAxisRightInset(rightAxisWidth);
final count = widget.days.length;
if (count <= 1 || width <= _xAxisLeftInset + _xAxisRightInset) {
if (count <= 1 || width <= _xAxisLeftInset + xAxisRightInset) {
return width / 2;
}
final dataWidth = width - _xAxisLeftInset - _xAxisRightInset;
final dataWidth = width - _xAxisLeftInset - xAxisRightInset;
return _xAxisLeftInset + dataWidth * dayIndex / (count - 1);
}
LineChartData _chartData(
_BedtimeAxis axis,
double chartWidth,
double rightAxisWidth,
) {
final points = <FlSpot>[
for (var i = 0; i < widget.days.length; i++)
... ... @@ -1072,7 +1083,7 @@ class _BedtimeChartState extends State<_BedtimeChart> {
}
final count = widget.days.length;
final xBounds = _xBounds(chartWidth, count);
final xBounds = _xBounds(chartWidth, count, rightAxisWidth);
return LineChartData(
minX: xBounds.minX,
... ... @@ -1087,7 +1098,10 @@ class _BedtimeChartState extends State<_BedtimeChart> {
getTouchedSpotIndicator: _lineTouchedIndicators,
touchTooltipData: _lineTooltipData(
days: widget.days,
dataBuilder: _ChartSummaryData.bedtime,
dataBuilder: (report) => _ChartSummaryData.bedtime(
report,
context: context,
),
),
),
titlesData: _SleepTrendXAxis.titlesData(
... ... @@ -1103,9 +1117,13 @@ class _BedtimeChartState extends State<_BedtimeChart> {
);
}
void _selectSpotAt(double dx, double chartWidth) {
void _selectSpotAt(
double dx,
double chartWidth,
double rightAxisWidth,
) {
_tooltipTimer?.cancel();
final index = _dayIndexForTouch(dx, chartWidth);
final index = _dayIndexForTouch(dx, chartWidth, rightAxisWidth);
final hasData = index != null &&
widget.days[index].hasSleepData &&
widget.days[index].heartRate.sleepStart != null;
... ... @@ -1114,31 +1132,40 @@ class _BedtimeChartState extends State<_BedtimeChart> {
setState(() => _selectedDayIndex = nextIndex);
}
({double minX, double maxX}) _xBounds(double width, int count) {
if (count <= 1 || width <= _xAxisLeftInset + _xAxisRightInset) {
({double minX, double maxX}) _xBounds(
double width,
int count,
double rightAxisWidth,
) {
final xAxisRightInset = _xAxisRightInset(rightAxisWidth);
if (count <= 1 || width <= _xAxisLeftInset + xAxisRightInset) {
return (minX: -0.5, maxX: 0.5);
}
final scale = (count - 1) / (width - _xAxisLeftInset - _xAxisRightInset);
final scale = (count - 1) / (width - _xAxisLeftInset - xAxisRightInset);
return (
minX: -_xAxisLeftInset * scale,
maxX: count - 1 + _xAxisRightInset * scale,
maxX: count - 1 + xAxisRightInset * scale,
);
}
int? _dayIndexForTouch(double? dx, double width) {
int? _dayIndexForTouch(
double? dx,
double width,
double rightAxisWidth,
) {
final xAxisRightInset = _xAxisRightInset(rightAxisWidth);
final count = widget.days.length;
if (dx == null || width <= 0 || count <= 0) return null;
if (count == 1 || width <= _xAxisLeftInset + _xAxisRightInset) {
return dx <= width - _xAxisRightInset ? 0 : null;
if (count == 1 || width <= _xAxisLeftInset + xAxisRightInset) {
return dx <= width - xAxisRightInset ? 0 : null;
}
final dataWidth = width - _xAxisLeftInset - _xAxisRightInset;
final dataWidth = width - _xAxisLeftInset - xAxisRightInset;
final firstX = _xAxisLeftInset;
final lastX = width - _xAxisRightInset;
final lastX = width - xAxisRightInset;
final step = dataWidth / (count - 1);
final minTouchX = (firstX - step / 2).clamp(0, width).toDouble();
final maxTouchX = (lastX + step / 2)
.clamp(0, width - _ChartPlotFrame.rightAxisWidth)
.toDouble();
final maxTouchX =
(lastX + step / 2).clamp(0, width - rightAxisWidth).toDouble();
if (dx < minTouchX || dx > maxTouchX) return null;
return ((dx - _xAxisLeftInset) / dataWidth * (count - 1))
.round()
... ... @@ -1169,9 +1196,31 @@ class _BedtimeChartState extends State<_BedtimeChart> {
}
String _timeAxisLabel(double value) {
final hour = value.toInt() % 24;
return '${hour.toString().padLeft(2, '0')}:00';
return AppTimeFormatter.hourAxis(value.toInt(), context: context);
}
double _rightAxisWidth(_BedtimeAxis axis) {
final steps = (axis.maxHour - axis.minHour).ceil();
final textPainter = TextPainter(
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
maxLines: 1,
);
var maxWidth = 0.0;
for (var index = 0; index <= steps; index++) {
textPainter.text = TextSpan(
text: _timeAxisLabel((axis.minHour + index).toDouble()),
style: _RightAxisLabels.labelStyle,
);
textPainter.layout();
if (textPainter.width > maxWidth) maxWidth = textPainter.width;
}
textPainter.dispose();
return maxWidth + _xAxisRightPadding;
}
double _xAxisRightInset(double rightAxisWidth) =>
rightAxisWidth + _xAxisRightPadding;
}
double _barWidth(bool compact) => compact ? 4 : 16;
... ... @@ -1448,11 +1497,14 @@ class _ChartSummaryData {
);
}
factory _ChartSummaryData.bedtime(SleepReport report) {
factory _ChartSummaryData.bedtime(
SleepReport report, {
required BuildContext context,
}) {
final start = report.heartRate.sleepStart!;
return _ChartSummaryData(
title: l10n.sleepFellAsleepAt(
'${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}',
AppTimeFormatter.time(start, context: context),
),
subtitle: _fullDateText(report.date),
titleColor: SleepWeekReportView.brand,
... ... @@ -1590,11 +1642,12 @@ class _ChartPlotFrame extends StatelessWidget {
this.minY = 0,
this.topInset = 0,
this.insetChildHorizontally = true,
this.rightAxisWidth = defaultRightAxisWidth,
this.referenceLines = const [],
});
static const horizontalInset = 20.0;
static const rightAxisWidth = 32.0;
static const defaultRightAxisWidth = 32.0;
static const bottomTitleHeight = 28.0;
final Widget child;
... ... @@ -1603,6 +1656,7 @@ class _ChartPlotFrame extends StatelessWidget {
final double interval;
final double topInset;
final bool insetChildHorizontally;
final double rightAxisWidth;
final String Function(double value) rightLabel;
final List<_ChartReferenceLine> referenceLines;
... ... @@ -1637,9 +1691,7 @@ class _ChartPlotFrame extends StatelessWidget {
padding: EdgeInsets.only(
top: topInset,
left: insetChildHorizontally ? horizontalInset : 0,
right: insetChildHorizontally
? rightAxisWidth
: 0,
right: insetChildHorizontally ? rightAxisWidth : 0,
),
child: child,
),
... ... @@ -1650,6 +1702,7 @@ class _ChartPlotFrame extends StatelessWidget {
minY: minY,
maxY: maxY,
interval: interval,
width: rightAxisWidth,
labelBuilder: rightLabel,
),
),
... ... @@ -1741,14 +1794,23 @@ class _RightAxisLabels extends StatelessWidget {
required this.minY,
required this.maxY,
required this.interval,
required this.width,
required this.labelBuilder,
});
final double minY;
final double maxY;
final double interval;
final double width;
final String Function(double value) labelBuilder;
static const labelStyle = TextStyle(
color: SleepWeekReportView.h3,
fontSize: 10,
fontWeight: FontWeight.w400,
height: 1.2,
);
@override
Widget build(BuildContext context) {
return IgnorePointer(
... ... @@ -1762,19 +1824,14 @@ class _RightAxisLabels extends StatelessWidget {
Positioned(
top: constraints.maxHeight * (1 - index / steps) - 16,
right: 0,
width: _ChartPlotFrame.rightAxisWidth,
width: width,
child: Align(
alignment: Alignment.centerRight,
child: Text(
labelBuilder(minY + interval * index),
maxLines: 1,
textAlign: TextAlign.right,
style: const TextStyle(
color: SleepWeekReportView.h3,
fontSize: 10,
fontWeight: FontWeight.w400,
height: 1.2,
),
style: labelStyle,
),
),
),
... ... @@ -1837,7 +1894,7 @@ class _SleepTrendXAxis {
return Column(
children: [
Text('${day.day}', style: labelStyle),
Text(reportWeekdayLabel(day.weekday), style: labelStyle),
Text(reportWeekdayNarrowLabel(day.weekday), style: labelStyle),
],
);
}
... ... @@ -1901,6 +1958,7 @@ class _ExtremeMetric extends StatelessWidget {
}
factory _ExtremeMetric.time({
required BuildContext context,
required String title,
required Color color,
required SleepReport? report,
... ... @@ -1912,7 +1970,7 @@ class _ExtremeMetric extends StatelessWidget {
color: color,
value: start == null
? '--:--'
: '${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}',
: AppTimeFormatter.time(start, context: context),
unit: '',
date: report?.date,
);
... ...
import 'package:doublefeel_flutter/core/error/http_error_handling_policy.dart';
import 'package:doublefeel_flutter/data/models/friend/friends_sort_models.dart';
import '../../../data/models/friend/friend_models.dart';
import '../../result/app_result.dart';
... ... @@ -109,4 +110,17 @@ class FriendApi {
},
);
}
Future<AppResult<void>> postFriendsSort(List<FriendsSortReq> list) {
return safeCall(
call: () async {
await _dioClient.dio.post(
ApiPaths.friendsSort,
data: {
'sort_list': list.map((item) => item.toJson()).toList(),
},
);
},
);
}
}
... ...
... ... @@ -89,4 +89,5 @@ abstract final class ApiPaths {
// friends
static const friends = '/client/doublefeel/health/v2/friends/';
static const friendInfo = '/client/doublefeel/health/v2/friend_info/';
static const friendsSort = '/client/doublefeel/health/v2/friends/sort/';
}
... ...
import 'dart:ui' show PlatformDispatcher;
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class AppTimeFormatter {
const AppTimeFormatter._();
static String time(
DateTime value, {
BuildContext? context,
bool? use24HourFormat,
String? localeName,
}) {
final uses24HourFormat = _uses24HourFormat(context, use24HourFormat);
if (context != null) {
return MaterialLocalizations.of(context).formatTimeOfDay(
TimeOfDay.fromDateTime(value),
alwaysUse24HourFormat: uses24HourFormat,
);
}
return uses24HourFormat
? DateFormat('HH:mm').format(value)
: DateFormat('h:mm a', localeName).format(value);
}
static String hourAxis(
int hour, {
BuildContext? context,
bool? use24HourFormat,
String? localeName,
bool showEndOfDayAs24 = false,
}) {
final uses24HourFormat = _uses24HourFormat(context, use24HourFormat);
if (showEndOfDayAs24 && hour == 24 && uses24HourFormat) {
return '24:00';
}
final clockTime = DateTime(2000, 1, 1).add(Duration(hours: hour));
if (context != null) {
return MaterialLocalizations.of(context).formatTimeOfDay(
TimeOfDay.fromDateTime(clockTime),
alwaysUse24HourFormat: uses24HourFormat,
);
}
return uses24HourFormat
? DateFormat('HH:mm').format(clockTime)
: DateFormat('h a', localeName).format(clockTime);
}
static bool _uses24HourFormat(BuildContext? context, bool? override) =>
override ??
(context == null
? PlatformDispatcher.instance.alwaysUse24HourFormat
: MediaQuery.alwaysUse24HourFormatOf(context));
}
... ...
... ... @@ -31,9 +31,6 @@ class UserAccountStorage {
static String _showRealtimeStressKey(int userId) =>
'account_show_realtime_stress_$userId';
/// 好友列表排序,按 userId 隔离。
static String _friendsOrderKey(int userId) => 'account_friends_order_$userId';
/// 在实时压力偏好更新后通知依赖它的页面刷新。
final realtimeStressSettingsVersion = 0.obs;
... ... @@ -95,17 +92,6 @@ class UserAccountStorage {
realtimeStressSettingsVersion.value++;
}
// ─── 好友列表排序 ─────────────────────────────────────────────────────────
List<String>? friendOrder(int userId) =>
_prefs.getStringList(_friendsOrderKey(userId));
Future<void> saveFriendOrder(int userId, List<int> friendUserIds) =>
_prefs.setStringList(
_friendsOrderKey(userId),
friendUserIds.map((id) => id.toString()).toList(),
);
// ─── Apple Health 上传记录 ────────────────────────────────────────────────
String? appleHealthUploadLocalRecordJson(int userId) =>
... ...
class FriendsSortReq {
const FriendsSortReq({
required this.friendUserId,
required this.sort,
});
final int friendUserId;
final int sort;
Map<String, dynamic> toJson() => {
'friend_user_id': friendUserId,
'sort': sort,
};
}
... ...
... ... @@ -15,7 +15,7 @@
"passwordEmptyHint": "Password cannot be empty",
"homeTitle": "Home",
"tabToday": "Today",
"tabTrend": "Trend",
"tabTrend": "Trends",
"tabFriends": "Friends",
"tabMy": "Me",
"switchLanguage": "Switch Language",
... ... @@ -371,7 +371,7 @@
"hrvTrendDayUnit": "",
"hrvTrendComparedLastWeekUnavailable": "vs last week -",
"hrvTrendSameAsLastWeek": "Same as last week",
"hrvTrendMoreDaysThanLastWeek": "{count}d vs last week",
"hrvTrendMoreDaysThanLastWeek": "+{count}d vs last week",
"@hrvTrendMoreDaysThanLastWeek": {
"placeholders": {
"count": {
... ... @@ -389,7 +389,7 @@
},
"hrvTrendComparedLastMonthUnavailable": "vs last month -",
"hrvTrendSameAsLastMonth": "Same as last month",
"hrvTrendMoreDaysThanLastMonth": "{count}d vs last month",
"hrvTrendMoreDaysThanLastMonth": "+{count}d vs last month",
"@hrvTrendMoreDaysThanLastMonth": {
"placeholders": {
"count": {
... ... @@ -624,8 +624,8 @@
"friendsRemarkSuffix": " ({remark})",
"friendsUnknownFriend": "Unknown friend",
"friendsUpdatedAt": "Updated at {time}",
"friendsRealtimeStressUpdatedAt": "Real-time stress updated at {time}",
"friendsHrvUpdatedAt": "HRV updated at {time}",
"friendsRealtimeStressUpdatedAt": "Live Stress Last Updated at {time}",
"friendsHrvUpdatedAt": "HRV Last Updated at {time}",
"friendsStepCount": "{count} steps",
"friendsStressAttention": "Stress alert",
"friendsWaitingForData": "No Data Yet",
... ... @@ -1065,4 +1065,4 @@
}
},
"updatePassword": "Update password"
}
\ No newline at end of file
}
... ...
... ... @@ -54,7 +54,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get tabToday => 'Today';
@override
String get tabTrend => 'Trend';
String get tabTrend => 'Trends';
@override
String get tabFriends => 'Friends';
... ... @@ -1234,7 +1234,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String hrvTrendMoreDaysThanLastWeek(int count) {
return '${count}d vs last week';
return '+${count}d vs last week';
}
@override
... ... @@ -1250,7 +1250,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String hrvTrendMoreDaysThanLastMonth(int count) {
return '${count}d vs last month';
return '+${count}d vs last month';
}
@override
... ... @@ -1786,12 +1786,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String friendsRealtimeStressUpdatedAt(String time) {
return 'Real-time stress updated at $time';
return 'Live Stress Last Updated at $time';
}
@override
String friendsHrvUpdatedAt(String time) {
return 'HRV updated at $time';
return 'HRV Last Updated at $time';
}
@override
... ...
import 'package:doublefeel_flutter/app/modules/sleep_report/models/sleep_report_models.dart';
import 'package:doublefeel_flutter/app/modules/sleep_report/widgets/sleep_heart_rate_card.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('keeps 12-hour X-axis labels aligned with data bounds',
(tester) async {
final start = DateTime(2026, 1, 1, 22, 30);
final end = DateTime(2026, 1, 2, 6, 30);
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
home: MediaQuery(
data: const MediaQueryData(alwaysUse24HourFormat: false),
child: Scaffold(
body: Center(
child: SizedBox(
width: 240,
child: SleepHeartRateCard(
summary: SleepHeartRateSummary(
averageBpm: 60,
maxBpm: 65,
minBpm: 55,
points: [
SleepHeartRatePoint(time: start, bpm: 55),
SleepHeartRatePoint(time: end, bpm: 65),
],
),
),
),
),
),
),
),
);
final cardRect = tester.getRect(find.byType(SleepHeartRateCard));
final firstLabelRect = tester.getRect(find.text('10:30 PM'));
final lastLabelRect = tester.getRect(find.text('6:30 AM'));
final chartFinder = find.byType(LineChart);
final chartRect = tester.getRect(chartFinder);
final chartData = tester.widget<LineChart>(chartFinder).data;
final endX = end.difference(start).inMinutes.toDouble();
final endPointX = chartRect.left +
(endX - chartData.minX) /
(chartData.maxX - chartData.minX) *
chartRect.width;
expect(firstLabelRect.left, greaterThanOrEqualTo(cardRect.left + 20));
expect(lastLabelRect.right, lessThanOrEqualTo(cardRect.right - 40));
expect(endPointX, closeTo(lastLabelRect.right, 1));
});
}
... ...
import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('AppTimeFormatter', () {
test('formats 24-hour time when requested', () {
expect(
AppTimeFormatter.time(
DateTime(2026, 1, 1, 13, 5),
use24HourFormat: true,
),
'13:05',
);
});
test('supports 12-hour time', () {
expect(
AppTimeFormatter.time(
DateTime(2026, 1, 1, 13, 5),
use24HourFormat: false,
localeName: 'en',
),
'1:05 PM',
);
});
test('normalizes overnight chart hours for their labels', () {
expect(
AppTimeFormatter.hourAxis(26, use24HourFormat: true),
'02:00',
);
expect(
AppTimeFormatter.hourAxis(
26,
use24HourFormat: false,
localeName: 'en',
),
'2 AM',
);
});
test('can retain 24:00 at a day boundary', () {
expect(
AppTimeFormatter.hourAxis(
24,
use24HourFormat: true,
showEndOfDayAs24: true,
),
'24:00',
);
});
testWidgets('honors the MediaQuery time-format preference', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(alwaysUse24HourFormat: false),
child: Builder(
builder: (context) => Text(
AppTimeFormatter.time(DateTime(2026, 1, 1, 13, 5),
context: context),
),
),
),
),
);
expect(find.text('1:05 PM'), findsOneWidget);
});
testWidgets('uses localized Material formatting for 24-hour time',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(alwaysUse24HourFormat: true),
child: Builder(
builder: (context) => Text(
AppTimeFormatter.time(DateTime(2026, 1, 1, 13, 5),
context: context),
),
),
),
),
);
expect(find.text('13:05'), findsOneWidget);
});
});
}
... ...