Commit 04eefd5a3b7a3eb5bf2a98224825e3e515de4aec

Authored by 刘宏哲
1 parent 6bb68ed8

feat(app): add friends module

Showing 46 changed files with 2288 additions and 818 deletions

Too many changes to show.

To preserve performance only 46 of 46+ files are displayed.

... ... @@ -8,6 +8,7 @@ import com.doublefeel.app.pigeon.platform.AppleProductInfo
import com.doublefeel.app.pigeon.platform.AppleProductPaymentResult
import com.doublefeel.app.pigeon.platform.AppleSignInModel
import com.doublefeel.app.pigeon.platform.PlatformHostApi
import com.doublefeel.app.pigeon.platform.WatchAppOtherInfo
import kotlin.math.max
import kotlin.math.min
... ... @@ -84,4 +85,12 @@ class PlatformHostApiImpl(private val application: android.app.Application) : Pl
override fun performRestore(callback: (Result<Boolean>) -> Unit) {
}
override fun updateWatchOtherUserInfo(otherInfo: WatchAppOtherInfo?) {
}
override fun refreshWatchSurface() {
}
}
... ...
... ... @@ -51,7 +51,7 @@ class WearEngineHostApiImpl(
return sendTextMessage(jsonPayload)
}
override fun pickImageAndRemoveBackground(): String? {
return null
override fun removeBackground(originImagePath: String, callback: (Result<String?>) -> Unit) {
}
}
... ...
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:get/get.dart';
import '../../report_common/controllers/report_period_logic.dart';
... ... @@ -7,22 +8,33 @@ import '../data/activity_burn_report_repository.dart';
import '../models/activity_burn_report_models.dart';
class ActivityBurnReportLogic extends ReportPeriodLogic {
ActivityBurnReportLogic({int? initialTargetUserId}) {
ActivityBurnReportLogic({
int? initialTargetUserId,
ActivityBurnReportRepository? repository,
}) : repository = repository ??
ActivityBurnReportRepositoryImpl(
dataSource:
ApiActivityBurnReportDataSource(Get.find<HealthApi>()),
) {
targetUserId.value = initialTargetUserId;
}
final targetUserId = RxnInt();
final ActivityBurnReportRepository repository =
const ActivityBurnReportRepositoryImpl(
dataSource: MockActivityBurnReportDataSource(),
);
final ActivityBurnReportRepository repository;
final report = Rxn<ActivityBurnReport>();
final weeklyReport = Rxn<WeeklyActivityBurnReport>();
final monthlyReport = Rxn<MonthlyActivityBurnReport>();
@override
List<ReportPeriod> get supportedPeriods => const [
ReportPeriod.day,
ReportPeriod.week,
ReportPeriod.month,
];
@override
Future<void> loadReport() async {
isLoading.value = true;
try {
... ...
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart';
import '../models/activity_burn_report_models.dart';
abstract class ActivityBurnReportDataSource {
... ... @@ -17,6 +21,224 @@ abstract class ActivityBurnReportDataSource {
});
}
class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
const ApiActivityBurnReportDataSource(this._healthApi);
static const _weekDateRangeType = 0;
static const _monthDateRangeType = 1;
static const _dayDateRangeType = 3;
final HealthApi _healthApi;
@override
Future<ActivityBurnReport> fetchDailyReport(
DateTime date, {
int? targetUserId,
}) async {
final day = _normalizeDate(date);
final result = await _healthApi.getActivityBurnStatistics(
_dayDateRangeType,
_dateKey(day),
queryUserId: targetUserId,
);
return switch (result) {
AppSuccess(:final data) => _dailyReport(day, data),
AppFailure() => ActivityBurnReport.empty(day),
};
}
@override
Future<WeeklyActivityBurnReport> fetchWeeklyReport(
DateTime weekStart, {
int? targetUserId,
}) async {
final start = _normalizeDate(weekStart);
final result = await _healthApi.getActivityBurnStatistics(
_weekDateRangeType,
_dateKey(start),
queryUserId: targetUserId,
);
return switch (result) {
AppSuccess(:final data) => WeeklyActivityBurnReport(
weekStart: start,
weekEnd: start.add(const Duration(days: 6)),
days: _periodReports(start, 7, data),
totalActiveEnergyOverride: data.totalMove?.round(),
totalExerciseMinutesOverride: data.totalExercise?.round(),
totalStandHoursOverride: data.totalStand?.round(),
averageDailyActiveEnergyOverride: data.avgMove?.round(),
activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(),
),
AppFailure() => WeeklyActivityBurnReport.empty(start),
};
}
@override
Future<MonthlyActivityBurnReport> fetchMonthlyReport(
DateTime monthStart, {
int? targetUserId,
}) async {
final start = DateTime(monthStart.year, monthStart.month);
final end = DateTime(start.year, start.month + 1, 0);
final result = await _healthApi.getActivityBurnStatistics(
_monthDateRangeType,
_dateKey(start),
queryUserId: targetUserId,
);
return switch (result) {
AppSuccess(:final data) => MonthlyActivityBurnReport(
monthStart: start,
monthEnd: end,
days: _periodReports(start, end.day, data),
totalActiveEnergyOverride: data.totalMove?.round(),
totalExerciseMinutesOverride: data.totalExercise?.round(),
totalStandHoursOverride: data.totalStand?.round(),
averageDailyActiveEnergyOverride: data.avgMove?.round(),
activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(),
),
AppFailure() => MonthlyActivityBurnReport.empty(start),
};
}
ActivityBurnReport _dailyReport(
DateTime date,
ActivityBurnStatisticsDataV2 data,
) {
final target = data.activityTargetInfo;
final overallValues = _dailyOverallValues(date, data.overallList);
final points = <ActivityBurnHeartRatePoint>[
for (final item in data.hrList ?? const <HrList>[])
if (_parseDateTime(item.dataTime) case final time?)
if (item.value case final bpm?)
if (bpm > 0 &&
!time.isBefore(date) &&
time.isBefore(date.add(const Duration(days: 1))))
ActivityBurnHeartRatePoint(time: time, bpm: bpm.toDouble()),
]..sort((a, b) => a.time.compareTo(b.time));
final sleepTimes = <DateTime>[
for (final item in data.sleepTimeList ?? const <SleepTimeList>[])
if (_parseDateTime(item.fromTime, fallbackDate: date) case final from?)
from,
for (final item in data.sleepTimeList ?? const <SleepTimeList>[])
if (_parseDateTime(item.toTime, fallbackDate: date) case final to?) to,
]..sort();
return ActivityBurnReport(
date: date,
activeEnergy:
_metric(overallValues?.move ?? data.totalMove, target?.move),
exerciseMinutes: _metric(
overallValues?.exercise ?? data.totalExercise,
target?.exercise,
),
standHours: _metric(
overallValues?.stand ?? data.totalStand,
target?.stand,
),
heartRate: ActivityBurnHeartRateSummary(
startTime: date,
endTime: points.isEmpty ? null : points.last.time,
sleepStartTime: sleepTimes.isEmpty ? null : sleepTimes.first,
sleepEndTime: sleepTimes.isEmpty ? null : sleepTimes.last,
points: points,
),
);
}
Value? _dailyOverallValues(
DateTime date,
List<OverallList>? overallList,
) {
final items = overallList ?? const <OverallList>[];
for (final item in items) {
final time = _parseDateTime(item.timeKey);
if (time != null && _normalizeDate(time) == date) return item.value;
}
if (items.length == 1) return items.first.value;
return null;
}
List<ActivityBurnReport> _periodReports(
DateTime start,
int dayCount,
ActivityBurnStatisticsDataV2 data,
) {
final target = data.activityTargetInfo;
final valuesByDate = <DateTime, Value>{
for (final item in data.overallList ?? const <OverallList>[])
if (_parseDateTime(item.timeKey) case final time?)
if (item.value case final value?) _normalizeDate(time): value,
};
final moveByDate = <DateTime, num>{
for (final item in data.moveTrendList ?? const <MoveTrendList>[])
if (_parseDateTime(item.timeKey) case final time?)
if (item.value case final value?) _normalizeDate(time): value,
};
return [
for (var i = 0; i < dayCount; i++)
_periodDay(
start.add(Duration(days: i)),
valuesByDate,
moveByDate,
target,
),
];
}
ActivityBurnReport _periodDay(
DateTime date,
Map<DateTime, Value> valuesByDate,
Map<DateTime, num> moveByDate,
ActivityTargetInfo? target,
) {
final values = valuesByDate[date];
final move = values?.move ?? moveByDate[date];
if (values == null && move == null) return ActivityBurnReport.empty(date);
return ActivityBurnReport(
date: date,
activeEnergy: _metric(move, target?.move),
exerciseMinutes: _metric(values?.exercise, target?.exercise),
standHours: _metric(values?.stand, target?.stand),
);
}
ActivityBurnMetric? _metric(num? value, num? goal) {
if (value == null) return null;
return ActivityBurnMetric(value: value.round(), goal: goal?.round() ?? 0);
}
DateTime? _parseDateTime(Object? value, {DateTime? fallbackDate}) {
if (value is num) {
if (value >= 1000000000) {
final milliseconds =
value >= 1000000000000 ? value.toInt() : (value * 1000).toInt();
return DateTime.fromMillisecondsSinceEpoch(milliseconds);
}
if (fallbackDate != null && value >= 0 && value < 86400) {
return fallbackDate.add(Duration(seconds: value.round()));
}
}
final raw = value?.toString();
if (raw == null || raw.isEmpty) return null;
final parsed = DateTime.tryParse(raw);
if (parsed != null) return parsed;
final digits = raw.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.length < 8) return null;
final year = int.tryParse(digits.substring(0, 4));
final month = int.tryParse(digits.substring(4, 6));
final day = int.tryParse(digits.substring(6, 8));
if (year == null || month == null || day == null) return null;
return DateTime(year, month, day);
}
DateTime _normalizeDate(DateTime date) =>
DateTime(date.year, date.month, date.day);
int _dateKey(DateTime date) =>
date.year * 10000 + date.month * 100 + date.day;
}
class MockActivityBurnReportDataSource implements ActivityBurnReportDataSource {
const MockActivityBurnReportDataSource();
... ...
... ... @@ -74,37 +74,64 @@ class WeeklyActivityBurnReport {
required this.weekStart,
required this.weekEnd,
required this.days,
this.totalActiveEnergyOverride,
this.totalExerciseMinutesOverride,
this.totalStandHoursOverride,
this.averageDailyActiveEnergyOverride,
this.activeEnergyGoalOverride,
});
final DateTime weekStart;
final DateTime weekEnd;
final List<ActivityBurnReport> days;
final int? totalActiveEnergyOverride;
final int? totalExerciseMinutesOverride;
final int? totalStandHoursOverride;
final int? averageDailyActiveEnergyOverride;
final int? activeEnergyGoalOverride;
List<ActivityBurnReport> get daysWithData =>
days.where((report) => report.hasData).toList();
bool get hasData => daysWithData.isNotEmpty;
int get totalActiveEnergy => daysWithData.fold(
bool get hasData =>
daysWithData.isNotEmpty ||
totalActiveEnergyOverride != null ||
totalExerciseMinutesOverride != null ||
totalStandHoursOverride != null;
int get totalActiveEnergy =>
totalActiveEnergyOverride ??
daysWithData.fold(
0,
(sum, report) => sum + (report.activeEnergy?.value ?? 0),
);
int get totalExerciseMinutes => daysWithData.fold(
int get totalExerciseMinutes =>
totalExerciseMinutesOverride ??
daysWithData.fold(
0,
(sum, report) => sum + (report.exerciseMinutes?.value ?? 0),
);
int get totalStandHours => daysWithData.fold(
int get totalStandHours =>
totalStandHoursOverride ??
daysWithData.fold(
0,
(sum, report) => sum + (report.standHours?.value ?? 0),
);
int get averageDailyActiveEnergy {
if (averageDailyActiveEnergyOverride case final value?) return value;
if (daysWithData.isEmpty) return 0;
return (totalActiveEnergy / daysWithData.length).round();
}
int get activeEnergyGoal =>
activeEnergyGoalOverride ??
days
.map((report) => report.activeEnergy?.goal ?? 0)
.firstWhere((goal) => goal > 0, orElse: () => 0);
int get perfectRingDays => daysWithData
.where((report) =>
(report.activeEnergy?.progress ?? 0) >= 1 &&
... ... @@ -141,37 +168,64 @@ class MonthlyActivityBurnReport {
required this.monthStart,
required this.monthEnd,
required this.days,
this.totalActiveEnergyOverride,
this.totalExerciseMinutesOverride,
this.totalStandHoursOverride,
this.averageDailyActiveEnergyOverride,
this.activeEnergyGoalOverride,
});
final DateTime monthStart;
final DateTime monthEnd;
final List<ActivityBurnReport> days;
final int? totalActiveEnergyOverride;
final int? totalExerciseMinutesOverride;
final int? totalStandHoursOverride;
final int? averageDailyActiveEnergyOverride;
final int? activeEnergyGoalOverride;
List<ActivityBurnReport> get daysWithData =>
days.where((report) => report.hasData).toList();
bool get hasData => daysWithData.isNotEmpty;
int get totalActiveEnergy => daysWithData.fold(
bool get hasData =>
daysWithData.isNotEmpty ||
totalActiveEnergyOverride != null ||
totalExerciseMinutesOverride != null ||
totalStandHoursOverride != null;
int get totalActiveEnergy =>
totalActiveEnergyOverride ??
daysWithData.fold(
0,
(sum, report) => sum + (report.activeEnergy?.value ?? 0),
);
int get totalExerciseMinutes => daysWithData.fold(
int get totalExerciseMinutes =>
totalExerciseMinutesOverride ??
daysWithData.fold(
0,
(sum, report) => sum + (report.exerciseMinutes?.value ?? 0),
);
int get totalStandHours => daysWithData.fold(
int get totalStandHours =>
totalStandHoursOverride ??
daysWithData.fold(
0,
(sum, report) => sum + (report.standHours?.value ?? 0),
);
int get averageDailyActiveEnergy {
if (averageDailyActiveEnergyOverride case final value?) return value;
if (daysWithData.isEmpty) return 0;
return (totalActiveEnergy / daysWithData.length).round();
}
int get activeEnergyGoal =>
activeEnergyGoalOverride ??
days
.map((report) => report.activeEnergy?.goal ?? 0)
.firstWhere((goal) => goal > 0, orElse: () => 0);
int get perfectRingDays => daysWithData
.where((report) =>
(report.activeEnergy?.progress ?? 0) >= 1 &&
... ...
... ... @@ -108,7 +108,7 @@ class _DailyReportContent extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
ActivityBurnRing(report: report),
ActivityBurnRing(report: report, animate: true),
const SizedBox(height: 24),
ActivityBurnSummaryCard(report: report),
const SizedBox(height: 12),
... ... @@ -132,6 +132,8 @@ class _DateSwitcher extends StatelessWidget {
label: logic.dateLabel,
onPrevious: logic.previousDay,
onNext: logic.nextDay,
canPrevious: logic.canGoPrevious,
canNext: logic.canGoNext,
onTapLabel: () => _showDatePicker(context),
),
);
... ...
... ... @@ -2,11 +2,13 @@ import 'dart:math' as math;
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 '../../../../r.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_trend_lines.dart';
class ActivityBurnHeartRateZoneCard extends StatelessWidget {
const ActivityBurnHeartRateZoneCard({
... ... @@ -19,11 +21,15 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
static const _h1 = Color(0xFF0F0F11);
static const _h3 = Color(0xFFB0B0B6);
static const _brand = Color(0xFF845EEE);
static const _grid = Color(0xFFF3F3F3);
static const _active = Color(0xFFFF5279);
static const _warm = Color(0xFFFF9A6E);
static const _stand = Color(0xFF7B9BFB);
static const _exercise = Color(0xFF3BD49D);
static const _chartHeight = 197.0;
static const _topAxisLabelHeight = 14.0;
static const _bottomAxisLabelHeight = 20.0;
static const _axisHeight =
_chartHeight - _topAxisLabelHeight - _bottomAxisLabelHeight;
@override
Widget build(BuildContext context) {
... ... @@ -36,7 +42,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(20, 20, 14, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
... ... @@ -44,7 +50,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
Text(
HealthReportSubjectScope.titleOf(
context,
summary.hasData ? '心率区间' : '实时心率',
summary.hasData
? context.l10n.activityHeartRateZone
: context.l10n.activityRealtimeHeartRate,
),
style: TextStyle(
color: _h1,
... ... @@ -54,19 +62,31 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
),
),
const SizedBox(height: 20),
Expanded(
SizedBox(
height: _chartHeight,
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
alignment: Alignment.center,
children: [
Positioned(
left: 0,
right: 6,
top: 0,
bottom: 20,
child: ActivityBurnHorizontalGridLines(
minY: axis.minY,
maxY: axis.chartMaxY,
values: axis.ticks,
),
),
Positioned.fill(
top: 18,
top: 0,
child: LineChart(_chartData()),
),
if (sleepRange != null)
Positioned(
top: 4,
top: 0,
left: _sleepIconLeft(
sleepRange,
axis.maxX,
... ... @@ -79,9 +99,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
),
),
if (!summary.hasData)
const Text(
'暂无本日数据',
style: TextStyle(
Text(
context.l10n.reportNoDataToday,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
... ... @@ -121,16 +141,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
minX: 0,
maxX: axis.maxX,
minY: axis.minY,
maxY: axis.maxY,
maxY: axis.chartMaxY,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 20,
getDrawingHorizontalLine: (_) => const FlLine(
color: _grid,
strokeWidth: 1,
dashArray: [2, 2],
),
show: false,
),
borderData: FlBorderData(show: false),
lineTouchData: const LineTouchData(enabled: false),
... ... @@ -141,21 +154,24 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
sideTitles: SideTitles(
showTitles: points.isNotEmpty,
reservedSize: 28,
interval: 20,
interval: 1,
getTitlesWidget: (value, meta) {
if (!axis.shouldShowYLabel(value)) {
return const SizedBox.shrink();
}
return Transform.translate(
offset: const Offset(0, -6),
child: Text(
axis.formatYLabel(value),
maxLines: 1,
softWrap: false,
style: const TextStyle(
color: _h3,
fontSize: 10,
fontWeight: FontWeight.w400,
offset: const Offset(0, -8),
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
axis.formatYLabel(value),
maxLines: 1,
softWrap: false,
style: const TextStyle(
color: _h3,
fontSize: 10,
fontWeight: FontWeight.w400,
),
),
),
);
... ... @@ -256,7 +272,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
if (points.length < 2) return const [];
final segments = <_HeartRateSegment>[];
final maxHr = _maxHeartRate(summary.userAge);
Color? currentColor;
var currentSpots = <FlSpot>[];
var previousPoint = points.first;
... ... @@ -265,13 +280,12 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
final splitSpots = _splitByZoneBoundaries(
_spot(previousPoint, start),
_spot(point, start),
maxHr,
);
for (var i = 0; i < splitSpots.length - 1; i++) {
final from = splitSpots[i];
final to = splitSpots[i + 1];
final color = _zoneColor(_zoneLevel((from.y + to.y) / 2, maxHr));
final color = _zoneColor(_zoneLevel((from.y + to.y) / 2));
if (currentColor == color && currentSpots.isNotEmpty) {
currentSpots.add(to);
... ... @@ -312,7 +326,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
List<FlSpot> _splitByZoneBoundaries(
FlSpot start,
FlSpot end,
double maxHr,
) {
if (start.y == end.y) return [start, end];
... ... @@ -320,7 +333,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
final maxY = math.max(start.y, end.y);
final crossings = <FlSpot>[];
for (final boundary in _zoneBoundaries(maxHr)) {
for (final boundary in _zoneBoundaries) {
if (boundary <= minY || boundary >= maxY) continue;
final t = (boundary - start.y) / (end.y - start.y);
crossings.add(
... ... @@ -335,28 +348,14 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
return [start, ...crossings, end];
}
int _zoneLevel(double bpm, double maxHr) {
final zoneRatio = maxHr <= 0 ? 0 : bpm / maxHr;
if (bpm >= 160 || zoneRatio >= 0.9) return 3;
if (bpm >= 140 || zoneRatio >= 0.8) return 2;
if (zoneRatio >= 0.6) return 1;
int _zoneLevel(double bpm) {
if (bpm >= 170) return 3;
if (bpm >= 151) return 2;
if (bpm >= 108) return 1;
return 0;
}
List<double> _zoneBoundaries(double maxHr) {
final boundaries = [
160.0,
140.0,
maxHr * 0.9,
maxHr * 0.8,
maxHr * 0.6,
]..sort();
return [
for (var i = 0; i < boundaries.length; i++)
if (i == 0 || (boundaries[i] - boundaries[i - 1]).abs() > 0.001)
boundaries[i],
];
}
static const _zoneBoundaries = [108.0, 151.0, 170.0];
Color _zoneColor(int zone) {
switch (zone) {
... ... @@ -378,11 +377,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
);
}
double _maxHeartRate(int? userAge) {
final age = userAge == null || userAge <= 0 ? 30 : userAge;
return 208 - 0.7 * age;
}
String? _bottomLabel(double value, DateTime start, double maxX) {
final rounded = value.round();
if (rounded % 360 != 0 || rounded < 0 || rounded > maxX.round()) {
... ... @@ -407,12 +401,38 @@ class _HeartRateAxis {
final double maxX;
final double minY;
final double maxY;
double get chartMaxY {
final range = maxY - minY;
if (range <= 0) return maxY;
return maxY +
range *
ActivityBurnHeartRateZoneCard._topAxisLabelHeight /
ActivityBurnHeartRateZoneCard._axisHeight;
}
List<double> get ticks {
final values = <double>[minY];
for (var value = minY + 20; value < maxY; value += 20) {
values.add(value);
}
if ((values.last - maxY).abs() > 0.001) values.add(maxY);
return values;
}
factory _HeartRateAxis.fromSummary(
ActivityBurnHeartRateSummary summary,
DateTime start,
) {
if (!summary.hasData) {
final dayEnd = start.add(const Duration(days: 1));
final points = summary.points
.where(
(point) =>
point.bpm > 0 &&
!point.time.isBefore(start) &&
point.time.isBefore(dayEnd),
)
.toList();
if (points.isEmpty) {
return _HeartRateAxis(
endTime: start.add(const Duration(hours: 18)),
maxX: 1080,
... ... @@ -421,30 +441,24 @@ class _HeartRateAxis {
);
}
final values = summary.points.map((point) => point.bpm).toList();
final minBpm = values.reduce(math.min);
final maxBpm = values.reduce(math.max);
final coveredEnd = summary.endTime ?? summary.points.last.time;
final coveredEnd = points.last.time;
final coveredMinutes = coveredEnd.difference(start).inMinutes;
final maxMinutes = _nextSixHourTick(coveredMinutes);
var maxY = _roundUpToFiveOrTen(maxBpm);
if (maxY <= minBpm) {
maxY = minBpm + 20;
}
final heartRates = points.map((point) => point.bpm);
final minY = heartRates.reduce(math.min);
var maxY = _roundUpToFiveOrTen(heartRates.reduce(math.max));
if (maxY <= minY) maxY = minY + 5;
return _HeartRateAxis(
endTime: start.add(Duration(minutes: maxMinutes)),
maxX: maxMinutes.toDouble(),
minY: minBpm,
minY: minY,
maxY: maxY,
);
}
bool shouldShowYLabel(double value) {
if (value < minY || value > maxY) return false;
final offset = value - minY;
return (offset / 20 - (offset / 20).round()).abs() < 0.001;
return ticks.any((tick) => (tick - value).abs() < 0.001);
}
String formatYLabel(double value) {
... ... @@ -456,12 +470,10 @@ class _HeartRateAxis {
const tickMinutes = 360;
final clamped = coveredMinutes.clamp(0, 1440);
if (clamped == 0) return tickMinutes;
return (clamped / tickMinutes).ceil().clamp(1, 4) * tickMinutes;
return (clamped ~/ tickMinutes + 1).clamp(1, 4) * tickMinutes;
}
static double _roundUpToFiveOrTen(double value) {
return (value / 5).ceil() * 5;
}
static double _roundUpToFiveOrTen(double value) => (value / 5).ceil() * 5.0;
}
class _HeartRateSegment {
... ...
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../../report_common/utils/report_localization.dart';
import '../../report_common/widgets/report_month_calendar_grid.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_ring.dart';
import 'activity_burn_trend_lines.dart';
class ActivityBurnMonthReportView extends StatelessWidget {
const ActivityBurnMonthReportView({
... ... @@ -54,7 +56,8 @@ class _MonthlySummary extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(context, '活动总消耗'),
HealthReportSubjectScope.titleOf(
context, context.l10n.activityTotalBurn),
style: const TextStyle(
color: ActivityBurnMonthReportView._active,
fontSize: 12,
... ... @@ -76,11 +79,11 @@ class _MonthlySummary extends StatelessWidget {
),
),
const SizedBox(width: 4),
const Padding(
padding: EdgeInsets.only(bottom: 5),
Padding(
padding: const EdgeInsets.only(bottom: 5),
child: Text(
'千卡',
style: TextStyle(
context.l10n.reportUnitKcal,
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 12,
fontWeight: FontWeight.w500,
... ... @@ -90,9 +93,9 @@ class _MonthlySummary extends StatelessWidget {
],
)
else
const Text(
'等待数据',
style: TextStyle(
Text(
context.l10n.reportWaitingForData,
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 24,
fontWeight: FontWeight.w600,
... ... @@ -102,16 +105,18 @@ class _MonthlySummary extends StatelessWidget {
Row(
children: [
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(context, '锻炼总时长'),
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityExerciseTotalDuration),
value: hasData ? report.totalExerciseMinutes.toString() : '-',
unit: '分钟',
unit: context.l10n.reportUnitMinute,
color: ActivityBurnMonthReportView._exercise,
),
const SizedBox(width: 34),
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(context, '站立总时长'),
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityStandTotalDuration),
value: hasData ? report.totalStandHours.toString() : '-',
unit: '小时',
unit: context.l10n.reportUnitHour,
color: ActivityBurnMonthReportView._stand,
),
],
... ... @@ -199,7 +204,7 @@ class _MonthRingCard extends StatelessWidget {
children: [
_RingStat(
color: ActivityBurnMonthReportView._active,
label: '完美合环',
label: context.l10n.activityPerfectRings,
value: report.perfectRingDays,
hasData: hasData,
report: ActivityBurnReport(
... ... @@ -212,7 +217,7 @@ class _MonthRingCard extends StatelessWidget {
const SizedBox(width: 34),
_RingStat(
color: ActivityBurnMonthReportView._active,
label: '合上活动圆环',
label: context.l10n.activityCloseMoveRing,
value: report.activeRingDays,
hasData: hasData,
report: ActivityBurnReport(
... ... @@ -227,7 +232,7 @@ class _MonthRingCard extends StatelessWidget {
children: [
_RingStat(
color: ActivityBurnMonthReportView._exercise,
label: '合上锻炼圆环',
label: context.l10n.activityCloseExerciseRing,
value: report.exerciseRingDays,
hasData: hasData,
report: ActivityBurnReport(
... ... @@ -238,7 +243,7 @@ class _MonthRingCard extends StatelessWidget {
const SizedBox(width: 34),
_RingStat(
color: ActivityBurnMonthReportView._stand,
label: '合上站立圆环',
label: context.l10n.activityCloseStandRing,
value: report.standRingDays,
hasData: hasData,
report: ActivityBurnReport(
... ... @@ -249,16 +254,11 @@ class _MonthRingCard extends StatelessWidget {
],
),
const SizedBox(height: 22),
const Row(
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_WeekdayLabel('一'),
_WeekdayLabel('二'),
_WeekdayLabel('三'),
_WeekdayLabel('四'),
_WeekdayLabel('五'),
_WeekdayLabel('六'),
_WeekdayLabel('日'),
for (var weekday = 1; weekday <= 7; weekday++)
_WeekdayLabel(reportWeekdayLabel(weekday)),
],
),
const SizedBox(height: 10),
... ... @@ -318,9 +318,9 @@ class _RingStat extends StatelessWidget {
fontWeight: FontWeight.w600,
),
),
const TextSpan(
text: ' 天',
style: TextStyle(
TextSpan(
text: ' ${context.l10n.reportUnitDay}',
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 14,
fontWeight: FontWeight.w500,
... ... @@ -407,25 +407,27 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
@override
Widget build(BuildContext context) {
final hasData = widget.report.hasData;
final maxY = _maxY;
return Container(
height: 294,
padding: const EdgeInsets.fromLTRB(20, 18, 14, 14),
height: 344,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 40),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(context, '热量消耗趋势'),
HealthReportSubjectScope.titleOf(
context, context.l10n.activityCalorieTrend),
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 14),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
... ... @@ -440,9 +442,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
),
),
const SizedBox(width: 4),
const Text(
'千卡/平均每日',
style: TextStyle(
Text(
context.l10n.activityKcalDailyAverage,
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 12,
),
... ... @@ -450,19 +452,56 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
],
),
const SizedBox(height: 2),
Text(
hasData ? '比上月少34%' : '比上周-',
style: TextStyle(
color: ActivityBurnMonthReportView._h2,
fontSize: 12,
),
Row(
children: [
Text(
hasData
? context.l10n.activityComparedLastMonth
: context.l10n.activityComparedUnavailable,
style: TextStyle(
color: ActivityBurnMonthReportView._h2,
fontSize: 12,
),
),
const Spacer(),
_TrendLegend(
color: const Color(0xFFFFC0CE),
label: context.l10n.sleepAverage,
),
const SizedBox(width: 12),
_TrendLegend(
color: const Color(0xFF96E9CB),
label: context.l10n.sleepTarget,
dashed: true,
),
],
),
const SizedBox(height: 8),
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
BarChart(_chartData()),
Positioned(
left: 0,
right: 6,
top: 0,
bottom: 32,
child: ActivityBurnTrendLines(
maxY: maxY,
average: widget.report.averageDailyActiveEnergy.toDouble(),
target: widget.report.activeEnergyGoal.toDouble(),
),
),
BarChart(_chartData(maxY)),
const Positioned(
left: 0,
right: 6,
bottom: 24,
child: SizedBox(
height: 1,
child: ActivityBurnAxisDashedLine(),
),
),
if (_touchedOffset != null)
Positioned.fill(
child: ChartSelectionLineOverlay(
... ... @@ -473,9 +512,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
),
),
if (!hasData)
const Text(
'等待数据',
style: TextStyle(
Text(
context.l10n.reportWaitingForData,
style: const TextStyle(
color: ActivityBurnMonthReportView._h3,
fontSize: 12,
),
... ... @@ -488,20 +527,25 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
);
}
BarChartData _chartData() {
double get _maxY {
final maxValue = widget.report.days
.map((day) => day.activeEnergy?.value ?? 0)
.fold<int>(0, (max, value) => value > max ? value : max);
final referenceMax = [
maxValue,
widget.report.averageDailyActiveEnergy,
widget.report.activeEnergyGoal,
].reduce((max, value) => value > max ? value : max);
return ((referenceMax / 100).ceil().clamp(4, 9) * 100).toDouble();
}
BarChartData _chartData(double maxY) {
final hasData = widget.report.hasData;
return BarChartData(
minY: 0,
maxY: 400,
maxY: maxY,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 100,
getDrawingHorizontalLine: (_) => const FlLine(
color: Color(0xFFF3F3F3),
strokeWidth: 1,
dashArray: [2, 2],
),
show: false,
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
... ... @@ -537,7 +581,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final report = widget.report.days[groupIndex];
return BarTooltipItem(
'${report.activeEnergy?.value ?? 0}千卡\n',
'${report.activeEnergy?.value ?? 0}${context.l10n.reportUnitKcal}\n',
const TextStyle(
color: ActivityBurnMonthReportView._active,
fontSize: 16,
... ... @@ -563,16 +607,19 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
reservedSize: 36,
interval: 100,
getTitlesWidget: (value, meta) {
return Transform.translate(
offset: const Offset(0, -5),
child: Text(
value.toInt().toString(),
style: const TextStyle(
color: ActivityBurnMonthReportView._h3,
fontSize: 10,
offset: const Offset(0, -8),
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
value.toInt().toString(),
style: const TextStyle(
color: ActivityBurnMonthReportView._h3,
fontSize: 10,
),
),
),
);
... ... @@ -582,7 +629,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
reservedSize: 32,
interval: 5,
getTitlesWidget: (value, meta) {
final day = value.toInt() + 1;
... ... @@ -592,7 +639,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.only(top: 14),
child: Text(
day.toString(),
style: const TextStyle(
... ... @@ -605,22 +652,14 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
),
),
),
extraLinesData: ExtraLinesData(
horizontalLines: [
HorizontalLine(
y: 320,
color: ActivityBurnMonthReportView._exercise,
strokeWidth: 1,
dashArray: [2, 2],
),
],
),
baselineY: 0,
barGroups: [
for (var i = 0; i < widget.report.days.length; i++)
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
fromY: 0,
toY:
(widget.report.days[i].activeEnergy?.value ?? 0).toDouble(),
width: 4,
... ... @@ -635,7 +674,50 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
}
String _tooltipDate(DateTime date) {
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
return '${DateFormat('M月d日').format(date)} 星期${weekdays[date.weekday - 1]}';
return l10n.reportDateWithWeekday(
reportMonthDay(date),
reportWeekdayLabel(date.weekday),
);
}
}
class _TrendLegend extends StatelessWidget {
const _TrendLegend({
required this.color,
required this.label,
this.dashed = false,
});
final Color color;
final String label;
final bool dashed;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 19,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: dashed
? List.generate(
5,
(_) => Container(width: 2, height: 2, color: color),
)
: [Container(width: 19, height: 2, color: color)],
),
),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(
color: ActivityBurnMonthReportView._h2,
fontSize: 12,
),
),
],
);
}
}
... ...
... ... @@ -4,32 +4,134 @@ import 'package:flutter/material.dart';
import '../models/activity_burn_report_models.dart';
class ActivityBurnRing extends StatelessWidget {
class ActivityBurnRing extends StatefulWidget {
const ActivityBurnRing({
super.key,
required this.report,
this.size = 200,
this.animate = false,
});
final ActivityBurnReport? report;
final double size;
final bool animate;
@override
State<ActivityBurnRing> createState() => _ActivityBurnRingState();
}
class _ActivityBurnRingState extends State<ActivityBurnRing>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late Animation<_RingProgress> _animation;
_RingProgress get _target => _RingProgress.fromReport(widget.report);
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
_animateTo(_target, from: const _RingProgress.zero());
}
@override
void didUpdateWidget(covariant ActivityBurnRing oldWidget) {
super.didUpdateWidget(oldWidget);
final target = _target;
if (_RingProgress.fromReport(oldWidget.report) != target ||
oldWidget.animate != widget.animate) {
_animateTo(target, from: _animation.value);
}
}
void _animateTo(_RingProgress target, {required _RingProgress from}) {
_animation = _RingProgressTween(begin: from, end: target).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOutQuad),
);
_controller.duration =
widget.animate ? const Duration(seconds: 1) : Duration.zero;
_controller.forward(from: 0);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: CustomPaint(
painter: _ActivityBurnRingPainter(report: report),
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) => CustomPaint(
painter: _ActivityBurnRingPainter(
progress: _animation.value,
hasData: widget.report?.hasData == true,
),
),
),
);
}
}
class _RingProgress {
const _RingProgress({
required this.active,
required this.exercise,
required this.stand,
});
const _RingProgress.zero()
: active = 0,
exercise = 0,
stand = 0;
factory _RingProgress.fromReport(ActivityBurnReport? report) => _RingProgress(
active: report?.activeEnergy?.progress ?? 0,
exercise: report?.exerciseMinutes?.progress ?? 0,
stand: report?.standHours?.progress ?? 0,
);
final double active;
final double exercise;
final double stand;
@override
bool operator ==(Object other) =>
other is _RingProgress &&
active == other.active &&
exercise == other.exercise &&
stand == other.stand;
@override
int get hashCode => Object.hash(active, exercise, stand);
}
class _RingProgressTween extends Tween<_RingProgress> {
_RingProgressTween({required super.begin, required super.end});
@override
_RingProgress lerp(double t) => _RingProgress(
active: begin!.active + (end!.active - begin!.active) * t,
exercise: begin!.exercise + (end!.exercise - begin!.exercise) * t,
stand: begin!.stand + (end!.stand - begin!.stand) * t,
);
}
class _ActivityBurnRingPainter extends CustomPainter {
const _ActivityBurnRingPainter({required this.report});
const _ActivityBurnRingPainter({
required this.progress,
required this.hasData,
});
final ActivityBurnReport? report;
final _RingProgress progress;
final bool hasData;
static const _active = Color(0xFFFF5279);
static const _exercise = Color(0xFF3BD49D);
... ... @@ -50,7 +152,7 @@ class _ActivityBurnRingPainter extends CustomPainter {
_drawEmptyRing(canvas, center: center, scale: scale);
if (report?.hasData != true) {
if (!hasData) {
return;
}
... ... @@ -61,7 +163,7 @@ class _ActivityBurnRingPainter extends CustomPainter {
width: _ringWidth * scale,
trackColor: _track,
progressColor: _active,
progress: report?.activeEnergy?.progress ?? 0,
progress: progress.active,
startAngle: startAngle,
);
_drawRing(
... ... @@ -71,7 +173,7 @@ class _ActivityBurnRingPainter extends CustomPainter {
width: _ringWidth * scale,
trackColor: _innerTrack,
progressColor: _exercise,
progress: report?.exerciseMinutes?.progress ?? 0,
progress: progress.exercise,
startAngle: startAngle,
);
_drawRing(
... ... @@ -81,7 +183,7 @@ class _ActivityBurnRingPainter extends CustomPainter {
width: _ringWidth * scale,
trackColor: _center,
progressColor: _stand,
progress: report?.standHours?.progress ?? 0,
progress: progress.stand,
startAngle: startAngle,
);
}
... ... @@ -169,6 +271,6 @@ class _ActivityBurnRingPainter extends CustomPainter {
@override
bool shouldRepaint(covariant _ActivityBurnRingPainter oldDelegate) {
return oldDelegate.report != report;
return oldDelegate.progress != progress || oldDelegate.hasData != hasData;
}
}
... ...
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../models/activity_burn_report_models.dart';
... ... @@ -27,26 +28,26 @@ class ActivityBurnSummaryCard extends StatelessWidget {
children: [
Expanded(
child: _MetricColumn(
title: '活动',
title: context.l10n.activityMove,
color: _active,
value: report?.activeEnergy?.value,
unit: '千卡',
unit: context.l10n.reportUnitKcal,
),
),
Expanded(
child: _MetricColumn(
title: '锻炼',
title: context.l10n.activityExercise,
color: _exercise,
value: report?.exerciseMinutes?.value,
unit: '分钟',
unit: context.l10n.reportUnitMinute,
),
),
Expanded(
child: _MetricColumn(
title: '站立',
title: context.l10n.activityStand,
color: _stand,
value: report?.standHours?.value,
unit: '小时',
unit: context.l10n.reportUnitHour,
),
),
],
... ...
import 'package:flutter/material.dart';
class ActivityBurnTrendLines extends StatelessWidget {
const ActivityBurnTrendLines({
super.key,
required this.maxY,
required this.average,
required this.target,
});
final double maxY;
final double average;
final double target;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: CustomPaint(
painter: _TrendLinesPainter(
maxY: maxY,
average: average,
target: target,
),
),
);
}
}
class ActivityBurnAxisDashedLine extends StatelessWidget {
const ActivityBurnAxisDashedLine({super.key});
@override
Widget build(BuildContext context) {
return const IgnorePointer(
child: CustomPaint(painter: _AxisDashedLinePainter()),
);
}
}
class ActivityBurnHorizontalGridLines extends StatelessWidget {
const ActivityBurnHorizontalGridLines({
super.key,
required this.minY,
required this.maxY,
required this.values,
});
final double minY;
final double maxY;
final List<double> values;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: CustomPaint(
painter: _HorizontalGridLinesPainter(
minY: minY,
maxY: maxY,
values: values,
),
),
);
}
}
class _TrendLinesPainter extends CustomPainter {
const _TrendLinesPainter({
required this.maxY,
required this.average,
required this.target,
});
final double maxY;
final double average;
final double target;
static const _gridColor = Color(0xFFF3F3F3);
static const _averageColor = Color(0xFFFFC0CE);
static const _targetColor = Color(0xFF96E9CB);
@override
void paint(Canvas canvas, Size size) {
if (maxY <= 0 || size.isEmpty) return;
for (var value = 100.0; value <= maxY; value += 100) {
_drawDashedLine(
canvas,
size.width,
_yFor(value, size.height),
color: _gridColor,
strokeWidth: 1,
);
}
if (average > 0) {
_drawSolidLine(
canvas,
size.width,
_yFor(average, size.height),
color: _averageColor,
);
}
if (target > 0) {
_drawDashedLine(
canvas,
size.width,
_yFor(target, size.height),
color: _targetColor,
strokeWidth: 2,
);
}
}
double _yFor(double value, double height) =>
height * (1 - (value / maxY).clamp(0, 1));
void _drawSolidLine(
Canvas canvas,
double width,
double y, {
required Color color,
}) {
canvas.drawLine(
Offset(0, y),
Offset(width, y),
Paint()
..color = color
..strokeWidth = 2,
);
}
void _drawDashedLine(
Canvas canvas,
double width,
double y, {
required Color color,
required double strokeWidth,
}) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth;
for (var x = 0.0; x < width; x += 4) {
canvas.drawLine(Offset(x, y), Offset((x + 2).clamp(0, width), y), paint);
}
}
@override
bool shouldRepaint(_TrendLinesPainter oldDelegate) =>
maxY != oldDelegate.maxY ||
average != oldDelegate.average ||
target != oldDelegate.target;
}
class _AxisDashedLinePainter extends CustomPainter {
const _AxisDashedLinePainter();
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = const Color(0xFFF3F3F3)
..strokeWidth = 1;
for (var x = 0.0; x < size.width; x += 4) {
canvas.drawLine(
Offset(x, 0),
Offset((x + 2).clamp(0, size.width), 0),
paint,
);
}
}
@override
bool shouldRepaint(_AxisDashedLinePainter oldDelegate) => false;
}
class _HorizontalGridLinesPainter extends CustomPainter {
const _HorizontalGridLinesPainter({
required this.minY,
required this.maxY,
required this.values,
});
final double minY;
final double maxY;
final List<double> values;
@override
void paint(Canvas canvas, Size size) {
if (maxY <= minY || size.isEmpty) return;
final paint = Paint()
..color = const Color(0xFFF3F3F3)
..strokeWidth = 1;
for (final value in values) {
if (value < minY || value > maxY) continue;
final y = size.height * (1 - (value - minY) / (maxY - minY));
for (var x = 0.0; x < size.width; x += 4) {
canvas.drawLine(
Offset(x, y),
Offset((x + 2).clamp(0, size.width), y),
paint,
);
}
}
}
@override
bool shouldRepaint(_HorizontalGridLinesPainter oldDelegate) =>
minY != oldDelegate.minY ||
maxY != oldDelegate.maxY ||
values != oldDelegate.values;
}
... ...
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../../report_common/utils/report_localization.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_ring.dart';
import 'activity_burn_trend_lines.dart';
class ActivityBurnWeekReportView extends StatelessWidget {
const ActivityBurnWeekReportView({
... ... @@ -53,7 +55,8 @@ class _WeeklySummary extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(context, '活动总消耗'),
HealthReportSubjectScope.titleOf(
context, context.l10n.activityTotalBurn),
style: const TextStyle(
color: ActivityBurnWeekReportView._active,
fontSize: 12,
... ... @@ -76,11 +79,11 @@ class _WeeklySummary extends StatelessWidget {
),
),
const SizedBox(width: 4),
const Padding(
padding: EdgeInsets.only(bottom: 5),
Padding(
padding: const EdgeInsets.only(bottom: 5),
child: Text(
'千卡',
style: TextStyle(
context.l10n.reportUnitKcal,
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 12,
fontWeight: FontWeight.w500,
... ... @@ -90,9 +93,9 @@ class _WeeklySummary extends StatelessWidget {
],
)
else
const Text(
'等待数据',
style: TextStyle(
Text(
context.l10n.reportWaitingForData,
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 24,
fontWeight: FontWeight.w600,
... ... @@ -103,18 +106,20 @@ class _WeeklySummary extends StatelessWidget {
Row(
children: [
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(context, '锻炼总时长'),
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityExerciseTotalDuration),
value: report.hasData
? report.totalExerciseMinutes.toString()
: '-',
unit: '分钟',
unit: context.l10n.reportUnitMinute,
color: ActivityBurnWeekReportView._exercise,
),
const SizedBox(width: 34),
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(context, '站立总时长'),
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityStandTotalDuration),
value: report.hasData ? report.totalStandHours.toString() : '-',
unit: '小时',
unit: context.l10n.reportUnitHour,
color: ActivityBurnWeekReportView._stand,
),
],
... ... @@ -202,7 +207,7 @@ class _RingOverviewCard extends StatelessWidget {
children: [
_RingStat(
color: ActivityBurnWeekReportView._active,
label: '完美合环',
label: context.l10n.activityPerfectRings,
value: report.perfectRingDays,
hasData: report.hasData,
report: ActivityBurnReport(
... ... @@ -215,7 +220,7 @@ class _RingOverviewCard extends StatelessWidget {
const SizedBox(width: 34),
_RingStat(
color: ActivityBurnWeekReportView._active,
label: '合上活动圆环',
label: context.l10n.activityCloseMoveRing,
value: report.activeRingDays,
hasData: report.hasData,
report: ActivityBurnReport(
... ... @@ -230,7 +235,7 @@ class _RingOverviewCard extends StatelessWidget {
children: [
_RingStat(
color: ActivityBurnWeekReportView._exercise,
label: '合上锻炼圆环',
label: context.l10n.activityCloseExerciseRing,
value: report.exerciseRingDays,
hasData: report.hasData,
report: ActivityBurnReport(
... ... @@ -241,7 +246,7 @@ class _RingOverviewCard extends StatelessWidget {
const SizedBox(width: 34),
_RingStat(
color: ActivityBurnWeekReportView._stand,
label: '合上站立圆环',
label: context.l10n.activityCloseStandRing,
value: report.standRingDays,
hasData: report.hasData,
report: ActivityBurnReport(
... ... @@ -313,9 +318,9 @@ class _RingStat extends StatelessWidget {
fontWeight: FontWeight.w600,
),
),
const TextSpan(
text: ' 天',
style: TextStyle(
TextSpan(
text: ' ${context.l10n.reportUnitDay}',
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 14,
fontWeight: FontWeight.w500,
... ... @@ -335,8 +340,6 @@ class _DayRing extends StatelessWidget {
final ActivityBurnReport report;
static const _weekdays = ['一', '二', '三', '四', '五', '六', '日'];
@override
Widget build(BuildContext context) {
return SizedBox(
... ... @@ -344,7 +347,7 @@ class _DayRing extends StatelessWidget {
child: Column(
children: [
Text(
_weekdays[report.date.weekday - 1],
reportWeekdayLabel(report.date.weekday),
style: const TextStyle(
color: ActivityBurnWeekReportView._h3,
fontSize: 12,
... ... @@ -384,25 +387,27 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
@override
Widget build(BuildContext context) {
final maxY = _maxY;
return Container(
height: 294,
padding: const EdgeInsets.fromLTRB(20, 18, 14, 14),
height: 344,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(context, '热量消耗趋势'),
HealthReportSubjectScope.titleOf(
context, context.l10n.activityCalorieTrend),
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 14),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
... ... @@ -417,9 +422,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
),
),
const SizedBox(width: 4),
const Text(
'千卡/平均每日',
style: TextStyle(
Text(
context.l10n.activityKcalDailyAverage,
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 12,
),
... ... @@ -427,19 +432,56 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
],
),
const SizedBox(height: 2),
Text(
widget.report.hasData ? '比上周少34%' : '比上周-',
style: const TextStyle(
color: ActivityBurnWeekReportView._h2,
fontSize: 12,
),
Row(
children: [
Text(
widget.report.hasData
? context.l10n.activityComparedLastWeek
: context.l10n.activityComparedUnavailable,
style: const TextStyle(
color: ActivityBurnWeekReportView._h2,
fontSize: 12,
),
),
const Spacer(),
_TrendLegend(
color: const Color(0xFFFFC0CE),
label: context.l10n.sleepAverage,
),
const SizedBox(width: 12),
_TrendLegend(
color: const Color(0xFF96E9CB),
label: context.l10n.sleepTarget,
dashed: true,
),
],
),
const SizedBox(height: 8),
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
BarChart(_chartData()),
Positioned(
left: 0,
right: 6,
top: 0,
bottom: 42,
child: ActivityBurnTrendLines(
maxY: maxY,
average: widget.report.averageDailyActiveEnergy.toDouble(),
target: widget.report.activeEnergyGoal.toDouble(),
),
),
BarChart(_chartData(maxY)),
const Positioned(
left: 0,
right: 6,
bottom: 34,
child: SizedBox(
height: 1,
child: ActivityBurnAxisDashedLine(),
),
),
if (_touchedOffset != null)
Positioned.fill(
child: ChartSelectionLineOverlay(
... ... @@ -450,9 +492,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
),
),
if (!widget.report.hasData)
const Text(
'等待数据',
style: TextStyle(
Text(
context.l10n.reportWaitingForData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
),
... ... @@ -465,25 +507,25 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
);
}
BarChartData _chartData() {
final hasData = widget.report.hasData;
double get _maxY {
final maxValue = widget.report.days
.map((day) => day.activeEnergy?.value ?? 0)
.fold<int>(0, (max, value) => value > max ? value : max);
final maxY = ((maxValue / 100).ceil().clamp(4, 9) * 100).toDouble();
final referenceMax = [
maxValue,
widget.report.averageDailyActiveEnergy,
widget.report.activeEnergyGoal,
].reduce((max, value) => value > max ? value : max);
return ((referenceMax / 100).ceil().clamp(4, 9) * 100).toDouble();
}
BarChartData _chartData(double maxY) {
final hasData = widget.report.hasData;
return BarChartData(
minY: 0,
maxY: maxY,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 100,
getDrawingHorizontalLine: (_) => const FlLine(
color: Color(0xFFF3F3F3),
strokeWidth: 1,
dashArray: [2, 2],
),
show: false,
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
... ... @@ -519,7 +561,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final report = widget.report.days[groupIndex];
return BarTooltipItem(
'${report.activeEnergy?.value ?? 0}千卡\n',
'${report.activeEnergy?.value ?? 0}${context.l10n.reportUnitKcal}\n',
const TextStyle(
color: ActivityBurnWeekReportView._active,
fontSize: 16,
... ... @@ -545,16 +587,19 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
reservedSize: 36,
interval: 100,
getTitlesWidget: (value, meta) {
return Transform.translate(
offset: const Offset(0, -5),
child: Text(
value.toInt().toString(),
style: const TextStyle(
color: ActivityBurnWeekReportView._h3,
fontSize: 10,
offset: const Offset(0, -8),
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
value.toInt().toString(),
style: const TextStyle(
color: ActivityBurnWeekReportView._h3,
fontSize: 10,
),
),
),
);
... ... @@ -564,16 +609,15 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
reservedSize: 42,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= widget.report.days.length) {
return const SizedBox.shrink();
}
final day = widget.report.days[index];
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
return Padding(
padding: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.only(top: 14),
child: Column(
children: [
Text(
... ... @@ -584,7 +628,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
),
),
Text(
weekdays[day.date.weekday - 1],
reportWeekdayLabel(day.date.weekday),
style: const TextStyle(
color: ActivityBurnWeekReportView._h3,
fontSize: 10,
... ... @@ -597,22 +641,14 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
),
),
),
extraLinesData: ExtraLinesData(
horizontalLines: [
HorizontalLine(
y: 320,
color: ActivityBurnWeekReportView._exercise,
strokeWidth: 1,
dashArray: [2, 2],
),
],
),
baselineY: 0,
barGroups: [
for (var i = 0; i < widget.report.days.length; i++)
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
fromY: 0,
toY:
(widget.report.days[i].activeEnergy?.value ?? 0).toDouble(),
width: 16,
... ... @@ -628,7 +664,50 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
}
String _tooltipDate(DateTime date) {
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
return '${DateFormat('M月d日').format(date)} 星期${weekdays[date.weekday - 1]}';
return l10n.reportDateWithWeekday(
reportMonthDay(date),
reportWeekdayLabel(date.weekday),
);
}
}
class _TrendLegend extends StatelessWidget {
const _TrendLegend({
required this.color,
required this.label,
this.dashed = false,
});
final Color color;
final String label;
final bool dashed;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 19,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: dashed
? List.generate(
5,
(_) => Container(width: 2, height: 2, color: color),
)
: [Container(width: 19, height: 2, color: color)],
),
),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(
color: ActivityBurnWeekReportView._h2,
fontSize: 12,
),
),
],
);
}
}
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/data/local/local_storage.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../widgets/app_review_prompt_dialog.dart';
class AppReviewPromptController extends GetxController {
AppReviewPromptController({LocalStorage? storage})
: _storage = storage ?? Get.find<LocalStorage>();
class AppReviewPromptLogic {
AppReviewPromptLogic({required LocalStorage storage}) : _storage = storage;
final LocalStorage _storage;
... ... @@ -15,39 +14,49 @@ class AppReviewPromptController extends GetxController {
static const _likedCooldown = Duration(days: 90);
static const _feedbackCooldown = Duration(days: 30);
Future<void> maybeShowPrompt() async {
final context = Get.context;
if (_isShowing || context == null || !context.mounted) return;
Future<void> maybeShowPrompt(BuildContext context) async {
if (_isShowing || !context.mounted) return;
final now = DateTime.now();
final nextShowAt = _storage.appReviewPromptNextShowAt;
if (nextShowAt != null && now.isBefore(nextShowAt)) return;
_isShowing = true;
final result = await Get.dialog<AppReviewPromptResult>(
const AppReviewPromptDialog(),
barrierColor: Colors.black.withValues(alpha: 0.7),
barrierDismissible: true,
);
try {
final result = await showDialog<AppReviewPromptResult>(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.7),
barrierDismissible: false,
builder: (_) => const AppReviewPromptDialog(),
);
switch (result) {
case AppReviewPromptResult.liked:
await _coolDown(_likedCooldown);
case AppReviewPromptResult.feedback:
await _coolDown(_feedbackCooldown);
await _showFeedbackPrompt();
case null:
await _coolDown(_feedbackCooldown);
switch (result) {
case AppReviewPromptResult.liked:
await _coolDown(_likedCooldown);
case AppReviewPromptResult.feedback:
await _coolDown(_feedbackCooldown);
if (context.mounted) {
await _showFeedbackPrompt(context);
}
case null:
await _coolDown(_feedbackCooldown);
}
} finally {
_isShowing = false;
}
_isShowing = false;
}
Future<void> _showFeedbackPrompt() async {
await Get.dialog<AppReviewPromptResult>(
const AppReviewFeedbackDialog(),
Future<void> _showFeedbackPrompt(BuildContext context) async {
final result = await showDialog<AppReviewPromptResult>(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.7),
barrierDismissible: true,
barrierDismissible: false,
builder: (_) => const AppReviewFeedbackDialog(),
);
if (result == AppReviewPromptResult.feedback && context.mounted) {
await Navigator.of(context).pushNamed(Routes.SUBMIT_FEEDBACK);
}
}
Future<void> _coolDown(Duration duration) {
... ...
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
enum AppReviewPromptResult {
liked,
... ... @@ -13,12 +13,13 @@ class AppReviewPromptDialog extends StatelessWidget {
Widget build(BuildContext context) {
return _ReviewPromptCard(
height: 380,
title: '喜欢 DoubleFeel 吗?',
message: '嗨~想知道 DoubleFeel 是否正在帮助你\n更了解自己的压力与睡眠状态 💜',
primaryText: '😍 很喜欢',
secondaryText: '我有意见',
onPrimaryTap: () => Get.back(result: AppReviewPromptResult.liked),
onSecondaryTap: () => Get.back(result: AppReviewPromptResult.feedback),
title: context.l10n.appReviewPromptTitle,
message: context.l10n.appReviewPromptMessage,
primaryText: context.l10n.appReviewPromptLikeAction,
secondaryText: context.l10n.appReviewPromptFeedbackAction,
onPrimaryTap: () => Navigator.of(context).pop(AppReviewPromptResult.liked),
onSecondaryTap: () =>
Navigator.of(context).pop(AppReviewPromptResult.feedback),
);
}
}
... ... @@ -30,12 +31,13 @@ class AppReviewFeedbackDialog extends StatelessWidget {
Widget build(BuildContext context) {
return _ReviewPromptCard(
height: 425,
title: '很抱歉 DoubleFeel 没有带\n给你好的体验',
message: '愿意告诉我们遇到了什么问题吗?\n你的反馈可以帮助我们持续改进压力与健康体验 💜',
primaryText: '发送反馈',
secondaryText: '稍后再说',
onPrimaryTap: () => Get.back(result: AppReviewPromptResult.feedback),
onSecondaryTap: () => Get.back(),
title: context.l10n.appReviewFeedbackTitle,
message: context.l10n.appReviewFeedbackMessage,
primaryText: context.l10n.appReviewFeedbackSendAction,
secondaryText: context.l10n.appReviewFeedbackLaterAction,
onPrimaryTap: () =>
Navigator.of(context).pop(AppReviewPromptResult.feedback),
onSecondaryTap: () => Navigator.of(context).pop(),
);
}
}
... ... @@ -68,102 +70,105 @@ class _ReviewPromptCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isFeedback = height > 400;
return Center(
child: Material(
color: Colors.transparent,
child: Container(
width: _cardWidth,
height: height,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFE6DBFF),
Color(0xFFEDE4FF),
Color(0xFFEEE6FF),
Color(0xFFF9F6FF),
],
stops: [0, 0.25, 0.75, 1],
return PopScope(
canPop: false,
child: Center(
child: Material(
color: Colors.transparent,
child: Container(
width: _cardWidth,
height: height,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFE6DBFF),
Color(0xFFEDE4FF),
Color(0xFFEEE6FF),
Color(0xFFF9F6FF),
],
stops: [0, 0.25, 0.75, 1],
),
),
),
child: Stack(
children: [
const Positioned(
top: 0,
left: 0,
right: 0,
height: _illustrationHeight,
child: ColoredBox(
color: Color(0xFFF09AB8),
child: Center(
child: Text(
'插图占位',
style: TextStyle(
color: Colors.red,
fontSize: 14,
fontWeight: FontWeight.w400,
child: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
height: _illustrationHeight,
child: ColoredBox(
color: const Color(0xFFF09AB8),
child: Center(
child: Text(
context.l10n.appReviewIllustrationPlaceholder,
style: const TextStyle(
color: Colors.red,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
),
),
),
Positioned(
top: isFeedback ? 162 : 163,
left: 38,
right: 38,
child: Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: _h1,
fontSize: 18,
height: 1.35,
fontWeight: FontWeight.w600,
Positioned(
top: isFeedback ? 162 : 163,
left: 38,
right: 38,
child: Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: _h1,
fontSize: 18,
height: 1.35,
fontWeight: FontWeight.w600,
),
),
),
),
Positioned(
top: isFeedback ? 222 : 195,
left: 20,
right: 20,
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
color: _h2,
fontSize: 14,
height: 1.35,
fontWeight: FontWeight.w400,
Positioned(
top: isFeedback ? 222 : 195,
left: 20,
right: 20,
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
color: _h2,
fontSize: 14,
height: 1.35,
fontWeight: FontWeight.w400,
),
),
),
),
Positioned(
top: isFeedback ? 305 : 260,
left: 40,
right: 40,
child: _PromptButton(
text: primaryText,
textColor: Colors.white,
fontWeight: FontWeight.w600,
backgroundColor: _brandColor,
onTap: onPrimaryTap,
Positioned(
top: isFeedback ? 305 : 260,
left: 40,
right: 40,
child: _PromptButton(
text: primaryText,
textColor: Colors.white,
fontWeight: FontWeight.w600,
backgroundColor: _brandColor,
onTap: onPrimaryTap,
),
),
),
Positioned(
top: isFeedback ? 357 : 312,
left: 40,
right: 40,
child: _PromptButton(
text: secondaryText,
textColor: _h2,
fontWeight: FontWeight.w400,
onTap: onSecondaryTap,
Positioned(
top: isFeedback ? 357 : 312,
left: 40,
right: 40,
child: _PromptButton(
text: secondaryText,
textColor: _h2,
fontWeight: FontWeight.w400,
onTap: onSecondaryTap,
),
),
),
],
],
),
),
),
),
... ...
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:get/get.dart';
import '../controllers/add_friend_controller.dart';
... ... @@ -5,6 +6,6 @@ import '../controllers/add_friend_controller.dart';
class AddFriendBinding extends Bindings {
@override
void dependencies() {
Get.put(AddFriendController());
Get.put(AddFriendController(Get.find<FriendApi>()));
}
}
... ...
... ... @@ -22,7 +22,7 @@ class FriendHomeBinding extends Bindings {
Get.find<AppleHealthUploadTool>(),
friendInfo: args,
),
tag: 'friend_${args.userId}', // 动态 tag,支持多个好友同时在路由栈中
tag: 'friend_${args.friendUserId}', // 动态 tag,支持多个好友同时在路由栈中
);
}
}
... ...
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
import 'package:get/get.dart';
import '../controllers/friend_trend_controller.dart';
... ... @@ -6,15 +7,18 @@ class FriendTrendBinding extends Bindings {
@override
void dependencies() {
final arguments = Get.arguments;
if (arguments is! FriendTrendArguments) {
throw ArgumentError('FriendTrendView requires FriendTrendArguments');
}
final trendArguments = arguments is FriendTrendArguments
? arguments
: FriendTrendArguments(
friendItem: arguments is FriendItem ? arguments : null,
);
Get.lazyPut<FriendTrendController>(
() => FriendTrendController(
userId: arguments.userId,
friendName: arguments.name,
avatarUrl: arguments.avatarUrl,
friendItem: trendArguments.friendItem,
initialDate: trendArguments.initialDate,
initialPeriod: trendArguments.initialPeriod,
initialTypeIndex: trendArguments.initialTypeIndex,
),
);
}
... ...
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import '../models/add_friend_prompt_type.dart';
class AddFriendController extends GetxController {
AddFriendController(this._friendApi);
final FriendApi _friendApi;
final TextEditingController friendCodeController = TextEditingController();
final FocusNode friendCodeFocusNode = FocusNode();
... ... @@ -33,16 +37,16 @@ class AddFriendController extends GetxController {
Clipboard.setData(ClipboardData(text: myInviteCode.value));
}
Future<AddFriendPromptType?> submitFriendCode() async {
if (!canSubmit || isSubmitting.value) return null;
Future<bool> submitFriendCode() async {
if (!canSubmit || isSubmitting.value) return false;
isSubmitting.value = true;
try {
final code = friendCodeInput.value;
if (code == myInviteCode.value) {
return AddFriendPromptType.selfId;
final result = await _friendApi.addFriend(friendCodeInput.value);
if (result is AppSuccess<void>) {
Get.back(result: true);
return true;
}
return AddFriendPromptType.idNotFound;
return false;
} finally {
isSubmitting.value = false;
}
... ...
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
import 'package:get/get.dart';
import '../../health_trend/controllers/health_trend_control.dart';
import '../../report_common/models/health_report_query.dart';
import '../../report_common/models/report_period.dart';
class FriendTrendArguments {
const FriendTrendArguments({
required this.userId,
required this.name,
this.avatarUrl,
required this.friendItem,
this.initialDate,
this.initialPeriod,
this.initialTypeIndex = 0,
});
final int userId;
final String name;
final String? avatarUrl;
final FriendItem? friendItem;
final DateTime? initialDate;
final ReportPeriod? initialPeriod;
final int initialTypeIndex;
}
class FriendTrendController extends GetxController {
class FriendTrendController extends GetxController with HealthTrendControl {
FriendTrendController({
required this.userId,
required this.friendName,
this.avatarUrl,
});
final int userId;
final String friendName;
final String? avatarUrl;
final selectedTypeIndex = 0.obs;
required this.friendItem,
DateTime? initialDate,
ReportPeriod? initialPeriod,
int initialTypeIndex = 0,
}) {
changeType(initialTypeIndex);
if (initialPeriod != null) changePeriod(initialPeriod);
if (initialDate != null) changeDate(initialDate);
}
HealthReportQuery get query => HealthReportQuery(targetUserId: userId);
final FriendItem? friendItem;
void changeType(int index) {
if (index == selectedTypeIndex.value) return;
selectedTypeIndex.value = index;
}
HealthReportQuery get query => HealthReportQuery(
targetUserId: friendItem?.id,
period: selectedPeriod.value,
date: selectedDate.value,
);
}
... ...
... ... @@ -4,64 +4,125 @@ import 'package:doublefeel_flutter/app/actions/dialog_action.dart';
import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart';
import 'package:doublefeel_flutter/app/models/input_dialog_meta_data.dart';
import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';
import 'package:doublefeel_flutter/core/error/app_error.dart';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:get/get.dart';
import '../data/friends_repository.dart';
import '../data/self_health_repository.dart';
import '../models/friend_health_data.dart';
class FriendsController extends GetxController {
FriendsController({
FriendsRepository? repository,
SelfHealthRepository? selfHealthRepository,
Future<void> Function(WatchAppOtherInfo info)? updateWatchOtherUserInfo,
}) : _repository =
repository ?? FriendsRepositoryImpl(Get.find<FriendApi>()),
_selfHealthRepository = selfHealthRepository ??
SelfHealthRepositoryImpl(Get.find<HealthApi>()),
_updateWatchOtherUserInfo = updateWatchOtherUserInfo ??
((info) => PlatformHostApi().updateWatchOtherUserInfo(info));
static const maxFriends = 10;
final FriendsRepository _repository;
final SelfHealthRepository _selfHealthRepository;
final Future<void> Function(WatchAppOtherInfo info) _updateWatchOtherUserInfo;
final friends = <FriendHealthData>[].obs;
final isLoading = false.obs;
final selfHealthData = Rxn<V2HealthData>();
final selfStressScore = Rxn<V2StressScore>();
final selfHealthUpdatedAt = Rxn<DateTime>();
final isSelfHealthLoading = false.obs;
Future<void>? _friendsRequest;
Future<void>? _selfHealthRequest;
bool get isFull => friends.length >= maxFriends;
@override
void onInit() {
super.onInit();
unawaited(loadFriends());
unawaited(refreshData());
}
Future<void> refreshData() {
return Future.wait([loadFriends(), loadSelfHealth()]);
}
Future<void> loadFriends() async {
if (isLoading.value) return;
Future<void> loadFriends() {
return _friendsRequest ??= _loadFriends();
}
Future<void> _loadFriends() async {
isLoading.value = true;
try {
// TODO: Replace mock data with the friend list API when it is available.
await Future<void>.delayed(const Duration(milliseconds: 150));
friends.assignAll(const [
FriendHealthData(
userId: 10001,
name: '女朋友(不爱吃热干面)',
updatedAt: '更新于15:12',
sleepQualityScore: 92,
steps: '10254步',
statusText: '注意压力',
stressValue: 72,
isOnWatchFace: true,
),
FriendHealthData(
userId: 10002,
name: 'John',
updatedAt: '更新于15:12',
sleepQualityScore: null,
steps: null,
statusText: '等待数据',
stressValue: null,
),
]);
friends.assignAll(await _repository.getFriends());
} catch (error, stackTrace) {
AppLogger.e('FriendsController.loadFriends failed', error, stackTrace);
} finally {
isLoading.value = false;
_friendsRequest = null;
}
}
Future<void> loadSelfHealth() {
return _selfHealthRequest ??= _loadSelfHealth();
}
Future<void> _loadSelfHealth() async {
isSelfHealthLoading.value = true;
try {
final today = DateTime.now();
await Future.wait([
_loadSelfHealthData(today),
_loadSelfStressScore(today),
]);
} finally {
isSelfHealthLoading.value = false;
_selfHealthRequest = null;
}
}
Future<void> _loadSelfHealthData(DateTime date) async {
try {
selfHealthData.value = await _selfHealthRepository.getHealthData(date);
selfHealthUpdatedAt.value = DateTime.now();
} catch (error, stackTrace) {
AppLogger.e(
'FriendsController.loadSelfHealthData failed',
error,
stackTrace,
);
}
}
Future<void> _loadSelfStressScore(DateTime date) async {
try {
selfStressScore.value = await _selfHealthRepository.getStressScore(date);
selfHealthUpdatedAt.value = DateTime.now();
} catch (error, stackTrace) {
AppLogger.e(
'FriendsController.loadSelfStressScore failed',
error,
stackTrace,
);
}
}
Future<void> showEditRemarkDialog(FriendHealthData friend) async {
final result = await DialogUtils.showInputDialog(
InputDialogMetaData(
title: '修改好友备注',
initialValue: friend.name,
hintText: '请输入昵称',
confirmText: '保存',
title: l10n.friendsEditRemarkTitle,
initialValue: friend.remark ?? '',
hintText: l10n.friendsEditRemarkHint,
confirmText: l10n.friendsSave,
maxLength: 6,
),
);
... ... @@ -73,77 +134,87 @@ class FriendsController extends GetxController {
return;
}
final index = friends.indexOf(friend);
if (index < 0) return;
friends[index] = friend.copyWith(name: remark);
final userId = friend.userId;
if (userId == null) return;
try {
await _repository.updateRemark(userId, remark);
final index = friends.indexOf(friend);
if (index >= 0) friends[index] = friend.copyWith(remark: remark);
} catch (error, stackTrace) {
if (error is AppError) {
AppToast.show(error.displayMessage);
} else {
AppLogger.e(
'FriendsController.showEditRemarkDialog failed',
error,
stackTrace,
);
}
}
}
// TODO: Submit the remark to the friend API when it is available.
Future<void> applyWatchFaceSelection(FriendHealthData selectedFriend) async {
final friendUserId = selectedFriend.userId;
if (friendUserId == null) {
return;
}
try {
await _repository.selectWatchFaceFriend(friendUserId);
final updatedFriends = friends
.map(
(friend) => friend.userId == friendUserId
? friend.copyWith(isOnWatchFace: true)
: friend,
)
.toList(growable: false);
friends.assignAll(updatedFriends);
await _updateWatchOtherUserInfo(
WatchAppOtherInfo(
userId: friendUserId,
nickname: selectedFriend.name,
markName: selectedFriend.remark,
),
);
} catch (error, stackTrace) {
await loadFriends();
if (error is AppError) {
AppToast.show(error.displayMessage);
} else {
AppLogger.e(
'FriendsController.applyWatchFaceSelection failed',
error,
stackTrace,
);
}
}
}
Future<void> showDeleteConfirmDialog(FriendHealthData friend) async {
try {
final dialogMetaData = DialogMetaData(
iconAsset: null,
title: '确认要和${friend.name}解除好友关系吗?',
message: '解除后你将无法查看对方的情绪、健康状态',
confirmText: '确认解除',
cancelText: '取消',
title: l10n.friendsDeleteConfirmTitle(friend.displayName),
message: l10n.friendsDeleteConfirmMessage,
confirmText: l10n.friendsDeleteConfirmAction,
cancelText: l10n.cancel,
);
await DialogUtils.showCommonDialog(dialogMetaData);
final result = await DialogUtils.showCommonDialog(dialogMetaData);
if (result.action != DialogAction.confirm) return;
final userId = friend.userId;
if (userId == null) return;
await _repository.deleteFriend(userId);
await loadFriends();
} catch (error, stackTrace) {
AppLogger.e(
'FriendsController.showDeleteConfirmDialog failed',
error,
stackTrace,
);
if (error is AppError) {
AppToast.show(error.displayMessage);
} else {
AppLogger.e(
'FriendsController.showDeleteConfirmDialog failed',
error,
stackTrace,
);
}
}
}
}
class FriendHealthData {
const FriendHealthData({
this.userId,
this.avatarUrl,
required this.name,
required this.updatedAt,
required this.sleepQualityScore,
required this.steps,
required this.statusText,
required this.stressValue,
this.isOnWatchFace = false,
});
final int? userId;
final String? avatarUrl;
final String name;
final String updatedAt;
final int? sleepQualityScore;
final String? steps;
final String statusText;
final double? stressValue;
final bool isOnWatchFace;
FriendHealthData copyWith({
int? userId,
String? avatarUrl,
String? name,
String? updatedAt,
int? sleepQualityScore,
String? steps,
String? statusText,
double? stressValue,
bool? isOnWatchFace,
}) {
return FriendHealthData(
userId: userId ?? this.userId,
avatarUrl: avatarUrl ?? this.avatarUrl,
name: name ?? this.name,
updatedAt: updatedAt ?? this.updatedAt,
sleepQualityScore: sleepQualityScore ?? this.sleepQualityScore,
steps: steps ?? this.steps,
statusText: statusText ?? this.statusText,
stressValue: stressValue ?? this.stressValue,
isOnWatchFace: isOnWatchFace ?? this.isOnWatchFace,
);
}
}
... ...
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:get/get.dart';
import 'friends_controller.dart';
import '../data/friends_repository.dart';
import '../models/friend_health_data.dart';
class SelectFriendLogic {
SelectFriendLogic(this._friendsController);
SelectFriendLogic({FriendsRepository? repository})
: _repository =
repository ?? FriendsRepositoryImpl(Get.find<FriendApi>());
final FriendsController _friendsController;
final FriendsRepository _repository;
final friends = <FriendHealthData>[].obs;
final isLoading = false.obs;
final selectedIndex = (-1).obs;
List<FriendHealthData> get friends => _friendsController.friends;
Future<void> initialize() async {
if (friends.isEmpty) {
await _friendsController.loadFriends();
if (isLoading.value) return;
isLoading.value = true;
try {
friends.assignAll(await _repository.getFriends());
final watchFaceIndex = friends.indexWhere(
(friend) => friend.isOnWatchFace,
);
selectedIndex.value = friends.isEmpty
? -1
: watchFaceIndex < 0
? 0
: watchFaceIndex;
} finally {
isLoading.value = false;
}
final items = friends;
if (items.isEmpty) return;
final watchFaceIndex = items.indexWhere((friend) => friend.isOnWatchFace);
selectedIndex.value = watchFaceIndex < 0 ? 0 : watchFaceIndex;
}
void selectFriendAt(int index) {
... ... @@ -25,21 +37,16 @@ class SelectFriendLogic {
selectedIndex.value = index;
}
bool syncSelectedFriendToWatchFace() {
Future<FriendHealthData?> syncSelectedFriendToWatchFace() async {
final index = selectedIndex.value;
if (index < 0 || index >= friends.length) return false;
final updated = List.generate(
friends.length,
(itemIndex) => friends[itemIndex].copyWith(
isOnWatchFace: itemIndex == index,
),
growable: false,
);
_friendsController.friends.assignAll(updated);
// TODO: Submit selected watch-face friend to the friend API when available.
return true;
if (index < 0 || index >= friends.length) return null;
final selectedFriend = friends[index];
final userId = selectedFriend.userId;
if (userId == null) return null;
await _repository.selectWatchFaceFriend(userId);
return selectedFriend;
}
void dispose() {}
... ...
import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'
as api_models;
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:intl/intl.dart';
import '../models/friend_health_data.dart';
abstract class FriendsRepository {
Future<List<FriendHealthData>> getFriends();
Future<void> updateRemark(int userId, String remark);
Future<void> selectWatchFaceFriend(int userId);
Future<void> deleteFriend(int userId);
}
class FriendsRepositoryImpl implements FriendsRepository {
const FriendsRepositoryImpl(this._friendApi);
final FriendApi _friendApi;
@override
Future<List<FriendHealthData>> getFriends() async {
return switch (await _friendApi.friendList(true)) {
AppSuccess(:final data) => data.list.map(_mapFriend).toList(),
AppFailure(:final error) => throw error,
};
}
@override
Future<void> deleteFriend(int userId) async {
switch (await _friendApi.deleteFriend(userId)) {
case AppSuccess():
return;
case AppFailure(:final error):
throw error;
}
}
@override
Future<void> selectWatchFaceFriend(int userId) async {
switch (await _friendApi.updateFriend(userId, showInDial: 1)) {
case AppSuccess():
return;
case AppFailure(:final error):
throw error;
}
}
@override
Future<void> updateRemark(int userId, String remark) async {
switch (await _friendApi.updateFriend(userId, remarkName: remark)) {
case AppSuccess():
return;
case AppFailure(:final error):
throw error;
}
}
FriendHealthData _mapFriend(api_models.FriendItem friend) {
final healthData = friend.healthData;
final stressValue = healthData?.latestHrv;
final name = friend.remarkName?.trim();
return FriendHealthData(
friendItem: friend,
userId: friend.friendUserId,
avatarUrl: friend.avatar,
name: name == null || name.isEmpty ? '未知好友' : name,
updatedAt: _updatedAt(friend.updateTime),
sleepQualityScore: healthData?.sleepEvaluate,
steps: healthData?.totalSteps == null
? null
: l10n.friendsStepCount(healthData!.totalSteps!),
statusText: stressValue == null
? l10n.friendsWaitingForData
: HrvStressLevel.fromRealtimeStress(stressValue).label,
stressValue: stressValue,
isOnWatchFace: friend.isShowInDial,
);
}
String _updatedAt(int? timestamp) {
if (timestamp == null) return l10n.friendsWaitingForData;
final milliseconds =
timestamp < 1000000000000 ? timestamp * 1000 : timestamp;
final time = DateTime.fromMillisecondsSinceEpoch(milliseconds);
return l10n.friendsUpdatedAt(DateFormat('HH:mm').format(time));
}
}
... ...
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:intl/intl.dart';
abstract class SelfHealthRepository {
Future<V2HealthData> getHealthData(DateTime date);
Future<V2StressScore> getStressScore(DateTime date);
}
class SelfHealthRepositoryImpl implements SelfHealthRepository {
const SelfHealthRepositoryImpl(this._healthApi);
final HealthApi _healthApi;
@override
Future<V2HealthData> getHealthData(DateTime date) async {
final intDate = int.parse(DateFormat('yyyyMMdd').format(date));
return switch (await _healthApi.getV2HealthData(null, intDate)) {
AppSuccess(:final data) => data,
AppFailure(:final error) => throw error,
};
}
@override
Future<V2StressScore> getStressScore(DateTime date) async {
final intDate = int.parse(DateFormat('yyyyMMdd').format(date));
return switch (await _healthApi.getV2StressScore(null, intDate)) {
AppSuccess(:final data) => data,
AppFailure(:final error) => throw error,
};
}
}
... ...
... ... @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart';
import 'package:doublefeel_flutter/app/widget/common_dialog_view.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../models/add_friend_prompt_type.dart';
... ... @@ -15,29 +16,29 @@ class AddFriendPromptDialog extends StatelessWidget {
iconAsset: null,
title: _titleFor(type),
message: _messageFor(type),
confirmText: '我知道了',
confirmText: l10n.friendsPromptGotIt,
);
}
static String _titleFor(AddFriendPromptType type) {
switch (type) {
case AddFriendPromptType.idNotFound:
return '该ID不存在';
return l10n.friendsPromptIdNotFoundTitle;
case AddFriendPromptType.alreadyFriend:
return '已经是亲密联系人';
return l10n.friendsPromptAlreadyFriendTitle;
case AddFriendPromptType.selfId:
return '不能添加自己';
return l10n.friendsPromptSelfIdTitle;
}
}
static String _messageFor(AddFriendPromptType type) {
switch (type) {
case AddFriendPromptType.idNotFound:
return '这个ID不存在哦,请检查后重新输入';
return l10n.friendsPromptIdNotFoundMessage;
case AddFriendPromptType.alreadyFriend:
return '你们已经是亲密联系人啦';
return l10n.friendsPromptAlreadyFriendMessage;
case AddFriendPromptType.selfId:
return '请输入亲密联系人的ID';
return l10n.friendsPromptSelfIdMessage;
}
}
... ...
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'
as api_models;
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
class FriendHealthData {
const FriendHealthData({
required this.friendItem,
this.userId,
this.avatarUrl,
required this.name,
this.remark,
required this.updatedAt,
required this.sleepQualityScore,
required this.steps,
required this.statusText,
required this.stressValue,
this.isOnWatchFace = false,
});
final api_models.FriendItem friendItem;
final int? userId;
final String? avatarUrl;
final String name;
final String? remark;
final String updatedAt;
final int? sleepQualityScore;
final String? steps;
final String statusText;
final double? stressValue;
final bool isOnWatchFace;
String get displayName {
final value = remark?.trim();
if (value == null || value.isEmpty) return name;
return l10n.friendsRemarkedDisplayName(value, name);
}
FriendHealthData copyWith({
api_models.FriendItem? friendItem,
int? userId,
String? avatarUrl,
String? name,
String? remark,
String? updatedAt,
int? sleepQualityScore,
String? steps,
String? statusText,
double? stressValue,
bool? isOnWatchFace,
}) {
return FriendHealthData(
friendItem: friendItem ?? this.friendItem,
userId: userId ?? this.userId,
avatarUrl: avatarUrl ?? this.avatarUrl,
name: name ?? this.name,
remark: remark ?? this.remark,
updatedAt: updatedAt ?? this.updatedAt,
sleepQualityScore: sleepQualityScore ?? this.sleepQualityScore,
steps: steps ?? this.steps,
statusText: statusText ?? this.statusText,
stressValue: stressValue ?? this.stressValue,
isOnWatchFace: isOnWatchFace ?? this.isOnWatchFace,
);
}
}
... ...
import 'package:doublefeel_flutter/app/ext/font_ext.dart';
import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';
import 'package:doublefeel_flutter/core/theme/app_colors.dart';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/add_friend_controller.dart';
import '../dialog/add_friend_prompt_dialog.dart';
import '../widgets/primary_button.dart';
class AddFriendView extends GetView<AddFriendController> {
... ... @@ -91,8 +90,8 @@ class AddFriendView extends GetView<AddFriendController> {
child: Column(
children: [
SizedBox(height: 4),
const Text(
'添加亲密联系人\n多一个人关注你的健康',
Text(
context.l10n.bindPartnerTitle,
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xFF141414),
... ... @@ -183,8 +182,8 @@ class AddFriendView extends GetView<AddFriendController> {
padding: EdgeInsets.fromLTRB(32.dp, 28, 32.dp, 0),
child: Column(
children: [
const Text(
'我的ID',
Text(
l10n.bindPartnerMyId,
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.textPrimary,
... ... @@ -196,7 +195,7 @@ class AddFriendView extends GetView<AddFriendController> {
_MyInviteCode(controller: controller),
SizedBox(height: 26),
PrimaryButton(
label: '分享我的码',
label: l10n.bindPartnerShareMyCode,
enabled: true,
loading: false,
onTap: controller.copyMyInviteCode,
... ... @@ -211,8 +210,8 @@ class AddFriendView extends GetView<AddFriendController> {
padding: EdgeInsets.fromLTRB(32.dp, 46, 32.dp, 0),
child: Column(
children: [
const Text(
'亲密联系人的ID',
Text(
l10n.bindPartnerContactId,
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xFF141414),
... ... @@ -226,7 +225,7 @@ class AddFriendView extends GetView<AddFriendController> {
SizedBox(height: 16),
Obx(
() => PrimaryButton(
label: '添加',
label: l10n.friendsAddAction,
enabled: controller.canSubmit && !controller.isSubmitting.value,
loading: controller.isSubmitting.value,
backgroundColor: const Color(0xFFFF916A),
... ... @@ -265,8 +264,8 @@ class AddFriendView extends GetView<AddFriendController> {
color: Color(0xFFE8DDFF),
shape: BoxShape.circle,
),
child: const Text(
'或',
child: Text(
l10n.bindPartnerOr,
style: TextStyle(
color: AppColors.primary,
fontSize: 14,
... ... @@ -292,20 +291,9 @@ class AddFriendView extends GetView<AddFriendController> {
await Future.delayed(const Duration(milliseconds: 120));
}
final promptType = await controller.submitFriendCode();
if (promptType == null) {
controller.friendCodeFocusNode.canRequestFocus = true;
return;
}
try {
await DialogUtils.showCommonDialog(
AddFriendPromptDialog.metaDataFor(promptType),
barrierColor: Colors.black.withValues(alpha: 0.45),
);
} finally {
final didAddFriend = await controller.submitFriendCode();
if (!didAddFriend) {
controller.friendCodeFocusNode.canRequestFocus = true;
_dismissKeyboard();
}
}
... ... @@ -384,10 +372,10 @@ class _FriendIdInput extends StatelessWidget {
textAlign: TextAlign.center,
minLines: 1,
maxLines: 1,
decoration: const InputDecoration(
decoration: InputDecoration(
border: InputBorder.none,
hintText: '输入ID',
hintStyle: TextStyle(
hintText: context.l10n.friendsEnterId,
hintStyle: const TextStyle(
color: AppColors.disabled,
fontSize: 26,
fontWeight: FontWeightExt.medium,
... ...
... ... @@ -11,7 +11,7 @@ class FriendHomePage extends GetView<TodayController> {
/// 与 FriendHomeBinding 中注册的 tag 保持一致
@override
String? get tag => 'friend_${_args.userId}';
String? get tag => 'friend_${_args.friendUserId}';
@override
Widget build(BuildContext context) {
... ...
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
... ... @@ -29,12 +30,13 @@ class FriendTrendView extends GetView<FriendTrendController> {
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: const Color(0xFF0F0F11),
iconSize: 20,
tooltip: '返回',
tooltip: context.l10n.friendsBack,
),
titleSpacing: 0,
title: _FriendTrendTitle(
title: '${controller.friendName}的趋势',
avatarUrl: controller.avatarUrl,
title: context.l10n
.friendsTrendTitle(controller.friendItem?.remarkName ?? ""),
avatarUrl: controller.friendItem?.avatar ?? "",
),
),
body: Stack(
... ... @@ -58,6 +60,8 @@ class FriendTrendView extends GetView<FriendTrendController> {
query: controller.query,
selectedTypeIndex: controller.selectedTypeIndex.value,
onTypeChanged: controller.changeType,
onPeriodChanged: controller.changePeriod,
onDateChanged: controller.changeDate,
),
),
),
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../controllers/friends_controller.dart';
import '../controllers/friend_trend_controller.dart';
import '../models/friend_health_data.dart';
import '../widgets/friend_health_card.dart';
import 'select_friend_view.dart';
class FriendsTab extends GetView<FriendsController> {
const FriendsTab({super.key});
@override
Widget build(BuildContext context) {
final userPreferences = Get.find<UserPreferencesStorage>();
return Scaffold(
backgroundColor: const Color(0xFFF2F2F7),
backgroundColor: Colors.white,
body: SafeArea(
bottom: false,
child: Column(
... ... @@ -23,87 +27,133 @@ class FriendsTab extends GetView<FriendsController> {
children: [
const _FriendsHeader(),
Expanded(
child: Obx(
() {
final friends = controller.friends;
final hasFriends = friends.isNotEmpty;
final isFull = controller.isFull;
child: ColoredBox(
color: const Color(0xFFF5F2FF),
child: Obx(
() {
final friends = controller.friends;
final hasFriends = friends.isNotEmpty;
final isFull = controller.isFull;
final healthData = controller.selfHealthData.value;
final stressScore = controller.selfStressScore.value;
final updatedAt = controller.selfHealthUpdatedAt.value;
final currentUser =
userPreferences.preferences.value.meUserInfo;
final isSelfInitialLoading =
controller.isSelfHealthLoading.value &&
healthData == null &&
stressScore == null;
final isFriendsInitialLoading =
controller.isLoading.value && !hasFriends;
final stressValue = stressScore?.state == 0
? null
: stressScore?.comprehensiveScore?.toDouble();
return Stack(
children: [
RefreshIndicator(
onRefresh: controller.loadFriends,
child: ListView(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
hasFriends ? 188.dp : 172,
),
children: [
const FriendHealthCard(
name: '蓝胖子(我)',
updatedAt: '更新于15:12',
sleepQualityScore: 92,
steps: '8424步',
statusText: '注意压力',
stressValue: 72,
isSelf: true,
return Stack(
children: [
RefreshIndicator(
onRefresh: controller.refreshData,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(
0,
0,
0,
hasFriends ? 188.dp : 172,
),
if (!hasFriends)
const _EmptyFriendsView()
else ...[
const SizedBox(height: 12),
...friends.map(
(friend) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: FriendHealthCard(
name: friend.name,
updatedAt: friend.updatedAt,
sleepQualityScore: friend.sleepQualityScore,
steps: friend.steps,
statusText: friend.statusText,
stressValue: friend.stressValue,
isOnWatchFace: friend.isOnWatchFace,
onTap: () => _openFriendTrend(friend),
onMoreSelected: (action) {
_handleFriendAction(action, friend);
},
),
),
),
],
if (!hasFriends) ...[
const SizedBox(height: 8),
_AddFriendButton(
enabled: !isFull,
onTap: isFull ? null : _handleAddFriend,
),
if (isFull) const _FullFriendsTip(),
],
],
),
),
if (hasFriends)
Positioned(
left: 0,
right: 0,
bottom: _floatingAddButtonBottom(context),
child: Column(
children: [
_AddFriendButton(
enabled: !isFull,
friendCount: friends.length,
maxFriends: FriendsController.maxFriends,
onTap: isFull ? null : _handleAddFriend,
_SelfHealthCard(
name:
currentUser?.nickname?.trim().isNotEmpty ==
true
? currentUser!.nickname!.trim()
: '-',
avatarUrl: currentUser?.avatar,
updatedAt: updatedAt == null
? context.l10n.friendsWaitingForData
: context.l10n.friendsUpdatedAt(
DateFormat('HH:mm').format(updatedAt),
),
sleepQualityScore:
_normalizedScore(healthData?.sleepScore),
steps: healthData?.steps == null
? null
: context.l10n.friendsStepCount(
healthData!.steps!,
),
statusText: stressValue == null
? context.l10n.friendsWaitingForData
: HrvStressLevel.fromRealtimeStress(
stressValue,
).label,
stressValue: stressValue,
isLoading: isSelfInitialLoading,
),
if (isFull) const _FullFriendsTip(),
if (isFriendsInitialLoading)
const _FriendsLoadingIndicator()
else if (!hasFriends)
const _EmptyFriendsView()
else ...[
const SizedBox(height: 12),
...friends.map(
(friend) => Padding(
padding: const EdgeInsets.fromLTRB(
16,
0,
16,
12,
),
child: FriendHealthCard(
name: friend.name,
remark: friend.remark,
avatarUrl: friend.avatarUrl,
updatedAt: friend.updatedAt,
sleepQualityScore:
friend.sleepQualityScore,
steps: friend.steps,
statusText: friend.statusText,
stressValue: friend.stressValue,
isOnWatchFace: friend.isOnWatchFace,
onTap: () => _openFriendHome(friend),
onMoreSelected: (action) {
_handleFriendAction(action, friend);
},
),
),
),
],
if (!hasFriends && !isFriendsInitialLoading) ...[
const SizedBox(height: 8),
_AddFriendButton(
enabled: !isFull,
onTap: isFull ? null : _handleAddFriend,
),
if (isFull) const _FullFriendsTip(),
],
],
),
),
],
);
},
if (hasFriends)
Positioned(
left: 0,
right: 0,
bottom: _floatingAddButtonBottom(context),
child: Column(
children: [
_AddFriendButton(
enabled: !isFull,
friendCount: friends.length,
maxFriends: FriendsController.maxFriends,
onTap: isFull ? null : _handleAddFriend,
),
if (isFull) const _FullFriendsTip(),
],
),
),
],
);
},
),
),
),
],
... ... @@ -122,20 +172,14 @@ class FriendsTab extends GetView<FriendsController> {
}
Future<void> _handleAddFriend() async {
await Get.toNamed(Routes.ADD_FRIEND);
await controller.loadFriends();
final didAddFriend = await Get.toNamed(Routes.ADD_FRIEND);
if (didAddFriend == true) await controller.loadFriends();
}
void _openFriendTrend(FriendHealthData friend) {
final userId = friend.userId;
if (userId == null) return;
void _openFriendHome(FriendHealthData friend) {
Get.toNamed(
Routes.FRIEND_TREND,
arguments: FriendTrendArguments(
userId: userId,
name: friend.name,
avatarUrl: friend.avatarUrl,
),
Routes.FRIEND_HOME,
arguments: friend.friendItem,
);
}
... ... @@ -146,24 +190,87 @@ class FriendsTab extends GetView<FriendsController> {
case FriendCardAction.remove:
controller.showDeleteConfirmDialog(friend);
case FriendCardAction.watchFace:
_showSelectFriendSheet();
controller.applyWatchFaceSelection(friend);
}
}
void _showSelectFriendSheet() {
final context = Get.context;
if (context == null) return;
int? _normalizedScore(double? value) {
if (value == null || !value.isFinite) return null;
return value.round().clamp(0, 100);
}
}
class _SelfHealthCard extends StatelessWidget {
const _SelfHealthCard({
required this.name,
required this.avatarUrl,
required this.updatedAt,
required this.sleepQualityScore,
required this.steps,
required this.statusText,
required this.stressValue,
required this.isLoading,
});
final String name;
final String? avatarUrl;
final String updatedAt;
final int? sleepQualityScore;
final String? steps;
final String statusText;
final double? stressValue;
final bool isLoading;
@override
Widget build(BuildContext context) {
const borderRadius = BorderRadius.vertical(bottom: Radius.circular(12));
return Stack(
children: [
FriendHealthCard(
name: name,
avatarUrl: avatarUrl,
updatedAt: updatedAt,
sleepQualityScore: sleepQualityScore,
steps: steps,
statusText: statusText,
stressValue: stressValue,
isSelf: true,
borderRadius: borderRadius,
contentPadding: const EdgeInsets.fromLTRB(36, 20, 35, 20),
),
if (isLoading)
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: Color(0xCCFFFFFF),
borderRadius: borderRadius,
),
child: Center(
child: SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
),
),
),
],
);
}
}
class _FriendsLoadingIndicator extends StatelessWidget {
const _FriendsLoadingIndicator();
final mediaQuery = MediaQuery.of(context);
Get.bottomSheet<void>(
SizedBox(
height: mediaQuery.size.height - mediaQuery.viewPadding.top,
child: SelectFriendView(friendsController: controller),
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
),
barrierColor: Colors.black.withValues(alpha: 0.7),
enableDrag: true,
isScrollControlled: true,
persistent: false,
);
}
}
... ... @@ -179,9 +286,9 @@ class _FriendsHeader extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Text(
'好友',
style: TextStyle(
Text(
context.l10n.tabFriends,
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 24,
fontWeight: FontWeight.w600,
... ... @@ -237,9 +344,9 @@ class _EmptyFriendsView extends StatelessWidget {
),
),
const SizedBox(height: 16),
const Text(
'添加亲密联系人,多一个人关注你的健康',
style: TextStyle(
Text(
context.l10n.friendsAddCloseContactDescription,
style: const TextStyle(
color: Color(0xFF78787D),
fontSize: 12,
fontWeight: FontWeight.w400,
... ... @@ -256,12 +363,12 @@ class _FullFriendsTip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.only(top: 10),
return Padding(
padding: const EdgeInsets.only(top: 10),
child: Center(
child: Text(
'好友数量已达上限',
style: TextStyle(
context.l10n.friendsLimitReached,
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 12,
fontWeight: FontWeight.w400,
... ... @@ -312,7 +419,7 @@ class _AddFriendButton extends StatelessWidget {
),
const SizedBox(width: 6),
Text(
_buttonText,
_buttonText(context),
style: const TextStyle(
color: Colors.white,
fontSize: 16,
... ... @@ -328,10 +435,12 @@ class _AddFriendButton extends StatelessWidget {
);
}
String get _buttonText {
String _buttonText(BuildContext context) {
final count = friendCount;
final max = maxFriends;
if (count == null || max == null) return '添加亲密联系人';
return '添加亲密联系人($count/$max)';
if (count == null || max == null) {
return context.l10n.friendsAddCloseContact;
}
return context.l10n.friendsAddCloseContactWithCount(count, max);
}
}
... ...
import 'package:doublefeel_flutter/core/theme/app_colors.dart';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/friends_controller.dart';
import '../controllers/select_friend_logic.dart';
import '../widgets/friend_health_card.dart';
class SelectFriendView extends StatefulWidget {
const SelectFriendView({super.key, required this.friendsController});
final FriendsController friendsController;
const SelectFriendView({super.key});
@override
State<SelectFriendView> createState() => _SelectFriendViewState();
... ... @@ -22,7 +20,7 @@ class _SelectFriendViewState extends State<SelectFriendView> {
@override
void initState() {
super.initState();
_logic = SelectFriendLogic(widget.friendsController);
_logic = SelectFriendLogic();
_logic.initialize();
}
... ... @@ -47,8 +45,7 @@ class _SelectFriendViewState extends State<SelectFriendView> {
_SelectFriendHeader(onClose: () => Get.back<void>()),
Expanded(
child: Obx(() {
if (widget.friendsController.isLoading.value &&
_logic.friends.isEmpty) {
if (_logic.isLoading.value && _logic.friends.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
... ... @@ -65,6 +62,8 @@ class _SelectFriendViewState extends State<SelectFriendView> {
final isSelected = selectedIndex == index;
return FriendHealthCard(
name: friend.name,
remark: friend.remark,
avatarUrl: friend.avatarUrl,
updatedAt: friend.updatedAt,
sleepQualityScore: friend.sleepQualityScore,
steps: friend.steps,
... ... @@ -94,10 +93,9 @@ class _SelectFriendViewState extends State<SelectFriendView> {
);
}
void _syncSelectedFriend() {
if (_logic.syncSelectedFriendToWatchFace()) {
Get.back<void>();
}
Future<void> _syncSelectedFriend() async {
final selectedFriend = await _logic.syncSelectedFriendToWatchFace();
if (selectedFriend != null) Get.back(result: selectedFriend);
}
}
... ... @@ -130,11 +128,11 @@ class _SelectFriendHeader extends StatelessWidget {
),
),
),
const Expanded(
Expanded(
child: Center(
child: Text(
'选择好友',
style: TextStyle(
context.l10n.friendsSelect,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
... ... @@ -167,9 +165,9 @@ class _SyncButton extends StatelessWidget {
color: AppColors.primary,
borderRadius: BorderRadius.circular(24.dp),
),
child: const Text(
'选择并同步至表盘',
style: TextStyle(
child: Text(
context.l10n.friendsSelectAndSync,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
... ...
import 'dart:math' as math;
import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart';
import 'package:doublefeel_flutter/app/modules/sleep_report/models/sleep_report_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
... ... @@ -14,6 +16,8 @@ class FriendHealthCard extends StatelessWidget {
const FriendHealthCard({
super.key,
required this.name,
this.remark,
this.avatarUrl,
required this.updatedAt,
required this.sleepQualityScore,
required this.steps,
... ... @@ -23,11 +27,15 @@ class FriendHealthCard extends StatelessWidget {
this.isOnWatchFace = false,
this.isSelected = false,
this.showCheckbox = false,
this.borderRadius,
this.contentPadding,
this.onTap,
this.onMoreSelected,
});
final String name;
final String? remark;
final String? avatarUrl;
final String updatedAt;
final int? sleepQualityScore;
final String? steps;
... ... @@ -37,13 +45,14 @@ class FriendHealthCard extends StatelessWidget {
final bool isOnWatchFace;
final bool isSelected;
final bool showCheckbox;
final BorderRadius? borderRadius;
final EdgeInsetsGeometry? contentPadding;
final VoidCallback? onTap;
final ValueChanged<FriendCardAction>? onMoreSelected;
@override
Widget build(BuildContext context) {
final sleepQualityLevel = sleepQualityLevelFromScore(sleepQualityScore);
final hasData = sleepQualityScore != null || steps != null;
return GestureDetector(
onTap: onTap,
... ... @@ -54,10 +63,11 @@ class FriendHealthCard extends StatelessWidget {
AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
padding: const EdgeInsets.fromLTRB(20, 20, 16, 20),
padding:
contentPadding ?? const EdgeInsets.fromLTRB(20, 20, 16, 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
borderRadius: borderRadius ?? BorderRadius.circular(20),
border: Border.all(
color:
isSelected ? const Color(0xFF845EEE) : Colors.transparent,
... ... @@ -68,9 +78,12 @@ class FriendHealthCard extends StatelessWidget {
children: [
_FriendCardHeader(
name: name,
remark: remark,
avatarUrl: avatarUrl,
updatedAt: updatedAt,
isSelf: isSelf,
showMoreButton: !showCheckbox,
isOnWatchFace: isOnWatchFace,
showMoreButton: !showCheckbox && !isSelf,
onMoreSelected: onMoreSelected,
),
const SizedBox(height: 17),
... ... @@ -81,7 +94,7 @@ class FriendHealthCard extends StatelessWidget {
child: _StatusFigure(
statusText: statusText,
stressValue: stressValue,
waiting: !hasData,
waiting: stressValue == null,
),
),
const SizedBox(width: 18),
... ... @@ -91,8 +104,9 @@ class FriendHealthCard extends StatelessWidget {
children: [
_MetricTile(
iconPath: R.assetsImagesHealthFriendSleepQuality,
title: '睡眠质量',
value: sleepQualityLevel.shortText,
title: context.l10n.friendsSleepQuality,
value:
_sleepQualityText(context, sleepQualityLevel),
valueColor: sleepQualityScore == null
? null
: Color(sleepQualityLevel.qualityColorValue),
... ... @@ -100,7 +114,7 @@ class FriendHealthCard extends StatelessWidget {
const SizedBox(height: 4),
_MetricTile(
iconPath: R.assetsImagesHealthFriendSteps,
title: '今日步数',
title: context.l10n.friendsTodaySteps,
value: steps ?? '-',
),
],
... ... @@ -132,35 +146,75 @@ class FriendHealthCard extends StatelessWidget {
),
);
}
String _sleepQualityText(
BuildContext context,
SleepQualityLevel level,
) {
switch (level) {
case SleepQualityLevel.excellent:
return context.l10n.friendsSleepQualityExcellent;
case SleepQualityLevel.good:
return context.l10n.friendsSleepQualityNormal;
case SleepQualityLevel.poor:
return context.l10n.friendsSleepQualityAttention;
case SleepQualityLevel.unknown:
return '-';
}
}
}
class _FriendCardHeader extends StatelessWidget {
const _FriendCardHeader({
required this.name,
required this.remark,
required this.avatarUrl,
required this.updatedAt,
required this.isSelf,
required this.isOnWatchFace,
required this.showMoreButton,
this.onMoreSelected,
});
final String name;
final String? remark;
final String? avatarUrl;
final String updatedAt;
final bool isSelf;
final bool isOnWatchFace;
final bool showMoreButton;
final ValueChanged<FriendCardAction>? onMoreSelected;
@override
Widget build(BuildContext context) {
final friendRemark = remark?.trim();
final hasRemark = friendRemark?.isNotEmpty == true;
final primaryName = !isSelf && hasRemark ? friendRemark! : name;
final suffix = isSelf
? context.l10n.friendsMe
: hasRemark
? name
: null;
return Row(
children: [
const _Avatar(),
_Avatar(avatarUrl: avatarUrl),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
Text.rich(
TextSpan(
children: [
TextSpan(text: primaryName),
if (suffix != null)
TextSpan(
text: context.l10n.friendsRemarkSuffix(suffix),
style: const TextStyle(color: Color(0xFF78787D)),
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
... ... @@ -183,7 +237,7 @@ class _FriendCardHeader extends StatelessWidget {
],
),
),
if (showMoreButton && !isSelf)
if (showMoreButton)
PopupMenuButton<FriendCardAction>(
onSelected: onMoreSelected,
elevation: 10,
... ... @@ -193,23 +247,26 @@ class _FriendCardHeader extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
itemBuilder: (context) => const [
itemBuilder: (context) => [
PopupMenuItem(
height: 42,
value: FriendCardAction.remove,
child: Center(child: Text('删除Ta')),
child: Center(child: Text(context.l10n.friendsRemove)),
),
PopupMenuItem(
height: 42,
padding: EdgeInsets.symmetric(vertical: 0),
value: FriendCardAction.editRemark,
child: Center(child: Text('修改备注')),
),
PopupMenuItem(
height: 42,
value: FriendCardAction.watchFace,
child: Center(child: Text('在表盘显示')),
child: Center(child: Text(context.l10n.friendsEditRemark)),
),
if (!isOnWatchFace)
PopupMenuItem(
height: 42,
value: FriendCardAction.watchFace,
child: Center(
child: Text(context.l10n.friendsShowOnWatchFace),
),
),
],
child: Padding(
padding: const EdgeInsets.all(6),
... ... @@ -226,13 +283,17 @@ class _FriendCardHeader extends StatelessWidget {
}
class _Avatar extends StatelessWidget {
const _Avatar();
const _Avatar({this.avatarUrl});
final String? avatarUrl;
@override
Widget build(BuildContext context) {
final imageUrl = avatarUrl?.trim();
return Container(
width: 36,
height: 36,
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
... ... @@ -244,11 +305,26 @@ class _Avatar extends StatelessWidget {
],
),
),
child: const Icon(
Icons.person_rounded,
size: 22,
color: Colors.white,
),
child: imageUrl?.isNotEmpty == true
? Image.network(
imageUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const _AvatarFallback(),
)
: const _AvatarFallback(),
);
}
}
class _AvatarFallback extends StatelessWidget {
const _AvatarFallback();
@override
Widget build(BuildContext context) {
return const Icon(
Icons.person_rounded,
size: 22,
color: Colors.white,
);
}
}
... ... @@ -266,6 +342,10 @@ class _StatusFigure extends StatelessWidget {
@override
Widget build(BuildContext context) {
final stressLevel = stressValue == null
? null
: HrvStressLevel.fromRealtimeStress(stressValue!);
return SizedBox(
height: 144,
child: Column(
... ... @@ -288,27 +368,12 @@ class _StatusFigure extends StatelessWidget {
Positioned(
left: 22,
top: 24,
child: Container(
child: Image.asset(
stressLevel?.realtimeStressIconPath ??
R.assetsImagesRealtimeStressNoDataIcon,
width: 80,
height: 80,
decoration: BoxDecoration(
color: waiting
? const Color(0xFFF3F3F3)
: const Color(0xFF5AE1A8),
shape: BoxShape.circle,
),
child: Center(
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: waiting
? const Color(0xFFD0D0D2)
: const Color(0xFF0F0F11),
shape: BoxShape.circle,
),
),
),
fit: BoxFit.contain,
),
),
],
... ... @@ -545,10 +610,10 @@ class _WatchFaceBadge extends StatelessWidget {
bottomLeft: Radius.circular(12),
),
),
child: const Center(
child: Center(
child: Text(
'已在表盘显示Ta',
style: TextStyle(
context.l10n.friendsShownOnWatchFace,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w500,
... ...
import 'package:get/get.dart';
import '../../report_common/config/report_date_range_config.dart';
import '../../report_common/models/report_period.dart';
mixin HealthTrendControl on GetxController {
final selectedTypeIndex = 0.obs;
final selectedPeriod = ReportPeriod.week.obs;
final selectedDate = ReportDateRangeConfig.clampDate(DateTime.now()).obs;
void changeType(int index) {
final nextIndex = normalizeTypeIndex(index);
selectedTypeIndex.value = nextIndex;
selectedPeriod.value = normalizePeriod(nextIndex, selectedPeriod.value);
}
void changePeriod(ReportPeriod period) {
selectedPeriod.value = normalizePeriod(selectedTypeIndex.value, period);
}
void changeDate(DateTime date) {
selectedDate.value = ReportDateRangeConfig.clampDate(date);
}
int normalizeTypeIndex(int index) => index >= 0 && index < 3 ? index : 0;
ReportPeriod normalizePeriod(int typeIndex, ReportPeriod period) {
final periods = supportedPeriods(normalizeTypeIndex(typeIndex));
return periods.contains(period) ? period : periods.first;
}
List<ReportPeriod> supportedPeriods(int typeIndex) {
return normalizeTypeIndex(typeIndex) == 0
? const [ReportPeriod.week, ReportPeriod.month, ReportPeriod.year]
: const [ReportPeriod.day, ReportPeriod.week, ReportPeriod.month];
}
}
... ...
... ... @@ -9,6 +9,7 @@ import '../../home/widgets/trend/trend_type_tab_bar.dart';
import '../../hrv_report/controllers/hrv_report_logic.dart';
import '../../hrv_report/views/hrv_report_view.dart';
import '../../report_common/models/health_report_query.dart';
import '../../report_common/models/report_period.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../../sleep_report/controllers/sleep_report_logic.dart';
import '../../sleep_report/views/sleep_report_view.dart';
... ... @@ -19,11 +20,15 @@ class HealthTrendContent extends StatefulWidget {
required this.query,
required this.selectedTypeIndex,
required this.onTypeChanged,
required this.onPeriodChanged,
required this.onDateChanged,
});
final HealthReportQuery query;
final int selectedTypeIndex;
final ValueChanged<int> onTypeChanged;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
@override
State<HealthTrendContent> createState() => _HealthTrendContentState();
... ... @@ -31,32 +36,50 @@ class HealthTrendContent extends StatefulWidget {
class _HealthTrendContentState extends State<HealthTrendContent>
with SingleTickerProviderStateMixin {
static const _typeCount = 3;
late final TabController _tabController;
@override
void initState() {
super.initState();
final initialIndex = _normalizedTypeIndex(widget.selectedTypeIndex);
_tabController = TabController(
length: 3,
length: _typeCount,
vsync: this,
initialIndex: widget.selectedTypeIndex,
initialIndex: initialIndex,
);
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
widget.onTypeChanged(_tabController.index);
}
});
_correctInvalidTypeIndex(widget.selectedTypeIndex, initialIndex);
}
@override
void didUpdateWidget(covariant HealthTrendContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedTypeIndex != widget.selectedTypeIndex &&
_tabController.index != widget.selectedTypeIndex) {
_tabController.animateTo(widget.selectedTypeIndex);
if (oldWidget.selectedTypeIndex != widget.selectedTypeIndex) {
final nextIndex = _normalizedTypeIndex(widget.selectedTypeIndex);
if (_tabController.index != nextIndex) {
_tabController.animateTo(nextIndex);
}
_correctInvalidTypeIndex(widget.selectedTypeIndex, nextIndex);
}
}
int _normalizedTypeIndex(int index) {
return index >= 0 && index < _typeCount ? index : 0;
}
void _correctInvalidTypeIndex(int requestedIndex, int normalizedIndex) {
if (requestedIndex == normalizedIndex) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.onTypeChanged(normalizedIndex);
});
}
@override
void dispose() {
_tabController.dispose();
... ... @@ -86,6 +109,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
query: widget.query,
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
onDateChanged: widget.onDateChanged,
),
),
_KeepAliveWrapper(
... ... @@ -93,6 +118,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
query: widget.query,
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
onDateChanged: widget.onDateChanged,
),
),
_KeepAliveWrapper(
... ... @@ -100,6 +127,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
query: widget.query,
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
onDateChanged: widget.onDateChanged,
),
),
],
... ... @@ -117,11 +146,15 @@ class _HrvTrendSection extends StatefulWidget {
required this.query,
required this.isVip,
required this.onSubscribe,
required this.onPeriodChanged,
required this.onDateChanged,
});
final HealthReportQuery query;
final bool isVip;
final VoidCallback onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
@override
State<_HrvTrendSection> createState() => _HrvTrendSectionState();
... ... @@ -129,11 +162,21 @@ class _HrvTrendSection extends StatefulWidget {
class _HrvTrendSectionState extends State<_HrvTrendSection> {
late final HrvReportLogic _logic;
late final Worker _periodWorker;
late final Worker _dateWorker;
var _syncingExternalQuery = false;
@override
void initState() {
super.initState();
_logic = HrvReportLogic(initialTargetUserId: widget.query.targetUserId);
_logic.initializeQuery(widget.query.period, widget.query.date);
_periodWorker = ever(_logic.selectedPeriod, (period) {
if (!_syncingExternalQuery) widget.onPeriodChanged(period);
});
_dateWorker = ever(_logic.selectedDate, (date) {
if (!_syncingExternalQuery) widget.onDateChanged(date);
});
_logic.loadReport();
}
... ... @@ -141,12 +184,24 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
void didUpdateWidget(covariant _HrvTrendSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) {
_logic.updateTargetUserId(widget.query.targetUserId);
_syncExternalQuery();
}
}
Future<void> _syncExternalQuery() async {
_syncingExternalQuery = true;
_logic.targetUserId.value = widget.query.targetUserId;
try {
await _logic.selectQuery(widget.query.period, widget.query.date);
} finally {
_syncingExternalQuery = false;
}
}
@override
void dispose() {
_periodWorker.dispose();
_dateWorker.dispose();
_logic.dispose();
super.dispose();
}
... ... @@ -164,11 +219,15 @@ class _ActivityBurnTrendSection extends StatefulWidget {
required this.query,
required this.isVip,
required this.onSubscribe,
required this.onPeriodChanged,
required this.onDateChanged,
});
final HealthReportQuery query;
final bool isVip;
final VoidCallback onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
@override
State<_ActivityBurnTrendSection> createState() =>
... ... @@ -177,6 +236,9 @@ class _ActivityBurnTrendSection extends StatefulWidget {
class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
late final ActivityBurnReportLogic _logic;
late final Worker _periodWorker;
late final Worker _dateWorker;
var _syncingExternalQuery = false;
@override
void initState() {
... ... @@ -184,6 +246,13 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
_logic = ActivityBurnReportLogic(
initialTargetUserId: widget.query.targetUserId,
);
_logic.initializeQuery(widget.query.period, widget.query.date);
_periodWorker = ever(_logic.selectedPeriod, (period) {
if (!_syncingExternalQuery) widget.onPeriodChanged(period);
});
_dateWorker = ever(_logic.selectedDate, (date) {
if (!_syncingExternalQuery) widget.onDateChanged(date);
});
_logic.loadReport();
}
... ... @@ -191,12 +260,24 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
void didUpdateWidget(covariant _ActivityBurnTrendSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) {
_logic.updateTargetUserId(widget.query.targetUserId);
_syncExternalQuery();
}
}
Future<void> _syncExternalQuery() async {
_syncingExternalQuery = true;
_logic.targetUserId.value = widget.query.targetUserId;
try {
await _logic.selectQuery(widget.query.period, widget.query.date);
} finally {
_syncingExternalQuery = false;
}
}
@override
void dispose() {
_periodWorker.dispose();
_dateWorker.dispose();
_logic.dispose();
super.dispose();
}
... ... @@ -214,11 +295,15 @@ class _SleepTrendSection extends StatefulWidget {
required this.query,
required this.isVip,
required this.onSubscribe,
required this.onPeriodChanged,
required this.onDateChanged,
});
final HealthReportQuery query;
final bool isVip;
final VoidCallback onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
@override
State<_SleepTrendSection> createState() => _SleepTrendSectionState();
... ... @@ -226,6 +311,9 @@ class _SleepTrendSection extends StatefulWidget {
class _SleepTrendSectionState extends State<_SleepTrendSection> {
late final SleepReportLogic _logic;
late final Worker _periodWorker;
late final Worker _dateWorker;
var _syncingExternalQuery = false;
@override
void initState() {
... ... @@ -233,6 +321,13 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
_logic = SleepReportLogic(
initialTargetUserId: widget.query.targetUserId,
);
_logic.initializeQuery(widget.query.period, widget.query.date);
_periodWorker = ever(_logic.selectedPeriod, (period) {
if (!_syncingExternalQuery) widget.onPeriodChanged(period);
});
_dateWorker = ever(_logic.selectedDate, (date) {
if (!_syncingExternalQuery) widget.onDateChanged(date);
});
_logic.loadReport();
}
... ... @@ -240,12 +335,24 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
void didUpdateWidget(covariant _SleepTrendSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) {
_logic.updateTargetUserId(widget.query.targetUserId);
_syncExternalQuery();
}
}
Future<void> _syncExternalQuery() async {
_syncingExternalQuery = true;
_logic.targetUserId.value = widget.query.targetUserId;
try {
await _logic.selectQuery(widget.query.period, widget.query.date);
} finally {
_syncingExternalQuery = false;
}
}
@override
void dispose() {
_periodWorker.dispose();
_dateWorker.dispose();
_logic.dispose();
super.dispose();
}
... ...
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/local/local_storage.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../app_review_prompt/logic/app_review_prompt_logic.dart';
import '../../report_common/models/report_period.dart';
import 'trend/trend_controller.dart';
class HomeController extends GetxController {
static const trendTabIndex = 1;
final AppReviewPromptLogic _appReviewPromptLogic = AppReviewPromptLogic(
storage: Get.find<LocalStorage>(),
);
final UserStateService userStateService = Get.find<UserStateService>();
/// 当前选中的底部 tab 索引
final selectedIndex = 0.obs;
Future<void> maybeShowAppReviewPrompt(BuildContext context) {
return _appReviewPromptLogic.maybeShowPrompt(context);
}
void changeTab(int index) {
selectedIndex.value = index;
}
void openTrend(TrendType type) {
Get.find<TrendController>().selectType(type);
void openTrend(TrendType type, {DateTime? date, ReportPeriod? period}) {
Get.find<TrendController>().selectType(type, date: date, period: period);
changeTab(trendTabIndex);
}
}
... ...
import 'dart:async';
import 'dart:math';
import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart';
import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart';
... ... @@ -379,7 +378,7 @@ class TodayController extends GetxController {
if (isFriend) {
Get.toNamed(
Routes.FRIEND_TREND,
arguments: getFriendTrendArguments(),
arguments: getFriendTrendArguments(TrendType.hrv),
);
} else {
Get.find<HomeController>().openTrend(TrendType.hrv);
... ... @@ -388,7 +387,10 @@ class TodayController extends GetxController {
toTrendSleepPage() {
if (isFriend) {
Get.toNamed(Routes.FRIEND_TREND, arguments: getFriendTrendArguments());
Get.toNamed(
Routes.FRIEND_TREND,
arguments: getFriendTrendArguments(TrendType.sleep),
);
} else {
Get.find<HomeController>().openTrend(TrendType.sleep);
}
... ... @@ -396,23 +398,20 @@ class TodayController extends GetxController {
toTrendActivityPage() {
if (isFriend) {
Get.toNamed(Routes.FRIEND_TREND, arguments: getFriendTrendArguments());
Get.toNamed(
Routes.FRIEND_TREND,
arguments: getFriendTrendArguments(TrendType.activity),
);
} else {
Get.find<HomeController>().openTrend(TrendType.activity);
}
}
getFriendTrendArguments() {
var value = targetFriendInfo.value;
if (value != null && value.friendUserId != null) {
return FriendTrendArguments(
userId: value.friendUserId!,
name: value.remarkName ?? '',
avatarUrl: value.avatar,
);
} else {
return null;
}
FriendTrendArguments getFriendTrendArguments(TrendType type) {
return FriendTrendArguments(
friendItem: targetFriendInfo.value,
initialTypeIndex: type.tabIndex,
);
}
void selectFriend(FriendItem friendInfo) {
... ...
import 'package:get/get.dart';
import '../../../health_trend/controllers/health_trend_control.dart';
import '../../../report_common/models/health_report_query.dart';
import '../../../report_common/models/report_period.dart';
enum TrendType {
hrv,
... ... @@ -12,24 +14,26 @@ enum TrendType {
/// 趋势页顶层 Controller,仅负责:
/// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex)
class TrendController extends GetxController {
// 第1层类型 Tab(0 = HRV, 1 = 活动, 2 = 睡眠)
final selectedTypeIndex = 0.obs;
class TrendController extends GetxController with HealthTrendControl {
// 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。
final targetUserId = RxnInt();
HealthReportQuery get query => HealthReportQuery(
targetUserId: targetUserId.value,
period: selectedPeriod.value,
date: selectedDate.value,
);
void changeType(int index) {
if (index == selectedTypeIndex.value) return;
selectedTypeIndex.value = index;
void selectType(TrendType type, {DateTime? date, ReportPeriod? period}) {
changeType(type.tabIndex);
if (period != null) {
changePeriod(period);
}
if (date != null) {
changeDate(date);
}
}
void selectType(TrendType type) => changeType(type.tabIndex);
void changeTargetUser(int? userId) {
if (userId == targetUserId.value) return;
targetUserId.value = userId;
... ...
... ... @@ -20,23 +20,54 @@ class HomePage extends GetView<HomeController> {
@override
Widget build(BuildContext context) {
return Scaffold(
// 不使用系统 AppBar,Today 页自带状态栏适配
extendBody: true, // 让内容延伸到 bottomNavigationBar 下方
extendBodyBehindAppBar: true,
body: Obx(
() => IndexedStack(
index: controller.selectedIndex.value,
children: _tabs,
return _HomeReviewPromptGate(
controller: controller,
child: Scaffold(
// 不使用系统 AppBar,Today 页自带状态栏适配
extendBody: true, // 让内容延伸到 bottomNavigationBar 下方
extendBodyBehindAppBar: true,
body: Obx(
() => IndexedStack(
index: controller.selectedIndex.value,
children: _tabs,
),
),
),
bottomNavigationBar: Obx(
() => DfTabBar(
selectedIndex: controller.selectedIndex.value,
onTap: controller.changeTab,
bottomNavigationBar: Obx(
() => DfTabBar(
selectedIndex: controller.selectedIndex.value,
onTap: controller.changeTab,
),
),
),
);
}
}
class _HomeReviewPromptGate extends StatefulWidget {
const _HomeReviewPromptGate({
required this.controller,
required this.child,
});
final HomeController controller;
final Widget child;
@override
State<_HomeReviewPromptGate> createState() => _HomeReviewPromptGateState();
}
class _HomeReviewPromptGateState extends State<_HomeReviewPromptGate> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
widget.controller.maybeShowAppReviewPrompt(context);
});
}
@override
Widget build(BuildContext context) => widget.child;
}
... ...
... ... @@ -33,6 +33,8 @@ class _HomeTrendBody extends GetView<TrendController> {
query: controller.query,
selectedTypeIndex: controller.selectedTypeIndex.value,
onTypeChanged: controller.changeType,
onPeriodChanged: controller.changePeriod,
onDateChanged: controller.changeDate,
),
);
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
/// 第1层:HRV心率 / 活动消耗 / 睡眠报告 Tab
... ... @@ -40,10 +41,10 @@ class TrendTypeTabBar extends StatelessWidget {
fontSize: 18,
fontWeight: FontWeight.w500,
),
tabs: const [
Tab(text: 'HRV心率'),
Tab(text: '活动消耗'),
Tab(text: '睡眠报告'),
tabs: [
Tab(text: context.l10n.trendHrvHeartRate),
Tab(text: context.l10n.trendActivityBurn),
Tab(text: context.l10n.trendSleepReport),
],
);
},
... ...
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:get/get.dart';
import '../../report_common/controllers/report_period_logic.dart';
... ... @@ -7,7 +8,13 @@ import '../data/hrv_report_repository.dart';
import '../models/hrv_report_models.dart';
class HrvReportLogic extends ReportPeriodLogic {
HrvReportLogic({int? initialTargetUserId}) {
HrvReportLogic({
int? initialTargetUserId,
HrvReportRepository? repository,
}) : repository = repository ??
HrvReportRepositoryImpl(
dataSource: ApiHrvReportDataSource(Get.find<HealthApi>()),
) {
targetUserId.value = initialTargetUserId;
selectedPeriod.value = ReportPeriod.week;
}
... ... @@ -16,9 +23,31 @@ class HrvReportLogic extends ReportPeriodLogic {
final weeklyReport = Rxn<WeeklyHrvReport>();
final monthlyReport = Rxn<MonthlyHrvReport>();
final yearlyReport = Rxn<YearlyHrvReport>();
final HrvReportRepository repository = const HrvReportRepositoryImpl(
dataSource: MockHrvReportDataSource(),
);
final HrvReportRepository repository;
@override
List<ReportPeriod> get supportedPeriods => const [
ReportPeriod.week,
ReportPeriod.month,
ReportPeriod.year,
];
@override
Future<void> selectPeriod(
ReportPeriod period, {
bool? forceRefresh,
}) {
final nextPeriod = normalizePeriod(period);
if (selectedPeriod.value == ReportPeriod.year &&
(nextPeriod == ReportPeriod.week || nextPeriod == ReportPeriod.month)) {
final now = DateTime.now();
final selectedYear = selectedDate.value.year;
selectedDate.value = selectedYear == now.year
? DateTime(now.year, now.month, now.day)
: DateTime(selectedYear);
}
return super.selectPeriod(nextPeriod, forceRefresh: forceRefresh);
}
@override
Future<void> loadReport() async {
... ...