Commit bdad0c115786e47f94f4018d4741c02c19f1b5fb

Authored by 刘宏哲
1 parent 44701675

feat(app): add friends module

Showing 25 changed files with 858 additions and 336 deletions
No preview for this file type
... ... @@ -5,6 +5,7 @@ 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_loading_indicator.dart';
import '../../report_common/widgets/report_period_tab_bar.dart';
import '../controllers/activity_burn_report_logic.dart';
import '../models/activity_burn_report_models.dart';
... ... @@ -18,9 +19,13 @@ class ActivityBurnReportView extends StatelessWidget {
const ActivityBurnReportView({
super.key,
required this.logic,
required this.isVip,
required this.onSubscribe,
});
final ActivityBurnReportLogic logic;
final bool isVip;
final VoidCallback onSubscribe;
@override
Widget build(BuildContext context) {
... ... @@ -39,6 +44,10 @@ class ActivityBurnReportView extends StatelessWidget {
() => ReportPeriodTabBar(
selectedPeriod: logic.selectedPeriod.value,
onChanged: logic.selectPeriod,
lockedPeriods: isVip
? const {}
: const {ReportPeriod.week, ReportPeriod.month},
onLockedPeriodTap: (_) => onSubscribe(),
),
),
const SizedBox(height: 8),
... ... @@ -47,50 +56,39 @@ class ActivityBurnReportView extends StatelessWidget {
),
),
Expanded(
child: CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
Obx(
() {
child: Obx(
() {
if (logic.isLoading.value) {
return const ReportLoadingIndicator();
}
return CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
if (logic.selectedPeriod.value ==
ReportPeriod.week) {
return ActivityBurnWeekReportView(
ReportPeriod.week)
ActivityBurnWeekReportView(
report: logic.weeklyReport.value,
weekStart: logic.weekStart,
);
}
if (logic.selectedPeriod.value ==
ReportPeriod.month) {
return ActivityBurnMonthReportView(
)
else if (logic.selectedPeriod.value ==
ReportPeriod.month)
ActivityBurnMonthReportView(
report: logic.monthlyReport.value,
monthStart: logic.monthStart,
);
}
final report = logic.report.value;
return Column(
children: [
ActivityBurnRing(report: report),
const SizedBox(height: 24),
ActivityBurnSummaryCard(report: report),
const SizedBox(height: 12),
ActivityBurnHeartRateZoneCard(
summary: report?.heartRate ??
const ActivityBurnHeartRateSummary(),
),
],
);
},
)
else
_DailyReportContent(report: logic.report.value),
const ReportBottomSlogan(),
]),
),
const ReportBottomSlogan(),
]),
),
),
],
),
],
);
},
),
),
],
... ... @@ -101,6 +99,27 @@ class ActivityBurnReportView extends StatelessWidget {
}
}
class _DailyReportContent extends StatelessWidget {
const _DailyReportContent({required this.report});
final ActivityBurnReport? report;
@override
Widget build(BuildContext context) {
return Column(
children: [
ActivityBurnRing(report: report),
const SizedBox(height: 24),
ActivityBurnSummaryCard(report: report),
const SizedBox(height: 12),
ActivityBurnHeartRateZoneCard(
summary: report?.heartRate ?? const ActivityBurnHeartRateSummary(),
),
],
);
}
}
class _DateSwitcher extends StatelessWidget {
const _DateSwitcher({required this.logic});
... ...
... ... @@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../r.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../models/activity_burn_report_models.dart';
class ActivityBurnHeartRateZoneCard extends StatelessWidget {
... ... @@ -41,7 +42,10 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
summary.hasData ? '心率区间' : '实时心率',
HealthReportSubjectScope.titleOf(
context,
summary.hasData ? '心率区间' : '实时心率',
),
style: TextStyle(
color: _h1,
fontSize: 16,
... ...
... ... @@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../../report_common/widgets/report_month_calendar_grid.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_ring.dart';
... ... @@ -52,9 +53,9 @@ class _MonthlySummary extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'活动总消耗',
style: TextStyle(
Text(
HealthReportSubjectScope.titleOf(context, '活动总消耗'),
style: const TextStyle(
color: ActivityBurnMonthReportView._active,
fontSize: 12,
fontWeight: FontWeight.w600,
... ... @@ -101,14 +102,14 @@ class _MonthlySummary extends StatelessWidget {
Row(
children: [
_SummaryMetric(
title: '锻炼总时长',
title: HealthReportSubjectScope.titleOf(context, '锻炼总时长'),
value: hasData ? report.totalExerciseMinutes.toString() : '-',
unit: '分钟',
color: ActivityBurnMonthReportView._exercise,
),
const SizedBox(width: 34),
_SummaryMetric(
title: '站立总时长',
title: HealthReportSubjectScope.titleOf(context, '站立总时长'),
value: hasData ? report.totalStandHours.toString() : '-',
unit: '小时',
color: ActivityBurnMonthReportView._stand,
... ... @@ -416,9 +417,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'热量消耗趋势',
style: TextStyle(
Text(
HealthReportSubjectScope.titleOf(context, '热量消耗趋势'),
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 16,
fontWeight: FontWeight.w600,
... ...
... ... @@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_ring.dart';
... ... @@ -51,9 +52,9 @@ class _WeeklySummary extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'活动总消耗',
style: TextStyle(
Text(
HealthReportSubjectScope.titleOf(context, '活动总消耗'),
style: const TextStyle(
color: ActivityBurnWeekReportView._active,
fontSize: 12,
fontWeight: FontWeight.w600,
... ... @@ -102,7 +103,7 @@ class _WeeklySummary extends StatelessWidget {
Row(
children: [
_SummaryMetric(
title: '锻炼总时长',
title: HealthReportSubjectScope.titleOf(context, '锻炼总时长'),
value: report.hasData
? report.totalExerciseMinutes.toString()
: '-',
... ... @@ -111,7 +112,7 @@ class _WeeklySummary extends StatelessWidget {
),
const SizedBox(width: 34),
_SummaryMetric(
title: '站立总时长',
title: HealthReportSubjectScope.titleOf(context, '站立总时长'),
value: report.hasData ? report.totalStandHours.toString() : '-',
unit: '小时',
color: ActivityBurnWeekReportView._stand,
... ... @@ -393,9 +394,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'热量消耗趋势',
style: TextStyle(
Text(
HealthReportSubjectScope.titleOf(context, '热量消耗趋势'),
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 16,
fontWeight: FontWeight.w600,
... ...
import 'package:get/get.dart';
import '../controllers/friends_controller.dart';
import '../controllers/select_friend_controller.dart';
class SelectFriendBinding extends Bindings {
@override
void dependencies() {
if (!Get.isRegistered<FriendsController>()) {
Get.lazyPut<FriendsController>(() => FriendsController(), fenix: true);
}
Get.put(SelectFriendController(Get.find<FriendsController>()));
}
}
import 'dart:async';
import 'package:get/get.dart';
import 'friends_controller.dart';
class SelectFriendController extends GetxController {
SelectFriendController(this._friendsController);
class SelectFriendLogic {
SelectFriendLogic(this._friendsController);
final FriendsController _friendsController;
final selectedIndex = (-1).obs;
List<FriendHealthData> get friends => _friendsController.friends;
@override
void onInit() {
super.onInit();
unawaited(_prepareFriends());
}
Future<void> _prepareFriends() async {
Future<void> initialize() async {
if (friends.isEmpty) {
await _friendsController.loadFriends();
}
... ... @@ -33,9 +25,9 @@ class SelectFriendController extends GetxController {
selectedIndex.value = index;
}
Future<void> syncSelectedFriendToWatchFace() async {
bool syncSelectedFriendToWatchFace() {
final index = selectedIndex.value;
if (index < 0 || index >= friends.length) return;
if (index < 0 || index >= friends.length) return false;
final updated = List.generate(
friends.length,
... ... @@ -47,6 +39,8 @@ class SelectFriendController extends GetxController {
_friendsController.friends.assignAll(updated);
// TODO: Submit selected watch-face friend to the friend API when available.
Get.back<void>();
return true;
}
void dispose() {}
}
... ...
... ... @@ -7,6 +7,7 @@ import 'package:get/get.dart';
import '../controllers/friends_controller.dart';
import '../controllers/friend_trend_controller.dart';
import '../widgets/friend_health_card.dart';
import 'select_friend_view.dart';
class FriendsTab extends GetView<FriendsController> {
const FriendsTab({super.key});
... ... @@ -145,9 +146,26 @@ class FriendsTab extends GetView<FriendsController> {
case FriendCardAction.remove:
controller.showDeleteConfirmDialog(friend);
case FriendCardAction.watchFace:
Get.toNamed(Routes.SELECT_FRIEND);
_showSelectFriendSheet();
}
}
void _showSelectFriendSheet() {
final context = Get.context;
if (context == null) return;
final mediaQuery = MediaQuery.of(context);
Get.bottomSheet<void>(
SizedBox(
height: mediaQuery.size.height - mediaQuery.viewPadding.top,
child: SelectFriendView(friendsController: controller),
),
barrierColor: Colors.black.withValues(alpha: 0.7),
enableDrag: true,
isScrollControlled: true,
persistent: false,
);
}
}
class _FriendsHeader extends StatelessWidget {
... ...
... ... @@ -3,83 +3,102 @@ import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/select_friend_controller.dart';
import '../controllers/friends_controller.dart';
import '../controllers/select_friend_logic.dart';
import '../widgets/friend_health_card.dart';
class SelectFriendView extends GetView<SelectFriendController> {
const SelectFriendView({super.key});
class SelectFriendView extends StatefulWidget {
const SelectFriendView({super.key, required this.friendsController});
final FriendsController friendsController;
@override
State<SelectFriendView> createState() => _SelectFriendViewState();
}
class _SelectFriendViewState extends State<SelectFriendView> {
late final SelectFriendLogic _logic;
@override
void initState() {
super.initState();
_logic = SelectFriendLogic(widget.friendsController);
_logic.initialize();
}
@override
void dispose() {
_logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF000000).withValues(alpha: 0.70),
body: Column(
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFFF5F2FF),
borderRadius: BorderRadius.vertical(top: Radius.circular(12.dp)),
),
child: Stack(
children: [
SizedBox(height: MediaQuery.viewPaddingOf(context).top + 8.dp),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFFF5F2FF),
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.dp),
),
),
child: Stack(
children: [
Column(
children: [
_SelectFriendHeader(onClose: () => Get.back<void>()),
Expanded(
child: Obx(
() => ListView.separated(
padding: EdgeInsets.fromLTRB(
16.dp,
0,
16.dp,
118.dp + MediaQuery.viewPaddingOf(context).bottom,
),
itemBuilder: (context, index) {
final friend = controller.friends[index];
final isSelected =
controller.selectedIndex.value == index;
return FriendHealthCard(
name: friend.name,
updatedAt: friend.updatedAt,
sleepQualityScore: friend.sleepQualityScore,
steps: friend.steps,
statusText: friend.statusText,
stressValue: friend.stressValue,
isOnWatchFace: friend.isOnWatchFace,
isSelected: isSelected,
showCheckbox: true,
onTap: () => controller.selectFriendAt(index),
);
},
separatorBuilder: (_, __) =>
SizedBox(height: 12.dp),
itemCount: controller.friends.length,
),
),
),
],
),
Positioned(
left: 49.dp,
right: 49.dp,
bottom: 36.dp + MediaQuery.viewPaddingOf(context).bottom,
child: _SyncButton(
onTap: controller.syncSelectedFriendToWatchFace,
Column(
children: [
_SelectFriendHeader(onClose: () => Get.back<void>()),
Expanded(
child: Obx(() {
if (widget.friendsController.isLoading.value &&
_logic.friends.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
final selectedIndex = _logic.selectedIndex.value;
return ListView.separated(
padding: EdgeInsets.fromLTRB(
16.dp,
0,
16.dp,
118.dp + MediaQuery.viewPaddingOf(context).bottom,
),
),
],
itemBuilder: (context, index) {
final friend = _logic.friends[index];
final isSelected = selectedIndex == index;
return FriendHealthCard(
name: friend.name,
updatedAt: friend.updatedAt,
sleepQualityScore: friend.sleepQualityScore,
steps: friend.steps,
statusText: friend.statusText,
stressValue: friend.stressValue,
isOnWatchFace: friend.isOnWatchFace,
isSelected: isSelected,
showCheckbox: true,
onTap: () => _logic.selectFriendAt(index),
);
},
separatorBuilder: (_, __) => SizedBox(height: 12.dp),
itemCount: _logic.friends.length,
);
}),
),
),
],
),
Positioned(
left: 49.dp,
right: 49.dp,
bottom: 36.dp + MediaQuery.viewPaddingOf(context).bottom,
child: _SyncButton(onTap: _syncSelectedFriend),
),
],
),
);
}
void _syncSelectedFriend() {
if (_logic.syncSelectedFriendToWatchFace()) {
Get.back<void>();
}
}
}
class _SelectFriendHeader extends StatelessWidget {
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../../data/local/user_preferences_storage.dart';
import '../../../routes/app_pages.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 '../../report_common/widgets/health_report_subject_scope.dart';
import '../../sleep_report/controllers/sleep_report_logic.dart';
import '../../sleep_report/views/sleep_report_view.dart';
... ... @@ -61,34 +65,63 @@ class _HealthTrendContentState extends State<HealthTrendContent>
@override
Widget build(BuildContext context) {
return Column(
children: [
TrendTypeTabBar(tabController: _tabController),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_KeepAliveWrapper(
child: _HrvTrendSection(query: widget.query),
final userPrefs = Get.find<UserPreferencesStorage>();
return Obx(() {
final preferences = userPrefs.preferences.value;
final vipInfo = preferences.vipInfo;
final isVip = vipInfo?.isVip ?? false;
void openPurchase() => Get.toNamed(Routes.PURCHASE);
return HealthReportSubjectScope(
isSelf: widget.query.isSelf,
child: Column(
children: [
TrendTypeTabBar(tabController: _tabController),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_KeepAliveWrapper(
child: _HrvTrendSection(
query: widget.query,
isVip: isVip,
onSubscribe: openPurchase,
),
),
_KeepAliveWrapper(
child: _ActivityBurnTrendSection(
query: widget.query,
isVip: isVip,
onSubscribe: openPurchase,
),
),
_KeepAliveWrapper(
child: _SleepTrendSection(
query: widget.query,
isVip: isVip,
onSubscribe: openPurchase,
),
),
],
),
_KeepAliveWrapper(
child: _ActivityBurnTrendSection(query: widget.query),
),
_KeepAliveWrapper(
child: _SleepTrendSection(query: widget.query),
),
],
),
),
],
),
],
);
);
});
}
}
class _HrvTrendSection extends StatefulWidget {
const _HrvTrendSection({required this.query});
const _HrvTrendSection({
required this.query,
required this.isVip,
required this.onSubscribe,
});
final HealthReportQuery query;
final bool isVip;
final VoidCallback onSubscribe;
@override
State<_HrvTrendSection> createState() => _HrvTrendSectionState();
... ... @@ -119,13 +152,23 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
}
@override
Widget build(BuildContext context) => HrvReportView(logic: _logic);
Widget build(BuildContext context) => HrvReportView(
logic: _logic,
isVip: widget.isVip,
onSubscribe: widget.onSubscribe,
);
}
class _ActivityBurnTrendSection extends StatefulWidget {
const _ActivityBurnTrendSection({required this.query});
const _ActivityBurnTrendSection({
required this.query,
required this.isVip,
required this.onSubscribe,
});
final HealthReportQuery query;
final bool isVip;
final VoidCallback onSubscribe;
@override
State<_ActivityBurnTrendSection> createState() =>
... ... @@ -159,13 +202,23 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
}
@override
Widget build(BuildContext context) => ActivityBurnReportView(logic: _logic);
Widget build(BuildContext context) => ActivityBurnReportView(
logic: _logic,
isVip: widget.isVip,
onSubscribe: widget.onSubscribe,
);
}
class _SleepTrendSection extends StatefulWidget {
const _SleepTrendSection({required this.query});
const _SleepTrendSection({
required this.query,
required this.isVip,
required this.onSubscribe,
});
final HealthReportQuery query;
final bool isVip;
final VoidCallback onSubscribe;
@override
State<_SleepTrendSection> createState() => _SleepTrendSectionState();
... ... @@ -198,7 +251,11 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
}
@override
Widget build(BuildContext context) => SleepReportView(logic: _logic);
Widget build(BuildContext context) => SleepReportView(
logic: _logic,
isVip: widget.isVip,
onSubscribe: widget.onSubscribe,
);
}
class _KeepAliveWrapper extends StatefulWidget {
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../../r.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_loading_indicator.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});
const HrvReportView({
super.key,
required this.logic,
required this.isVip,
required this.onSubscribe,
});
final HrvReportLogic logic;
final bool isVip;
final VoidCallback onSubscribe;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.transparent,
child: Column(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
const SizedBox(height: 8),
Obx(
() => _HrvPeriodBar(
selectedPeriod: logic.selectedPeriod.value,
onChanged: logic.selectPeriod,
),
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,
lockedPeriods: isVip
? const {}
: const {ReportPeriod.month, ReportPeriod.year},
onLockedPeriodTap: (_) => onSubscribe(),
),
),
const SizedBox(height: 8),
Obx(
() => ReportDateSwitcher(
label: logic.dateLabel,
onPrevious: isVip ? logic.previousDay : onSubscribe,
onNext: isVip ? logic.nextDay : onSubscribe,
onTapLabel: isVip
? () => _showDatePicker(context)
: onSubscribe,
),
),
],
),
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(
() {
if (logic.isLoading.value) {
return const ReportLoadingIndicator();
}
return 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,
showExample: !isVip,
onSubscribe: onSubscribe,
),
const ReportBottomSlogan(),
],
);
},
),
],
),
),
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(),
],
),
),
],
),
if (!isVip)
Obx(
() => logic.selectedPeriod.value == ReportPeriod.week
? Positioned(
left: 0,
right: 0,
bottom: MediaQuery.paddingOf(context).bottom + 88,
child: Center(
child: _UnlockButton(onPressed: onSubscribe),
),
)
: const SizedBox.shrink(),
),
],
),
);
... ... @@ -78,14 +118,61 @@ class HrvReportView extends StatelessWidget {
}
}
class _UnlockButton extends StatelessWidget {
const _UnlockButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 240,
height: 48,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
elevation: 0,
shadowColor: Colors.transparent,
backgroundColor: const Color(0xFFFFDF51),
foregroundColor: const Color(0xFF0F0F11),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
R.assetsImagesProIcon,
width: 24,
height: 24,
color: const Color(0xFF0F0F11),
),
const SizedBox(width: 8),
const Text(
'立即解锁',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
],
),
),
);
}
}
class _HrvPeriodBar extends StatelessWidget {
const _HrvPeriodBar({
required this.selectedPeriod,
required this.onChanged,
required this.lockedPeriods,
required this.onLockedPeriodTap,
});
final ReportPeriod selectedPeriod;
final ValueChanged<ReportPeriod> onChanged;
final Set<ReportPeriod> lockedPeriods;
final ValueChanged<ReportPeriod> onLockedPeriodTap;
@override
Widget build(BuildContext context) {
... ... @@ -106,7 +193,13 @@ class _HrvPeriodBar extends StatelessWidget {
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(item.period),
onTap: () {
if (lockedPeriods.contains(item.period)) {
onLockedPeriodTap(item.period);
} else {
onChanged(item.period);
}
},
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
... ... @@ -117,15 +210,30 @@ class _HrvPeriodBar extends StatelessWidget {
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,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (lockedPeriods.contains(item.period)) ...[
Image.asset(
R.assetsImagesLockIcon,
width: 14,
height: 14,
color: const Color(0xFF845EEE),
colorBlendMode: BlendMode.srcIn,
),
const SizedBox(width: 2),
],
Text(
item.label,
style: TextStyle(
color: selectedPeriod == item.period
? const Color(0xFF0F0F11)
: const Color(0xFF78787D),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../../../r.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../models/hrv_report_models.dart';
class HrvWeekReportView extends StatelessWidget {
const HrvWeekReportView({super.key, required this.report});
const HrvWeekReportView({
super.key,
required this.report,
this.showExample = false,
this.onSubscribe,
});
final WeeklyHrvReport? report;
final bool showExample;
final VoidCallback? onSubscribe;
@override
Widget build(BuildContext context) {
final data = report ?? WeeklyHrvReport.empty(DateTime(2026, 5, 4));
return _HrvPeriodReportView(report: data, isMonth: false);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final data = report ??
WeeklyHrvReport.empty(
today.subtract(Duration(days: today.weekday - 1)),
);
return _HrvPeriodReportView(
report: data,
isMonth: false,
showExample: showExample,
onSubscribe: onSubscribe,
);
}
}
... ... @@ -23,44 +43,70 @@ class HrvMonthReportView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final data = report ?? MonthlyHrvReport.empty(DateTime(2026, 5));
final now = DateTime.now();
final data =
report ?? MonthlyHrvReport.empty(DateTime(now.year, now.month));
return _HrvPeriodReportView(report: data, isMonth: true);
}
}
class _HrvPeriodReportView extends StatelessWidget {
const _HrvPeriodReportView({required this.report, required this.isMonth});
const _HrvPeriodReportView({
required this.report,
required this.isMonth,
this.showExample = false,
this.onSubscribe,
});
final HrvPeriodReport report;
final bool isMonth;
final bool showExample;
final VoidCallback? onSubscribe;
@override
Widget build(BuildContext context) {
return Column(
children: [
_DailyStressCard(report: report, isMonth: isMonth),
_DailyStressCard(
report: report,
isMonth: isMonth,
showExample: showExample,
onTap: showExample ? onSubscribe : null,
),
const SizedBox(height: 12),
_DistributionCard(report: report),
_DistributionCard(
report: report,
showExample: showExample,
onTap: showExample ? onSubscribe : null,
),
],
);
}
}
class _DailyStressCard extends StatelessWidget {
const _DailyStressCard({required this.report, required this.isMonth});
const _DailyStressCard({
required this.report,
required this.isMonth,
required this.showExample,
this.onTap,
});
final HrvPeriodReport report;
final bool isMonth;
final bool showExample;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return _Card(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'每日压力趋势',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
_ReportTitle(
title: HealthReportSubjectScope.titleOf(context, '每日压力趋势'),
showExample: showExample,
),
const SizedBox(height: 12),
SizedBox(
... ... @@ -325,18 +371,27 @@ class _TrendMetric extends StatelessWidget {
}
class _DistributionCard extends StatelessWidget {
const _DistributionCard({required this.report});
const _DistributionCard({
required this.report,
required this.showExample,
this.onTap,
});
final HrvPeriodReport report;
final bool showExample;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return _Card(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('压力分布',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
_ReportTitle(
title: HealthReportSubjectScope.titleOf(context, '压力分布'),
showExample: showExample,
),
const SizedBox(height: 17),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
... ... @@ -374,6 +429,7 @@ class _DistributionCard extends StatelessWidget {
class _DistributionGrid extends StatelessWidget {
const _DistributionGrid({required this.report});
final HrvPeriodReport report;
@override
... ... @@ -393,6 +449,7 @@ class _DistributionGrid extends StatelessWidget {
class _DistributionItem extends StatelessWidget {
const _DistributionItem({required this.level, required this.report});
final HrvStressLevel level;
final HrvPeriodReport report;
... ... @@ -432,6 +489,7 @@ class _DistributionItem extends StatelessWidget {
class _DistributionBar extends StatelessWidget {
const _DistributionBar({required this.report});
final HrvPeriodReport report;
@override
... ... @@ -467,6 +525,7 @@ class _DistributionBar extends StatelessWidget {
class _ExtremeHrv extends StatelessWidget {
const _ExtremeHrv({required this.title, required this.day});
final String title;
final HrvDayReport? day;
... ... @@ -493,6 +552,7 @@ class _ExtremeHrv extends StatelessWidget {
class _ValueWithUnit extends StatelessWidget {
const _ValueWithUnit({required this.value, required this.unit});
final String value;
final String unit;
... ... @@ -513,18 +573,60 @@ class _ValueWithUnit extends StatelessWidget {
}
}
class _ReportTitle extends StatelessWidget {
const _ReportTitle({required this.title, required this.showExample});
final String title;
final bool showExample;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
showExample ? '$title(示例)' : title,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
if (showExample) ...[
Image.asset(
R.assetsImagesTrendProIcon,
width: 46.w,
height: 13.h,
fit: BoxFit.contain,
),
],
],
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
const _Card({required this.child, this.onTap});
final Widget child;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Container(
final content = Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 17, 16, 18),
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: child,
);
if (onTap == null) return content;
return Stack(
children: [
content,
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
),
),
],
);
}
}
... ...
... ... @@ -2,6 +2,7 @@ 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/health_report_subject_scope.dart';
import '../../report_common/widgets/report_month_calendar_grid.dart';
import '../models/hrv_report_models.dart';
... ... @@ -12,7 +13,7 @@ class HrvYearReportView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final data = report ?? YearlyHrvReport.empty(2026);
final data = report ?? YearlyHrvReport.empty(DateTime.now().year);
return Column(
children: [
_YearTrendCard(report: data),
... ... @@ -37,7 +38,10 @@ class _YearTrendCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('每日压力趋势', style: _titleStyle),
Text(
HealthReportSubjectScope.titleOf(context, '每日压力趋势'),
style: _titleStyle,
),
const SizedBox(height: 12),
SizedBox(height: 174, child: _YearBarChart(report: report)),
const Divider(height: 25, color: _grid),
... ... @@ -252,7 +256,10 @@ class _YearDistributionCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('压力分布', style: _titleStyle),
Text(
HealthReportSubjectScope.titleOf(context, '压力分布'),
style: _titleStyle,
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
... ... @@ -299,7 +306,10 @@ class _DailyDistributionCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('每日压力分布', style: _titleStyle),
Text(
HealthReportSubjectScope.titleOf(context, '每日压力分布'),
style: _titleStyle,
),
const SizedBox(height: 20),
GridView.builder(
shrinkWrap: true,
... ...
... ... @@ -3,9 +3,17 @@ import 'package:intl/intl.dart';
import '../models/report_period.dart';
DateTime _today() {
final now = DateTime.now();
return DateTime(now.year, now.month, now.day);
}
abstract class ReportPeriodLogic {
ReportPeriodLogic({this.forceRefresh = true});
final bool forceRefresh;
final selectedPeriod = ReportPeriod.day.obs;
final selectedDate = DateTime(2026, 5, 12).obs;
final selectedDate = _today().obs;
final isLoading = false.obs;
DateTime get weekStart {
... ... @@ -46,38 +54,80 @@ abstract class ReportPeriodLogic {
return DateFormat('M月d日').format(selectedDate.value);
}
Future<void> selectPeriod(ReportPeriod period) {
Future<void> selectPeriod(
ReportPeriod period, {
bool? forceRefresh,
}) {
final shouldRefresh = forceRefresh ?? this.forceRefresh;
if (!shouldRefresh && selectedPeriod.value == period) return Future.value();
selectedPeriod.value = period;
return loadReport();
}
Future<void> selectDate(DateTime date) {
selectedDate.value = DateTime(date.year, date.month, date.day);
Future<void> selectDate(
DateTime date, {
bool? forceRefresh,
}) {
final shouldRefresh = forceRefresh ?? this.forceRefresh;
final nextDate = DateTime(date.year, date.month, date.day);
if (!shouldRefresh && selectedDate.value == nextDate) return Future.value();
selectedDate.value = nextDate;
return loadReport();
}
Future<void> selectWeek(
DateTime date, {
bool? forceRefresh,
}) {
final shouldRefresh = forceRefresh ?? this.forceRefresh;
final nextWeekStart = _weekStartOf(date);
if (!shouldRefresh && weekStart == nextWeekStart) return Future.value();
selectedDate.value = nextWeekStart;
return loadReport();
}
Future<void> selectWeek(DateTime weekStart) {
selectedDate.value =
DateTime(weekStart.year, weekStart.month, weekStart.day);
Future<void> selectMonth(
int year,
int month, {
bool? forceRefresh,
}) {
final shouldRefresh = forceRefresh ?? this.forceRefresh;
final nextMonth = DateTime(year, month);
if (!shouldRefresh && monthStart == nextMonth) return Future.value();
selectedDate.value = nextMonth;
return loadReport();
}
Future<void> selectMonth(int year, int month) {
selectedDate.value = DateTime(year, month);
Future<void> selectYear(
int year, {
bool? forceRefresh,
}) {
final shouldRefresh = forceRefresh ?? this.forceRefresh;
if (!shouldRefresh && selectedDate.value.year == year) {
return Future.value();
}
selectedDate.value = DateTime(year);
return loadReport();
}
Future<void> selectPickedDate(DateTime date) {
Future<void> selectPickedDate(
DateTime date, {
bool? forceRefresh,
}) {
if (selectedPeriod.value == ReportPeriod.week) {
return selectWeek(date);
return selectWeek(date, forceRefresh: forceRefresh);
}
if (selectedPeriod.value == ReportPeriod.month) {
return selectMonth(date.year, date.month);
return selectMonth(
date.year,
date.month,
forceRefresh: forceRefresh,
);
}
if (selectedPeriod.value == ReportPeriod.year) {
return selectDate(DateTime(date.year));
return selectYear(date.year, forceRefresh: forceRefresh);
}
return selectDate(date);
return selectDate(date, forceRefresh: forceRefresh);
}
Future<void> previousDay() {
... ... @@ -112,6 +162,11 @@ abstract class ReportPeriodLogic {
void dispose() {}
DateTime _weekStartOf(DateTime date) {
final normalized = DateTime(date.year, date.month, date.day);
return normalized.subtract(Duration(days: normalized.weekday - 1));
}
String _formatWeekRangeDate(DateTime date) {
final prefix = date.year == DateTime.now().year ? '' : '${date.year}年';
return '$prefix${DateFormat('M月d日').format(date)}';
... ...
import 'package:flutter/widgets.dart';
class HealthReportSubjectScope extends InheritedWidget {
const HealthReportSubjectScope({
super.key,
required this.isSelf,
required super.child,
});
final bool isSelf;
static String titleOf(
BuildContext context,
String title, {
bool possessive = true,
}) {
final scope =
context.dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>();
if (scope == null || scope.isSelf) return title;
return possessive ? 'Ta的$title' : 'Ta$title';
}
@override
bool updateShouldNotify(HealthReportSubjectScope oldWidget) {
return oldWidget.isSelf != isSelf;
}
}
... ...
... ... @@ -8,11 +8,11 @@ Future<DateTime?> showReportDatePickerSheet({
required ReportPeriod period,
required DateTime selectedDate,
required DateTime weekStart,
}) {
}) async {
final backgroundColor = const Color(0xFFF8F4FF);
final barrierAlpha = period == ReportPeriod.day ? 0.25 : 0.70;
return showModalBottomSheet<DateTime>(
final picked = await showModalBottomSheet<DateTime>(
context: context,
backgroundColor: backgroundColor,
barrierColor: Colors.black.withValues(alpha: barrierAlpha),
... ... @@ -32,6 +32,13 @@ Future<DateTime?> showReportDatePickerSheet({
}
},
);
// Navigator.pop completes before the bottom sheet's reverse animation ends.
// Defer the report rebuild so heavy views do not compete with that animation.
if (picked != null) {
await Future<void>.delayed(const Duration(milliseconds: 220));
}
return picked;
}
class _YearDatePickerSheet extends StatefulWidget {
... ... @@ -88,8 +95,7 @@ class _YearDatePickerSheetState extends State<_YearDatePickerSheet> {
count: _yearRange.count,
selectedIndex: _year - _yearRange.first,
itemExtent: _itemExtent,
labelBuilder: (index) =>
'${_yearRange.first + index} 年',
labelBuilder: (index) => '${_yearRange.first + index} 年',
onSelectedItemChanged: (index) {
setState(() => _year = _yearRange.first + index);
},
... ...
import 'package:flutter/material.dart';
class ReportLoadingIndicator extends StatelessWidget {
const ReportLoadingIndicator({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Color(0xFF845EEE),
strokeWidth: 2,
),
),
);
}
}
... ...
import 'package:flutter/material.dart';
import '../../../../r.dart';
import '../models/report_period.dart';
class ReportPeriodTabBar extends StatelessWidget {
... ... @@ -8,11 +9,13 @@ class ReportPeriodTabBar extends StatelessWidget {
required this.selectedPeriod,
required this.onChanged,
this.lockedPeriods = const {},
this.onLockedPeriodTap,
});
final ReportPeriod selectedPeriod;
final ValueChanged<ReportPeriod> onChanged;
final Set<ReportPeriod> lockedPeriods;
final ValueChanged<ReportPeriod>? onLockedPeriodTap;
static const _brand = Color(0xFF845EEE);
static const _h1 = Color(0xFF0F0F11);
... ... @@ -39,9 +42,13 @@ class ReportPeriodTabBar extends StatelessWidget {
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: lockedPeriods.contains(period)
? null
: () => onChanged(period),
onTap: () {
if (lockedPeriods.contains(period)) {
onLockedPeriodTap?.call(period);
} else {
onChanged(period);
}
},
child: Container(
height: 28,
decoration: BoxDecoration(
... ... @@ -54,10 +61,12 @@ class ReportPeriodTabBar extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (lockedPeriods.contains(period)) ...[
const Icon(
Icons.lock_outline,
size: 14,
Image.asset(
R.assetsImagesLockIcon,
width: 14,
height: 14,
color: _brand,
colorBlendMode: BlendMode.srcIn,
),
const SizedBox(width: 2),
],
... ...
... ... @@ -5,7 +5,9 @@ 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_loading_indicator.dart';
import '../../report_common/widgets/report_period_tab_bar.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../controllers/sleep_report_logic.dart';
import '../models/sleep_report_models.dart';
import '../widgets/sleep_heart_rate_card.dart';
... ... @@ -18,9 +20,13 @@ class SleepReportView extends StatelessWidget {
const SleepReportView({
super.key,
required this.logic,
required this.isVip,
required this.onSubscribe,
});
final SleepReportLogic logic;
final bool isVip;
final VoidCallback onSubscribe;
@override
Widget build(BuildContext context) {
... ... @@ -39,6 +45,10 @@ class SleepReportView extends StatelessWidget {
() => ReportPeriodTabBar(
selectedPeriod: logic.selectedPeriod.value,
onChanged: logic.selectPeriod,
lockedPeriods: isVip
? const {}
: const {ReportPeriod.week, ReportPeriod.month},
onLockedPeriodTap: (_) => onSubscribe(),
),
),
const SizedBox(height: 8),
... ... @@ -47,66 +57,38 @@ class SleepReportView extends StatelessWidget {
),
),
Expanded(
child: CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
Obx(
() {
child: Obx(
() {
if (logic.isLoading.value) {
return const ReportLoadingIndicator();
}
return CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
sliver: SliverList(
delegate: SliverChildListDelegate([
if (logic.selectedPeriod.value ==
ReportPeriod.week) {
return SleepWeekReportView(
ReportPeriod.week)
SleepWeekReportView(
report: logic.weeklyReport.value,
);
}
if (logic.selectedPeriod.value ==
ReportPeriod.month) {
return SleepMonthReportView(
)
else if (logic.selectedPeriod.value ==
ReportPeriod.month)
SleepMonthReportView(
monthStart: logic.monthStart,
report: logic.monthlyReport.value,
);
}
final report = logic.report.value;
final quality = report?.qualityLevel ??
SleepQualityLevel.unknown;
return Column(
children: [
SleepQualityRing(report: report),
const SizedBox(height: 16),
Text(
quality.reportText,
textAlign: TextAlign.center,
style: TextStyle(
color: Color(quality.qualityColorValue),
fontSize: 24,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
const SizedBox(height: 25),
SleepSummaryCard(
report: report,
onQualityInfoTap: () =>
showSleepQualityDialog(context),
),
const SizedBox(height: 12),
SleepHeartRateCard(
summary: report?.heartRate ??
const SleepHeartRateSummary(),
),
],
);
},
)
else
_DailyReportContent(report: logic.report.value),
const ReportBottomSlogan(),
]),
),
const ReportBottomSlogan(),
]),
),
),
],
),
],
);
},
),
),
],
... ... @@ -117,6 +99,46 @@ class SleepReportView extends StatelessWidget {
}
}
class _DailyReportContent extends StatelessWidget {
const _DailyReportContent({required this.report});
final SleepReport? report;
@override
Widget build(BuildContext context) {
final quality = report?.qualityLevel ?? SleepQualityLevel.unknown;
return Column(
children: [
SleepQualityRing(report: report),
const SizedBox(height: 16),
Text(
HealthReportSubjectScope.titleOf(
context,
quality.reportText,
possessive: false,
),
textAlign: TextAlign.center,
style: TextStyle(
color: Color(quality.qualityColorValue),
fontSize: 24,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
const SizedBox(height: 25),
SleepSummaryCard(
report: report,
onQualityInfoTap: () => showSleepQualityDialog(context),
),
const SizedBox(height: 12),
SleepHeartRateCard(
summary: report?.heartRate ?? const SleepHeartRateSummary(),
),
],
);
}
}
class _DateSwitcher extends StatelessWidget {
const _DateSwitcher({required this.logic});
... ...
... ... @@ -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/health_report_subject_scope.dart';
import '../models/sleep_report_models.dart';
class SleepHeartRateCard extends StatelessWidget {
... ... @@ -29,9 +30,9 @@ class SleepHeartRateCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'睡眠时心率',
style: TextStyle(
Text(
HealthReportSubjectScope.titleOf(context, '睡眠时心率'),
style: const TextStyle(
color: _h1,
fontSize: 16,
fontWeight: FontWeight.w600,
... ...
import 'package:fl_chart/fl_chart.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../models/sleep_report_models.dart';
class SleepWeekReportView extends StatelessWidget {
... ... @@ -96,7 +97,7 @@ class _SleepPeriodTrendContent extends StatelessWidget {
),
const SizedBox(height: 20),
_TrendCard(
title: '睡眠时长',
title: HealthReportSubjectScope.titleOf(context, '睡眠时长'),
chart: _DurationChart(days: days, compact: compact),
leftMetric: _ExtremeMetric.duration(
title: '睡眠最长',
... ... @@ -111,7 +112,7 @@ class _SleepPeriodTrendContent extends StatelessWidget {
),
const SizedBox(height: 12),
_TrendCard(
title: '睡眠质量',
title: HealthReportSubjectScope.titleOf(context, '睡眠质量'),
chart: _QualityChart(days: days, compact: compact),
leftMetric: _ExtremeMetric.score(
title: '质量最佳',
... ... @@ -126,7 +127,7 @@ class _SleepPeriodTrendContent extends StatelessWidget {
),
const SizedBox(height: 12),
_TrendCard(
title: '入睡时间',
title: HealthReportSubjectScope.titleOf(context, '入睡时间'),
chart: _BedtimeChart(days: days, compact: compact),
leftMetric: _ExtremeMetric.time(
title: '最早入睡',
... ...
... ... @@ -8,11 +8,9 @@ import '../modules/devtools/views/route_list_view.dart';
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';
import '../modules/home/bindings/home_binding.dart';
... ... @@ -108,11 +106,6 @@ abstract final class AppPages {
binding: AddFriendBinding(),
),
GetPage(
name: Routes.SELECT_FRIEND,
page: () => const SelectFriendView(),
binding: SelectFriendBinding(),
),
GetPage(
name: Routes.FRIEND_TREND,
page: () => const FriendTrendView(),
binding: FriendTrendBinding(),
... ...
... ... @@ -8,7 +8,6 @@ abstract class Routes {
static const ROUTE_LIST = _Paths.ROUTE_LIST;
static const PURCHASE = _Paths.PURCHASE;
static const ADD_FRIEND = _Paths.ADD_FRIEND;
static const SELECT_FRIEND = _Paths.SELECT_FRIEND;
static const PREMIUM_ACTIVATED = _Paths.PREMIUM_ACTIVATED;
static const PRIVACY_SETTINGS = _Paths.PRIVACY_SETTINGS;
static const HELP = _Paths.HELP;
... ... @@ -29,7 +28,6 @@ abstract class _Paths {
static const ROUTE_LIST = '/route-list';
static const PURCHASE = '/purchase';
static const ADD_FRIEND = '/add-friend';
static const SELECT_FRIEND = '/select-friend';
static const PREMIUM_ACTIVATED = '/premium-activated';
static const PRIVACY_SETTINGS = '/privacy-settings';
static const HELP = '/help';
... ...
... ... @@ -20,6 +20,9 @@ class R {
static final String assetsImagesProIcon = 'assets/images/common/ic_pro.webp';
static final String assetsImagesHealthBedIcon =
'assets/images/common/ic_health_bed.webp';
static final String assetsImagesLockIcon = 'assets/images/common/ic_lock.png';
static final String assetsImagesTrendProIcon =
'assets/images/common/ic_trend_pro.webp';
// friends
static final String assetsImagesFriendsFriendAddMeIllustration =
... ...
import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart';
import 'package:doublefeel_flutter/app/modules/friends/controllers/select_friend_logic.dart';
import 'package:doublefeel_flutter/app/modules/friends/views/select_friend_view.dart';
import 'package:doublefeel_flutter/app/modules/friends/widgets/friend_health_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
void main() {
late FriendsController friendsController;
setUp(() {
friendsController = FriendsController();
friendsController.friends.assignAll(_friends);
});
test('sync moves the watch-face selection to the selected friend', () {
final logic = SelectFriendLogic(friendsController);
logic.selectFriendAt(1);
final didSync = logic.syncSelectedFriendToWatchFace();
expect(didSync, isTrue);
expect(friendsController.friends[0].isOnWatchFace, isFalse);
expect(friendsController.friends[1].isOnWatchFace, isTrue);
});
testWidgets('tapping a friend updates the selected card', (tester) async {
await tester.pumpWidget(
GetMaterialApp(
home: Scaffold(
body: SelectFriendView(friendsController: friendsController),
),
),
);
await tester.pump();
var cards = tester.widgetList<FriendHealthCard>(
find.byType(FriendHealthCard),
);
expect(cards.map((card) => card.isSelected), [isTrue, isFalse]);
await tester.tap(find.text('Bob'));
await tester.pump();
cards = tester.widgetList<FriendHealthCard>(find.byType(FriendHealthCard));
expect(cards.map((card) => card.isSelected), [isFalse, isTrue]);
});
}
const _friends = [
FriendHealthData(
name: 'Alice',
updatedAt: '',
sleepQualityScore: null,
steps: null,
statusText: '',
stressValue: null,
isOnWatchFace: true,
),
FriendHealthData(
name: 'Bob',
updatedAt: '',
sleepQualityScore: null,
steps: null,
statusText: '',
stressValue: null,
),
];
... ...