Commit 1393e4bf4eff19f0eea075a32aa25c6a350ad8ec

Authored by 刘宏哲
1 parent 0710e7eb

feat(app): add friend trend view

Showing 49 changed files with 3031 additions and 442 deletions
No preview for this file type
import 'package:get/get.dart';
import '../../report_common/controllers/report_period_controller.dart';
import '../../report_common/controllers/report_period_logic.dart';
import '../../report_common/models/report_period.dart';
import '../data/activity_burn_report_datasource.dart';
import '../data/activity_burn_report_repository.dart';
import '../models/activity_burn_report_models.dart';
class ActivityBurnReportController extends ReportPeriodController {
ActivityBurnReportController();
class ActivityBurnReportLogic extends ReportPeriodLogic {
ActivityBurnReportLogic({int? initialTargetUserId}) {
targetUserId.value = initialTargetUserId;
}
final targetUserId = RxnInt();
final ActivityBurnReportRepository repository =
const ActivityBurnReportRepositoryImpl(
... ... @@ -19,24 +23,33 @@ class ActivityBurnReportController extends ReportPeriodController {
final monthlyReport = Rxn<MonthlyActivityBurnReport>();
@override
void onInit() {
super.onInit();
loadReport();
}
@override
Future<void> loadReport() async {
isLoading.value = true;
try {
if (selectedPeriod.value == ReportPeriod.week) {
weeklyReport.value = await repository.getWeeklyReport(weekStart);
weeklyReport.value = await repository.getWeeklyReport(
weekStart,
targetUserId: targetUserId.value,
);
} else if (selectedPeriod.value == ReportPeriod.month) {
monthlyReport.value = await repository.getMonthlyReport(monthStart);
monthlyReport.value = await repository.getMonthlyReport(
monthStart,
targetUserId: targetUserId.value,
);
} else {
report.value = await repository.getDailyReport(selectedDate.value);
report.value = await repository.getDailyReport(
selectedDate.value,
targetUserId: targetUserId.value,
);
}
} finally {
isLoading.value = false;
}
}
Future<void> updateTargetUserId(int? userId) {
if (userId == targetUserId.value) return Future.value();
targetUserId.value = userId;
return loadReport();
}
}
... ...
import '../models/activity_burn_report_models.dart';
abstract class ActivityBurnReportDataSource {
Future<ActivityBurnReport> fetchDailyReport(DateTime date);
Future<ActivityBurnReport> fetchDailyReport(
DateTime date, {
int? targetUserId,
});
Future<WeeklyActivityBurnReport> fetchWeeklyReport(DateTime weekStart);
Future<WeeklyActivityBurnReport> fetchWeeklyReport(
DateTime weekStart, {
int? targetUserId,
});
Future<MonthlyActivityBurnReport> fetchMonthlyReport(DateTime monthStart);
Future<MonthlyActivityBurnReport> fetchMonthlyReport(
DateTime monthStart, {
int? targetUserId,
});
}
class MockActivityBurnReportDataSource implements ActivityBurnReportDataSource {
const MockActivityBurnReportDataSource();
@override
Future<ActivityBurnReport> fetchDailyReport(DateTime date) async {
Future<ActivityBurnReport> fetchDailyReport(
DateTime date, {
int? targetUserId,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
if (date.day == 13) {
... ... @@ -36,6 +48,8 @@ class MockActivityBurnReportDataSource implements ActivityBurnReportDataSource {
heartRate: ActivityBurnHeartRateSummary(
startTime: start,
endTime: start.add(const Duration(hours: 18)),
sleepStartTime: start,
sleepEndTime: start.add(const Duration(hours: 5)),
userAge: null,
points: points,
),
... ... @@ -43,7 +57,10 @@ class MockActivityBurnReportDataSource implements ActivityBurnReportDataSource {
}
@override
Future<WeeklyActivityBurnReport> fetchWeeklyReport(DateTime weekStart) async {
Future<WeeklyActivityBurnReport> fetchWeeklyReport(
DateTime weekStart, {
int? targetUserId,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
final normalizedStart =
... ... @@ -82,7 +99,9 @@ class MockActivityBurnReportDataSource implements ActivityBurnReportDataSource {
@override
Future<MonthlyActivityBurnReport> fetchMonthlyReport(
DateTime monthStart) async {
DateTime monthStart, {
int? targetUserId,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
final start = DateTime(monthStart.year, monthStart.month);
... ...
... ... @@ -2,11 +2,20 @@ import '../models/activity_burn_report_models.dart';
import 'activity_burn_report_datasource.dart';
abstract class ActivityBurnReportRepository {
Future<ActivityBurnReport> getDailyReport(DateTime date);
Future<WeeklyActivityBurnReport> getWeeklyReport(DateTime weekStart);
Future<MonthlyActivityBurnReport> getMonthlyReport(DateTime monthStart);
Future<ActivityBurnReport> getDailyReport(
DateTime date, {
int? targetUserId,
});
Future<WeeklyActivityBurnReport> getWeeklyReport(
DateTime weekStart, {
int? targetUserId,
});
Future<MonthlyActivityBurnReport> getMonthlyReport(
DateTime monthStart, {
int? targetUserId,
});
}
class ActivityBurnReportRepositoryImpl implements ActivityBurnReportRepository {
... ... @@ -15,17 +24,29 @@ class ActivityBurnReportRepositoryImpl implements ActivityBurnReportRepository {
final ActivityBurnReportDataSource dataSource;
@override
Future<ActivityBurnReport> getDailyReport(DateTime date) {
return dataSource.fetchDailyReport(date);
Future<ActivityBurnReport> getDailyReport(
DateTime date, {
int? targetUserId,
}) {
return dataSource.fetchDailyReport(date, targetUserId: targetUserId);
}
@override
Future<WeeklyActivityBurnReport> getWeeklyReport(DateTime weekStart) {
return dataSource.fetchWeeklyReport(weekStart);
Future<WeeklyActivityBurnReport> getWeeklyReport(
DateTime weekStart, {
int? targetUserId,
}) {
return dataSource.fetchWeeklyReport(weekStart, targetUserId: targetUserId);
}
@override
Future<MonthlyActivityBurnReport> getMonthlyReport(DateTime monthStart) {
return dataSource.fetchMonthlyReport(monthStart);
Future<MonthlyActivityBurnReport> getMonthlyReport(
DateTime monthStart, {
int? targetUserId,
}) {
return dataSource.fetchMonthlyReport(
monthStart,
targetUserId: targetUserId,
);
}
}
... ...
... ... @@ -27,12 +27,16 @@ class ActivityBurnHeartRateSummary {
const ActivityBurnHeartRateSummary({
this.startTime,
this.endTime,
this.sleepStartTime,
this.sleepEndTime,
this.userAge,
this.points = const [],
});
final DateTime? startTime;
final DateTime? endTime;
final DateTime? sleepStartTime;
final DateTime? sleepEndTime;
final int? userAge;
final List<ActivityBurnHeartRatePoint> points;
... ...
... ... @@ -2,10 +2,11 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../report_common/models/report_period.dart';
import '../../report_common/widgets/report_bottom_slogan.dart';
import '../../report_common/widgets/report_date_picker_sheet.dart';
import '../../report_common/widgets/report_date_switcher.dart';
import '../../report_common/widgets/report_period_tab_bar.dart';
import '../controllers/activity_burn_report_controller.dart';
import '../controllers/activity_burn_report_logic.dart';
import '../models/activity_burn_report_models.dart';
import '../widgets/activity_burn_heart_rate_zone_card.dart';
import '../widgets/activity_burn_month_report_view.dart';
... ... @@ -13,15 +14,18 @@ import '../widgets/activity_burn_ring.dart';
import '../widgets/activity_burn_summary_card.dart';
import '../widgets/activity_burn_week_report_view.dart';
class ActivityBurnReportView extends GetView<ActivityBurnReportController> {
const ActivityBurnReportView({super.key});
class ActivityBurnReportView extends StatelessWidget {
const ActivityBurnReportView({
super.key,
required this.logic,
});
static const _bg = Color(0xFFF5F2FF);
final ActivityBurnReportLogic logic;
@override
Widget build(BuildContext context) {
return Container(
color: _bg,
color: Colors.transparent,
child: Stack(
children: [
Column(
... ... @@ -33,12 +37,12 @@ class ActivityBurnReportView extends GetView<ActivityBurnReportController> {
const SizedBox(height: 8),
Obx(
() => ReportPeriodTabBar(
selectedPeriod: controller.selectedPeriod.value,
onChanged: controller.selectPeriod,
selectedPeriod: logic.selectedPeriod.value,
onChanged: logic.selectPeriod,
),
),
const SizedBox(height: 8),
_DateSwitcher(controller: controller),
_DateSwitcher(logic: logic),
],
),
),
... ... @@ -52,22 +56,22 @@ class ActivityBurnReportView extends GetView<ActivityBurnReportController> {
delegate: SliverChildListDelegate([
Obx(
() {
if (controller.selectedPeriod.value ==
if (logic.selectedPeriod.value ==
ReportPeriod.week) {
return ActivityBurnWeekReportView(
report: controller.weeklyReport.value,
weekStart: controller.weekStart,
report: logic.weeklyReport.value,
weekStart: logic.weekStart,
);
}
if (controller.selectedPeriod.value ==
if (logic.selectedPeriod.value ==
ReportPeriod.month) {
return ActivityBurnMonthReportView(
report: controller.monthlyReport.value,
monthStart: controller.monthStart,
report: logic.monthlyReport.value,
monthStart: logic.monthStart,
);
}
final report = controller.report.value;
final report = logic.report.value;
return Column(
children: [
ActivityBurnRing(report: report),
... ... @@ -82,7 +86,7 @@ class ActivityBurnReportView extends GetView<ActivityBurnReportController> {
);
},
),
const SizedBox(height: 108),
const ReportBottomSlogan(),
]),
),
),
... ... @@ -98,17 +102,17 @@ class ActivityBurnReportView extends GetView<ActivityBurnReportController> {
}
class _DateSwitcher extends StatelessWidget {
const _DateSwitcher({required this.controller});
const _DateSwitcher({required this.logic});
final ActivityBurnReportController controller;
final ActivityBurnReportLogic logic;
@override
Widget build(BuildContext context) {
return Obx(
() => ReportDateSwitcher(
label: controller.dateLabel,
onPrevious: controller.previousDay,
onNext: controller.nextDay,
label: logic.dateLabel,
onPrevious: logic.previousDay,
onNext: logic.nextDay,
onTapLabel: () => _showDatePicker(context),
),
);
... ... @@ -117,12 +121,12 @@ class _DateSwitcher extends StatelessWidget {
Future<void> _showDatePicker(BuildContext context) async {
final picked = await showReportDatePickerSheet(
context: context,
period: controller.selectedPeriod.value,
selectedDate: controller.selectedDate.value,
weekStart: controller.weekStart,
period: logic.selectedPeriod.value,
selectedDate: logic.selectedDate.value,
weekStart: logic.weekStart,
);
if (picked != null) {
await controller.selectPickedDate(picked);
await logic.selectPickedDate(picked);
}
}
}
... ...
... ... @@ -4,6 +4,7 @@ import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../r.dart';
import '../models/activity_burn_report_models.dart';
class ActivityBurnHeartRateZoneCard extends StatelessWidget {
... ... @@ -25,6 +26,10 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final start = _startTime;
final axis = _HeartRateAxis.fromSummary(summary, start);
final sleepRange = summary.hasData ? _sleepRange(start, axis.maxX) : null;
return Container(
height: 273,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 16),
... ... @@ -46,20 +51,41 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
),
const SizedBox(height: 20),
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
LineChart(_chartData()),
if (!summary.hasData)
const Text(
'暂无本日数据',
style: TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
top: 18,
child: LineChart(_chartData()),
),
),
],
if (sleepRange != null)
Positioned(
top: 4,
left: _sleepIconLeft(
sleepRange,
axis.maxX,
constraints.maxWidth,
),
child: Image.asset(
R.assetsImagesHealthBedIcon,
width: 12,
height: 12,
),
),
if (!summary.hasData)
const Text(
'暂无本日数据',
style: TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
],
);
},
),
),
],
... ... @@ -67,27 +93,31 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
);
}
double _sleepIconLeft(
_SleepRange range,
double maxX,
double chartWidth,
) {
const rightTitleWidth = 28.0;
const iconWidth = 12.0;
final plotWidth = math.max(0, chartWidth - rightTitleWidth);
final centerX = (range.startX + range.endX) / 2;
return (centerX / maxX * plotWidth - iconWidth / 2)
.clamp(0, math.max(0, chartWidth - iconWidth))
.toDouble();
}
LineChartData _chartData() {
final start = _startTime;
final axis = _HeartRateAxis.fromSummary(summary, start);
final points = _points(start, axis.endTime);
final sleepRange = _sleepRange(start, axis.maxX);
return LineChartData(
minX: 0,
maxX: axis.maxX,
minY: axis.minY,
maxY: axis.maxY,
rangeAnnotations: RangeAnnotations(
verticalRangeAnnotations: points.isEmpty
? const []
: [
VerticalRangeAnnotation(
x1: 0,
x2: math.min(300, axis.maxX),
color: _brand.withValues(alpha: 0.12),
),
],
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
... ... @@ -151,21 +181,57 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
),
),
),
extraLinesData: ExtraLinesData(
verticalLines: points.isEmpty
? const []
: [
VerticalLine(
x: math.min(300, axis.maxX),
color: _brand,
strokeWidth: 2,
),
],
lineBarsData: points.isEmpty
? []
: [
if (sleepRange != null)
_sleepRangeBar(sleepRange, axis.maxY, axis.minY),
..._lineSegments(points, start),
],
);
}
LineChartBarData _sleepRangeBar(
_SleepRange range,
double maxY,
double minY,
) {
return LineChartBarData(
spots: [FlSpot(range.startX, maxY), FlSpot(range.endX, maxY)],
color: _brand,
barWidth: 2,
isCurved: false,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
cutOffY: minY,
applyCutOffY: true,
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x4D845EEE), Color(0x00845EEE)],
),
),
lineBarsData: points.isEmpty ? [] : _lineSegments(points, start),
);
}
_SleepRange? _sleepRange(DateTime start, double maxX) {
final sleepStart = summary.sleepStartTime;
final sleepEnd = summary.sleepEndTime;
if (sleepStart == null ||
sleepEnd == null ||
!sleepEnd.isAfter(sleepStart)) {
return null;
}
final startX = sleepStart.difference(start).inMinutes.toDouble();
final endX = sleepEnd.difference(start).inMinutes.toDouble();
final visibleStart = startX.clamp(0, maxX).toDouble();
final visibleEnd = endX.clamp(0, maxX).toDouble();
if (visibleEnd <= visibleStart) return null;
return _SleepRange(visibleStart, visibleEnd);
}
DateTime get _startTime {
if (summary.startTime != null) return summary.startTime!;
if (summary.points.isNotEmpty) return summary.points.first.time;
... ... @@ -400,3 +466,10 @@ class _HeartRateSegment {
final Color color;
final List<FlSpot> spots;
}
class _SleepRange {
const _SleepRange(this.startX, this.endX);
final double startX;
final double endX;
}
... ...
... ... @@ -2,6 +2,8 @@ import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/report_month_calendar_grid.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_ring.dart';
... ... @@ -362,43 +364,28 @@ class _MonthGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
final leading = report.monthStart.weekday - 1;
final cells = <ActivityBurnReport?>[
for (var i = 0; i < leading; i++) null,
...report.days,
];
while (cells.length % 7 != 0) {
cells.add(null);
}
return GridView.builder(
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
itemCount: cells.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
mainAxisSpacing: 8,
crossAxisSpacing: 11,
childAspectRatio: 0.62,
),
itemBuilder: (context, index) {
final day = cells[index];
if (day == null) return const SizedBox.shrink();
return Column(
children: [
ActivityBurnRing(report: day, size: 32),
const SizedBox(height: 4),
Text(
day.date.day.toString(),
style: const TextStyle(
color: ActivityBurnMonthReportView._h3,
fontSize: 10,
height: 1,
),
return ReportMonthCalendarGrid<ActivityBurnReport>(
monthStart: report.monthStart,
days: report.days,
dateOf: (day) => day.date,
mainAxisSpacing: 8,
crossAxisSpacing: 11,
childAspectRatio: 0.62,
shrinkWrap: false,
dayBuilder: (context, day) => Column(
children: [
ActivityBurnRing(report: day, size: 32),
const SizedBox(height: 4),
Text(
day.date.day.toString(),
style: const TextStyle(
color: ActivityBurnMonthReportView._h3,
fontSize: 10,
height: 1,
),
],
);
},
),
],
),
);
}
}
... ... @@ -413,7 +400,8 @@ class _MonthEnergyTrendCard extends StatefulWidget {
}
class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
int? _touchedIndex = 14;
int? _touchedIndex;
Offset? _touchedOffset;
@override
Widget build(BuildContext context) {
... ... @@ -474,6 +462,15 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
alignment: Alignment.center,
children: [
BarChart(_chartData()),
if (_touchedOffset != null)
Positioned.fill(
child: ChartSelectionLineOverlay(
offset: _touchedOffset!,
color: ActivityBurnMonthReportView._h3,
bottomTitleHeight: 20,
tooltipMargin: 6,
),
),
if (!hasData)
const Text(
'等待数据',
... ... @@ -509,11 +506,24 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
barTouchData: BarTouchData(
enabled: hasData,
touchCallback: (event, response) {
if (!event.isInterestedForInteractions ||
response?.spot?.touchedBarGroupIndex == null) {
final touchedIndex = response?.spot?.touchedBarGroupIndex;
if (!event.isInterestedForInteractions || touchedIndex == null) {
if (_touchedIndex != null || _touchedOffset != null) {
setState(() {
_touchedIndex = null;
_touchedOffset = null;
});
}
return;
}
setState(() => _touchedIndex = response!.spot!.touchedBarGroupIndex);
final touchedOffset = response?.spot?.offset;
if (_touchedIndex != touchedIndex ||
_touchedOffset != touchedOffset) {
setState(() {
_touchedIndex = touchedIndex;
_touchedOffset = touchedOffset;
});
}
},
touchTooltipData: BarTouchTooltipData(
tooltipRoundedRadius: 4,
... ... @@ -603,22 +613,11 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
dashArray: [2, 2],
),
],
verticalLines: !hasData || _touchedIndex == null
? const []
: [
VerticalLine(
x: _touchedIndex!.toDouble(),
color: ActivityBurnMonthReportView._h3,
strokeWidth: 2,
),
],
),
barGroups: [
for (var i = 0; i < widget.report.days.length; i++)
BarChartGroupData(
x: i,
showingTooltipIndicators:
hasData && i == _touchedIndex ? [0] : const [],
barRods: [
BarChartRodData(
toY:
... ...
... ... @@ -2,6 +2,7 @@ import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_ring.dart';
... ... @@ -377,7 +378,8 @@ class _EnergyTrendCard extends StatefulWidget {
}
class _EnergyTrendCardState extends State<_EnergyTrendCard> {
int? _touchedIndex = 1;
int? _touchedIndex;
Offset? _touchedOffset;
@override
Widget build(BuildContext context) {
... ... @@ -437,6 +439,15 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
alignment: Alignment.center,
children: [
BarChart(_chartData()),
if (_touchedOffset != null)
Positioned.fill(
child: ChartSelectionLineOverlay(
offset: _touchedOffset!,
color: ActivityBurnWeekReportView._h3,
bottomTitleHeight: 32,
tooltipMargin: 6,
),
),
if (!widget.report.hasData)
const Text(
'等待数据',
... ... @@ -477,13 +488,24 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
barTouchData: BarTouchData(
enabled: hasData,
touchCallback: (event, response) {
if (!event.isInterestedForInteractions ||
response?.spot?.touchedBarGroupIndex == null) {
final touchedIndex = response?.spot?.touchedBarGroupIndex;
if (!event.isInterestedForInteractions || touchedIndex == null) {
if (_touchedIndex != null || _touchedOffset != null) {
setState(() {
_touchedIndex = null;
_touchedOffset = null;
});
}
return;
}
setState(() {
_touchedIndex = response!.spot!.touchedBarGroupIndex;
});
final touchedOffset = response?.spot?.offset;
if (_touchedIndex != touchedIndex ||
_touchedOffset != touchedOffset) {
setState(() {
_touchedIndex = touchedIndex;
_touchedOffset = touchedOffset;
});
}
},
touchTooltipData: BarTouchTooltipData(
tooltipRoundedRadius: 4,
... ... @@ -583,22 +605,11 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
dashArray: [2, 2],
),
],
verticalLines: !hasData || _touchedIndex == null
? const []
: [
VerticalLine(
x: _touchedIndex!.toDouble(),
color: ActivityBurnWeekReportView._h3,
strokeWidth: 2,
),
],
),
barGroups: [
for (var i = 0; i < widget.report.days.length; i++)
BarChartGroupData(
x: i,
showingTooltipIndicators:
hasData && i == _touchedIndex ? [0] : const [],
barRods: [
BarChartRodData(
toY:
... ...
import 'package:get/get.dart';
import '../controllers/friend_trend_controller.dart';
class FriendTrendBinding extends Bindings {
@override
void dependencies() {
final arguments = Get.arguments;
if (arguments is! FriendTrendArguments) {
throw ArgumentError('FriendTrendView requires FriendTrendArguments');
}
Get.lazyPut<FriendTrendController>(
() => FriendTrendController(
userId: arguments.userId,
friendName: arguments.name,
avatarUrl: arguments.avatarUrl,
),
);
}
}
... ...
import 'package:get/get.dart';
import '../../report_common/models/health_report_query.dart';
class FriendTrendArguments {
const FriendTrendArguments({
required this.userId,
required this.name,
this.avatarUrl,
});
final int userId;
final String name;
final String? avatarUrl;
}
class FriendTrendController extends GetxController {
FriendTrendController({
required this.userId,
required this.friendName,
this.avatarUrl,
});
final int userId;
final String friendName;
final String? avatarUrl;
final selectedTypeIndex = 0.obs;
HealthReportQuery get query => HealthReportQuery(targetUserId: userId);
void changeType(int index) {
if (index == selectedTypeIndex.value) return;
selectedTypeIndex.value = index;
}
}
... ...
... ... @@ -29,6 +29,7 @@ class FriendsController extends GetxController {
await Future<void>.delayed(const Duration(milliseconds: 150));
friends.assignAll(const [
FriendHealthData(
userId: 10001,
name: '女朋友(不爱吃热干面)',
updatedAt: '更新于15:12',
sleepQualityScore: 92,
... ... @@ -38,6 +39,7 @@ class FriendsController extends GetxController {
isOnWatchFace: true,
),
FriendHealthData(
userId: 10002,
name: 'John',
updatedAt: '更新于15:12',
sleepQualityScore: null,
... ... @@ -100,6 +102,8 @@ class FriendsController extends GetxController {
class FriendHealthData {
const FriendHealthData({
this.userId,
this.avatarUrl,
required this.name,
required this.updatedAt,
required this.sleepQualityScore,
... ... @@ -109,6 +113,8 @@ class FriendHealthData {
this.isOnWatchFace = false,
});
final int? userId;
final String? avatarUrl;
final String name;
final String updatedAt;
final int? sleepQualityScore;
... ... @@ -118,6 +124,8 @@ class FriendHealthData {
final bool isOnWatchFace;
FriendHealthData copyWith({
int? userId,
String? avatarUrl,
String? name,
String? updatedAt,
int? sleepQualityScore,
... ... @@ -127,6 +135,8 @@ class FriendHealthData {
bool? isOnWatchFace,
}) {
return FriendHealthData(
userId: userId ?? this.userId,
avatarUrl: avatarUrl ?? this.avatarUrl,
name: name ?? this.name,
updatedAt: updatedAt ?? this.updatedAt,
sleepQualityScore: sleepQualityScore ?? this.sleepQualityScore,
... ...
... ... @@ -8,7 +8,7 @@ class SelectFriendController extends GetxController {
SelectFriendController(this._friendsController);
final FriendsController _friendsController;
final selectedFriend = Rxn<FriendHealthData>();
final selectedIndex = (-1).obs;
List<FriendHealthData> get friends => _friendsController.friends;
... ... @@ -24,27 +24,26 @@ class SelectFriendController extends GetxController {
}
final items = friends;
if (items.isEmpty) return;
selectedFriend.value = items.firstWhere(
(friend) => friend.isOnWatchFace,
orElse: () => items.first,
);
final watchFaceIndex = items.indexWhere((friend) => friend.isOnWatchFace);
selectedIndex.value = watchFaceIndex < 0 ? 0 : watchFaceIndex;
}
void selectFriend(FriendHealthData friend) {
selectedFriend.value = friend;
void selectFriendAt(int index) {
if (index < 0 || index >= friends.length) return;
selectedIndex.value = index;
}
Future<void> syncSelectedFriendToWatchFace() async {
final selected = selectedFriend.value;
if (selected == null) return;
final updated = friends
.map(
(friend) => friend.copyWith(
isOnWatchFace: identical(friend, selected),
),
)
.toList(growable: false);
final index = selectedIndex.value;
if (index < 0 || index >= friends.length) return;
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.
... ...
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../../health_trend/views/health_trend_content.dart';
import '../controllers/friend_trend_controller.dart';
class FriendTrendView extends GetView<FriendTrendController> {
const FriendTrendView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F2FF),
extendBodyBehindAppBar: true,
appBar: AppBar(
toolbarHeight: 44,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
),
leadingWidth: 44,
leading: IconButton(
onPressed: Get.back,
padding: const EdgeInsets.only(left: 12),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: const Color(0xFF0F0F11),
iconSize: 20,
tooltip: '返回',
),
titleSpacing: 0,
title: _FriendTrendTitle(
title: '${controller.friendName}的趋势',
avatarUrl: controller.avatarUrl,
),
),
body: Stack(
children: [
Container(
height: 300,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFC5B0FF), Color(0xFFF5F2FF)],
),
),
),
Column(
children: [
SizedBox(height: MediaQuery.paddingOf(context).top + 44),
Expanded(
child: Obx(
() => HealthTrendContent(
query: controller.query,
selectedTypeIndex: controller.selectedTypeIndex.value,
onTypeChanged: controller.changeType,
),
),
),
],
),
],
),
);
}
}
class _FriendTrendTitle extends StatelessWidget {
const _FriendTrendTitle({
required this.title,
required this.avatarUrl,
});
final String title;
final String? avatarUrl;
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 28,
height: 28,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 0.8),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFB7A6FF), Color(0xFFFFD7EA)],
),
),
child: avatarUrl?.trim().isNotEmpty == true
? Image.network(
avatarUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const _AvatarFallback(),
)
: const _AvatarFallback(),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
);
}
}
class _AvatarFallback extends StatelessWidget {
const _AvatarFallback();
@override
Widget build(BuildContext context) {
return const Icon(Icons.person_rounded, size: 18, color: Colors.white);
}
}
... ...
... ... @@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/friends_controller.dart';
import '../controllers/friend_trend_controller.dart';
import '../widgets/friend_health_card.dart';
class FriendsTab extends GetView<FriendsController> {
... ... @@ -63,6 +64,7 @@ class FriendsTab extends GetView<FriendsController> {
statusText: friend.statusText,
stressValue: friend.stressValue,
isOnWatchFace: friend.isOnWatchFace,
onTap: () => _openFriendTrend(friend),
onMoreSelected: (action) {
_handleFriendAction(action, friend);
},
... ... @@ -123,6 +125,19 @@ class FriendsTab extends GetView<FriendsController> {
await controller.loadFriends();
}
void _openFriendTrend(FriendHealthData friend) {
final userId = friend.userId;
if (userId == null) return;
Get.toNamed(
Routes.FRIEND_TREND,
arguments: FriendTrendArguments(
userId: userId,
name: friend.name,
avatarUrl: friend.avatarUrl,
),
);
}
void _handleFriendAction(FriendCardAction action, FriendHealthData friend) {
switch (action) {
case FriendCardAction.editRemark:
... ...
... ... @@ -12,7 +12,7 @@ class SelectFriendView extends GetView<SelectFriendController> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black.withValues(alpha: 0.70),
backgroundColor: const Color(0xFF000000).withValues(alpha: 0.70),
body: Column(
children: [
SizedBox(height: MediaQuery.viewPaddingOf(context).top + 8.dp),
... ... @@ -41,8 +41,8 @@ class SelectFriendView extends GetView<SelectFriendController> {
),
itemBuilder: (context, index) {
final friend = controller.friends[index];
final isSelected = identical(
controller.selectedFriend.value, friend);
final isSelected =
controller.selectedIndex.value == index;
return FriendHealthCard(
name: friend.name,
updatedAt: friend.updatedAt,
... ... @@ -52,8 +52,8 @@ class SelectFriendView extends GetView<SelectFriendController> {
stressValue: friend.stressValue,
isOnWatchFace: friend.isOnWatchFace,
isSelected: isSelected,
showCheckbox: isSelected,
onTap: () => controller.selectFriend(friend),
showCheckbox: true,
onTap: () => controller.selectFriendAt(index),
);
},
separatorBuilder: (_, __) =>
... ... @@ -91,31 +91,40 @@ class _SelectFriendHeader extends StatelessWidget {
Widget build(BuildContext context) {
return SizedBox(
height: 56.dp,
child: Stack(
alignment: Alignment.center,
child: Row(
children: [
const Text(
'选择好友',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
Padding(
padding: EdgeInsets.only(left: 22.dp),
child: GestureDetector(
onTap: onClose,
behavior: HitTestBehavior.opaque,
child: SizedBox(
width: 40.dp,
height: 40.dp,
child: const Center(
child: Icon(
Icons.close,
color: AppColors.primary,
size: 20,
),
),
),
),
),
Positioned(
left: 22.dp,
child: IconButton(
onPressed: onClose,
icon: const Icon(
Icons.close,
color: AppColors.primary,
size: 22,
const Expanded(
child: Center(
child: Text(
'选择好友',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
),
),
padding: EdgeInsets.zero,
constraints: BoxConstraints.tight(Size(40.dp, 40.dp)),
),
),
SizedBox(width: 62.dp),
],
),
);
... ...
... ... @@ -51,14 +51,18 @@ class FriendHealthCard extends StatelessWidget {
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
padding: const EdgeInsets.fromLTRB(20, 20, 16, 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: isSelected
? Border.all(color: const Color(0xFF845EEE), width: 2)
: null,
border: Border.all(
color:
isSelected ? const Color(0xFF845EEE) : Colors.transparent,
width: 2,
),
),
child: Column(
children: [
... ... @@ -66,7 +70,8 @@ class FriendHealthCard extends StatelessWidget {
name: name,
updatedAt: updatedAt,
isSelf: isSelf,
onMoreSelected: showCheckbox ? null : onMoreSelected,
showMoreButton: !showCheckbox,
onMoreSelected: onMoreSelected,
),
const SizedBox(height: 17),
Row(
... ... @@ -116,7 +121,12 @@ class FriendHealthCard extends StatelessWidget {
Positioned(
right: 8,
bottom: 8,
child: _FriendSelectMark(isSelected: isSelected),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
transform: Matrix4.identity()..scale(isSelected ? 1.0 : 0.92),
child: _FriendSelectMark(isSelected: isSelected),
),
),
],
),
... ... @@ -129,12 +139,14 @@ class _FriendCardHeader extends StatelessWidget {
required this.name,
required this.updatedAt,
required this.isSelf,
required this.showMoreButton,
this.onMoreSelected,
});
final String name;
final String updatedAt;
final bool isSelf;
final bool showMoreButton;
final ValueChanged<FriendCardAction>? onMoreSelected;
@override
... ... @@ -171,7 +183,7 @@ class _FriendCardHeader extends StatelessWidget {
],
),
),
if (!isSelf)
if (showMoreButton && !isSelf)
PopupMenuButton<FriendCardAction>(
onSelected: onMoreSelected,
elevation: 10,
... ...
import 'package:flutter/material.dart';
import '../../activity_burn_report/controllers/activity_burn_report_logic.dart';
import '../../activity_burn_report/views/activity_burn_report_view.dart';
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 '../../sleep_report/controllers/sleep_report_logic.dart';
import '../../sleep_report/views/sleep_report_view.dart';
class HealthTrendContent extends StatefulWidget {
const HealthTrendContent({
super.key,
required this.query,
required this.selectedTypeIndex,
required this.onTypeChanged,
});
final HealthReportQuery query;
final int selectedTypeIndex;
final ValueChanged<int> onTypeChanged;
@override
State<HealthTrendContent> createState() => _HealthTrendContentState();
}
class _HealthTrendContentState extends State<HealthTrendContent>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(
length: 3,
vsync: this,
initialIndex: widget.selectedTypeIndex,
);
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
widget.onTypeChanged(_tabController.index);
}
});
}
@override
void didUpdateWidget(covariant HealthTrendContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedTypeIndex != widget.selectedTypeIndex &&
_tabController.index != widget.selectedTypeIndex) {
_tabController.animateTo(widget.selectedTypeIndex);
}
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TrendTypeTabBar(tabController: _tabController),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_KeepAliveWrapper(
child: _HrvTrendSection(query: widget.query),
),
_KeepAliveWrapper(
child: _ActivityBurnTrendSection(query: widget.query),
),
_KeepAliveWrapper(
child: _SleepTrendSection(query: widget.query),
),
],
),
),
],
);
}
}
class _HrvTrendSection extends StatefulWidget {
const _HrvTrendSection({required this.query});
final HealthReportQuery query;
@override
State<_HrvTrendSection> createState() => _HrvTrendSectionState();
}
class _HrvTrendSectionState extends State<_HrvTrendSection> {
late final HrvReportLogic _logic;
@override
void initState() {
super.initState();
_logic = HrvReportLogic(initialTargetUserId: widget.query.targetUserId);
_logic.loadReport();
}
@override
void didUpdateWidget(covariant _HrvTrendSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) {
_logic.updateTargetUserId(widget.query.targetUserId);
}
}
@override
void dispose() {
_logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => HrvReportView(logic: _logic);
}
class _ActivityBurnTrendSection extends StatefulWidget {
const _ActivityBurnTrendSection({required this.query});
final HealthReportQuery query;
@override
State<_ActivityBurnTrendSection> createState() =>
_ActivityBurnTrendSectionState();
}
class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
late final ActivityBurnReportLogic _logic;
@override
void initState() {
super.initState();
_logic = ActivityBurnReportLogic(
initialTargetUserId: widget.query.targetUserId,
);
_logic.loadReport();
}
@override
void didUpdateWidget(covariant _ActivityBurnTrendSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) {
_logic.updateTargetUserId(widget.query.targetUserId);
}
}
@override
void dispose() {
_logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ActivityBurnReportView(logic: _logic);
}
class _SleepTrendSection extends StatefulWidget {
const _SleepTrendSection({required this.query});
final HealthReportQuery query;
@override
State<_SleepTrendSection> createState() => _SleepTrendSectionState();
}
class _SleepTrendSectionState extends State<_SleepTrendSection> {
late final SleepReportLogic _logic;
@override
void initState() {
super.initState();
_logic = SleepReportLogic(
initialTargetUserId: widget.query.targetUserId,
);
_logic.loadReport();
}
@override
void didUpdateWidget(covariant _SleepTrendSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) {
_logic.updateTargetUserId(widget.query.targetUserId);
}
}
@override
void dispose() {
_logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => SleepReportView(logic: _logic);
}
class _KeepAliveWrapper extends StatefulWidget {
const _KeepAliveWrapper({required this.child});
final Widget child;
@override
State<_KeepAliveWrapper> createState() => _KeepAliveWrapperState();
}
class _KeepAliveWrapperState extends State<_KeepAliveWrapper>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
... ...
... ... @@ -9,9 +9,6 @@ import '../../friends/controllers/friends_controller.dart';
import '../controllers/home_controller.dart';
import '../controllers/today_controller.dart';
import '../controllers/trend/trend_controller.dart';
import '../controllers/trend/hrv_controller.dart';
import '../../activity_burn_report/controllers/activity_burn_report_controller.dart';
import '../../sleep_report/controllers/sleep_report_controller.dart';
class HomeBinding extends Bindings {
@override
... ... @@ -29,15 +26,6 @@ class HomeBinding extends Bindings {
Get.lazyPut<MyController>(() => MyController(Get.find<UserApi>()),
fenix: true);
Get.lazyPut<TrendController>(() => TrendController(), fenix: true);
Get.lazyPut<HrvController>(() => HrvController(), fenix: true);
Get.lazyPut<FriendsController>(() => FriendsController(), fenix: true);
Get.lazyPut<ActivityBurnReportController>(
() => ActivityBurnReportController(),
fenix: true,
);
Get.lazyPut<SleepReportController>(
() => SleepReportController(),
fenix: true,
);
}
}
... ...
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:get/get.dart';
import 'trend/trend_controller.dart';
class HomeController extends GetxController {
static const trendTabIndex = 1;
final UserStateService userStateService = Get.find<UserStateService>();
/// 当前选中的底部 tab 索引
... ... @@ -10,4 +14,9 @@ class HomeController extends GetxController {
void changeTab(int index) {
selectedIndex.value = index;
}
void openTrend(TrendType type) {
Get.find<TrendController>().selectType(type);
changeTab(trendTabIndex);
}
}
... ...
import 'package:get/get.dart';
import '../../../report_common/models/health_report_query.dart';
enum TrendType {
hrv,
activity,
sleep;
int get tabIndex => index;
}
/// 趋势页顶层 Controller,仅负责:
/// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex)
class TrendController extends GetxController {
// 第1层类型 Tab(0 = HRV, 1 = 活动, 2 = 睡眠)
final selectedTypeIndex = 0.obs;
// 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。
final targetUserId = RxnInt();
HealthReportQuery get query => HealthReportQuery(
targetUserId: targetUserId.value,
);
void changeType(int index) {
if (index == selectedTypeIndex.value) return;
selectedTypeIndex.value = index;
}
void selectType(TrendType type) => changeType(type.tabIndex);
void changeTargetUser(int? userId) {
if (userId == targetUserId.value) return;
targetUserId.value = userId;
}
}
... ...
... ... @@ -4,8 +4,8 @@ import 'package:get/get.dart';
import '../controllers/home_controller.dart';
import '../widgets/df_tab_bar.dart';
import '../../friends/views/friends_tab.dart';
import 'tabs/home_trend_view.dart';
import 'tabs/today_tab.dart';
import 'tabs/trend_tab.dart';
import 'tabs/my_tab.dart';
class HomePage extends GetView<HomeController> {
... ... @@ -13,7 +13,7 @@ class HomePage extends GetView<HomeController> {
static const _tabs = [
TodayTab(),
TrendTab(),
HomeTrendView(),
FriendsTab(),
MyTab(),
];
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../health_trend/views/health_trend_content.dart';
import '../../controllers/trend/trend_controller.dart';
class HomeTrendView extends GetView<TrendController> {
const HomeTrendView({super.key});
@override
Widget build(BuildContext context) {
return const _TrendPageBackground(
child: Column(
children: [
SafeArea(
bottom: false,
child: _HomeTrendHeader(),
),
Expanded(child: _HomeTrendBody()),
],
),
);
}
}
class _HomeTrendBody extends GetView<TrendController> {
const _HomeTrendBody();
@override
Widget build(BuildContext context) {
return Obx(
() => HealthTrendContent(
query: controller.query,
selectedTypeIndex: controller.selectedTypeIndex.value,
onTypeChanged: controller.changeType,
),
);
}
}
class _HomeTrendHeader extends StatelessWidget {
const _HomeTrendHeader();
@override
Widget build(BuildContext context) {
return const SizedBox(
height: 48,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'趋势',
style: TextStyle(
color: Color(0xFF0F0F11),
fontSize: 24,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
}
class _TrendPageBackground extends StatelessWidget {
const _TrendPageBackground({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xFFF5F2FF),
child: Stack(
children: [
Container(
height: 300,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFC5B0FF), Color(0xFFF5F2FF)],
),
),
),
child,
],
),
);
}
}
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../controllers/trend/trend_controller.dart';
import '../../widgets/trend/trend_type_tab_bar.dart';
import '../../widgets/trend/hrv_trend_view.dart';
import '../../../activity_burn_report/views/activity_burn_report_view.dart';
import '../../../sleep_report/views/sleep_report_view.dart';
/// 趋势大 Tab 页面主视图
/// 采用 StatefulWidget 绑定 SingleTickerProviderStateMixin 并通过 TabController
/// 控制 TabBar 与 TabBarView 的同步滑动,实现极具高级感的跟手划动效果。
class TrendTab extends StatefulWidget {
const TrendTab({super.key});
@override
State<TrendTab> createState() => _TrendTabState();
}
class _TrendTabState extends State<TrendTab>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
late final TrendController _trendController;
late final Worker _rxWorker;
static const _bgColor = Color(0xFFF5F2FF);
static const _h1 = Color(0xFF0F0F11);
// 三个内容区(包裹 KeepAliveWrapper 以记忆滑动/图表状态,确保子 View 能继续保持纯净的 GetView 规范)
static const _contentViews = [
KeepAliveWrapper(child: HrvTrendView()),
KeepAliveWrapper(child: ActivityBurnReportView()),
KeepAliveWrapper(child: SleepReportView()),
];
@override
void initState() {
super.initState();
_trendController = Get.find<TrendController>();
// 初始化 TabController
_tabController = TabController(
length: 3,
vsync: this,
initialIndex: _trendController.selectedTypeIndex.value,
);
// 1. 当滑动/点击切换页面时,同步更新 TrendController 状态
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
_trendController.changeType(_tabController.index);
}
});
// 2. 当外部修改 selectedTypeIndex 时,同步动画跳转 TabBarView
_rxWorker = ever(_trendController.selectedTypeIndex, (index) {
if (_tabController.index != index) {
_tabController.animateTo(index);
}
});
}
@override
void dispose() {
_rxWorker.dispose();
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final topPadding = MediaQuery.of(context).padding.top;
return Container(
color: _bgColor,
child: Column(
children: [
// ── 状态栏占位 + 标题栏 ──
Container(
color: _bgColor,
padding: EdgeInsets.only(top: topPadding),
child: _buildAppBar(),
),
// ── 第1层:原生 TabBar(支持跟手滑动指示器) ──
Container(
color: _bgColor,
child: TrendTypeTabBar(tabController: _tabController),
),
// ── 内容区:TabBarView 提供极其平滑、跟手的原生翻页动画 ──
Expanded(
child: TabBarView(
controller: _tabController,
children: _contentViews,
),
),
],
),
);
}
Widget _buildAppBar() {
return SizedBox(
height: 48,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Text(
'趋势',
style: TextStyle(
color: _h1,
fontSize: 24,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
/// 专用于在 TabBarView/PageView 中保持子组件状态的轻量级包装器
/// 使得子 View 可以维持无状态的 GetView 极简设计规范
class KeepAliveWrapper extends StatefulWidget {
final Widget child;
const KeepAliveWrapper({super.key, required this.child});
@override
State<KeepAliveWrapper> createState() => _KeepAliveWrapperState();
}
class _KeepAliveWrapperState extends State<KeepAliveWrapper>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:flutter/material.dart';
/// 第1层:HRV心率 / 活动消耗 / 睡眠报告 Tab
... ... @@ -13,29 +14,39 @@ class TrendTypeTabBar extends StatelessWidget {
Widget build(BuildContext context) {
return SizedBox(
height: 40,
child: TabBar(
controller: tabController,
dividerColor: Colors.transparent, // 移除底部默认分割线
indicatorColor: _selectedColor,
indicatorSize: TabBarIndicatorSize.tab, // 指示线与 Tab 等宽
indicatorWeight: 3, // 指示线粗细为 3px
labelColor: _selectedColor,
unselectedLabelColor: _unselectedColor,
labelStyle: const TextStyle(
fontFamily: 'PingFang SC',
fontSize: 18,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: const TextStyle(
fontFamily: 'PingFang SC',
fontSize: 18,
fontWeight: FontWeight.w500,
),
tabs: const [
Tab(text: 'HRV心率'),
Tab(text: '活动消耗'),
Tab(text: '睡眠报告'),
],
child: LayoutBuilder(
builder: (context, constraints) {
final indicatorWidth = 36.dp;
final horizontalInset =
(constraints.maxWidth / 3 - indicatorWidth) / 2;
return TabBar(
controller: tabController,
dividerColor: Colors.transparent, // 移除底部默认分割线
indicator: UnderlineTabIndicator(
borderSide: const BorderSide(color: _selectedColor, width: 3),
insets: EdgeInsets.symmetric(horizontal: horizontalInset),
),
indicatorSize: TabBarIndicatorSize.tab,
labelColor: _selectedColor,
unselectedLabelColor: _unselectedColor,
labelStyle: const TextStyle(
fontFamily: 'PingFang SC',
fontSize: 18,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: const TextStyle(
fontFamily: 'PingFang SC',
fontSize: 18,
fontWeight: FontWeight.w500,
),
tabs: const [
Tab(text: 'HRV心率'),
Tab(text: '活动消耗'),
Tab(text: '睡眠报告'),
],
);
},
),
);
}
... ...
import 'package:get/get.dart';
import '../../report_common/controllers/report_period_logic.dart';
import '../../report_common/models/report_period.dart';
import '../data/hrv_report_datasource.dart';
import '../data/hrv_report_repository.dart';
import '../models/hrv_report_models.dart';
class HrvReportLogic extends ReportPeriodLogic {
HrvReportLogic({int? initialTargetUserId}) {
targetUserId.value = initialTargetUserId;
selectedPeriod.value = ReportPeriod.week;
}
final targetUserId = RxnInt();
final weeklyReport = Rxn<WeeklyHrvReport>();
final monthlyReport = Rxn<MonthlyHrvReport>();
final yearlyReport = Rxn<YearlyHrvReport>();
final HrvReportRepository repository = const HrvReportRepositoryImpl(
dataSource: MockHrvReportDataSource(),
);
@override
Future<void> loadReport() async {
isLoading.value = true;
try {
if (selectedPeriod.value == ReportPeriod.year) {
yearlyReport.value = await repository.getYearlyReport(
selectedDate.value.year,
targetUserId: targetUserId.value,
);
} else if (selectedPeriod.value == ReportPeriod.month) {
monthlyReport.value = await repository.getMonthlyReport(
monthStart,
targetUserId: targetUserId.value,
);
} else {
weeklyReport.value = await repository.getWeeklyReport(
weekStart,
targetUserId: targetUserId.value,
);
}
} finally {
isLoading.value = false;
}
}
Future<void> updateTargetUserId(int? userId) {
if (userId == targetUserId.value) return Future.value();
targetUserId.value = userId;
return loadReport();
}
}
... ...
import '../models/hrv_report_models.dart';
abstract class HrvReportDataSource {
Future<WeeklyHrvReport> fetchWeeklyReport(
DateTime weekStart, {
int? targetUserId,
});
Future<MonthlyHrvReport> fetchMonthlyReport(
DateTime monthStart, {
int? targetUserId,
});
Future<YearlyHrvReport> fetchYearlyReport(
int year, {
int? targetUserId,
});
}
class MockHrvReportDataSource implements HrvReportDataSource {
const MockHrvReportDataSource();
@override
Future<WeeklyHrvReport> fetchWeeklyReport(
DateTime weekStart, {
int? targetUserId,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
final start = DateTime(weekStart.year, weekStart.month, weekStart.day);
// The selected Figma empty state is the week of May 4.
if (start.month == 5 && start.day == 4) {
return WeeklyHrvReport.empty(start);
}
const hrv = [65.0, 94.0, 68.0, 84.0, 85.0, 43.0, 29.0];
const bpm = [66, 64, 68, 65, 67, 70, 72];
const levels = [
HrvStressLevel.normal,
HrvStressLevel.excellent,
HrvStressLevel.excellent,
HrvStressLevel.excellent,
HrvStressLevel.excellent,
HrvStressLevel.attention,
HrvStressLevel.overload,
];
return WeeklyHrvReport(
weekStart: start,
previousRelaxedDays: 5,
previousStressedDays: 2,
days: [
for (var i = 0; i < 7; i++)
HrvDayReport(
date: start.add(Duration(days: i)),
averageHrv: hrv[i],
averageHeartRate: bpm[i],
level: levels[i],
),
],
);
}
@override
Future<MonthlyHrvReport> fetchMonthlyReport(
DateTime monthStart, {
int? targetUserId,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
final start = DateTime(monthStart.year, monthStart.month);
if (start.month == 4) return MonthlyHrvReport.empty(start);
final end = DateTime(start.year, start.month + 1, 0);
const values = [65.0, 84.0, 68.0, 95.0, 95.0, 43.0, 29.0];
const levels = [
HrvStressLevel.normal,
HrvStressLevel.excellent,
HrvStressLevel.excellent,
HrvStressLevel.excellent,
HrvStressLevel.excellent,
HrvStressLevel.attention,
HrvStressLevel.overload,
];
return MonthlyHrvReport(
monthStart: start,
validDayCount: 24,
previousRelaxedDays: 5,
previousStressedDays: 2,
days: [
for (var i = 0; i < end.day; i++)
HrvDayReport(
date: start.add(Duration(days: i)),
averageHrv: i < values.length ? values[i] : null,
averageHeartRate: i < values.length ? 67 : null,
level: i < levels.length ? levels[i] : null,
),
],
);
}
@override
Future<YearlyHrvReport> fetchYearlyReport(
int year, {
int? targetUserId,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
if (year == 2025) return YearlyHrvReport.empty(year);
const monthlyValues = [58.0, 82.0, 64.0, 74.0, 31.0, 45.0, 31.0];
final days = <HrvDayReport>[];
var dataIndex = 0;
final levels = <HrvStressLevel>[
...List.filled(33, HrvStressLevel.excellent),
...List.filled(49, HrvStressLevel.normal),
...List.filled(45, HrvStressLevel.attention),
...List.filled(6, HrvStressLevel.overload),
];
for (var month = 1; month <= 12; month++) {
final count = DateTime(year, month + 1, 0).day;
for (var day = 1; day <= count; day++) {
final hasData = month <= monthlyValues.length && day <= 19;
final level = hasData ? levels[dataIndex++] : null;
days.add(
HrvDayReport(
date: DateTime(year, month, day),
averageHrv: hasData ? monthlyValues[month - 1] : null,
averageHeartRate: hasData ? 67 : null,
level: level,
),
);
}
}
return YearlyHrvReport(year: year, days: days, validDayCount: 133);
}
}
... ...
import '../models/hrv_report_models.dart';
import 'hrv_report_datasource.dart';
abstract class HrvReportRepository {
Future<WeeklyHrvReport> getWeeklyReport(
DateTime weekStart, {
int? targetUserId,
});
Future<MonthlyHrvReport> getMonthlyReport(
DateTime monthStart, {
int? targetUserId,
});
Future<YearlyHrvReport> getYearlyReport(
int year, {
int? targetUserId,
});
}
class HrvReportRepositoryImpl implements HrvReportRepository {
const HrvReportRepositoryImpl({required this.dataSource});
final HrvReportDataSource dataSource;
@override
Future<WeeklyHrvReport> getWeeklyReport(
DateTime weekStart, {
int? targetUserId,
}) {
return dataSource.fetchWeeklyReport(
weekStart,
targetUserId: targetUserId,
);
}
@override
Future<MonthlyHrvReport> getMonthlyReport(
DateTime monthStart, {
int? targetUserId,
}) {
return dataSource.fetchMonthlyReport(
monthStart,
targetUserId: targetUserId,
);
}
@override
Future<YearlyHrvReport> getYearlyReport(
int year, {
int? targetUserId,
}) {
return dataSource.fetchYearlyReport(year, targetUserId: targetUserId);
}
}
... ...
enum HrvStressLevel { excellent, normal, attention, overload }
extension HrvStressLevelPresentation on HrvStressLevel {
String get label => switch (this) {
HrvStressLevel.excellent => '状态优秀',
HrvStressLevel.normal => '状态正常',
HrvStressLevel.attention => '注意压力',
HrvStressLevel.overload => '压力过载',
};
int get colorValue => switch (this) {
HrvStressLevel.excellent => 0xFF3BD49D,
HrvStressLevel.normal => 0xFF7B9BFB,
HrvStressLevel.attention => 0xFFFF9A6E,
HrvStressLevel.overload => 0xFFFF5279,
};
}
class HrvDayReport {
const HrvDayReport({
required this.date,
this.averageHrv,
this.averageHeartRate,
this.level,
});
final DateTime date;
final double? averageHrv;
final int? averageHeartRate;
final HrvStressLevel? level;
bool get hasData => averageHrv != null;
}
abstract interface class HrvPeriodReport {
List<HrvDayReport> get days;
int? get previousRelaxedDays;
int? get previousStressedDays;
List<HrvDayReport> get daysWithData;
bool get hasData;
int get validDays;
int countFor(HrvStressLevel level);
int get relaxedDays;
int get stressedDays;
HrvDayReport? get minDay;
HrvDayReport? get maxDay;
}
class WeeklyHrvReport implements HrvPeriodReport {
const WeeklyHrvReport({
required this.weekStart,
required this.days,
this.previousRelaxedDays,
this.previousStressedDays,
});
final DateTime weekStart;
@override
final List<HrvDayReport> days;
@override
final int? previousRelaxedDays;
@override
final int? previousStressedDays;
DateTime get weekEnd => weekStart.add(const Duration(days: 6));
@override
List<HrvDayReport> get daysWithData =>
days.where((day) => day.hasData).toList();
@override
bool get hasData => daysWithData.isNotEmpty;
@override
int get validDays => daysWithData.length;
@override
int countFor(HrvStressLevel level) =>
daysWithData.where((day) => day.level == level).length;
@override
int get relaxedDays => countFor(HrvStressLevel.excellent);
@override
int get stressedDays => countFor(HrvStressLevel.overload);
@override
HrvDayReport? get minDay => _extreme(false);
@override
HrvDayReport? get maxDay => _extreme(true);
HrvDayReport? _extreme(bool maximum) {
if (daysWithData.isEmpty) return null;
return daysWithData.reduce((current, next) {
final selected = current.averageHrv!;
final candidate = next.averageHrv!;
return maximum
? (candidate > selected ? next : current)
: (candidate < selected ? next : current);
});
}
factory WeeklyHrvReport.empty(DateTime weekStart) => WeeklyHrvReport(
weekStart: weekStart,
days: [
for (var i = 0; i < 7; i++)
HrvDayReport(date: weekStart.add(Duration(days: i))),
],
);
}
class MonthlyHrvReport implements HrvPeriodReport {
const MonthlyHrvReport({
required this.monthStart,
required this.days,
this.previousRelaxedDays,
this.previousStressedDays,
this.validDayCount,
});
final DateTime monthStart;
@override
final List<HrvDayReport> days;
@override
final int? previousRelaxedDays;
@override
final int? previousStressedDays;
final int? validDayCount;
DateTime get monthEnd => DateTime(monthStart.year, monthStart.month + 1, 0);
@override
List<HrvDayReport> get daysWithData =>
days.where((day) => day.hasData).toList();
@override
bool get hasData => daysWithData.isNotEmpty;
@override
int get validDays => validDayCount ?? daysWithData.length;
@override
int countFor(HrvStressLevel level) =>
daysWithData.where((day) => day.level == level).length;
@override
int get relaxedDays => countFor(HrvStressLevel.excellent);
@override
int get stressedDays => countFor(HrvStressLevel.overload);
@override
HrvDayReport? get minDay => _extreme(false);
@override
HrvDayReport? get maxDay => _extreme(true);
HrvDayReport? _extreme(bool maximum) {
if (daysWithData.isEmpty) return null;
return daysWithData.reduce((current, next) {
final selected = current.averageHrv!;
final candidate = next.averageHrv!;
return maximum
? (candidate > selected ? next : current)
: (candidate < selected ? next : current);
});
}
factory MonthlyHrvReport.empty(DateTime monthStart) {
final start = DateTime(monthStart.year, monthStart.month);
final end = DateTime(start.year, start.month + 1, 0);
return MonthlyHrvReport(
monthStart: start,
days: [
for (var i = 0; i < end.day; i++)
HrvDayReport(date: start.add(Duration(days: i))),
],
);
}
}
class YearlyHrvReport implements HrvPeriodReport {
const YearlyHrvReport({
required this.year,
required this.days,
this.validDayCount,
});
final int year;
@override
final List<HrvDayReport> days;
final int? validDayCount;
@override
int? get previousRelaxedDays => null;
@override
int? get previousStressedDays => null;
@override
List<HrvDayReport> get daysWithData =>
days.where((day) => day.hasData).toList();
@override
bool get hasData => daysWithData.isNotEmpty;
@override
int get validDays => validDayCount ?? daysWithData.length;
@override
int countFor(HrvStressLevel level) =>
daysWithData.where((day) => day.level == level).length;
@override
int get relaxedDays => countFor(HrvStressLevel.excellent);
@override
int get stressedDays => countFor(HrvStressLevel.overload);
@override
HrvDayReport? get minDay => _extreme(false);
@override
HrvDayReport? get maxDay => _extreme(true);
List<HrvDayReport> daysForMonth(int month) =>
days.where((day) => day.date.month == month).toList();
double? averageForMonth(int month) {
final values = daysForMonth(month)
.map((day) => day.averageHrv)
.whereType<double>()
.toList();
if (values.isEmpty) return null;
return values.reduce((sum, value) => sum + value) / values.length;
}
List<int> monthsAtExtreme({required bool maximum}) {
final values = <int, double>{
for (var month = 1; month <= 12; month++)
if (averageForMonth(month) case final value?) month: value,
};
if (values.isEmpty) return const [];
final target = maximum
? values.values.reduce((a, b) => a > b ? a : b)
: values.values.reduce((a, b) => a < b ? a : b);
return values.entries
.where((entry) => (entry.value - target).abs() < .01)
.map((entry) => entry.key)
.toList();
}
HrvDayReport? _extreme(bool maximum) {
if (daysWithData.isEmpty) return null;
return daysWithData.reduce((current, next) {
final selected = current.averageHrv!;
final candidate = next.averageHrv!;
return maximum
? (candidate > selected ? next : current)
: (candidate < selected ? next : current);
});
}
factory YearlyHrvReport.empty(int year) => YearlyHrvReport(
year: year,
days: [
for (var i = 0;
i < DateTime(year + 1).difference(DateTime(year)).inDays;
i++)
HrvDayReport(date: DateTime(year).add(Duration(days: i))),
],
);
}
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../report_common/models/report_period.dart';
import '../../report_common/widgets/report_bottom_slogan.dart';
import '../../report_common/widgets/report_date_picker_sheet.dart';
import '../../report_common/widgets/report_date_switcher.dart';
import '../controllers/hrv_report_logic.dart';
import '../widgets/hrv_week_report_view.dart';
import '../widgets/hrv_year_report_view.dart';
class HrvReportView extends StatelessWidget {
const HrvReportView({super.key, required this.logic});
final HrvReportLogic logic;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.transparent,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
const SizedBox(height: 8),
Obx(
() => _HrvPeriodBar(
selectedPeriod: logic.selectedPeriod.value,
onChanged: logic.selectPeriod,
),
),
const SizedBox(height: 8),
Obx(() {
print("------, ${logic.dateLabel}");
return ReportDateSwitcher(
label: logic.dateLabel,
onPrevious: logic.previousDay,
onNext: logic.nextDay,
onTapLabel: () => _showDatePicker(context),
);
},
),
],
),
),
Expanded(
child: Obx(
() => ListView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
children: [
if (logic.selectedPeriod.value == ReportPeriod.year)
HrvYearReportView(report: logic.yearlyReport.value)
else if (logic.selectedPeriod.value == ReportPeriod.month)
HrvMonthReportView(report: logic.monthlyReport.value)
else
HrvWeekReportView(report: logic.weeklyReport.value),
const ReportBottomSlogan(),
],
),
),
),
],
),
);
}
Future<void> _showDatePicker(BuildContext context) async {
final picked = await showReportDatePickerSheet(
context: context,
period: logic.selectedPeriod.value,
selectedDate: logic.selectedDate.value,
weekStart: logic.weekStart,
);
if (picked != null) await logic.selectPickedDate(picked);
}
}
class _HrvPeriodBar extends StatelessWidget {
const _HrvPeriodBar({
required this.selectedPeriod,
required this.onChanged,
});
final ReportPeriod selectedPeriod;
final ValueChanged<ReportPeriod> onChanged;
@override
Widget build(BuildContext context) {
return Container(
height: 36,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFF845EEE).withValues(alpha: 0.20),
borderRadius: BorderRadius.circular(25),
),
child: Row(
children: [
for (final item in const [
(label: '周', period: ReportPeriod.week),
(label: '月', period: ReportPeriod.month),
(label: '年', period: ReportPeriod.year),
])
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(item.period),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: selectedPeriod == item.period
? Colors.white
: Colors.transparent,
borderRadius: BorderRadius.circular(
selectedPeriod == item.period ? 26 : 8,
),
),
child: Text(
item.label,
style: TextStyle(
color: selectedPeriod == item.period
? const Color(0xFF0F0F11)
: const Color(0xFF78787D),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
),
);
}
}
... ...
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../models/hrv_report_models.dart';
class HrvWeekReportView extends StatelessWidget {
const HrvWeekReportView({super.key, required this.report});
final WeeklyHrvReport? report;
@override
Widget build(BuildContext context) {
final data = report ?? WeeklyHrvReport.empty(DateTime(2026, 5, 4));
return _HrvPeriodReportView(report: data, isMonth: false);
}
}
class HrvMonthReportView extends StatelessWidget {
const HrvMonthReportView({super.key, required this.report});
final MonthlyHrvReport? report;
@override
Widget build(BuildContext context) {
final data = report ?? MonthlyHrvReport.empty(DateTime(2026, 5));
return _HrvPeriodReportView(report: data, isMonth: true);
}
}
class _HrvPeriodReportView extends StatelessWidget {
const _HrvPeriodReportView({required this.report, required this.isMonth});
final HrvPeriodReport report;
final bool isMonth;
@override
Widget build(BuildContext context) {
return Column(
children: [
_DailyStressCard(report: report, isMonth: isMonth),
const SizedBox(height: 12),
_DistributionCard(report: report),
],
);
}
}
class _DailyStressCard extends StatelessWidget {
const _DailyStressCard({required this.report, required this.isMonth});
final HrvPeriodReport report;
final bool isMonth;
@override
Widget build(BuildContext context) {
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'每日压力趋势',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
SizedBox(
height: isMonth ? 160 : 174,
child: _HrvBarChart(report: report, isMonth: isMonth),
),
const Divider(height: 25, color: Color(0xFFF3F3F3)),
Row(
children: [
Expanded(
child: _TrendMetric(
label: '较轻松',
value: report.hasData ? '${report.relaxedDays}' : '-',
previousValue: report.previousRelaxedDays,
currentValue: report.relaxedDays,
),
),
Expanded(
child: _TrendMetric(
label: '压力较大',
value: report.hasData ? '${report.stressedDays}' : '-',
previousValue: report.previousStressedDays,
currentValue: report.stressedDays,
),
),
],
),
],
),
);
}
}
class _HrvBarChart extends StatefulWidget {
const _HrvBarChart({required this.report, required this.isMonth});
final HrvPeriodReport report;
final bool isMonth;
@override
State<_HrvBarChart> createState() => _HrvBarChartState();
}
class _HrvBarChartState extends State<_HrvBarChart> {
int? _touchedIndex;
Offset? _touchedOffset;
@override
Widget build(BuildContext context) {
final bars = <BarChartGroupData>[];
for (var i = 0; i < widget.report.days.length; i++) {
final day = widget.report.days[i];
bars.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: day.averageHrv ?? 0,
width: widget.isMonth ? 4 : 13,
color: day.level == null
? Colors.transparent
: Color(day.level!.colorValue),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(7),
),
),
],
),
);
}
return Stack(
alignment: Alignment.center,
children: [
BarChart(
BarChartData(
minY: 0,
maxY: 110,
alignment: BarChartAlignment.spaceAround,
barGroups: bars,
borderData: FlBorderData(show: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 27.5,
getDrawingHorizontalLine: (_) => const FlLine(
color: Color(0xFFF3F3F3),
strokeWidth: 1,
dashArray: [2, 2],
),
),
titlesData: FlTitlesData(
topTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 31,
interval: 1,
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];
if (widget.isMonth &&
!const [1, 5, 10, 15, 20, 25, 31]
.contains(day.date.day)) {
return const SizedBox.shrink();
}
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
return Padding(
padding: const EdgeInsets.only(top: 5),
child: Text(
widget.isMonth
? '${day.date.day}'
: '${day.date.day}\n${weekdays[index]}',
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 9,
height: 1.35,
),
),
);
},
),
),
),
barTouchData: BarTouchData(
enabled: widget.report.hasData,
touchCallback: (event, response) {
final touchedIndex = response?.spot?.touchedBarGroupIndex;
final hasTouchedData = touchedIndex != null &&
touchedIndex >= 0 &&
touchedIndex < widget.report.days.length &&
widget.report.days[touchedIndex].averageHrv != null;
final nextIndex =
event.isInterestedForInteractions && hasTouchedData
? touchedIndex
: null;
final nextOffset =
nextIndex == null ? null : response?.spot?.offset;
if (_touchedIndex != nextIndex ||
_touchedOffset != nextOffset) {
setState(() {
_touchedIndex = nextIndex;
_touchedOffset = nextOffset;
});
}
},
touchTooltipData: BarTouchTooltipData(
getTooltipColor: (_) => const Color(0xFFF3F3F3),
tooltipRoundedRadius: 8,
tooltipBorder: BorderSide.none,
tooltipPadding: const EdgeInsets.fromLTRB(8, 7, 8, 6),
tooltipMargin: 2,
maxContentWidth: 128,
fitInsideHorizontally: true,
fitInsideVertically: false,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final day = widget.report.days[group.x];
if (day.averageHrv == null) return null;
return BarTooltipItem(
'${day.date.month}${day.date.day}\n',
const TextStyle(color: Color(0xFF78787D), fontSize: 10),
children: [
TextSpan(
text: '${day.level?.label ?? ''}\n',
style: TextStyle(
color: Color(
day.level?.colorValue ?? 0xFFB0B0B6,
),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
TextSpan(
text:
'${day.averageHrv?.round() ?? '-'}ms · ${day.averageHeartRate ?? '-'}bpm',
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 10,
),
),
],
);
},
),
),
),
),
if (_touchedOffset != null)
Positioned.fill(
child: ChartSelectionLineOverlay(
offset: _touchedOffset!,
color: const Color(0xFFB0B0B6),
bottomTitleHeight: 31,
),
),
if (!widget.report.hasData)
const Text(
'暂无数据',
style: TextStyle(color: Color(0xFFB0B0B6), fontSize: 11),
),
],
);
}
}
class _TrendMetric extends StatelessWidget {
const _TrendMetric({
required this.label,
required this.value,
required this.previousValue,
required this.currentValue,
});
final String label;
final String value;
final int? previousValue;
final int currentValue;
@override
Widget build(BuildContext context) {
final difference =
previousValue == null ? null : currentValue - previousValue!;
final comparison = difference == null
? '比上周少-天'
: difference == 0
? '与上周持平'
: '比上周${difference > 0 ? '多' : '少'}${difference.abs()}天';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 11)),
const SizedBox(height: 3),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(value,
style: const TextStyle(
fontSize: 25, fontWeight: FontWeight.w600, height: 1)),
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 2),
child: Text('天',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500)),
),
],
),
const SizedBox(height: 5),
Text(comparison,
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10)),
],
);
}
}
class _DistributionCard extends StatelessWidget {
const _DistributionCard({required this.report});
final HrvPeriodReport report;
@override
Widget build(BuildContext context) {
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('压力分布',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 17),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('HRV有效天数',
style:
TextStyle(color: Color(0xFFB0B0B6), fontSize: 11)),
const SizedBox(height: 2),
_ValueWithUnit(value: '${report.validDays}', unit: '天'),
const SizedBox(height: 18),
_DistributionGrid(report: report),
],
),
),
_DistributionBar(report: report),
const SizedBox(width: 20),
],
),
const Divider(height: 29, color: Color(0xFFF3F3F3)),
Row(
children: [
Expanded(child: _ExtremeHrv(title: '最低HRV', day: report.minDay)),
Expanded(child: _ExtremeHrv(title: '最高HRV', day: report.maxDay)),
],
),
],
),
);
}
}
class _DistributionGrid extends StatelessWidget {
const _DistributionGrid({required this.report});
final HrvPeriodReport report;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 22,
runSpacing: 13,
children: [
for (final level in HrvStressLevel.values)
SizedBox(
width: 74,
child: _DistributionItem(level: level, report: report)),
],
);
}
}
class _DistributionItem extends StatelessWidget {
const _DistributionItem({required this.level, required this.report});
final HrvStressLevel level;
final HrvPeriodReport report;
@override
Widget build(BuildContext context) {
final count = report.countFor(level);
final percent =
report.validDays == 0 ? 0 : (count / report.validDays * 100).round();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color: Color(level.colorValue), shape: BoxShape.circle)),
const SizedBox(width: 3),
Text(level.label,
style:
const TextStyle(fontSize: 10, fontWeight: FontWeight.w500)),
]),
const SizedBox(height: 3),
Text(report.hasData ? '$count天' : '等待数据',
style: TextStyle(
color: report.hasData
? const Color(0xFF0F0F11)
: const Color(0xFFB0B0B6),
fontSize: report.hasData ? 20 : 14,
fontWeight: FontWeight.w500)),
Text('$percent%',
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10)),
],
);
}
}
class _DistributionBar extends StatelessWidget {
const _DistributionBar({required this.report});
final HrvPeriodReport report;
@override
Widget build(BuildContext context) {
return Container(
width: 33,
height: 180,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: const Color(0xFFF3F3F3),
borderRadius: BorderRadius.circular(7)),
child: report.hasData
? Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (final level in HrvStressLevel.values)
if (report.countFor(level) > 0)
Expanded(
flex: report.countFor(level),
child: Container(
decoration: BoxDecoration(
color: Color(level.colorValue),
border: Border.all(color: Colors.white, width: .5),
),
),
),
],
)
: null,
);
}
}
class _ExtremeHrv extends StatelessWidget {
const _ExtremeHrv({required this.title, required this.day});
final String title;
final HrvDayReport? day;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500)),
const SizedBox(height: 5),
_ValueWithUnit(
value: day == null ? '-' : '${day!.averageHrv!.round()}',
unit: 'ms'),
const SizedBox(height: 3),
Text(
day == null ? '-月-日' : '${day!.date.month}${day!.date.day}日',
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10),
),
],
);
}
}
class _ValueWithUnit extends StatelessWidget {
const _ValueWithUnit({required this.value, required this.unit});
final String value;
final String unit;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(value,
style: const TextStyle(
fontSize: 25, fontWeight: FontWeight.w600, height: 1)),
Padding(
padding: const EdgeInsets.only(left: 3, bottom: 2),
child: Text(unit, style: const TextStyle(fontSize: 10)),
),
],
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 17, 16, 18),
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: child,
);
}
}
... ...
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/report_month_calendar_grid.dart';
import '../models/hrv_report_models.dart';
class HrvYearReportView extends StatelessWidget {
const HrvYearReportView({super.key, required this.report});
final YearlyHrvReport? report;
@override
Widget build(BuildContext context) {
final data = report ?? YearlyHrvReport.empty(2026);
return Column(
children: [
_YearTrendCard(report: data),
const SizedBox(height: 12),
_YearDistributionCard(report: data),
const SizedBox(height: 12),
_DailyDistributionCard(report: data),
],
);
}
}
class _YearTrendCard extends StatelessWidget {
const _YearTrendCard({required this.report});
final YearlyHrvReport report;
@override
Widget build(BuildContext context) {
final minimumMonths = report.monthsAtExtreme(maximum: false);
final maximumMonths = report.monthsAtExtreme(maximum: true);
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('每日压力趋势', style: _titleStyle),
const SizedBox(height: 12),
SizedBox(height: 174, child: _YearBarChart(report: report)),
const Divider(height: 25, color: _grid),
Row(
children: [
Expanded(
child: _MonthExtreme(
label: '压力最大',
months: minimumMonths,
),
),
Expanded(
child: _MonthExtreme(
label: '压力最小',
months: maximumMonths,
),
),
],
),
],
),
);
}
}
class _YearBarChart extends StatefulWidget {
const _YearBarChart({required this.report});
final YearlyHrvReport report;
@override
State<_YearBarChart> createState() => _YearBarChartState();
}
class _YearBarChartState extends State<_YearBarChart> {
int? _touchedMonth;
Offset? _touchedOffset;
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
BarChart(
BarChartData(
minY: 0,
maxY: 100,
alignment: BarChartAlignment.spaceAround,
barGroups: [
for (var month = 1; month <= 12; month++)
BarChartGroupData(
x: month,
barRods: [
BarChartRodData(
toY: widget.report.averageForMonth(month) ?? 0,
width: 8,
color:
_colorForValue(widget.report.averageForMonth(month)),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(5),
),
),
],
),
],
borderData: FlBorderData(show: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 25,
getDrawingHorizontalLine: (_) => const FlLine(
color: _grid,
dashArray: [2, 2],
),
),
titlesData: FlTitlesData(
topTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
interval: 1,
getTitlesWidget: (value, meta) {
const labels = [
'一',
'二',
'三',
'四',
'五',
'六',
'七',
'八',
'九',
'十',
'十一',
'十二'
];
final month = value.toInt();
if (month < 1 || month > 12) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(labels[month - 1],
style: const TextStyle(color: _h3, fontSize: 8)),
);
},
),
),
),
barTouchData: BarTouchData(
enabled: widget.report.hasData,
touchCallback: (event, response) {
final month = response?.spot?.touchedBarGroup.x;
final hasTouchedData = month != null &&
widget.report.averageForMonth(month) != null;
final nextMonth =
event.isInterestedForInteractions && hasTouchedData
? month
: null;
final nextOffset =
nextMonth == null ? null : response?.spot?.offset;
if (_touchedMonth != nextMonth ||
_touchedOffset != nextOffset) {
setState(() {
_touchedMonth = nextMonth;
_touchedOffset = nextOffset;
});
}
},
touchTooltipData: BarTouchTooltipData(
getTooltipColor: (_) => const Color(0xFFF3F3F3),
tooltipRoundedRadius: 8,
tooltipBorder: BorderSide.none,
tooltipPadding: const EdgeInsets.fromLTRB(8, 7, 8, 6),
tooltipMargin: 2,
maxContentWidth: 128,
fitInsideHorizontally: true,
fitInsideVertically: false,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
if (widget.report.averageForMonth(group.x) == null) {
return null;
}
return BarTooltipItem(
'${group.x}\n${rod.toY.round()}ms',
const TextStyle(
color: _h1,
fontSize: 11,
fontWeight: FontWeight.w500,
),
);
},
),
),
),
),
if (_touchedOffset != null)
Positioned.fill(
child: ChartSelectionLineOverlay(
offset: _touchedOffset!,
color: _h3,
bottomTitleHeight: 24,
),
),
if (!widget.report.hasData)
const Text('暂无数据', style: TextStyle(color: _h3, fontSize: 11)),
],
);
}
Color _colorForValue(double? value) {
if (value == null) return Colors.transparent;
if (value >= 75) return const Color(0xFF3BD49D);
if (value >= 55) return const Color(0xFF7B9BFB);
if (value >= 40) return const Color(0xFFFF9A6E);
return const Color(0xFFFF5279);
}
}
class _MonthExtreme extends StatelessWidget {
const _MonthExtreme({required this.label, required this.months});
final String label;
final List<int> months;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: _h3, fontSize: 11)),
const SizedBox(height: 5),
Text(
months.isEmpty
? '-月, -月'
: months.take(2).map((m) => '$m月').join(', '),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
],
);
}
}
class _YearDistributionCard extends StatelessWidget {
const _YearDistributionCard({required this.report});
final YearlyHrvReport report;
@override
Widget build(BuildContext context) {
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('压力分布', style: _titleStyle),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('HRV有效天数',
style: TextStyle(color: _h3, fontSize: 11)),
const SizedBox(height: 3),
_Number(value: '${report.validDays}', unit: '天'),
const SizedBox(height: 18),
Wrap(
spacing: 22,
runSpacing: 13,
children: [
for (final level in HrvStressLevel.values)
SizedBox(
width: 74,
child: _LevelItem(report: report, level: level)),
],
),
],
),
),
_StackedBar(report: report),
const SizedBox(width: 20),
],
),
],
),
);
}
}
class _DailyDistributionCard extends StatelessWidget {
const _DailyDistributionCard({required this.report});
final YearlyHrvReport report;
@override
Widget build(BuildContext context) {
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('每日压力分布', style: _titleStyle),
const SizedBox(height: 20),
GridView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
itemCount: 12,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 7,
mainAxisSpacing: 8,
childAspectRatio: 1,
),
itemBuilder: (context, index) => _MonthDots(
month: index + 1,
days: report.daysForMonth(index + 1),
),
),
const Divider(height: 25, color: _grid),
_DateExtreme(
label: '压力最大', days: _extremeDays(report, maximum: false)),
const SizedBox(height: 18),
_DateExtreme(label: '最轻松', days: _extremeDays(report, maximum: true)),
],
),
);
}
List<HrvDayReport> _extremeDays(YearlyHrvReport report,
{required bool maximum}) {
if (!report.hasData) return const [];
final target =
maximum ? report.maxDay!.averageHrv! : report.minDay!.averageHrv!;
return report.daysWithData
.where((day) => day.averageHrv == target)
.take(2)
.toList();
}
}
class _MonthDots extends StatelessWidget {
const _MonthDots({required this.month, required this.days});
final int month;
final List<HrvDayReport> days;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$month月', style: const TextStyle(fontSize: 8)),
const SizedBox(height: 8),
SizedBox(
width: 80,
child: ReportMonthCalendarGrid<HrvDayReport>(
monthStart: DateTime(days.first.date.year, month),
days: days,
dateOf: (day) => day.date,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
dayBuilder: (context, day) => DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: day.level == null
? const Color(0xFFF3F3F3)
: Color(day.level!.colorValue),
),
),
),
),
],
);
}
}
class _DateExtreme extends StatelessWidget {
const _DateExtreme({required this.label, required this.days});
final String label;
final List<HrvDayReport> days;
@override
Widget build(BuildContext context) {
final value = days.isEmpty
? '-月-日, -月-日'
: days.map((day) => '${day.date.month}${day.date.day}日').join(', ');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: _h3, fontSize: 10)),
const SizedBox(height: 6),
Text(value,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
],
);
}
}
class _LevelItem extends StatelessWidget {
const _LevelItem({required this.report, required this.level});
final YearlyHrvReport report;
final HrvStressLevel level;
@override
Widget build(BuildContext context) {
final count = report.countFor(level);
final percent =
report.validDays == 0 ? 0 : (count / report.validDays * 100).round();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: Color(level.colorValue), shape: BoxShape.circle)),
const SizedBox(width: 4),
Text(level.label,
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w500)),
]),
const SizedBox(height: 3),
Text(report.hasData ? '$count天' : '等待数据',
style: TextStyle(
color: report.hasData ? _h1 : _h3,
fontSize: report.hasData ? 19 : 13,
fontWeight: FontWeight.w500)),
Text('$percent%', style: const TextStyle(color: _h3, fontSize: 9)),
],
);
}
}
class _StackedBar extends StatelessWidget {
const _StackedBar({required this.report});
final YearlyHrvReport report;
@override
Widget build(BuildContext context) {
return Container(
width: 33,
height: 180,
clipBehavior: Clip.antiAlias,
decoration:
BoxDecoration(color: _grid, borderRadius: BorderRadius.circular(7)),
child: report.hasData
? Column(
children: [
for (final level in HrvStressLevel.values)
Expanded(
flex: report.countFor(level),
child: Container(color: Color(level.colorValue)),
),
],
)
: null,
);
}
}
class _Number extends StatelessWidget {
const _Number({required this.value, required this.unit});
final String value;
final String unit;
@override
Widget build(BuildContext context) => Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(value,
style: const TextStyle(
fontSize: 25, fontWeight: FontWeight.w600, height: 1)),
Padding(
padding: const EdgeInsets.only(left: 3, bottom: 2),
child: Text(unit, style: const TextStyle(fontSize: 10))),
],
);
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 17, 16, 18),
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: child,
);
}
const _titleStyle = TextStyle(fontSize: 15, fontWeight: FontWeight.w600);
const _h1 = Color(0xFF0F0F11);
const _h3 = Color(0xFFB0B0B6);
const _grid = Color(0xFFF3F3F3);
... ...
... ... @@ -3,7 +3,7 @@ import 'package:intl/intl.dart';
import '../models/report_period.dart';
abstract class ReportPeriodController extends GetxController {
abstract class ReportPeriodLogic {
final selectedPeriod = ReportPeriod.day.obs;
final selectedDate = DateTime(2026, 5, 12).obs;
final isLoading = false.obs;
... ... @@ -40,6 +40,9 @@ abstract class ReportPeriodController extends GetxController {
if (selectedPeriod.value == ReportPeriod.month) {
return DateFormat('M月').format(monthStart);
}
if (selectedPeriod.value == ReportPeriod.year) {
return '${selectedDate.value.year}年';
}
return DateFormat('M月d日').format(selectedDate.value);
}
... ... @@ -71,11 +74,16 @@ abstract class ReportPeriodController extends GetxController {
if (selectedPeriod.value == ReportPeriod.month) {
return selectMonth(date.year, date.month);
}
if (selectedPeriod.value == ReportPeriod.year) {
return selectDate(DateTime(date.year));
}
return selectDate(date);
}
Future<void> previousDay() {
if (selectedPeriod.value == ReportPeriod.month) {
if (selectedPeriod.value == ReportPeriod.year) {
selectedDate.value = DateTime(selectedDate.value.year - 1);
} else if (selectedPeriod.value == ReportPeriod.month) {
final date = selectedDate.value;
selectedDate.value = DateTime(date.year, date.month - 1, 1);
} else {
... ... @@ -87,7 +95,9 @@ abstract class ReportPeriodController extends GetxController {
}
Future<void> nextDay() {
if (selectedPeriod.value == ReportPeriod.month) {
if (selectedPeriod.value == ReportPeriod.year) {
selectedDate.value = DateTime(selectedDate.value.year + 1);
} else if (selectedPeriod.value == ReportPeriod.month) {
final date = selectedDate.value;
selectedDate.value = DateTime(date.year, date.month + 1, 1);
} else {
... ... @@ -100,6 +110,8 @@ abstract class ReportPeriodController extends GetxController {
Future<void> loadReport();
void dispose() {}
String _formatWeekRangeDate(DateTime date) {
final prefix = date.year == DateTime.now().year ? '' : '${date.year}年';
return '$prefix${DateFormat('M月d日').format(date)}';
... ...
import 'package:flutter/foundation.dart';
@immutable
class HealthReportQuery {
const HealthReportQuery({
this.targetUserId,
});
final int? targetUserId;
bool get isSelf => targetUserId == null;
HealthReportQuery copyWith({
ValueGetter<int?>? targetUserId,
}) {
return HealthReportQuery(
targetUserId: targetUserId == null ? this.targetUserId : targetUserId(),
);
}
@override
bool operator ==(Object other) {
return other is HealthReportQuery && other.targetUserId == targetUserId;
}
@override
int get hashCode => targetUserId.hashCode;
}
... ...
... ... @@ -2,6 +2,7 @@ enum ReportPeriod {
day,
week,
month,
year,
}
extension ReportPeriodLabel on ReportPeriod {
... ... @@ -13,6 +14,8 @@ extension ReportPeriodLabel on ReportPeriod {
return '周';
case ReportPeriod.month:
return '月';
case ReportPeriod.year:
return '年';
}
}
}
... ...
import 'package:flutter/material.dart';
class ChartSelectionLineOverlay extends StatelessWidget {
const ChartSelectionLineOverlay({
super.key,
required this.offset,
required this.color,
required this.bottomTitleHeight,
this.tooltipMargin = 2,
});
final Offset offset;
final Color color;
final double bottomTitleHeight;
final double tooltipMargin;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: CustomPaint(
painter: _ChartSelectionLinePainter(
offset: offset,
color: color,
bottomTitleHeight: bottomTitleHeight,
tooltipMargin: tooltipMargin,
),
),
);
}
}
class _ChartSelectionLinePainter extends CustomPainter {
const _ChartSelectionLinePainter({
required this.offset,
required this.color,
required this.bottomTitleHeight,
required this.tooltipMargin,
});
final Offset offset;
final Color color;
final double bottomTitleHeight;
final double tooltipMargin;
@override
void paint(Canvas canvas, Size size) {
final plotHeight =
(size.height - bottomTitleHeight).clamp(0, size.height).toDouble();
if (plotHeight <= 0) return;
final lineX = offset.dx.clamp(0, size.width).toDouble();
final lineTop = (offset.dy - tooltipMargin).clamp(0, plotHeight).toDouble();
if (lineTop >= plotHeight) return;
canvas.drawLine(
Offset(lineX, lineTop),
Offset(lineX, plotHeight),
Paint()
..color = color
..strokeWidth = 2,
);
}
@override
bool shouldRepaint(covariant _ChartSelectionLinePainter oldDelegate) {
return oldDelegate.offset != offset ||
oldDelegate.color != color ||
oldDelegate.bottomTitleHeight != bottomTitleHeight ||
oldDelegate.tooltipMargin != tooltipMargin;
}
}
... ...
import 'package:flutter/material.dart';
class ReportBottomSlogan extends StatelessWidget {
const ReportBottomSlogan({
super.key,
this.topSpacing = 28,
this.bottomSpacing = 16,
});
final double topSpacing;
final double bottomSpacing;
@override
Widget build(BuildContext context) {
var safeBottom = MediaQuery.of(context).padding.bottom;
return Padding(
padding:
EdgeInsets.only(top: topSpacing, bottom: bottomSpacing + safeBottom),
child: Center(
child: Image.asset(
'assets/images/common/ic_bottom_slogan.png',
width: 223,
height: 35,
),
),
);
}
}
... ...
... ... @@ -27,11 +27,89 @@ Future<DateTime?> showReportDatePickerSheet({
return _WeekDatePickerSheet(initialWeekStart: weekStart);
case ReportPeriod.month:
return _MonthDatePickerSheet(initialDate: selectedDate);
case ReportPeriod.year:
return _YearDatePickerSheet(initialDate: selectedDate);
}
},
);
}
class _YearDatePickerSheet extends StatefulWidget {
const _YearDatePickerSheet({required this.initialDate});
final DateTime initialDate;
@override
State<_YearDatePickerSheet> createState() => _YearDatePickerSheetState();
}
class _YearDatePickerSheetState extends State<_YearDatePickerSheet> {
static const _itemExtent = 42.0;
late int _year;
late final _DatePickerYearRange _yearRange;
late final FixedExtentScrollController _yearController;
@override
void initState() {
super.initState();
_yearRange = _DatePickerYearRange.current();
_year = widget.initialDate.year.clamp(_yearRange.first, _yearRange.last);
_yearController =
FixedExtentScrollController(initialItem: _year - _yearRange.first);
}
@override
void dispose() {
_yearController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: SizedBox(
height: 371,
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
child: Column(
children: [
const _PickerHeader(titleFontSize: 16),
const SizedBox(height: 40),
SizedBox(
height: 176,
child: Stack(
alignment: Alignment.center,
children: [
const _SelectionFrame(fillWhite: true),
_AdjacentPicker(
controller: _yearController,
count: _yearRange.count,
selectedIndex: _year - _yearRange.first,
itemExtent: _itemExtent,
labelBuilder: (index) =>
'${_yearRange.first + index} 年',
onSelectedItemChanged: (index) {
setState(() => _year = _yearRange.first + index);
},
),
],
),
),
const Spacer(),
_ConfirmButton(
width: 280,
onTap: () => Navigator.of(context).pop(DateTime(_year)),
),
],
),
),
),
);
}
}
class _DayDatePickerSheet extends StatefulWidget {
const _DayDatePickerSheet({required this.initialDate});
... ...
import 'package:flutter/material.dart';
class ReportMonthCalendarGrid<T> extends StatelessWidget {
const ReportMonthCalendarGrid({
super.key,
required this.monthStart,
required this.days,
required this.dateOf,
required this.dayBuilder,
this.mainAxisSpacing = 8,
this.crossAxisSpacing = 8,
this.childAspectRatio = 1,
this.shrinkWrap = true,
});
final DateTime monthStart;
final List<T> days;
final DateTime Function(T day) dateOf;
final Widget Function(BuildContext context, T day) dayBuilder;
final double mainAxisSpacing;
final double crossAxisSpacing;
final double childAspectRatio;
final bool shrinkWrap;
@override
Widget build(BuildContext context) {
final leading = monthStart.weekday - DateTime.monday;
final cells = <T?>[
for (var i = 0; i < leading; i++) null,
...days,
];
while (cells.length % DateTime.daysPerWeek != 0) {
cells.add(null);
}
return GridView.builder(
padding: EdgeInsets.zero,
shrinkWrap: shrinkWrap,
physics: const NeverScrollableScrollPhysics(),
itemCount: cells.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: DateTime.daysPerWeek,
mainAxisSpacing: mainAxisSpacing,
crossAxisSpacing: crossAxisSpacing,
childAspectRatio: childAspectRatio,
),
itemBuilder: (context, index) {
final day = cells[index];
if (day == null) return const SizedBox.shrink();
return KeyedSubtree(
key: ValueKey(dateOf(day)),
child: dayBuilder(context, day),
);
},
);
}
}
... ...
... ... @@ -20,7 +20,11 @@ class ReportPeriodTabBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
const periods = ReportPeriod.values;
const periods = [
ReportPeriod.day,
ReportPeriod.week,
ReportPeriod.month,
];
return Container(
height: 36,
... ...
import 'package:get/get.dart';
import '../../report_common/controllers/report_period_controller.dart';
import '../../report_common/controllers/report_period_logic.dart';
import '../../report_common/models/report_period.dart';
import '../data/sleep_report_datasource.dart';
import '../data/sleep_report_repository.dart';
import '../models/sleep_report_models.dart';
class SleepReportController extends ReportPeriodController {
SleepReportController();
class SleepReportLogic extends ReportPeriodLogic {
SleepReportLogic({int? initialTargetUserId}) {
targetUserId.value = initialTargetUserId;
}
final targetUserId = RxnInt();
final SleepReportRepository repository = const SleepReportRepositoryImpl(
dataSource: MockSleepReportDataSource(),
... ... @@ -18,12 +22,6 @@ class SleepReportController extends ReportPeriodController {
final monthlyReport = Rxn<MonthlySleepReport>();
@override
void onInit() {
super.onInit();
loadDailyReport();
}
@override
Future<void> loadReport() {
if (selectedPeriod.value == ReportPeriod.week) {
return loadWeeklyReport();
... ... @@ -37,7 +35,10 @@ class SleepReportController extends ReportPeriodController {
Future<void> loadDailyReport() async {
isLoading.value = true;
try {
report.value = await repository.getDailyReport(selectedDate.value);
report.value = await repository.getDailyReport(
selectedDate.value,
targetUserId: targetUserId.value,
);
} finally {
isLoading.value = false;
}
... ... @@ -46,7 +47,10 @@ class SleepReportController extends ReportPeriodController {
Future<void> loadWeeklyReport() async {
isLoading.value = true;
try {
weeklyReport.value = await repository.getWeeklyReport(weekStart);
weeklyReport.value = await repository.getWeeklyReport(
weekStart,
targetUserId: targetUserId.value,
);
} finally {
isLoading.value = false;
}
... ... @@ -56,9 +60,18 @@ class SleepReportController extends ReportPeriodController {
isLoading.value = true;
monthlyReport.value = null;
try {
monthlyReport.value = await repository.getMonthlyReport(monthStart);
monthlyReport.value = await repository.getMonthlyReport(
monthStart,
targetUserId: targetUserId.value,
);
} finally {
isLoading.value = false;
}
}
Future<void> updateTargetUserId(int? userId) {
if (userId == targetUserId.value) return Future.value();
targetUserId.value = userId;
return loadReport();
}
}
... ...
import '../models/sleep_report_models.dart';
abstract class SleepReportDataSource {
Future<SleepReport> fetchDailyReport(DateTime date);
Future<SleepReport> fetchDailyReport(
DateTime date, {
int? targetUserId,
});
Future<WeeklySleepReport> fetchWeeklyReport(DateTime weekStart);
Future<WeeklySleepReport> fetchWeeklyReport(
DateTime weekStart, {
int? targetUserId,
});
Future<MonthlySleepReport> fetchMonthlyReport(DateTime monthStart);
Future<MonthlySleepReport> fetchMonthlyReport(
DateTime monthStart, {
int? targetUserId,
});
}
class MockSleepReportDataSource implements SleepReportDataSource {
const MockSleepReportDataSource();
@override
Future<SleepReport> fetchDailyReport(DateTime date) async {
Future<SleepReport> fetchDailyReport(
DateTime date, {
int? targetUserId,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
if (date.day == 13) {
... ... @@ -43,7 +55,10 @@ class MockSleepReportDataSource implements SleepReportDataSource {
}
@override
Future<WeeklySleepReport> fetchWeeklyReport(DateTime weekStart) async {
Future<WeeklySleepReport> fetchWeeklyReport(
DateTime weekStart, {
int? targetUserId,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
final normalizedStart = DateTime(
... ... @@ -99,7 +114,10 @@ class MockSleepReportDataSource implements SleepReportDataSource {
}
@override
Future<MonthlySleepReport> fetchMonthlyReport(DateTime monthStart) async {
Future<MonthlySleepReport> fetchMonthlyReport(
DateTime monthStart, {
int? targetUserId,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
final normalizedStart = DateTime(monthStart.year, monthStart.month);
... ...
... ... @@ -4,11 +4,20 @@ import 'sleep_detection_engine.dart';
import 'sleep_report_datasource.dart';
abstract class SleepReportRepository {
Future<SleepReport> getDailyReport(DateTime date);
Future<SleepReport> getDailyReport(
DateTime date, {
int? targetUserId,
});
Future<WeeklySleepReport> getWeeklyReport(DateTime weekStart);
Future<WeeklySleepReport> getWeeklyReport(
DateTime weekStart, {
int? targetUserId,
});
Future<MonthlySleepReport> getMonthlyReport(DateTime monthStart);
Future<MonthlySleepReport> getMonthlyReport(
DateTime monthStart, {
int? targetUserId,
});
SleepDetectionResult detectSleepState(SleepDetectionInput input);
}
... ... @@ -23,18 +32,30 @@ class SleepReportRepositoryImpl implements SleepReportRepository {
});
@override
Future<SleepReport> getDailyReport(DateTime date) {
return dataSource.fetchDailyReport(date);
Future<SleepReport> getDailyReport(
DateTime date, {
int? targetUserId,
}) {
return dataSource.fetchDailyReport(date, targetUserId: targetUserId);
}
@override
Future<WeeklySleepReport> getWeeklyReport(DateTime weekStart) {
return dataSource.fetchWeeklyReport(weekStart);
Future<WeeklySleepReport> getWeeklyReport(
DateTime weekStart, {
int? targetUserId,
}) {
return dataSource.fetchWeeklyReport(weekStart, targetUserId: targetUserId);
}
@override
Future<MonthlySleepReport> getMonthlyReport(DateTime monthStart) {
return dataSource.fetchMonthlyReport(monthStart);
Future<MonthlySleepReport> getMonthlyReport(
DateTime monthStart, {
int? targetUserId,
}) {
return dataSource.fetchMonthlyReport(
monthStart,
targetUserId: targetUserId,
);
}
@override
... ...
... ... @@ -2,10 +2,11 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../report_common/models/report_period.dart';
import '../../report_common/widgets/report_bottom_slogan.dart';
import '../../report_common/widgets/report_date_picker_sheet.dart';
import '../../report_common/widgets/report_date_switcher.dart';
import '../../report_common/widgets/report_period_tab_bar.dart';
import '../controllers/sleep_report_controller.dart';
import '../controllers/sleep_report_logic.dart';
import '../models/sleep_report_models.dart';
import '../widgets/sleep_heart_rate_card.dart';
import '../widgets/sleep_quality_dialog.dart';
... ... @@ -13,15 +14,18 @@ import '../widgets/sleep_quality_ring.dart';
import '../widgets/sleep_summary_card.dart';
import '../widgets/sleep_week_report_view.dart';
class SleepReportView extends GetView<SleepReportController> {
const SleepReportView({super.key});
class SleepReportView extends StatelessWidget {
const SleepReportView({
super.key,
required this.logic,
});
static const _bg = Color(0xFFF5F2FF);
final SleepReportLogic logic;
@override
Widget build(BuildContext context) {
return Container(
color: _bg,
color: Colors.transparent,
child: Stack(
children: [
Column(
... ... @@ -33,12 +37,12 @@ class SleepReportView extends GetView<SleepReportController> {
const SizedBox(height: 8),
Obx(
() => ReportPeriodTabBar(
selectedPeriod: controller.selectedPeriod.value,
onChanged: controller.selectPeriod,
selectedPeriod: logic.selectedPeriod.value,
onChanged: logic.selectPeriod,
),
),
const SizedBox(height: 8),
_DateSwitcher(controller: controller),
_DateSwitcher(logic: logic),
],
),
),
... ... @@ -52,21 +56,21 @@ class SleepReportView extends GetView<SleepReportController> {
delegate: SliverChildListDelegate([
Obx(
() {
if (controller.selectedPeriod.value ==
if (logic.selectedPeriod.value ==
ReportPeriod.week) {
return SleepWeekReportView(
report: controller.weeklyReport.value,
report: logic.weeklyReport.value,
);
}
if (controller.selectedPeriod.value ==
if (logic.selectedPeriod.value ==
ReportPeriod.month) {
return SleepMonthReportView(
monthStart: controller.monthStart,
report: controller.monthlyReport.value,
monthStart: logic.monthStart,
report: logic.monthlyReport.value,
);
}
final report = controller.report.value;
final report = logic.report.value;
final quality = report?.qualityLevel ??
SleepQualityLevel.unknown;
return Column(
... ... @@ -98,7 +102,7 @@ class SleepReportView extends GetView<SleepReportController> {
);
},
),
const SizedBox(height: 108),
const ReportBottomSlogan(),
]),
),
),
... ... @@ -114,17 +118,17 @@ class SleepReportView extends GetView<SleepReportController> {
}
class _DateSwitcher extends StatelessWidget {
const _DateSwitcher({required this.controller});
const _DateSwitcher({required this.logic});
final SleepReportController controller;
final SleepReportLogic logic;
@override
Widget build(BuildContext context) {
return Obx(
() => ReportDateSwitcher(
label: controller.dateLabel,
onPrevious: controller.previousDay,
onNext: controller.nextDay,
label: logic.dateLabel,
onPrevious: logic.previousDay,
onNext: logic.nextDay,
onTapLabel: () => _showDatePicker(context),
),
);
... ... @@ -133,12 +137,12 @@ class _DateSwitcher extends StatelessWidget {
Future<void> _showDatePicker(BuildContext context) async {
final picked = await showReportDatePickerSheet(
context: context,
period: controller.selectedPeriod.value,
selectedDate: controller.selectedDate.value,
weekStart: controller.weekStart,
period: logic.selectedPeriod.value,
selectedDate: logic.selectedDate.value,
weekStart: logic.weekStart,
);
if (picked != null) {
await controller.selectPickedDate(picked);
await logic.selectPickedDate(picked);
}
}
}
... ...
... ... @@ -693,6 +693,10 @@ BarTouchData _barTouchData({
return BarTouchData(
handleBuiltInTouches: true,
touchCallback: (event, response) {
if (!event.isInterestedForInteractions) {
onSelected(null, null);
return;
}
final index = response?.spot?.touchedBarGroupIndex;
if (index == null ||
index < 0 ||
... ...
... ... @@ -9,7 +9,9 @@ import '../modules/feedback/feedback_list/bindings/feedback_list_binding.dart';
import '../modules/feedback/feedback_list/views/feedback_list_view.dart';
import '../modules/friends/bindings/add_friend_binding.dart';
import '../modules/friends/bindings/select_friend_binding.dart';
import '../modules/friends/bindings/friend_trend_binding.dart';
import '../modules/friends/views/add_friend_view.dart';
import '../modules/friends/views/friend_trend_view.dart';
import '../modules/friends/views/select_friend_view.dart';
import '../modules/help/bindings/help_binding.dart';
import '../modules/help/views/help_view.dart';
... ... @@ -111,6 +113,11 @@ abstract final class AppPages {
binding: SelectFriendBinding(),
),
GetPage(
name: Routes.FRIEND_TREND,
page: () => const FriendTrendView(),
binding: FriendTrendBinding(),
),
GetPage(
name: Routes.ROUTE_LIST,
page: () => const RouteListView(),
binding: PurchaseBinding(),
... ...
... ... @@ -19,6 +19,7 @@ abstract class Routes {
static const WATCH_THEME_CREATE = _Paths.WATCH_THEME_CREATE;
static const WATCH_THEME_CUSTOM_PREVIEW = _Paths.WATCH_THEME_CUSTOM_PREVIEW;
static const ACCOUNT_SETTINGS = _Paths.ACCOUNT_SETTINGS;
static const FRIEND_TREND = _Paths.FRIEND_TREND;
}
abstract class _Paths {
... ... @@ -39,4 +40,5 @@ abstract class _Paths {
static const WATCH_THEME_CREATE = '/watch-theme/create';
static const WATCH_THEME_CUSTOM_PREVIEW = '/watch-theme/custom-preview';
static const ACCOUNT_SETTINGS = '/account-settings';
static const FRIEND_TREND = '/friend-trend';
}
... ...
... ... @@ -145,6 +145,7 @@ class HealthApi {
required bool isOther,
required int dateRangeType,
required int startDate,
int? targetUserId,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
... ... @@ -156,6 +157,7 @@ class HealthApi {
'is_other': isOther ? 1 : 0,
'date_range_type': dateRangeType,
'start_date': startDate,
if (targetUserId != null) 'user_id': targetUserId,
},
);
return SleepStatisticsData.fromJson(
... ... @@ -169,6 +171,7 @@ class HealthApi {
required bool isOther,
required int dateRangeType,
required int startDate,
int? targetUserId,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
... ... @@ -180,6 +183,7 @@ class HealthApi {
'is_other': isOther ? 1 : 0,
'date_range_type': dateRangeType,
'start_date': startDate,
if (targetUserId != null) 'user_id': targetUserId,
},
);
return ActivityBurnStatisticsData.fromJson(
... ... @@ -194,6 +198,7 @@ class HealthApi {
required bool isOther,
required int dateRangeType,
required int startDate,
int? targetUserId,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
... ... @@ -205,6 +210,7 @@ class HealthApi {
'is_other': isOther ? 1 : 0,
'date_range_type': dateRangeType,
'start_date': startDate,
if (targetUserId != null) 'user_id': targetUserId,
},
);
return HrvStatisticsData.fromJson(
... ...
... ... @@ -18,6 +18,8 @@ class R {
static final String assetsImagesCopyPurple =
'assets/images/common/ic_copy_purple.webp';
static final String assetsImagesProIcon = 'assets/images/common/ic_pro.webp';
static final String assetsImagesHealthBedIcon =
'assets/images/common/ic_health_bed.webp';
// friends
static final String assetsImagesFriendsFriendAddMeIllustration =
... ...