Showing
48 changed files
with
2381 additions
and
1035 deletions
Too many changes to show.
To preserve performance only 48 of 48+ files are displayed.
5.39 KB
1.57 KB
3.48 KB
3.94 KB
3.42 KB
assets/images/pk/calories.png
0 → 100644
1019 Bytes
assets/images/pk/crown.png
0 → 100644
4.57 KB
assets/images/pk/left_banner.png
0 → 100644
6.66 KB
assets/images/pk/lightning.png
0 → 100644
1.61 KB
assets/images/pk/podium_loser.png
0 → 100644
10.4 KB
assets/images/pk/podium_winner.png
0 → 100644
11.3 KB
assets/images/pk/poke.png
0 → 100644
2.25 KB
assets/images/pk/preview_friend.png
0 → 100644
27.2 KB
assets/images/pk/preview_self.png
0 → 100644
29.6 KB
assets/images/pk/right_banner.png
0 → 100644
6.33 KB
assets/images/pk/score_stripes.png
0 → 100644
1.69 KB
assets/images/pk/standing.png
0 → 100644
1.1 KB
assets/images/pk/steps.png
0 → 100644
992 Bytes
assets/images/pk/tab_pk.png
0 → 100644
362 Bytes
assets/images/tabbar/icon_pk.png
0 → 100644
1.07 KB
assets/images/tabbar/icon_pk_selected.png
0 → 100644
1.12 KB
| 1 | import 'package:doublefeel_flutter/app/routes/app_pages.dart'; | 1 | import 'package:doublefeel_flutter/app/routes/app_pages.dart'; |
| 2 | import 'package:doublefeel_flutter/core/services/user_state_service.dart'; | 2 | import 'package:doublefeel_flutter/core/services/user_state_service.dart'; |
| 3 | +import 'package:doublefeel_flutter/core/util/app_toast.dart'; | ||
| 4 | +import 'package:doublefeel_flutter/data/local/local_storage.dart'; | ||
| 3 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | 5 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; |
| 4 | import 'package:get/get.dart'; | 6 | import 'package:get/get.dart'; |
| 5 | 7 | ||
| 6 | class AccountSettingsController extends GetxController { | 8 | class AccountSettingsController extends GetxController { |
| 7 | final UserPreferencesStorage _userPrefs = Get.find<UserPreferencesStorage>(); | 9 | final UserPreferencesStorage _userPrefs = Get.find<UserPreferencesStorage>(); |
| 8 | final UserStateService _userStateService = Get.find<UserStateService>(); | 10 | final UserStateService _userStateService = Get.find<UserStateService>(); |
| 11 | + final LocalStorage _localStorage = Get.find<LocalStorage>(); | ||
| 9 | 12 | ||
| 10 | UserPreferencesStorage get userPrefs => _userPrefs; | 13 | UserPreferencesStorage get userPrefs => _userPrefs; |
| 11 | 14 | ||
| @@ -21,4 +24,12 @@ class AccountSettingsController extends GetxController { | @@ -21,4 +24,12 @@ class AccountSettingsController extends GetxController { | ||
| 21 | void openOnboarding() { | 24 | void openOnboarding() { |
| 22 | Get.offAllNamed(AppRoutes.userOnboarding); | 25 | Get.offAllNamed(AppRoutes.userOnboarding); |
| 23 | } | 26 | } |
| 27 | + | ||
| 28 | + void openChangePhone() { | ||
| 29 | + if (_localStorage.lastLoginMethod != 'phone') { | ||
| 30 | + AppToast.show('仅支持手机号登录的账号更换手机号'); | ||
| 31 | + return; | ||
| 32 | + } | ||
| 33 | + Get.toNamed(Routes.CHANGE_PHONE); | ||
| 34 | + } | ||
| 24 | } | 35 | } |
| @@ -45,7 +45,11 @@ class AccountSettingsView extends GetView<AccountSettingsController> { | @@ -45,7 +45,11 @@ class AccountSettingsView extends GetView<AccountSettingsController> { | ||
| 45 | return Column( | 45 | return Column( |
| 46 | children: [ | 46 | children: [ |
| 47 | SizedBox(height: 16.dp), | 47 | SizedBox(height: 16.dp), |
| 48 | - _AccountCard(user: user, onLogout: controller.logout), | 48 | + _AccountCard( |
| 49 | + user: user, | ||
| 50 | + onChangePhone: controller.openChangePhone, | ||
| 51 | + onLogout: controller.logout, | ||
| 52 | + ), | ||
| 49 | SizedBox(height: 17.dp), | 53 | SizedBox(height: 17.dp), |
| 50 | GestureDetector( | 54 | GestureDetector( |
| 51 | behavior: HitTestBehavior.opaque, | 55 | behavior: HitTestBehavior.opaque, |
| @@ -77,10 +81,12 @@ class AccountSettingsView extends GetView<AccountSettingsController> { | @@ -77,10 +81,12 @@ class AccountSettingsView extends GetView<AccountSettingsController> { | ||
| 77 | class _AccountCard extends StatelessWidget { | 81 | class _AccountCard extends StatelessWidget { |
| 78 | const _AccountCard({ | 82 | const _AccountCard({ |
| 79 | required this.user, | 83 | required this.user, |
| 84 | + required this.onChangePhone, | ||
| 80 | required this.onLogout, | 85 | required this.onLogout, |
| 81 | }); | 86 | }); |
| 82 | 87 | ||
| 83 | final UserInfoResponse? user; | 88 | final UserInfoResponse? user; |
| 89 | + final VoidCallback onChangePhone; | ||
| 84 | final VoidCallback onLogout; | 90 | final VoidCallback onLogout; |
| 85 | 91 | ||
| 86 | @override | 92 | @override |
| @@ -94,32 +100,36 @@ class _AccountCard extends StatelessWidget { | @@ -94,32 +100,36 @@ class _AccountCard extends StatelessWidget { | ||
| 94 | ), | 100 | ), |
| 95 | child: Column( | 101 | child: Column( |
| 96 | children: [ | 102 | children: [ |
| 97 | - SizedBox( | ||
| 98 | - height: 54.dp, | ||
| 99 | - child: Padding( | ||
| 100 | - padding: EdgeInsets.symmetric(horizontal: 20.dp), | ||
| 101 | - child: Row( | ||
| 102 | - children: [ | ||
| 103 | - Text( | ||
| 104 | - '手机号', | ||
| 105 | - style: TextStyle( | ||
| 106 | - color: AppColors.textPrimary, | ||
| 107 | - fontSize: 14.dp, | ||
| 108 | - fontWeight: FontWeight.w400, | ||
| 109 | - height: 1.25, | 103 | + GestureDetector( |
| 104 | + behavior: HitTestBehavior.opaque, | ||
| 105 | + onTap: onChangePhone, | ||
| 106 | + child: SizedBox( | ||
| 107 | + height: 54.dp, | ||
| 108 | + child: Padding( | ||
| 109 | + padding: EdgeInsets.symmetric(horizontal: 20.dp), | ||
| 110 | + child: Row( | ||
| 111 | + children: [ | ||
| 112 | + Text( | ||
| 113 | + '手机号', | ||
| 114 | + style: TextStyle( | ||
| 115 | + color: AppColors.textPrimary, | ||
| 116 | + fontSize: 14.dp, | ||
| 117 | + fontWeight: FontWeight.w400, | ||
| 118 | + height: 1.25, | ||
| 119 | + ), | ||
| 110 | ), | 120 | ), |
| 111 | - ), | ||
| 112 | - const Spacer(), | ||
| 113 | - Text( | ||
| 114 | - _formatPhone(user?.telephone), | ||
| 115 | - style: TextStyle( | ||
| 116 | - color: AppColors.textSecondary, | ||
| 117 | - fontSize: 14.dp, | ||
| 118 | - fontWeight: FontWeight.w400, | ||
| 119 | - height: 1.25, | 121 | + const Spacer(), |
| 122 | + Text( | ||
| 123 | + _formatPhone(user?.telephone), | ||
| 124 | + style: TextStyle( | ||
| 125 | + color: AppColors.textSecondary, | ||
| 126 | + fontSize: 14.dp, | ||
| 127 | + fontWeight: FontWeight.w400, | ||
| 128 | + height: 1.25, | ||
| 129 | + ), | ||
| 120 | ), | 130 | ), |
| 121 | - ), | ||
| 122 | - ], | 131 | + ], |
| 132 | + ), | ||
| 123 | ), | 133 | ), |
| 124 | ), | 134 | ), |
| 125 | ), | 135 | ), |
| @@ -3,18 +3,17 @@ import 'dart:ui' as ui; | @@ -3,18 +3,17 @@ import 'dart:ui' as ui; | ||
| 3 | 3 | ||
| 4 | import 'package:fl_chart/fl_chart.dart'; | 4 | import 'package:fl_chart/fl_chart.dart'; |
| 5 | import 'package:flutter/material.dart'; | 5 | import 'package:flutter/material.dart'; |
| 6 | +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart'; | ||
| 6 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 7 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 7 | import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart'; | 8 | import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart'; |
| 8 | 9 | ||
| 10 | +import '../../../widget/friend_interact_click_view.dart'; | ||
| 9 | import '../../report_common/widgets/health_report_subject_scope.dart'; | 11 | import '../../report_common/widgets/health_report_subject_scope.dart'; |
| 10 | import '../models/activity_burn_report_models.dart'; | 12 | import '../models/activity_burn_report_models.dart'; |
| 11 | import 'activity_burn_trend_lines.dart'; | 13 | import 'activity_burn_trend_lines.dart'; |
| 12 | 14 | ||
| 13 | class ActivityBurnHeartRateZoneCard extends StatelessWidget { | 15 | class ActivityBurnHeartRateZoneCard extends StatelessWidget { |
| 14 | - const ActivityBurnHeartRateZoneCard({ | ||
| 15 | - super.key, | ||
| 16 | - required this.summary, | ||
| 17 | - }); | 16 | + const ActivityBurnHeartRateZoneCard({super.key, required this.summary}); |
| 18 | 17 | ||
| 19 | final ActivityBurnHeartRateSummary summary; | 18 | final ActivityBurnHeartRateSummary summary; |
| 20 | 19 | ||
| @@ -39,75 +38,79 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -39,75 +38,79 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 39 | Widget build(BuildContext context) { | 38 | Widget build(BuildContext context) { |
| 40 | final start = _startTime; | 39 | final start = _startTime; |
| 41 | final axis = _HeartRateAxis.fromSummary(summary, start); | 40 | final axis = _HeartRateAxis.fromSummary(summary, start); |
| 42 | - return Container( | ||
| 43 | - height: 273, | ||
| 44 | - padding: const EdgeInsets.fromLTRB(20, 20, 14, 16), | ||
| 45 | - decoration: BoxDecoration( | ||
| 46 | - color: Colors.white, | ||
| 47 | - borderRadius: BorderRadius.circular(16), | ||
| 48 | - ), | ||
| 49 | - child: Column( | ||
| 50 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 51 | - children: [ | ||
| 52 | - Text( | ||
| 53 | - HealthReportSubjectScope.titleOf( | ||
| 54 | - context, | ||
| 55 | - context.l10n.activityHeartRateZone, | ||
| 56 | - ), | ||
| 57 | - style: TextStyle( | ||
| 58 | - color: _h1, | ||
| 59 | - fontSize: 16, | ||
| 60 | - fontWeight: FontWeight.w600, | ||
| 61 | - height: 1.2, | 41 | + return FriendInteractCardOverlay( |
| 42 | + showInteraction: !HealthReportSubjectScope.isViewingSelf(context), | ||
| 43 | + interactionType: FriendInteractionType.dailyActivityBurn, | ||
| 44 | + child: Container( | ||
| 45 | + height: 273, | ||
| 46 | + padding: const EdgeInsets.fromLTRB(20, 20, 14, 16), | ||
| 47 | + decoration: BoxDecoration( | ||
| 48 | + color: Colors.white, | ||
| 49 | + borderRadius: BorderRadius.circular(16), | ||
| 50 | + ), | ||
| 51 | + child: Column( | ||
| 52 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 53 | + children: [ | ||
| 54 | + Text( | ||
| 55 | + HealthReportSubjectScope.titleOf( | ||
| 56 | + context, | ||
| 57 | + context.l10n.activityHeartRateZone, | ||
| 58 | + ), | ||
| 59 | + style: TextStyle( | ||
| 60 | + color: _h1, | ||
| 61 | + fontSize: 16, | ||
| 62 | + fontWeight: FontWeight.w600, | ||
| 63 | + height: 1.2, | ||
| 64 | + ), | ||
| 62 | ), | 65 | ), |
| 63 | - ), | ||
| 64 | - const SizedBox(height: 20), | ||
| 65 | - SizedBox( | ||
| 66 | - height: _chartHeight, | ||
| 67 | - child: LayoutBuilder( | ||
| 68 | - builder: (_, __) { | ||
| 69 | - return Stack( | ||
| 70 | - fit: StackFit.expand, | ||
| 71 | - alignment: Alignment.center, | ||
| 72 | - children: [ | ||
| 73 | - Positioned( | ||
| 74 | - left: 0, | ||
| 75 | - right: 6, | ||
| 76 | - top: 0, | ||
| 77 | - bottom: 20, | ||
| 78 | - child: ActivityBurnHorizontalGridLines( | ||
| 79 | - minY: axis.minY, | ||
| 80 | - maxY: axis.chartMaxY, | ||
| 81 | - values: axis.ticks, | ||
| 82 | - ), | ||
| 83 | - ), | ||
| 84 | - Positioned.fill( | ||
| 85 | - top: 0, | ||
| 86 | - child: LineChart(_chartData(context)), | ||
| 87 | - ), | ||
| 88 | - if (!summary.hasData) | 66 | + const SizedBox(height: 20), |
| 67 | + SizedBox( | ||
| 68 | + height: _chartHeight, | ||
| 69 | + child: LayoutBuilder( | ||
| 70 | + builder: (_, __) { | ||
| 71 | + return Stack( | ||
| 72 | + fit: StackFit.expand, | ||
| 73 | + alignment: Alignment.center, | ||
| 74 | + children: [ | ||
| 89 | Positioned( | 75 | Positioned( |
| 90 | left: 0, | 76 | left: 0, |
| 91 | right: 6, | 77 | right: 6, |
| 92 | top: 0, | 78 | top: 0, |
| 93 | bottom: 20, | 79 | bottom: 20, |
| 94 | - child: Center( | ||
| 95 | - child: Text( | ||
| 96 | - context.l10n.hrvTrendAwaitingData, | ||
| 97 | - style: const TextStyle( | ||
| 98 | - color: Color(0xFFA1A0A5), | ||
| 99 | - fontSize: 12, | ||
| 100 | - fontWeight: FontWeight.w400, | 80 | + child: ActivityBurnHorizontalGridLines( |
| 81 | + minY: axis.minY, | ||
| 82 | + maxY: axis.chartMaxY, | ||
| 83 | + values: axis.ticks, | ||
| 84 | + ), | ||
| 85 | + ), | ||
| 86 | + Positioned.fill( | ||
| 87 | + top: 0, | ||
| 88 | + child: LineChart(_chartData(context)), | ||
| 89 | + ), | ||
| 90 | + if (!summary.hasData) | ||
| 91 | + Positioned( | ||
| 92 | + left: 0, | ||
| 93 | + right: 6, | ||
| 94 | + top: 0, | ||
| 95 | + bottom: 20, | ||
| 96 | + child: Center( | ||
| 97 | + child: Text( | ||
| 98 | + context.l10n.hrvTrendAwaitingData, | ||
| 99 | + style: const TextStyle( | ||
| 100 | + color: Color(0xFFA1A0A5), | ||
| 101 | + fontSize: 12, | ||
| 102 | + fontWeight: FontWeight.w400, | ||
| 103 | + ), | ||
| 101 | ), | 104 | ), |
| 102 | ), | 105 | ), |
| 103 | ), | 106 | ), |
| 104 | - ), | ||
| 105 | - ], | ||
| 106 | - ); | ||
| 107 | - }, | 107 | + ], |
| 108 | + ); | ||
| 109 | + }, | ||
| 110 | + ), | ||
| 108 | ), | 111 | ), |
| 109 | - ), | ||
| 110 | - ], | 112 | + ], |
| 113 | + ), | ||
| 111 | ), | 114 | ), |
| 112 | ); | 115 | ); |
| 113 | } | 116 | } |
| @@ -122,9 +125,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -122,9 +125,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 122 | maxX: axis.maxX, | 125 | maxX: axis.maxX, |
| 123 | minY: axis.minY, | 126 | minY: axis.minY, |
| 124 | maxY: axis.chartMaxY, | 127 | maxY: axis.chartMaxY, |
| 125 | - gridData: FlGridData( | ||
| 126 | - show: false, | ||
| 127 | - ), | 128 | + gridData: FlGridData(show: false), |
| 128 | borderData: FlBorderData(show: false), | 129 | borderData: FlBorderData(show: false), |
| 129 | lineTouchData: const LineTouchData(enabled: false), | 130 | lineTouchData: const LineTouchData(enabled: false), |
| 130 | titlesData: FlTitlesData( | 131 | titlesData: FlTitlesData( |
| @@ -174,10 +175,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -174,10 +175,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 174 | isFirstLabel ? _textWidth(label, _bottomTitleStyle) / 2 : 0, | 175 | isFirstLabel ? _textWidth(label, _bottomTitleStyle) / 2 : 0, |
| 175 | 0, | 176 | 0, |
| 176 | ), | 177 | ), |
| 177 | - child: Text( | ||
| 178 | - label, | ||
| 179 | - style: _bottomTitleStyle, | ||
| 180 | - ), | 178 | + child: Text(label, style: _bottomTitleStyle), |
| 181 | ), | 179 | ), |
| 182 | ); | 180 | ); |
| 183 | }, | 181 | }, |
| @@ -192,10 +190,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -192,10 +190,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 192 | 190 | ||
| 193 | LineChartBarData _emptyPlaceholderBar(_HeartRateAxis axis) { | 191 | LineChartBarData _emptyPlaceholderBar(_HeartRateAxis axis) { |
| 194 | return LineChartBarData( | 192 | return LineChartBarData( |
| 195 | - spots: [ | ||
| 196 | - FlSpot(0, axis.minY), | ||
| 197 | - FlSpot(axis.maxX, axis.minY), | ||
| 198 | - ], | 193 | + spots: [FlSpot(0, axis.minY), FlSpot(axis.maxX, axis.minY)], |
| 199 | color: Colors.transparent, | 194 | color: Colors.transparent, |
| 200 | barWidth: 0, | 195 | barWidth: 0, |
| 201 | dotData: const FlDotData(show: false), | 196 | dotData: const FlDotData(show: false), |
| @@ -215,7 +210,8 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -215,7 +210,8 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 215 | List<ActivityBurnHeartRatePoint> _points(DateTime start, DateTime end) { | 210 | List<ActivityBurnHeartRatePoint> _points(DateTime start, DateTime end) { |
| 216 | return summary.points | 211 | return summary.points |
| 217 | .where( | 212 | .where( |
| 218 | - (point) => !point.time.isBefore(start) && !point.time.isAfter(end)) | 213 | + (point) => !point.time.isBefore(start) && !point.time.isAfter(end), |
| 214 | + ) | ||
| 219 | .toList(); | 215 | .toList(); |
| 220 | } | 216 | } |
| 221 | 217 | ||
| @@ -277,10 +273,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -277,10 +273,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 277 | ]; | 273 | ]; |
| 278 | } | 274 | } |
| 279 | 275 | ||
| 280 | - List<FlSpot> _splitByZoneBoundaries( | ||
| 281 | - FlSpot start, | ||
| 282 | - FlSpot end, | ||
| 283 | - ) { | 276 | + List<FlSpot> _splitByZoneBoundaries(FlSpot start, FlSpot end) { |
| 284 | if (start.y == end.y) return [start, end]; | 277 | if (start.y == end.y) return [start, end]; |
| 285 | 278 | ||
| 286 | final minY = math.min(start.y, end.y); | 279 | final minY = math.min(start.y, end.y); |
| @@ -290,12 +283,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -290,12 +283,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 290 | for (final boundary in _zoneBoundaries) { | 283 | for (final boundary in _zoneBoundaries) { |
| 291 | if (boundary <= minY || boundary >= maxY) continue; | 284 | if (boundary <= minY || boundary >= maxY) continue; |
| 292 | final t = (boundary - start.y) / (end.y - start.y); | 285 | final t = (boundary - start.y) / (end.y - start.y); |
| 293 | - crossings.add( | ||
| 294 | - FlSpot( | ||
| 295 | - start.x + (end.x - start.x) * t, | ||
| 296 | - boundary, | ||
| 297 | - ), | ||
| 298 | - ); | 286 | + crossings.add(FlSpot(start.x + (end.x - start.x) * t, boundary)); |
| 299 | } | 287 | } |
| 300 | 288 | ||
| 301 | crossings.sort((a, b) => a.x.compareTo(b.x)); | 289 | crossings.sort((a, b) => a.x.compareTo(b.x)); |
| @@ -325,10 +313,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -325,10 +313,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 325 | } | 313 | } |
| 326 | 314 | ||
| 327 | FlSpot _spot(ActivityBurnHeartRatePoint point, DateTime start) { | 315 | FlSpot _spot(ActivityBurnHeartRatePoint point, DateTime start) { |
| 328 | - return FlSpot( | ||
| 329 | - point.time.difference(start).inMinutes.toDouble(), | ||
| 330 | - point.bpm, | ||
| 331 | - ); | 316 | + return FlSpot(point.time.difference(start).inMinutes.toDouble(), point.bpm); |
| 332 | } | 317 | } |
| 333 | 318 | ||
| 334 | String? _bottomLabel( | 319 | String? _bottomLabel( |
| @@ -3,10 +3,12 @@ import 'dart:math' as math; | @@ -3,10 +3,12 @@ import 'dart:math' as math; | ||
| 3 | 3 | ||
| 4 | import 'package:fl_chart/fl_chart.dart'; | 4 | import 'package:fl_chart/fl_chart.dart'; |
| 5 | import 'package:flutter/material.dart'; | 5 | import 'package:flutter/material.dart'; |
| 6 | +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart'; | ||
| 6 | import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; | 7 | import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; |
| 7 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 8 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 8 | 9 | ||
| 9 | import '../../../../r.dart'; | 10 | import '../../../../r.dart'; |
| 11 | +import '../../../widget/friend_interact_click_view.dart'; | ||
| 10 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; | 12 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; |
| 11 | import '../../report_common/widgets/health_report_subject_scope.dart'; | 13 | import '../../report_common/widgets/health_report_subject_scope.dart'; |
| 12 | import '../../report_common/utils/report_localization.dart'; | 14 | import '../../report_common/utils/report_localization.dart'; |
| @@ -17,14 +19,14 @@ import 'activity_burn_ring.dart'; | @@ -17,14 +19,14 @@ import 'activity_burn_ring.dart'; | ||
| 17 | import 'activity_burn_trend_lines.dart'; | 19 | import 'activity_burn_trend_lines.dart'; |
| 18 | 20 | ||
| 19 | String _weekdayLabel(BuildContext context, int weekday) => switch (weekday) { | 21 | String _weekdayLabel(BuildContext context, int weekday) => switch (weekday) { |
| 20 | - DateTime.monday => context.l10n.activityWeeklyWeekdayMonday, | ||
| 21 | - DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday, | ||
| 22 | - DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday, | ||
| 23 | - DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday, | ||
| 24 | - DateTime.friday => context.l10n.activityWeeklyWeekdayFriday, | ||
| 25 | - DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday, | ||
| 26 | - _ => context.l10n.activityWeeklyWeekdaySunday, | ||
| 27 | - }; | 22 | + DateTime.monday => context.l10n.activityWeeklyWeekdayMonday, |
| 23 | + DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday, | ||
| 24 | + DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday, | ||
| 25 | + DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday, | ||
| 26 | + DateTime.friday => context.l10n.activityWeeklyWeekdayFriday, | ||
| 27 | + DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday, | ||
| 28 | + _ => context.l10n.activityWeeklyWeekdaySunday, | ||
| 29 | +}; | ||
| 28 | 30 | ||
| 29 | class ActivityBurnMonthReportView extends StatelessWidget { | 31 | class ActivityBurnMonthReportView extends StatelessWidget { |
| 30 | const ActivityBurnMonthReportView({ | 32 | const ActivityBurnMonthReportView({ |
| @@ -134,7 +136,9 @@ class _MonthlySummary extends StatelessWidget { | @@ -134,7 +136,9 @@ class _MonthlySummary extends StatelessWidget { | ||
| 134 | children: [ | 136 | children: [ |
| 135 | _SummaryMetric( | 137 | _SummaryMetric( |
| 136 | title: HealthReportSubjectScope.titleOf( | 138 | title: HealthReportSubjectScope.titleOf( |
| 137 | - context, context.l10n.activityWeeklyExerciseTitle), | 139 | + context, |
| 140 | + context.l10n.activityWeeklyExerciseTitle, | ||
| 141 | + ), | ||
| 138 | value: exercise.value, | 142 | value: exercise.value, |
| 139 | unit: exercise.unit, | 143 | unit: exercise.unit, |
| 140 | color: isOhos() | 144 | color: isOhos() |
| @@ -144,7 +148,9 @@ class _MonthlySummary extends StatelessWidget { | @@ -144,7 +148,9 @@ class _MonthlySummary extends StatelessWidget { | ||
| 144 | const SizedBox(width: 34), | 148 | const SizedBox(width: 34), |
| 145 | _SummaryMetric( | 149 | _SummaryMetric( |
| 146 | title: HealthReportSubjectScope.titleOf( | 150 | title: HealthReportSubjectScope.titleOf( |
| 147 | - context, context.l10n.activityWeeklyStandTitle), | 151 | + context, |
| 152 | + context.l10n.activityWeeklyStandTitle, | ||
| 153 | + ), | ||
| 148 | value: stand.value, | 154 | value: stand.value, |
| 149 | unit: stand.unit, | 155 | unit: stand.unit, |
| 150 | color: ActivityBurnMonthReportView._stand, | 156 | color: ActivityBurnMonthReportView._stand, |
| @@ -157,8 +163,8 @@ class _MonthlySummary extends StatelessWidget { | @@ -157,8 +163,8 @@ class _MonthlySummary extends StatelessWidget { | ||
| 157 | } | 163 | } |
| 158 | 164 | ||
| 159 | String _totalBurnTitle(BuildContext context) { | 165 | String _totalBurnTitle(BuildContext context) { |
| 160 | - final scope = | ||
| 161 | - context.dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>(); | 166 | + final scope = context |
| 167 | + .dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>(); | ||
| 162 | return scope == null || scope.isSelf | 168 | return scope == null || scope.isSelf |
| 163 | ? context.l10n.activityWeeklySelfTitle | 169 | ? context.l10n.activityWeeklySelfTitle |
| 164 | : context.l10n.activityWeeklyOtherTitle; | 170 | : context.l10n.activityWeeklyOtherTitle; |
| @@ -330,10 +336,7 @@ class _RingStat extends StatelessWidget { | @@ -330,10 +336,7 @@ class _RingStat extends StatelessWidget { | ||
| 330 | children: [ | 336 | children: [ |
| 331 | Row( | 337 | Row( |
| 332 | children: [ | 338 | children: [ |
| 333 | - ActivityBurnRing( | ||
| 334 | - report: report, | ||
| 335 | - size: 12, | ||
| 336 | - ), | 339 | + ActivityBurnRing(report: report, size: 12), |
| 337 | const SizedBox(width: 5), | 340 | const SizedBox(width: 5), |
| 338 | Text( | 341 | Text( |
| 339 | label, | 342 | label, |
| @@ -485,167 +488,175 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -485,167 +488,175 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 485 | final hasChartData = _hasChartData; | 488 | final hasChartData = _hasChartData; |
| 486 | final maxY = _maxY; | 489 | final maxY = _maxY; |
| 487 | final comparisonPercent = widget.report.activeEnergyComparisonPercent; | 490 | final comparisonPercent = widget.report.activeEnergyComparisonPercent; |
| 488 | - return Container( | ||
| 489 | - height: 344, | ||
| 490 | - padding: const EdgeInsets.fromLTRB(20, 20, 14, 40), | ||
| 491 | - decoration: BoxDecoration( | ||
| 492 | - color: Colors.white, | ||
| 493 | - borderRadius: BorderRadius.circular(16), | ||
| 494 | - ), | ||
| 495 | - child: Column( | ||
| 496 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 497 | - children: [ | ||
| 498 | - Text( | ||
| 499 | - HealthReportSubjectScope.titleOf( | ||
| 500 | - context, context.l10n.activityCalorieTrend), | ||
| 501 | - style: const TextStyle( | ||
| 502 | - color: ActivityBurnMonthReportView._h1, | ||
| 503 | - fontSize: 16, | ||
| 504 | - fontWeight: FontWeight.w600, | ||
| 505 | - ), | ||
| 506 | - ), | ||
| 507 | - const SizedBox(height: 10), | ||
| 508 | - Row( | ||
| 509 | - crossAxisAlignment: CrossAxisAlignment.baseline, | ||
| 510 | - textBaseline: TextBaseline.alphabetic, | ||
| 511 | - children: [ | ||
| 512 | - Text( | ||
| 513 | - hasData | ||
| 514 | - ? widget.report.averageDailyActiveEnergy.toString() | ||
| 515 | - : '-', | ||
| 516 | - style: const TextStyle( | ||
| 517 | - color: ActivityBurnMonthReportView._h1, | ||
| 518 | - fontSize: 18, | ||
| 519 | - fontWeight: FontWeight.w700, | ||
| 520 | - ), | 491 | + return FriendInteractCardOverlay( |
| 492 | + showInteraction: !HealthReportSubjectScope.isViewingSelf(context), | ||
| 493 | + interactionType: FriendInteractionType.monthlyActivityBurn, | ||
| 494 | + child: Container( | ||
| 495 | + height: 344, | ||
| 496 | + padding: const EdgeInsets.fromLTRB(20, 20, 14, 40), | ||
| 497 | + decoration: BoxDecoration( | ||
| 498 | + color: Colors.white, | ||
| 499 | + borderRadius: BorderRadius.circular(16), | ||
| 500 | + ), | ||
| 501 | + child: Column( | ||
| 502 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 503 | + children: [ | ||
| 504 | + Text( | ||
| 505 | + HealthReportSubjectScope.titleOf( | ||
| 506 | + context, | ||
| 507 | + context.l10n.activityCalorieTrend, | ||
| 521 | ), | 508 | ), |
| 522 | - Padding( | ||
| 523 | - padding: const EdgeInsets.only(left: 4), | ||
| 524 | - child: Text( | ||
| 525 | - context.l10n.activityKcalDailyAverage, | 509 | + style: const TextStyle( |
| 510 | + color: ActivityBurnMonthReportView._h1, | ||
| 511 | + fontSize: 16, | ||
| 512 | + fontWeight: FontWeight.w600, | ||
| 513 | + ), | ||
| 514 | + ), | ||
| 515 | + const SizedBox(height: 10), | ||
| 516 | + Row( | ||
| 517 | + crossAxisAlignment: CrossAxisAlignment.baseline, | ||
| 518 | + textBaseline: TextBaseline.alphabetic, | ||
| 519 | + children: [ | ||
| 520 | + Text( | ||
| 521 | + hasData | ||
| 522 | + ? widget.report.averageDailyActiveEnergy.toString() | ||
| 523 | + : '-', | ||
| 526 | style: const TextStyle( | 524 | style: const TextStyle( |
| 527 | color: ActivityBurnMonthReportView._h1, | 525 | color: ActivityBurnMonthReportView._h1, |
| 528 | - fontSize: 12, | 526 | + fontSize: 18, |
| 527 | + fontWeight: FontWeight.w700, | ||
| 529 | ), | 528 | ), |
| 530 | ), | 529 | ), |
| 531 | - ), | ||
| 532 | - ], | ||
| 533 | - ), | ||
| 534 | - const SizedBox(height: 2), | ||
| 535 | - Row( | ||
| 536 | - children: [ | ||
| 537 | - if (comparisonPercent != null) ...[ | ||
| 538 | - Image.asset( | ||
| 539 | - comparisonPercent >= 0 | ||
| 540 | - ? R.assetsImagesHealthTrendUp | ||
| 541 | - : R.assetsImagesHealthTrendDown, | ||
| 542 | - width: 12, | ||
| 543 | - height: 12, | 530 | + Padding( |
| 531 | + padding: const EdgeInsets.only(left: 4), | ||
| 532 | + child: Text( | ||
| 533 | + context.l10n.activityKcalDailyAverage, | ||
| 534 | + style: const TextStyle( | ||
| 535 | + color: ActivityBurnMonthReportView._h1, | ||
| 536 | + fontSize: 12, | ||
| 537 | + ), | ||
| 538 | + ), | ||
| 544 | ), | 539 | ), |
| 545 | - const SizedBox(width: 2), | ||
| 546 | ], | 540 | ], |
| 547 | - Text( | ||
| 548 | - _comparisonText(context, comparisonPercent), | ||
| 549 | - style: TextStyle( | ||
| 550 | - color: ActivityBurnMonthReportView._h2, | ||
| 551 | - fontSize: 12, | ||
| 552 | - ), | ||
| 553 | - ), | ||
| 554 | - const Spacer(), | ||
| 555 | - _TrendLegend( | ||
| 556 | - color: const Color(0xFFFFC0CE), | ||
| 557 | - label: context.l10n.activityTrendAverage, | ||
| 558 | - ), | ||
| 559 | - const SizedBox(width: 12), | ||
| 560 | - _TrendLegend( | ||
| 561 | - color: const Color(0xFF96E9CB), | ||
| 562 | - label: context.l10n.activityTrendGoal, | ||
| 563 | - dashed: true, | ||
| 564 | - ), | ||
| 565 | - ], | ||
| 566 | - ), | ||
| 567 | - const SizedBox(height: 8), | ||
| 568 | - Expanded( | ||
| 569 | - child: Stack( | ||
| 570 | - alignment: Alignment.center, | ||
| 571 | - clipBehavior: Clip.none, | 541 | + ), |
| 542 | + const SizedBox(height: 2), | ||
| 543 | + Row( | ||
| 572 | children: [ | 544 | children: [ |
| 573 | - Positioned( | ||
| 574 | - left: 0, | ||
| 575 | - right: 0, | ||
| 576 | - top: _chartTopInset, | ||
| 577 | - bottom: 0, | ||
| 578 | - child: ActivityBurnTrendPlotFrame( | ||
| 579 | - maxY: maxY, | ||
| 580 | - average: widget.report.averageDailyActiveEnergy.toDouble(), | ||
| 581 | - target: widget.report.activeEnergyGoal.toDouble(), | ||
| 582 | - child: LayoutBuilder( | ||
| 583 | - builder: (context, constraints) => Listener( | ||
| 584 | - behavior: HitTestBehavior.opaque, | ||
| 585 | - onPointerDown: (event) => _selectTooltipAt( | ||
| 586 | - event.localPosition.dx, | ||
| 587 | - constraints.maxWidth, | ||
| 588 | - ), | ||
| 589 | - onPointerMove: (event) => _selectTooltipAt( | ||
| 590 | - event.localPosition.dx, | ||
| 591 | - constraints.maxWidth, | ||
| 592 | - ), | ||
| 593 | - onPointerUp: (_) => _scheduleTooltipDismissal(), | ||
| 594 | - onPointerCancel: (_) => _scheduleTooltipDismissal(), | ||
| 595 | - child: Transform.translate( | ||
| 596 | - offset: const Offset( | ||
| 597 | - (_xAxisLeftInset - _xAxisRightInset) / 2, | ||
| 598 | - 0, | 545 | + if (comparisonPercent != null) ...[ |
| 546 | + Image.asset( | ||
| 547 | + comparisonPercent >= 0 | ||
| 548 | + ? R.assetsImagesHealthTrendUp | ||
| 549 | + : R.assetsImagesHealthTrendDown, | ||
| 550 | + width: 12, | ||
| 551 | + height: 12, | ||
| 552 | + ), | ||
| 553 | + const SizedBox(width: 2), | ||
| 554 | + ], | ||
| 555 | + Text( | ||
| 556 | + _comparisonText(context, comparisonPercent), | ||
| 557 | + style: TextStyle( | ||
| 558 | + color: ActivityBurnMonthReportView._h2, | ||
| 559 | + fontSize: 12, | ||
| 560 | + ), | ||
| 561 | + ), | ||
| 562 | + const Spacer(), | ||
| 563 | + _TrendLegend( | ||
| 564 | + color: const Color(0xFFFFC0CE), | ||
| 565 | + label: context.l10n.activityTrendAverage, | ||
| 566 | + ), | ||
| 567 | + const SizedBox(width: 12), | ||
| 568 | + _TrendLegend( | ||
| 569 | + color: const Color(0xFF96E9CB), | ||
| 570 | + label: context.l10n.activityTrendGoal, | ||
| 571 | + dashed: true, | ||
| 572 | + ), | ||
| 573 | + ], | ||
| 574 | + ), | ||
| 575 | + const SizedBox(height: 8), | ||
| 576 | + Expanded( | ||
| 577 | + child: Stack( | ||
| 578 | + alignment: Alignment.center, | ||
| 579 | + clipBehavior: Clip.none, | ||
| 580 | + children: [ | ||
| 581 | + Positioned( | ||
| 582 | + left: 0, | ||
| 583 | + right: 0, | ||
| 584 | + top: _chartTopInset, | ||
| 585 | + bottom: 0, | ||
| 586 | + child: ActivityBurnTrendPlotFrame( | ||
| 587 | + maxY: maxY, | ||
| 588 | + average: widget.report.averageDailyActiveEnergy | ||
| 589 | + .toDouble(), | ||
| 590 | + target: widget.report.activeEnergyGoal.toDouble(), | ||
| 591 | + child: LayoutBuilder( | ||
| 592 | + builder: (context, constraints) => Listener( | ||
| 593 | + behavior: HitTestBehavior.opaque, | ||
| 594 | + onPointerDown: (event) => _selectTooltipAt( | ||
| 595 | + event.localPosition.dx, | ||
| 596 | + constraints.maxWidth, | ||
| 597 | + ), | ||
| 598 | + onPointerMove: (event) => _selectTooltipAt( | ||
| 599 | + event.localPosition.dx, | ||
| 600 | + constraints.maxWidth, | ||
| 599 | ), | 601 | ), |
| 600 | - child: BarChart( | ||
| 601 | - _chartData(maxY, constraints.maxWidth), | 602 | + onPointerUp: (_) => _scheduleTooltipDismissal(), |
| 603 | + onPointerCancel: (_) => _scheduleTooltipDismissal(), | ||
| 604 | + child: Transform.translate( | ||
| 605 | + offset: const Offset( | ||
| 606 | + (_xAxisLeftInset - _xAxisRightInset) / 2, | ||
| 607 | + 0, | ||
| 608 | + ), | ||
| 609 | + child: BarChart( | ||
| 610 | + _chartData(maxY, constraints.maxWidth), | ||
| 611 | + ), | ||
| 602 | ), | 612 | ), |
| 603 | ), | 613 | ), |
| 604 | ), | 614 | ), |
| 605 | ), | 615 | ), |
| 606 | ), | 616 | ), |
| 607 | - ), | ||
| 608 | - if (_touchedIndex != null) | ||
| 609 | - Positioned.fill( | ||
| 610 | - top: _chartTopInset, | ||
| 611 | - child: LayoutBuilder( | ||
| 612 | - builder: (context, constraints) { | ||
| 613 | - return ChartSelectionLineOverlay( | ||
| 614 | - offset: Offset.zero, | ||
| 615 | - color: ActivityBurnMonthReportView._h3, | ||
| 616 | - bottomTitleHeight: | ||
| 617 | - ActivityBurnTrendPlotFrame.bottomTitleHeight, | ||
| 618 | - tooltipMargin: _tooltipMargin, | ||
| 619 | - plotLeft: _xAxisLeftInset, | ||
| 620 | - plotRight: _xAxisRightInset, | ||
| 621 | - lineX: _lineXForIndex(constraints.maxWidth), | ||
| 622 | - lineTop: | ||
| 623 | - _tooltipBottomForHeight(constraints.maxHeight), | ||
| 624 | - ); | ||
| 625 | - }, | 617 | + if (_touchedIndex != null) |
| 618 | + Positioned.fill( | ||
| 619 | + top: _chartTopInset, | ||
| 620 | + child: LayoutBuilder( | ||
| 621 | + builder: (context, constraints) { | ||
| 622 | + return ChartSelectionLineOverlay( | ||
| 623 | + offset: Offset.zero, | ||
| 624 | + color: ActivityBurnMonthReportView._h3, | ||
| 625 | + bottomTitleHeight: | ||
| 626 | + ActivityBurnTrendPlotFrame.bottomTitleHeight, | ||
| 627 | + tooltipMargin: _tooltipMargin, | ||
| 628 | + plotLeft: _xAxisLeftInset, | ||
| 629 | + plotRight: _xAxisRightInset, | ||
| 630 | + lineX: _lineXForIndex(constraints.maxWidth), | ||
| 631 | + lineTop: _tooltipBottomForHeight( | ||
| 632 | + constraints.maxHeight, | ||
| 633 | + ), | ||
| 634 | + ); | ||
| 635 | + }, | ||
| 636 | + ), | ||
| 626 | ), | 637 | ), |
| 627 | - ), | ||
| 628 | - if (!hasChartData) | ||
| 629 | - Positioned( | ||
| 630 | - left: _xAxisLeftInset, | ||
| 631 | - right: _xAxisRightInset, | ||
| 632 | - top: _chartTopInset, | ||
| 633 | - bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight, | ||
| 634 | - child: Center( | ||
| 635 | - child: Text( | ||
| 636 | - context.l10n.activityTrendAwaitingData, | ||
| 637 | - style: const TextStyle( | ||
| 638 | - color: Color(0xFFA1A0A5), | ||
| 639 | - fontSize: 12, | ||
| 640 | - fontWeight: FontWeight.w400, | 638 | + if (!hasChartData) |
| 639 | + Positioned( | ||
| 640 | + left: _xAxisLeftInset, | ||
| 641 | + right: _xAxisRightInset, | ||
| 642 | + top: _chartTopInset, | ||
| 643 | + bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight, | ||
| 644 | + child: Center( | ||
| 645 | + child: Text( | ||
| 646 | + context.l10n.activityTrendAwaitingData, | ||
| 647 | + style: const TextStyle( | ||
| 648 | + color: Color(0xFFA1A0A5), | ||
| 649 | + fontSize: 12, | ||
| 650 | + fontWeight: FontWeight.w400, | ||
| 651 | + ), | ||
| 641 | ), | 652 | ), |
| 642 | ), | 653 | ), |
| 643 | ), | 654 | ), |
| 644 | - ), | ||
| 645 | - ], | 655 | + ], |
| 656 | + ), | ||
| 646 | ), | 657 | ), |
| 647 | - ), | ||
| 648 | - ], | 658 | + ], |
| 659 | + ), | ||
| 649 | ), | 660 | ), |
| 650 | ); | 661 | ); |
| 651 | } | 662 | } |
| @@ -655,9 +666,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -655,9 +666,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 655 | .map((day) => day.activeEnergy?.value ?? 0) | 666 | .map((day) => day.activeEnergy?.value ?? 0) |
| 656 | .fold<int>(0, (max, value) => value > max ? value : max); | 667 | .fold<int>(0, (max, value) => value > max ? value : max); |
| 657 | final maxReference = math.max(maxValue, widget.report.activeEnergyGoal); | 668 | final maxReference = math.max(maxValue, widget.report.activeEnergyGoal); |
| 658 | - return _roundUpToFiveOrZero(maxReference) | ||
| 659 | - .clamp(100, double.infinity) | ||
| 660 | - .toDouble(); | 669 | + return _roundUpToFiveOrZero( |
| 670 | + maxReference, | ||
| 671 | + ).clamp(100, double.infinity).toDouble(); | ||
| 661 | } | 672 | } |
| 662 | 673 | ||
| 663 | bool get _hasChartData => | 674 | bool get _hasChartData => |
| @@ -716,15 +727,16 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -716,15 +727,16 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 716 | if (chartWidth <= 0) return null; | 727 | if (chartWidth <= 0) return null; |
| 717 | final groupsSpace = _groupsSpace(chartWidth, count); | 728 | final groupsSpace = _groupsSpace(chartWidth, count); |
| 718 | const chartLeft = _xAxisLeftInset; | 729 | const chartLeft = _xAxisLeftInset; |
| 719 | - final index = | ||
| 720 | - ((dx - chartLeft - _barWidth / 2) / (_barWidth + groupsSpace)).round(); | 730 | + final index = ((dx - chartLeft - _barWidth / 2) / (_barWidth + groupsSpace)) |
| 731 | + .round(); | ||
| 721 | return index.clamp(0, count - 1).toInt(); | 732 | return index.clamp(0, count - 1).toInt(); |
| 722 | } | 733 | } |
| 723 | 734 | ||
| 724 | void _selectTooltipAt(double? dx, double plotWidth) { | 735 | void _selectTooltipAt(double? dx, double plotWidth) { |
| 725 | _tooltipDismissTimer?.cancel(); | 736 | _tooltipDismissTimer?.cancel(); |
| 726 | final touchedIndex = _indexForTouch(dx, plotWidth); | 737 | final touchedIndex = _indexForTouch(dx, plotWidth); |
| 727 | - final hasTouchedData = touchedIndex != null && | 738 | + final hasTouchedData = |
| 739 | + touchedIndex != null && | ||
| 728 | (widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0; | 740 | (widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0; |
| 729 | final nextIndex = hasTouchedData ? touchedIndex : null; | 741 | final nextIndex = hasTouchedData ? touchedIndex : null; |
| 730 | if (_touchedIndex == nextIndex) return; | 742 | if (_touchedIndex == nextIndex) return; |
| @@ -739,16 +751,16 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -739,16 +751,16 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 739 | 751 | ||
| 740 | BarChartData _chartData(double maxY, double plotWidth) { | 752 | BarChartData _chartData(double maxY, double plotWidth) { |
| 741 | final hasData = widget.report.hasData; | 753 | final hasData = widget.report.hasData; |
| 742 | - final chartWidth = | ||
| 743 | - math.max(0.0, plotWidth - _xAxisLeftInset - _xAxisRightInset); | 754 | + final chartWidth = math.max( |
| 755 | + 0.0, | ||
| 756 | + plotWidth - _xAxisLeftInset - _xAxisRightInset, | ||
| 757 | + ); | ||
| 744 | return BarChartData( | 758 | return BarChartData( |
| 745 | minY: 0, | 759 | minY: 0, |
| 746 | maxY: maxY, | 760 | maxY: maxY, |
| 747 | alignment: BarChartAlignment.center, | 761 | alignment: BarChartAlignment.center, |
| 748 | groupsSpace: _groupsSpace(chartWidth, widget.report.days.length), | 762 | groupsSpace: _groupsSpace(chartWidth, widget.report.days.length), |
| 749 | - gridData: FlGridData( | ||
| 750 | - show: false, | ||
| 751 | - ), | 763 | + gridData: FlGridData(show: false), |
| 752 | borderData: FlBorderData(show: false), | 764 | borderData: FlBorderData(show: false), |
| 753 | barTouchData: BarTouchData( | 765 | barTouchData: BarTouchData( |
| 754 | enabled: hasData, | 766 | enabled: hasData, |
| @@ -762,10 +774,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -762,10 +774,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 762 | _scheduleTooltipDismissal(); | 774 | _scheduleTooltipDismissal(); |
| 763 | return; | 775 | return; |
| 764 | } | 776 | } |
| 765 | - _selectTooltipAt( | ||
| 766 | - event.localPosition?.dx, | ||
| 767 | - plotWidth, | ||
| 768 | - ); | 777 | + _selectTooltipAt(event.localPosition?.dx, plotWidth); |
| 769 | }, | 778 | }, |
| 770 | touchTooltipData: BarTouchTooltipData( | 779 | touchTooltipData: BarTouchTooltipData( |
| 771 | tooltipRoundedRadius: 4, | 780 | tooltipRoundedRadius: 4, |
| @@ -810,7 +819,8 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -810,7 +819,8 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 810 | reservedSize: 36, | 819 | reservedSize: 36, |
| 811 | interval: activityBurnTrendYInterval(maxY), | 820 | interval: activityBurnTrendYInterval(maxY), |
| 812 | getTitlesWidget: (value, meta) { | 821 | getTitlesWidget: (value, meta) { |
| 813 | - final isRegularTick = value % activityBurnTrendYInterval(maxY) == 0; | 822 | + final isRegularTick = |
| 823 | + value % activityBurnTrendYInterval(maxY) == 0; | ||
| 814 | final isMax = (value - maxY).abs() < 0.001; | 824 | final isMax = (value - maxY).abs() < 0.001; |
| 815 | if (!isRegularTick && !isMax) return const SizedBox.shrink(); | 825 | if (!isRegularTick && !isMax) return const SizedBox.shrink(); |
| 816 | return Transform.translate( | 826 | return Transform.translate( |
| @@ -873,8 +883,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -873,8 +883,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 873 | maxY, | 883 | maxY, |
| 874 | ), | 884 | ), |
| 875 | width: _barWidth, | 885 | width: _barWidth, |
| 876 | - borderRadius: | ||
| 877 | - const BorderRadius.vertical(top: Radius.circular(4)), | 886 | + borderRadius: const BorderRadius.vertical( |
| 887 | + top: Radius.circular(4), | ||
| 888 | + ), | ||
| 878 | color: _barColor, | 889 | color: _barColor, |
| 879 | ), | 890 | ), |
| 880 | // Anchor the tooltip at a constant height, independent of the | 891 | // Anchor the tooltip at a constant height, independent of the |
| @@ -897,8 +908,10 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -897,8 +908,10 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 897 | final count = widget.report.days.length; | 908 | final count = widget.report.days.length; |
| 898 | if (count <= 7) return true; | 909 | if (count <= 7) return true; |
| 899 | final lastIndex = count - 1; | 910 | final lastIndex = count - 1; |
| 900 | - return List.generate(7, (tick) => (tick * lastIndex / 6).round()) | ||
| 901 | - .contains(index); | 911 | + return List.generate( |
| 912 | + 7, | ||
| 913 | + (tick) => (tick * lastIndex / 6).round(), | ||
| 914 | + ).contains(index); | ||
| 902 | } | 915 | } |
| 903 | } | 916 | } |
| 904 | 917 |
| @@ -3,10 +3,12 @@ import 'dart:math' as math; | @@ -3,10 +3,12 @@ import 'dart:math' as math; | ||
| 3 | 3 | ||
| 4 | import 'package:fl_chart/fl_chart.dart'; | 4 | import 'package:fl_chart/fl_chart.dart'; |
| 5 | import 'package:flutter/material.dart'; | 5 | import 'package:flutter/material.dart'; |
| 6 | +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart'; | ||
| 6 | import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; | 7 | import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; |
| 7 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 8 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 8 | 9 | ||
| 9 | import '../../../../r.dart'; | 10 | import '../../../../r.dart'; |
| 11 | +import '../../../widget/friend_interact_click_view.dart'; | ||
| 10 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; | 12 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; |
| 11 | import '../../report_common/widgets/health_report_subject_scope.dart'; | 13 | import '../../report_common/widgets/health_report_subject_scope.dart'; |
| 12 | import '../../report_common/utils/report_localization.dart'; | 14 | import '../../report_common/utils/report_localization.dart'; |
| @@ -16,14 +18,14 @@ import 'activity_burn_ring.dart'; | @@ -16,14 +18,14 @@ import 'activity_burn_ring.dart'; | ||
| 16 | import 'activity_burn_trend_lines.dart'; | 18 | import 'activity_burn_trend_lines.dart'; |
| 17 | 19 | ||
| 18 | String _weekdayLabel(BuildContext context, int weekday) => switch (weekday) { | 20 | String _weekdayLabel(BuildContext context, int weekday) => switch (weekday) { |
| 19 | - DateTime.monday => context.l10n.activityWeeklyWeekdayMonday, | ||
| 20 | - DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday, | ||
| 21 | - DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday, | ||
| 22 | - DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday, | ||
| 23 | - DateTime.friday => context.l10n.activityWeeklyWeekdayFriday, | ||
| 24 | - DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday, | ||
| 25 | - _ => context.l10n.activityWeeklyWeekdaySunday, | ||
| 26 | - }; | 21 | + DateTime.monday => context.l10n.activityWeeklyWeekdayMonday, |
| 22 | + DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday, | ||
| 23 | + DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday, | ||
| 24 | + DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday, | ||
| 25 | + DateTime.friday => context.l10n.activityWeeklyWeekdayFriday, | ||
| 26 | + DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday, | ||
| 27 | + _ => context.l10n.activityWeeklyWeekdaySunday, | ||
| 28 | +}; | ||
| 27 | 29 | ||
| 28 | class ActivityBurnWeekReportView extends StatelessWidget { | 30 | class ActivityBurnWeekReportView extends StatelessWidget { |
| 29 | const ActivityBurnWeekReportView({ | 31 | const ActivityBurnWeekReportView({ |
| @@ -135,7 +137,9 @@ class _WeeklySummary extends StatelessWidget { | @@ -135,7 +137,9 @@ class _WeeklySummary extends StatelessWidget { | ||
| 135 | children: [ | 137 | children: [ |
| 136 | _SummaryMetric( | 138 | _SummaryMetric( |
| 137 | title: HealthReportSubjectScope.titleOf( | 139 | title: HealthReportSubjectScope.titleOf( |
| 138 | - context, context.l10n.activityWeeklyExerciseTitle), | 140 | + context, |
| 141 | + context.l10n.activityWeeklyExerciseTitle, | ||
| 142 | + ), | ||
| 139 | value: exercise.value, | 143 | value: exercise.value, |
| 140 | unit: exercise.unit, | 144 | unit: exercise.unit, |
| 141 | color: isOhos() | 145 | color: isOhos() |
| @@ -145,7 +149,9 @@ class _WeeklySummary extends StatelessWidget { | @@ -145,7 +149,9 @@ class _WeeklySummary extends StatelessWidget { | ||
| 145 | const SizedBox(width: 34), | 149 | const SizedBox(width: 34), |
| 146 | _SummaryMetric( | 150 | _SummaryMetric( |
| 147 | title: HealthReportSubjectScope.titleOf( | 151 | title: HealthReportSubjectScope.titleOf( |
| 148 | - context, context.l10n.activityWeeklyStandTitle), | 152 | + context, |
| 153 | + context.l10n.activityWeeklyStandTitle, | ||
| 154 | + ), | ||
| 149 | value: stand.value, | 155 | value: stand.value, |
| 150 | unit: stand.unit, | 156 | unit: stand.unit, |
| 151 | color: ActivityBurnWeekReportView._stand, | 157 | color: ActivityBurnWeekReportView._stand, |
| @@ -158,8 +164,8 @@ class _WeeklySummary extends StatelessWidget { | @@ -158,8 +164,8 @@ class _WeeklySummary extends StatelessWidget { | ||
| 158 | } | 164 | } |
| 159 | 165 | ||
| 160 | String _totalBurnTitle(BuildContext context) { | 166 | String _totalBurnTitle(BuildContext context) { |
| 161 | - final scope = | ||
| 162 | - context.dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>(); | 167 | + final scope = context |
| 168 | + .dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>(); | ||
| 163 | return scope == null || scope.isSelf | 169 | return scope == null || scope.isSelf |
| 164 | ? context.l10n.activityWeeklySelfTitle | 170 | ? context.l10n.activityWeeklySelfTitle |
| 165 | : context.l10n.activityWeeklyOtherTitle; | 171 | : context.l10n.activityWeeklyOtherTitle; |
| @@ -294,9 +300,7 @@ class _RingOverviewCard extends StatelessWidget { | @@ -294,9 +300,7 @@ class _RingOverviewCard extends StatelessWidget { | ||
| 294 | const SizedBox(height: 20), | 300 | const SizedBox(height: 20), |
| 295 | Row( | 301 | Row( |
| 296 | mainAxisAlignment: MainAxisAlignment.spaceBetween, | 302 | mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| 297 | - children: [ | ||
| 298 | - for (final day in report.days) _DayRing(report: day), | ||
| 299 | - ], | 303 | + children: [for (final day in report.days) _DayRing(report: day)], |
| 300 | ), | 304 | ), |
| 301 | ], | 305 | ], |
| 302 | ), | 306 | ), |
| @@ -327,10 +331,7 @@ class _RingStat extends StatelessWidget { | @@ -327,10 +331,7 @@ class _RingStat extends StatelessWidget { | ||
| 327 | children: [ | 331 | children: [ |
| 328 | Row( | 332 | Row( |
| 329 | children: [ | 333 | children: [ |
| 330 | - ActivityBurnRing( | ||
| 331 | - report: report, | ||
| 332 | - size: 12, | ||
| 333 | - ), | 334 | + ActivityBurnRing(report: report, size: 12), |
| 334 | const SizedBox(width: 5), | 335 | const SizedBox(width: 5), |
| 335 | Text( | 336 | Text( |
| 336 | label, | 337 | label, |
| @@ -506,169 +507,175 @@ class _ActivityBurnEnergyTrendCardState | @@ -506,169 +507,175 @@ class _ActivityBurnEnergyTrendCardState | ||
| 506 | final hasChartData = _hasChartData; | 507 | final hasChartData = _hasChartData; |
| 507 | final maxY = _maxY; | 508 | final maxY = _maxY; |
| 508 | final comparisonPercent = widget.report.activeEnergyComparisonPercent; | 509 | final comparisonPercent = widget.report.activeEnergyComparisonPercent; |
| 509 | - return Container( | ||
| 510 | - height: 344, | ||
| 511 | - padding: const EdgeInsets.fromLTRB(20, 20, 14, 32), | ||
| 512 | - decoration: BoxDecoration( | ||
| 513 | - color: Colors.white, | ||
| 514 | - borderRadius: BorderRadius.circular(16), | ||
| 515 | - ), | ||
| 516 | - child: Column( | ||
| 517 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 518 | - children: [ | ||
| 519 | - Text( | ||
| 520 | - HealthReportSubjectScope.titleOf( | ||
| 521 | - context, context.l10n.activityCalorieTrend), | ||
| 522 | - style: const TextStyle( | ||
| 523 | - color: ActivityBurnWeekReportView._h1, | ||
| 524 | - fontSize: 16, | ||
| 525 | - fontWeight: FontWeight.w600, | ||
| 526 | - ), | ||
| 527 | - ), | ||
| 528 | - const SizedBox(height: 10), | ||
| 529 | - Row( | ||
| 530 | - crossAxisAlignment: CrossAxisAlignment.baseline, | ||
| 531 | - textBaseline: TextBaseline.alphabetic, | ||
| 532 | - children: [ | ||
| 533 | - Text( | ||
| 534 | - hasData | ||
| 535 | - ? widget.report.averageDailyActiveEnergy.toString() | ||
| 536 | - : '-', | ||
| 537 | - style: const TextStyle( | ||
| 538 | - color: ActivityBurnWeekReportView._h1, | ||
| 539 | - fontSize: 18, | ||
| 540 | - fontWeight: FontWeight.w700, | ||
| 541 | - ), | 510 | + return FriendInteractCardOverlay( |
| 511 | + showInteraction: !HealthReportSubjectScope.isViewingSelf(context), | ||
| 512 | + interactionType: FriendInteractionType.weeklyActivityBurn, | ||
| 513 | + child: Container( | ||
| 514 | + height: 344, | ||
| 515 | + padding: const EdgeInsets.fromLTRB(20, 20, 14, 32), | ||
| 516 | + decoration: BoxDecoration( | ||
| 517 | + color: Colors.white, | ||
| 518 | + borderRadius: BorderRadius.circular(16), | ||
| 519 | + ), | ||
| 520 | + child: Column( | ||
| 521 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 522 | + children: [ | ||
| 523 | + Text( | ||
| 524 | + HealthReportSubjectScope.titleOf( | ||
| 525 | + context, | ||
| 526 | + context.l10n.activityCalorieTrend, | ||
| 542 | ), | 527 | ), |
| 543 | - Padding( | ||
| 544 | - padding: const EdgeInsets.only(left: 4), | ||
| 545 | - child: Text( | ||
| 546 | - context.l10n.activityKcalDailyAverage, | 528 | + style: const TextStyle( |
| 529 | + color: ActivityBurnWeekReportView._h1, | ||
| 530 | + fontSize: 16, | ||
| 531 | + fontWeight: FontWeight.w600, | ||
| 532 | + ), | ||
| 533 | + ), | ||
| 534 | + const SizedBox(height: 10), | ||
| 535 | + Row( | ||
| 536 | + crossAxisAlignment: CrossAxisAlignment.baseline, | ||
| 537 | + textBaseline: TextBaseline.alphabetic, | ||
| 538 | + children: [ | ||
| 539 | + Text( | ||
| 540 | + hasData | ||
| 541 | + ? widget.report.averageDailyActiveEnergy.toString() | ||
| 542 | + : '-', | ||
| 547 | style: const TextStyle( | 543 | style: const TextStyle( |
| 548 | color: ActivityBurnWeekReportView._h1, | 544 | color: ActivityBurnWeekReportView._h1, |
| 549 | - fontSize: 12, | 545 | + fontSize: 18, |
| 546 | + fontWeight: FontWeight.w700, | ||
| 550 | ), | 547 | ), |
| 551 | ), | 548 | ), |
| 552 | - ), | ||
| 553 | - ], | ||
| 554 | - ), | ||
| 555 | - const SizedBox(height: 2), | ||
| 556 | - Row( | ||
| 557 | - children: [ | ||
| 558 | - if (comparisonPercent != null) ...[ | ||
| 559 | - Image.asset( | ||
| 560 | - comparisonPercent >= 0 | ||
| 561 | - ? R.assetsImagesHealthTrendUp | ||
| 562 | - : R.assetsImagesHealthTrendDown, | ||
| 563 | - width: 12, | ||
| 564 | - height: 12, | 549 | + Padding( |
| 550 | + padding: const EdgeInsets.only(left: 4), | ||
| 551 | + child: Text( | ||
| 552 | + context.l10n.activityKcalDailyAverage, | ||
| 553 | + style: const TextStyle( | ||
| 554 | + color: ActivityBurnWeekReportView._h1, | ||
| 555 | + fontSize: 12, | ||
| 556 | + ), | ||
| 557 | + ), | ||
| 565 | ), | 558 | ), |
| 566 | - const SizedBox(width: 2), | ||
| 567 | ], | 559 | ], |
| 568 | - Text( | ||
| 569 | - _comparisonText(context, comparisonPercent), | ||
| 570 | - style: const TextStyle( | ||
| 571 | - color: ActivityBurnWeekReportView._h2, | ||
| 572 | - fontSize: 12, | ||
| 573 | - ), | ||
| 574 | - ), | ||
| 575 | - const Spacer(), | ||
| 576 | - _TrendLegend( | ||
| 577 | - color: const Color(0xFFFFC0CE), | ||
| 578 | - label: context.l10n.activityTrendAverage, | ||
| 579 | - ), | ||
| 580 | - const SizedBox(width: 12), | ||
| 581 | - _TrendLegend( | ||
| 582 | - color: const Color(0xFF96E9CB), | ||
| 583 | - label: context.l10n.activityTrendGoal, | ||
| 584 | - dashed: true, | ||
| 585 | - ), | ||
| 586 | - ], | ||
| 587 | - ), | ||
| 588 | - const SizedBox(height: 8), | ||
| 589 | - Expanded( | ||
| 590 | - child: Stack( | ||
| 591 | - alignment: Alignment.center, | ||
| 592 | - clipBehavior: Clip.none, | 560 | + ), |
| 561 | + const SizedBox(height: 2), | ||
| 562 | + Row( | ||
| 593 | children: [ | 563 | children: [ |
| 594 | - Positioned( | ||
| 595 | - left: 0, | ||
| 596 | - right: 0, | ||
| 597 | - top: _chartTopInset, | ||
| 598 | - bottom: 0, | ||
| 599 | - child: ActivityBurnTrendPlotFrame( | ||
| 600 | - maxY: maxY, | ||
| 601 | - average: widget.report.averageDailyActiveEnergy.toDouble(), | ||
| 602 | - target: widget.report.activeEnergyGoal.toDouble(), | ||
| 603 | - child: LayoutBuilder( | ||
| 604 | - builder: (context, constraints) => Listener( | ||
| 605 | - behavior: HitTestBehavior.opaque, | ||
| 606 | - onPointerDown: (event) => _selectTooltipAt( | ||
| 607 | - event.localPosition.dx, | ||
| 608 | - constraints.maxWidth, | ||
| 609 | - ), | ||
| 610 | - onPointerMove: (event) => _selectTooltipAt( | ||
| 611 | - event.localPosition.dx, | ||
| 612 | - constraints.maxWidth, | ||
| 613 | - ), | ||
| 614 | - onPointerUp: (_) => _scheduleTooltipDismissal(), | ||
| 615 | - onPointerCancel: (_) => _scheduleTooltipDismissal(), | ||
| 616 | - child: Transform.translate( | ||
| 617 | - offset: const Offset( | ||
| 618 | - (_xAxisLeftInset - _xAxisRightInset) / 2, | ||
| 619 | - 0, | ||
| 620 | - ), | ||
| 621 | - child: BarChart( | ||
| 622 | - _chartData(maxY, constraints.maxWidth), | ||
| 623 | - ), | ||
| 624 | - ), | ||
| 625 | - ), | ||
| 626 | - ), | 564 | + if (comparisonPercent != null) ...[ |
| 565 | + Image.asset( | ||
| 566 | + comparisonPercent >= 0 | ||
| 567 | + ? R.assetsImagesHealthTrendUp | ||
| 568 | + : R.assetsImagesHealthTrendDown, | ||
| 569 | + width: 12, | ||
| 570 | + height: 12, | ||
| 571 | + ), | ||
| 572 | + const SizedBox(width: 2), | ||
| 573 | + ], | ||
| 574 | + Text( | ||
| 575 | + _comparisonText(context, comparisonPercent), | ||
| 576 | + style: const TextStyle( | ||
| 577 | + color: ActivityBurnWeekReportView._h2, | ||
| 578 | + fontSize: 12, | ||
| 627 | ), | 579 | ), |
| 628 | ), | 580 | ), |
| 629 | - if (_touchedIndex != null) | ||
| 630 | - Positioned.fill( | 581 | + const Spacer(), |
| 582 | + _TrendLegend( | ||
| 583 | + color: const Color(0xFFFFC0CE), | ||
| 584 | + label: context.l10n.activityTrendAverage, | ||
| 585 | + ), | ||
| 586 | + const SizedBox(width: 12), | ||
| 587 | + _TrendLegend( | ||
| 588 | + color: const Color(0xFF96E9CB), | ||
| 589 | + label: context.l10n.activityTrendGoal, | ||
| 590 | + dashed: true, | ||
| 591 | + ), | ||
| 592 | + ], | ||
| 593 | + ), | ||
| 594 | + const SizedBox(height: 8), | ||
| 595 | + Expanded( | ||
| 596 | + child: Stack( | ||
| 597 | + alignment: Alignment.center, | ||
| 598 | + clipBehavior: Clip.none, | ||
| 599 | + children: [ | ||
| 600 | + Positioned( | ||
| 601 | + left: 0, | ||
| 602 | + right: 0, | ||
| 631 | top: _chartTopInset, | 603 | top: _chartTopInset, |
| 632 | - child: LayoutBuilder( | ||
| 633 | - builder: (context, constraints) { | ||
| 634 | - return ChartSelectionLineOverlay( | ||
| 635 | - offset: Offset.zero, | ||
| 636 | - color: ActivityBurnWeekReportView._h3, | ||
| 637 | - bottomTitleHeight: | ||
| 638 | - ActivityBurnTrendPlotFrame.bottomTitleHeight, | ||
| 639 | - tooltipMargin: _tooltipMargin, | ||
| 640 | - plotLeft: _xAxisLeftInset, | ||
| 641 | - plotRight: _xAxisRightInset, | ||
| 642 | - lineX: _lineXForIndex( | 604 | + bottom: 0, |
| 605 | + child: ActivityBurnTrendPlotFrame( | ||
| 606 | + maxY: maxY, | ||
| 607 | + average: widget.report.averageDailyActiveEnergy | ||
| 608 | + .toDouble(), | ||
| 609 | + target: widget.report.activeEnergyGoal.toDouble(), | ||
| 610 | + child: LayoutBuilder( | ||
| 611 | + builder: (context, constraints) => Listener( | ||
| 612 | + behavior: HitTestBehavior.opaque, | ||
| 613 | + onPointerDown: (event) => _selectTooltipAt( | ||
| 614 | + event.localPosition.dx, | ||
| 643 | constraints.maxWidth, | 615 | constraints.maxWidth, |
| 644 | ), | 616 | ), |
| 645 | - lineTop: | ||
| 646 | - _tooltipBottomForHeight(constraints.maxHeight), | ||
| 647 | - ); | ||
| 648 | - }, | 617 | + onPointerMove: (event) => _selectTooltipAt( |
| 618 | + event.localPosition.dx, | ||
| 619 | + constraints.maxWidth, | ||
| 620 | + ), | ||
| 621 | + onPointerUp: (_) => _scheduleTooltipDismissal(), | ||
| 622 | + onPointerCancel: (_) => _scheduleTooltipDismissal(), | ||
| 623 | + child: Transform.translate( | ||
| 624 | + offset: const Offset( | ||
| 625 | + (_xAxisLeftInset - _xAxisRightInset) / 2, | ||
| 626 | + 0, | ||
| 627 | + ), | ||
| 628 | + child: BarChart( | ||
| 629 | + _chartData(maxY, constraints.maxWidth), | ||
| 630 | + ), | ||
| 631 | + ), | ||
| 632 | + ), | ||
| 633 | + ), | ||
| 649 | ), | 634 | ), |
| 650 | ), | 635 | ), |
| 651 | - if (!hasChartData) | ||
| 652 | - Positioned( | ||
| 653 | - left: _xAxisLeftInset, | ||
| 654 | - right: _xAxisRightInset, | ||
| 655 | - top: _chartTopInset, | ||
| 656 | - bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight, | ||
| 657 | - child: Center( | ||
| 658 | - child: Text( | ||
| 659 | - context.l10n.activityTrendAwaitingData, | ||
| 660 | - style: const TextStyle( | ||
| 661 | - color: Color(0xFFA1A0A5), | ||
| 662 | - fontSize: 12, | ||
| 663 | - fontWeight: FontWeight.w400, | 636 | + if (_touchedIndex != null) |
| 637 | + Positioned.fill( | ||
| 638 | + top: _chartTopInset, | ||
| 639 | + child: LayoutBuilder( | ||
| 640 | + builder: (context, constraints) { | ||
| 641 | + return ChartSelectionLineOverlay( | ||
| 642 | + offset: Offset.zero, | ||
| 643 | + color: ActivityBurnWeekReportView._h3, | ||
| 644 | + bottomTitleHeight: | ||
| 645 | + ActivityBurnTrendPlotFrame.bottomTitleHeight, | ||
| 646 | + tooltipMargin: _tooltipMargin, | ||
| 647 | + plotLeft: _xAxisLeftInset, | ||
| 648 | + plotRight: _xAxisRightInset, | ||
| 649 | + lineX: _lineXForIndex(constraints.maxWidth), | ||
| 650 | + lineTop: _tooltipBottomForHeight( | ||
| 651 | + constraints.maxHeight, | ||
| 652 | + ), | ||
| 653 | + ); | ||
| 654 | + }, | ||
| 655 | + ), | ||
| 656 | + ), | ||
| 657 | + if (!hasChartData) | ||
| 658 | + Positioned( | ||
| 659 | + left: _xAxisLeftInset, | ||
| 660 | + right: _xAxisRightInset, | ||
| 661 | + top: _chartTopInset, | ||
| 662 | + bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight, | ||
| 663 | + child: Center( | ||
| 664 | + child: Text( | ||
| 665 | + context.l10n.activityTrendAwaitingData, | ||
| 666 | + style: const TextStyle( | ||
| 667 | + color: Color(0xFFA1A0A5), | ||
| 668 | + fontSize: 12, | ||
| 669 | + fontWeight: FontWeight.w400, | ||
| 670 | + ), | ||
| 664 | ), | 671 | ), |
| 665 | ), | 672 | ), |
| 666 | ), | 673 | ), |
| 667 | - ), | ||
| 668 | - ], | 674 | + ], |
| 675 | + ), | ||
| 669 | ), | 676 | ), |
| 670 | - ), | ||
| 671 | - ], | 677 | + ], |
| 678 | + ), | ||
| 672 | ), | 679 | ), |
| 673 | ); | 680 | ); |
| 674 | } | 681 | } |
| @@ -678,9 +685,9 @@ class _ActivityBurnEnergyTrendCardState | @@ -678,9 +685,9 @@ class _ActivityBurnEnergyTrendCardState | ||
| 678 | .map((day) => day.activeEnergy?.value ?? 0) | 685 | .map((day) => day.activeEnergy?.value ?? 0) |
| 679 | .fold<int>(0, (max, value) => value > max ? value : max); | 686 | .fold<int>(0, (max, value) => value > max ? value : max); |
| 680 | final maxReference = math.max(maxValue, widget.report.activeEnergyGoal); | 687 | final maxReference = math.max(maxValue, widget.report.activeEnergyGoal); |
| 681 | - return _roundUpToFiveOrZero(maxReference) | ||
| 682 | - .clamp(100, double.infinity) | ||
| 683 | - .toDouble(); | 688 | + return _roundUpToFiveOrZero( |
| 689 | + maxReference, | ||
| 690 | + ).clamp(100, double.infinity).toDouble(); | ||
| 684 | } | 691 | } |
| 685 | 692 | ||
| 686 | bool get _hasChartData => | 693 | bool get _hasChartData => |
| @@ -753,16 +760,18 @@ class _ActivityBurnEnergyTrendCardState | @@ -753,16 +760,18 @@ class _ActivityBurnEnergyTrendCardState | ||
| 753 | if (chartWidth <= 0) return null; | 760 | if (chartWidth <= 0) return null; |
| 754 | final groupsSpace = _groupsSpace(chartWidth, count); | 761 | final groupsSpace = _groupsSpace(chartWidth, count); |
| 755 | const chartLeft = _xAxisLeftInset; | 762 | const chartLeft = _xAxisLeftInset; |
| 756 | - final index = ((dx - chartLeft - widget.report.barWidth / 2) / | ||
| 757 | - (widget.report.barWidth + groupsSpace)) | ||
| 758 | - .round(); | 763 | + final index = |
| 764 | + ((dx - chartLeft - widget.report.barWidth / 2) / | ||
| 765 | + (widget.report.barWidth + groupsSpace)) | ||
| 766 | + .round(); | ||
| 759 | return index.clamp(0, count - 1).toInt(); | 767 | return index.clamp(0, count - 1).toInt(); |
| 760 | } | 768 | } |
| 761 | 769 | ||
| 762 | void _selectTooltipAt(double? dx, double plotWidth) { | 770 | void _selectTooltipAt(double? dx, double plotWidth) { |
| 763 | _tooltipDismissTimer?.cancel(); | 771 | _tooltipDismissTimer?.cancel(); |
| 764 | final touchedIndex = _indexForTouch(dx, plotWidth); | 772 | final touchedIndex = _indexForTouch(dx, plotWidth); |
| 765 | - final hasTouchedData = touchedIndex != null && | 773 | + final hasTouchedData = |
| 774 | + touchedIndex != null && | ||
| 766 | (widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0; | 775 | (widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0; |
| 767 | final nextIndex = hasTouchedData ? touchedIndex : null; | 776 | final nextIndex = hasTouchedData ? touchedIndex : null; |
| 768 | if (_touchedIndex == nextIndex) return; | 777 | if (_touchedIndex == nextIndex) return; |
| @@ -779,16 +788,16 @@ class _ActivityBurnEnergyTrendCardState | @@ -779,16 +788,16 @@ class _ActivityBurnEnergyTrendCardState | ||
| 779 | 788 | ||
| 780 | BarChartData _chartData(double maxY, double plotWidth) { | 789 | BarChartData _chartData(double maxY, double plotWidth) { |
| 781 | final hasData = widget.report.hasData; | 790 | final hasData = widget.report.hasData; |
| 782 | - final chartWidth = | ||
| 783 | - math.max(0.0, plotWidth - _xAxisLeftInset - _xAxisRightInset); | 791 | + final chartWidth = math.max( |
| 792 | + 0.0, | ||
| 793 | + plotWidth - _xAxisLeftInset - _xAxisRightInset, | ||
| 794 | + ); | ||
| 784 | return BarChartData( | 795 | return BarChartData( |
| 785 | minY: 0, | 796 | minY: 0, |
| 786 | maxY: maxY, | 797 | maxY: maxY, |
| 787 | alignment: BarChartAlignment.center, | 798 | alignment: BarChartAlignment.center, |
| 788 | groupsSpace: _groupsSpace(chartWidth, widget.report.days.length), | 799 | groupsSpace: _groupsSpace(chartWidth, widget.report.days.length), |
| 789 | - gridData: FlGridData( | ||
| 790 | - show: false, | ||
| 791 | - ), | 800 | + gridData: FlGridData(show: false), |
| 792 | borderData: FlBorderData(show: false), | 801 | borderData: FlBorderData(show: false), |
| 793 | barTouchData: BarTouchData( | 802 | barTouchData: BarTouchData( |
| 794 | enabled: hasData, | 803 | enabled: hasData, |
| @@ -802,10 +811,7 @@ class _ActivityBurnEnergyTrendCardState | @@ -802,10 +811,7 @@ class _ActivityBurnEnergyTrendCardState | ||
| 802 | _scheduleTooltipDismissal(); | 811 | _scheduleTooltipDismissal(); |
| 803 | return; | 812 | return; |
| 804 | } | 813 | } |
| 805 | - _selectTooltipAt( | ||
| 806 | - event.localPosition?.dx, | ||
| 807 | - plotWidth, | ||
| 808 | - ); | 814 | + _selectTooltipAt(event.localPosition?.dx, plotWidth); |
| 809 | }, | 815 | }, |
| 810 | touchTooltipData: BarTouchTooltipData( | 816 | touchTooltipData: BarTouchTooltipData( |
| 811 | tooltipRoundedRadius: 4, | 817 | tooltipRoundedRadius: 4, |
| @@ -850,7 +856,8 @@ class _ActivityBurnEnergyTrendCardState | @@ -850,7 +856,8 @@ class _ActivityBurnEnergyTrendCardState | ||
| 850 | reservedSize: 36, | 856 | reservedSize: 36, |
| 851 | interval: activityBurnTrendYInterval(maxY), | 857 | interval: activityBurnTrendYInterval(maxY), |
| 852 | getTitlesWidget: (value, meta) { | 858 | getTitlesWidget: (value, meta) { |
| 853 | - final isRegularTick = value % activityBurnTrendYInterval(maxY) == 0; | 859 | + final isRegularTick = |
| 860 | + value % activityBurnTrendYInterval(maxY) == 0; | ||
| 854 | final isMax = (value - maxY).abs() < 0.001; | 861 | final isMax = (value - maxY).abs() < 0.001; |
| 855 | if (!isRegularTick && !isMax) return const SizedBox.shrink(); | 862 | if (!isRegularTick && !isMax) return const SizedBox.shrink(); |
| 856 | return Transform.translate( | 863 | return Transform.translate( |
| @@ -939,8 +946,10 @@ class _ActivityBurnEnergyTrendCardState | @@ -939,8 +946,10 @@ class _ActivityBurnEnergyTrendCardState | ||
| 939 | final count = widget.report.days.length; | 946 | final count = widget.report.days.length; |
| 940 | if (count <= 7) return true; | 947 | if (count <= 7) return true; |
| 941 | final lastIndex = count - 1; | 948 | final lastIndex = count - 1; |
| 942 | - return List.generate(7, (tick) => (tick * lastIndex / 6).round()) | ||
| 943 | - .contains(index); | 949 | + return List.generate( |
| 950 | + 7, | ||
| 951 | + (tick) => (tick * lastIndex / 6).round(), | ||
| 952 | + ).contains(index); | ||
| 944 | } | 953 | } |
| 945 | } | 954 | } |
| 946 | 955 |
| 1 | +import 'dart:async'; | ||
| 2 | + | ||
| 1 | import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart'; | 3 | import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart'; |
| 4 | +import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart'; | ||
| 2 | import 'package:doublefeel_flutter/app/modules/purchase/purchase_route_args.dart'; | 5 | import 'package:doublefeel_flutter/app/modules/purchase/purchase_route_args.dart'; |
| 3 | import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/guide_common_scaffold.dart'; | 6 | import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/guide_common_scaffold.dart'; |
| 4 | import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart'; | 7 | import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart'; |
| @@ -137,6 +140,12 @@ class BindPartnerController extends GetxController { | @@ -137,6 +140,12 @@ class BindPartnerController extends GetxController { | ||
| 137 | break; | 140 | break; |
| 138 | case AppSuccess(data: final friendItem): | 141 | case AppSuccess(data: final friendItem): |
| 139 | _platformHostApi.refreshWatchAppAndWidgets(); | 142 | _platformHostApi.refreshWatchAppAndWidgets(); |
| 143 | + // The add-friend flow can be opened outside the Friends tab (for | ||
| 144 | + // example from Today). Refresh the home-owned list so PK appears as | ||
| 145 | + // soon as the new friend is available. | ||
| 146 | + if (Get.isRegistered<FriendsController>()) { | ||
| 147 | + unawaited(Get.find<FriendsController>().loadFriends()); | ||
| 148 | + } | ||
| 140 | Get.to(() => _BindSuccessPage(friendItem, isShowAppBarBack)); | 149 | Get.to(() => _BindSuccessPage(friendItem, isShowAppBarBack)); |
| 141 | ta.track('success_add_doubelfeel_friend'); | 150 | ta.track('success_add_doubelfeel_friend'); |
| 142 | break; | 151 | break; |
| 1 | +import 'package:doublefeel_flutter/app/modules/change_phone/controllers/change_phone_controller.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/app/modules/change_phone/services/change_phone_gateway.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/data/local/local_storage.dart'; | ||
| 4 | +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | ||
| 5 | +import 'package:get/get.dart'; | ||
| 6 | + | ||
| 7 | +class ChangePhoneBinding extends Bindings { | ||
| 8 | + @override | ||
| 9 | + void dependencies() { | ||
| 10 | + Get.lazyPut<ChangePhoneGateway>( | ||
| 11 | + () => const PlaceholderChangePhoneGateway()); | ||
| 12 | + Get.lazyPut<ChangePhoneController>( | ||
| 13 | + () => ChangePhoneController( | ||
| 14 | + userPreferencesStorage: Get.find<UserPreferencesStorage>(), | ||
| 15 | + localStorage: Get.find<LocalStorage>(), | ||
| 16 | + gateway: Get.find<ChangePhoneGateway>(), | ||
| 17 | + ), | ||
| 18 | + ); | ||
| 19 | + } | ||
| 20 | +} |
| 1 | +import 'dart:async'; | ||
| 2 | + | ||
| 3 | +import 'package:doublefeel_flutter/app/modules/change_phone/services/change_phone_gateway.dart'; | ||
| 4 | +import 'package:doublefeel_flutter/app/routes/app_pages.dart'; | ||
| 5 | +import 'package:doublefeel_flutter/core/util/app_toast.dart'; | ||
| 6 | +import 'package:doublefeel_flutter/data/local/local_storage.dart'; | ||
| 7 | +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | ||
| 8 | +import 'package:flutter/material.dart'; | ||
| 9 | +import 'package:get/get.dart'; | ||
| 10 | + | ||
| 11 | +enum ChangePhoneStep { verifyOldPhone, bindNewPhone } | ||
| 12 | + | ||
| 13 | +class ChangePhoneController extends GetxController { | ||
| 14 | + ChangePhoneController({ | ||
| 15 | + required UserPreferencesStorage userPreferencesStorage, | ||
| 16 | + required LocalStorage localStorage, | ||
| 17 | + required ChangePhoneGateway gateway, | ||
| 18 | + }) : _userPreferencesStorage = userPreferencesStorage, | ||
| 19 | + _localStorage = localStorage, | ||
| 20 | + _gateway = gateway; | ||
| 21 | + | ||
| 22 | + final UserPreferencesStorage _userPreferencesStorage; | ||
| 23 | + final LocalStorage _localStorage; | ||
| 24 | + final ChangePhoneGateway _gateway; | ||
| 25 | + | ||
| 26 | + final phoneController = TextEditingController(); | ||
| 27 | + final codeController = TextEditingController(); | ||
| 28 | + final codeFocusNode = FocusNode(); | ||
| 29 | + | ||
| 30 | + final step = ChangePhoneStep.verifyOldPhone.obs; | ||
| 31 | + final phoneInput = ''.obs; | ||
| 32 | + final codeInput = ''.obs; | ||
| 33 | + final isRequestingCode = false.obs; | ||
| 34 | + final isSubmitting = false.obs; | ||
| 35 | + final countdownSeconds = 0.obs; | ||
| 36 | + Timer? _countdownTimer; | ||
| 37 | + | ||
| 38 | + String get cleanPhone => phoneInput.value.replaceAll(RegExp(r'\s+'), ''); | ||
| 39 | + | ||
| 40 | + bool get isPhoneValid => RegExp(r'^\d{11}$').hasMatch(cleanPhone); | ||
| 41 | + | ||
| 42 | + bool get canRequestCode => | ||
| 43 | + isPhoneValid && !isRequestingCode.value && countdownSeconds.value == 0; | ||
| 44 | + | ||
| 45 | + bool get canSubmit => | ||
| 46 | + isPhoneValid && codeInput.value.isNotEmpty && !isSubmitting.value; | ||
| 47 | + | ||
| 48 | + bool get isOldPhoneStep => step.value == ChangePhoneStep.verifyOldPhone; | ||
| 49 | + | ||
| 50 | + bool get canChangePhone => _localStorage.lastLoginMethod == 'phone'; | ||
| 51 | + | ||
| 52 | + String get maskedOldPhone { | ||
| 53 | + final phone = _userPreferencesStorage | ||
| 54 | + .preferences.value.meUserInfo?.telephone | ||
| 55 | + ?.replaceAll(RegExp(r'\s+'), ''); | ||
| 56 | + if (phone == null || phone.length != 11) return '123****1212'; | ||
| 57 | + return '${phone.substring(0, 3)}****${phone.substring(7)}'; | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + @override | ||
| 61 | + void onInit() { | ||
| 62 | + super.onInit(); | ||
| 63 | + phoneController.addListener(_onPhoneChanged); | ||
| 64 | + codeController.addListener(() => codeInput.value = codeController.text); | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + @override | ||
| 68 | + void onReady() { | ||
| 69 | + super.onReady(); | ||
| 70 | + if (!canChangePhone) { | ||
| 71 | + AppToast.show('仅支持手机号登录的账号更换手机号'); | ||
| 72 | + Get.back(); | ||
| 73 | + } | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + Future<void> requestVerificationCode() async { | ||
| 77 | + if (!canRequestCode) return; | ||
| 78 | + | ||
| 79 | + isRequestingCode.value = true; | ||
| 80 | + try { | ||
| 81 | + await _gateway.requestVerificationCode( | ||
| 82 | + phone: cleanPhone, | ||
| 83 | + isOldPhone: isOldPhoneStep, | ||
| 84 | + ); | ||
| 85 | + _startCountdown(); | ||
| 86 | + codeFocusNode.requestFocus(); | ||
| 87 | + } on UnsupportedError { | ||
| 88 | + AppToast.show('手机号更换服务暂未开通'); | ||
| 89 | + } finally { | ||
| 90 | + isRequestingCode.value = false; | ||
| 91 | + } | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + Future<void> submit() async { | ||
| 95 | + if (!canSubmit) return; | ||
| 96 | + | ||
| 97 | + isSubmitting.value = true; | ||
| 98 | + try { | ||
| 99 | + if (isOldPhoneStep) { | ||
| 100 | + await _gateway.verifyOldPhone( | ||
| 101 | + phone: cleanPhone, | ||
| 102 | + verificationCode: codeInput.value, | ||
| 103 | + ); | ||
| 104 | + _showNewPhoneStep(); | ||
| 105 | + } else { | ||
| 106 | + await _gateway.bindNewPhone( | ||
| 107 | + phone: cleanPhone, | ||
| 108 | + verificationCode: codeInput.value, | ||
| 109 | + ); | ||
| 110 | + Get.back(); | ||
| 111 | + } | ||
| 112 | + } on UnsupportedError { | ||
| 113 | + AppToast.show('手机号更换服务暂未开通'); | ||
| 114 | + } finally { | ||
| 115 | + isSubmitting.value = false; | ||
| 116 | + } | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + void clearPhone() => phoneController.clear(); | ||
| 120 | + | ||
| 121 | + void openHelp() => Get.toNamed(Routes.HELP); | ||
| 122 | + | ||
| 123 | + void _onPhoneChanged() { | ||
| 124 | + final newPhone = phoneController.text; | ||
| 125 | + if (phoneInput.value != newPhone && | ||
| 126 | + (countdownSeconds.value > 0 || codeInput.value.isNotEmpty)) { | ||
| 127 | + _resetVerificationState(); | ||
| 128 | + } | ||
| 129 | + phoneInput.value = newPhone; | ||
| 130 | + } | ||
| 131 | + | ||
| 132 | + void _showNewPhoneStep() { | ||
| 133 | + step.value = ChangePhoneStep.bindNewPhone; | ||
| 134 | + phoneController.clear(); | ||
| 135 | + codeController.clear(); | ||
| 136 | + _resetVerificationState(); | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + void _resetVerificationState() { | ||
| 140 | + _countdownTimer?.cancel(); | ||
| 141 | + countdownSeconds.value = 0; | ||
| 142 | + codeController.clear(); | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + void _startCountdown() { | ||
| 146 | + _countdownTimer?.cancel(); | ||
| 147 | + countdownSeconds.value = 60; | ||
| 148 | + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { | ||
| 149 | + if (countdownSeconds.value <= 1) { | ||
| 150 | + countdownSeconds.value = 0; | ||
| 151 | + timer.cancel(); | ||
| 152 | + } else { | ||
| 153 | + countdownSeconds.value--; | ||
| 154 | + } | ||
| 155 | + }); | ||
| 156 | + } | ||
| 157 | + | ||
| 158 | + @override | ||
| 159 | + void onClose() { | ||
| 160 | + _countdownTimer?.cancel(); | ||
| 161 | + phoneController.dispose(); | ||
| 162 | + codeController.dispose(); | ||
| 163 | + codeFocusNode.dispose(); | ||
| 164 | + super.onClose(); | ||
| 165 | + } | ||
| 166 | +} |
| 1 | +/// Boundary for the phone-change APIs. | ||
| 2 | +/// | ||
| 3 | +/// Replace [PlaceholderChangePhoneGateway] in the binding with the real | ||
| 4 | +/// implementation once the server endpoints are available. Keeping the | ||
| 5 | +/// contract here makes the page and its interaction states usable meanwhile. | ||
| 6 | +abstract interface class ChangePhoneGateway { | ||
| 7 | + Future<void> requestVerificationCode({ | ||
| 8 | + required String phone, | ||
| 9 | + required bool isOldPhone, | ||
| 10 | + }); | ||
| 11 | + | ||
| 12 | + Future<void> verifyOldPhone({ | ||
| 13 | + required String phone, | ||
| 14 | + required String verificationCode, | ||
| 15 | + }); | ||
| 16 | + | ||
| 17 | + Future<void> bindNewPhone({ | ||
| 18 | + required String phone, | ||
| 19 | + required String verificationCode, | ||
| 20 | + }); | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +/// Temporary API implementation that deliberately does not report success. | ||
| 24 | +/// | ||
| 25 | +/// TODO(server): replace these methods with the change-phone endpoints. This | ||
| 26 | +/// prevents the UI from claiming that a phone number was changed before an API | ||
| 27 | +/// exists to persist and verify it. | ||
| 28 | +class PlaceholderChangePhoneGateway implements ChangePhoneGateway { | ||
| 29 | + const PlaceholderChangePhoneGateway(); | ||
| 30 | + | ||
| 31 | + @override | ||
| 32 | + Future<void> bindNewPhone({ | ||
| 33 | + required String phone, | ||
| 34 | + required String verificationCode, | ||
| 35 | + }) => | ||
| 36 | + throw UnsupportedError('Change-phone API is not configured.'); | ||
| 37 | + | ||
| 38 | + @override | ||
| 39 | + Future<void> requestVerificationCode({ | ||
| 40 | + required String phone, | ||
| 41 | + required bool isOldPhone, | ||
| 42 | + }) => | ||
| 43 | + throw UnsupportedError('Change-phone API is not configured.'); | ||
| 44 | + | ||
| 45 | + @override | ||
| 46 | + Future<void> verifyOldPhone({ | ||
| 47 | + required String phone, | ||
| 48 | + required String verificationCode, | ||
| 49 | + }) => | ||
| 50 | + throw UnsupportedError('Change-phone API is not configured.'); | ||
| 51 | +} |
| 1 | +import 'package:doublefeel_flutter/app/modules/change_phone/controllers/change_phone_controller.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/core/theme/app_colors.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | ||
| 4 | +import 'package:doublefeel_flutter/r.dart'; | ||
| 5 | +import 'package:flutter/material.dart'; | ||
| 6 | +import 'package:flutter/services.dart'; | ||
| 7 | +import 'package:get/get.dart'; | ||
| 8 | + | ||
| 9 | +class ChangePhoneView extends GetView<ChangePhoneController> { | ||
| 10 | + const ChangePhoneView({super.key}); | ||
| 11 | + | ||
| 12 | + @override | ||
| 13 | + Widget build(BuildContext context) { | ||
| 14 | + return AnnotatedRegion<SystemUiOverlayStyle>( | ||
| 15 | + value: const SystemUiOverlayStyle( | ||
| 16 | + statusBarColor: AppColors.backgroundPage, | ||
| 17 | + statusBarIconBrightness: Brightness.dark, | ||
| 18 | + statusBarBrightness: Brightness.light, | ||
| 19 | + systemNavigationBarColor: AppColors.backgroundPage, | ||
| 20 | + systemNavigationBarIconBrightness: Brightness.dark, | ||
| 21 | + ), | ||
| 22 | + child: Scaffold( | ||
| 23 | + resizeToAvoidBottomInset: false, | ||
| 24 | + backgroundColor: AppColors.backgroundPage, | ||
| 25 | + body: SafeArea( | ||
| 26 | + child: Column( | ||
| 27 | + crossAxisAlignment: CrossAxisAlignment.stretch, | ||
| 28 | + children: [ | ||
| 29 | + SizedBox( | ||
| 30 | + height: 44.dp, | ||
| 31 | + child: Align( | ||
| 32 | + alignment: Alignment.centerLeft, | ||
| 33 | + child: IconButton( | ||
| 34 | + onPressed: Get.back, | ||
| 35 | + padding: EdgeInsets.only(left: 16.dp), | ||
| 36 | + splashColor: Colors.transparent, | ||
| 37 | + highlightColor: Colors.transparent, | ||
| 38 | + icon: Image.asset( | ||
| 39 | + R.assetsImagesNavBackIcon, | ||
| 40 | + width: 28.dp, | ||
| 41 | + height: 28.dp, | ||
| 42 | + ), | ||
| 43 | + ), | ||
| 44 | + ), | ||
| 45 | + ), | ||
| 46 | + Expanded( | ||
| 47 | + child: Obx(() { | ||
| 48 | + final oldPhoneStep = controller.isOldPhoneStep; | ||
| 49 | + return SingleChildScrollView( | ||
| 50 | + padding: EdgeInsets.symmetric(horizontal: 28.dp), | ||
| 51 | + child: Column( | ||
| 52 | + children: [ | ||
| 53 | + SizedBox(height: 16.dp), | ||
| 54 | + Text( | ||
| 55 | + oldPhoneStep ? '原手机号验证' : '新手机号绑定', | ||
| 56 | + style: TextStyle( | ||
| 57 | + color: AppColors.textPrimary, | ||
| 58 | + fontSize: 24.dp, | ||
| 59 | + fontWeight: FontWeight.w600, | ||
| 60 | + height: 1.35, | ||
| 61 | + ), | ||
| 62 | + ), | ||
| 63 | + if (oldPhoneStep) ...[ | ||
| 64 | + SizedBox(height: 4.dp), | ||
| 65 | + SizedBox( | ||
| 66 | + height: 40.dp, | ||
| 67 | + child: Text( | ||
| 68 | + '请输入该账号绑定的原手机号${controller.maskedOldPhone},完成手机验证', | ||
| 69 | + textAlign: TextAlign.center, | ||
| 70 | + style: TextStyle( | ||
| 71 | + color: AppColors.textSecondary, | ||
| 72 | + fontSize: 14.dp, | ||
| 73 | + fontWeight: FontWeight.w400, | ||
| 74 | + height: 1.45, | ||
| 75 | + ), | ||
| 76 | + ), | ||
| 77 | + ), | ||
| 78 | + SizedBox(height: 55.dp), | ||
| 79 | + ] else | ||
| 80 | + SizedBox(height: 103.dp), | ||
| 81 | + _PhoneField(controller: controller), | ||
| 82 | + SizedBox(height: 8.dp), | ||
| 83 | + _CodeField(controller: controller), | ||
| 84 | + SizedBox(height: 68.dp), | ||
| 85 | + _SubmitButton(controller: controller), | ||
| 86 | + if (oldPhoneStep) ...[ | ||
| 87 | + SizedBox(height: 12.dp), | ||
| 88 | + TextButton( | ||
| 89 | + onPressed: controller.openHelp, | ||
| 90 | + style: TextButton.styleFrom( | ||
| 91 | + foregroundColor: AppColors.chartBlue, | ||
| 92 | + minimumSize: Size.zero, | ||
| 93 | + padding: EdgeInsets.symmetric( | ||
| 94 | + horizontal: 8.dp, | ||
| 95 | + vertical: 4.dp, | ||
| 96 | + ), | ||
| 97 | + tapTargetSize: MaterialTapTargetSize.shrinkWrap, | ||
| 98 | + ), | ||
| 99 | + child: Text( | ||
| 100 | + '现手机已不可用?', | ||
| 101 | + style: TextStyle( | ||
| 102 | + fontSize: 12.dp, | ||
| 103 | + fontWeight: FontWeight.w500, | ||
| 104 | + ), | ||
| 105 | + ), | ||
| 106 | + ), | ||
| 107 | + ], | ||
| 108 | + ], | ||
| 109 | + ), | ||
| 110 | + ); | ||
| 111 | + }), | ||
| 112 | + ), | ||
| 113 | + ], | ||
| 114 | + ), | ||
| 115 | + ), | ||
| 116 | + ), | ||
| 117 | + ); | ||
| 118 | + } | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +class _PhoneField extends StatelessWidget { | ||
| 122 | + const _PhoneField({required this.controller}); | ||
| 123 | + | ||
| 124 | + final ChangePhoneController controller; | ||
| 125 | + | ||
| 126 | + @override | ||
| 127 | + Widget build(BuildContext context) { | ||
| 128 | + return SizedBox( | ||
| 129 | + height: 52.dp, | ||
| 130 | + child: Obx(() => TextField( | ||
| 131 | + controller: controller.phoneController, | ||
| 132 | + keyboardType: TextInputType.phone, | ||
| 133 | + maxLength: 11, | ||
| 134 | + inputFormatters: [FilteringTextInputFormatter.digitsOnly], | ||
| 135 | + style: TextStyle( | ||
| 136 | + color: AppColors.textPrimary, | ||
| 137 | + fontSize: 16.dp, | ||
| 138 | + fontWeight: FontWeight.w400, | ||
| 139 | + ), | ||
| 140 | + decoration: _inputDecoration( | ||
| 141 | + hintText: '', | ||
| 142 | + prefix: Padding( | ||
| 143 | + padding: EdgeInsets.only(right: 12.dp), | ||
| 144 | + child: Text( | ||
| 145 | + '+86', | ||
| 146 | + style: TextStyle( | ||
| 147 | + color: AppColors.textTertiary, | ||
| 148 | + fontSize: 16.dp, | ||
| 149 | + fontWeight: FontWeight.w400, | ||
| 150 | + ), | ||
| 151 | + ), | ||
| 152 | + ), | ||
| 153 | + suffix: controller.phoneInput.value.isEmpty | ||
| 154 | + ? null | ||
| 155 | + : IconButton( | ||
| 156 | + onPressed: controller.clearPhone, | ||
| 157 | + icon: Icon( | ||
| 158 | + Icons.cancel, | ||
| 159 | + color: const Color(0xFFB0B0B6), | ||
| 160 | + size: 24.dp, | ||
| 161 | + ), | ||
| 162 | + ), | ||
| 163 | + ), | ||
| 164 | + )), | ||
| 165 | + ); | ||
| 166 | + } | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +class _CodeField extends StatelessWidget { | ||
| 170 | + const _CodeField({required this.controller}); | ||
| 171 | + | ||
| 172 | + final ChangePhoneController controller; | ||
| 173 | + | ||
| 174 | + @override | ||
| 175 | + Widget build(BuildContext context) { | ||
| 176 | + return SizedBox( | ||
| 177 | + height: 52.dp, | ||
| 178 | + child: Obx(() { | ||
| 179 | + final seconds = controller.countdownSeconds.value; | ||
| 180 | + final requesting = controller.isRequestingCode.value; | ||
| 181 | + final buttonText = requesting | ||
| 182 | + ? '发送中' | ||
| 183 | + : seconds > 0 | ||
| 184 | + ? '重新发送(${seconds}s)' | ||
| 185 | + : '发送验证码'; | ||
| 186 | + return TextField( | ||
| 187 | + controller: controller.codeController, | ||
| 188 | + focusNode: controller.codeFocusNode, | ||
| 189 | + keyboardType: TextInputType.number, | ||
| 190 | + maxLength: 6, | ||
| 191 | + inputFormatters: [FilteringTextInputFormatter.digitsOnly], | ||
| 192 | + style: TextStyle( | ||
| 193 | + color: AppColors.textPrimary, | ||
| 194 | + fontSize: 16.dp, | ||
| 195 | + fontWeight: FontWeight.w400, | ||
| 196 | + ), | ||
| 197 | + decoration: _inputDecoration( | ||
| 198 | + hintText: '输入验证码', | ||
| 199 | + suffix: controller.phoneInput.value.isEmpty | ||
| 200 | + ? null | ||
| 201 | + : TextButton( | ||
| 202 | + onPressed: controller.canRequestCode | ||
| 203 | + ? controller.requestVerificationCode | ||
| 204 | + : null, | ||
| 205 | + style: TextButton.styleFrom( | ||
| 206 | + minimumSize: Size.zero, | ||
| 207 | + padding: EdgeInsets.symmetric(horizontal: 12.dp), | ||
| 208 | + tapTargetSize: MaterialTapTargetSize.shrinkWrap, | ||
| 209 | + foregroundColor: AppColors.primary, | ||
| 210 | + disabledForegroundColor: AppColors.textTertiary, | ||
| 211 | + ), | ||
| 212 | + child: Text( | ||
| 213 | + buttonText, | ||
| 214 | + style: TextStyle( | ||
| 215 | + fontSize: 14.dp, | ||
| 216 | + fontWeight: FontWeight.w500, | ||
| 217 | + ), | ||
| 218 | + ), | ||
| 219 | + ), | ||
| 220 | + ), | ||
| 221 | + ); | ||
| 222 | + }), | ||
| 223 | + ); | ||
| 224 | + } | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +class _SubmitButton extends StatelessWidget { | ||
| 228 | + const _SubmitButton({required this.controller}); | ||
| 229 | + | ||
| 230 | + final ChangePhoneController controller; | ||
| 231 | + | ||
| 232 | + @override | ||
| 233 | + Widget build(BuildContext context) { | ||
| 234 | + return Obx(() => SizedBox( | ||
| 235 | + width: double.infinity, | ||
| 236 | + height: 48.dp, | ||
| 237 | + child: ElevatedButton( | ||
| 238 | + onPressed: controller.canSubmit ? controller.submit : null, | ||
| 239 | + style: ElevatedButton.styleFrom( | ||
| 240 | + elevation: 0, | ||
| 241 | + backgroundColor: AppColors.primary, | ||
| 242 | + disabledBackgroundColor: AppColors.primary.withValues(alpha: 0.4), | ||
| 243 | + foregroundColor: Colors.white, | ||
| 244 | + disabledForegroundColor: Colors.white, | ||
| 245 | + shape: RoundedRectangleBorder( | ||
| 246 | + borderRadius: BorderRadius.circular(24.dp), | ||
| 247 | + ), | ||
| 248 | + textStyle: | ||
| 249 | + TextStyle(fontSize: 16.dp, fontWeight: FontWeight.w600), | ||
| 250 | + ), | ||
| 251 | + child: const Text('确定'), | ||
| 252 | + ), | ||
| 253 | + )); | ||
| 254 | + } | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +InputDecoration _inputDecoration({ | ||
| 258 | + required String hintText, | ||
| 259 | + Widget? prefix, | ||
| 260 | + Widget? suffix, | ||
| 261 | +}) { | ||
| 262 | + final border = OutlineInputBorder( | ||
| 263 | + borderRadius: BorderRadius.circular(27), | ||
| 264 | + borderSide: BorderSide.none, | ||
| 265 | + ); | ||
| 266 | + return InputDecoration( | ||
| 267 | + hintText: hintText, | ||
| 268 | + hintStyle: const TextStyle(color: AppColors.textTertiary, fontSize: 16), | ||
| 269 | + counterText: '', | ||
| 270 | + filled: true, | ||
| 271 | + fillColor: Colors.white, | ||
| 272 | + contentPadding: const EdgeInsets.symmetric(horizontal: 28, vertical: 0), | ||
| 273 | + prefixIcon: prefix == null ? null : Center(child: prefix), | ||
| 274 | + prefixIconConstraints: const BoxConstraints(minWidth: 0), | ||
| 275 | + suffixIcon: suffix, | ||
| 276 | + suffixIconConstraints: const BoxConstraints(minWidth: 0), | ||
| 277 | + border: border, | ||
| 278 | + enabledBorder: border, | ||
| 279 | + focusedBorder: border, | ||
| 280 | + ); | ||
| 281 | +} |
| 1 | -import 'package:cached_network_image/cached_network_image.dart'; | ||
| 2 | import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'; | 1 | import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'; |
| 3 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 2 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 4 | import 'package:flutter/material.dart'; | 3 | import 'package:flutter/material.dart'; |
| @@ -65,6 +64,7 @@ class FriendTrendView extends GetView<FriendTrendController> { | @@ -65,6 +64,7 @@ class FriendTrendView extends GetView<FriendTrendController> { | ||
| 65 | selectedTypeIndex: controller.selectedTypeIndex.value, | 64 | selectedTypeIndex: controller.selectedTypeIndex.value, |
| 66 | onTypeChanged: controller.changeType, | 65 | onTypeChanged: controller.changeType, |
| 67 | onQueryChanged: controller.changeQuery, | 66 | onQueryChanged: controller.changeQuery, |
| 67 | + friendAvatarUrl: controller.friendItem?.avatar, | ||
| 68 | ), | 68 | ), |
| 69 | ), | 69 | ), |
| 70 | ), | 70 | ), |
| @@ -87,10 +87,7 @@ class FriendTrendView extends GetView<FriendTrendController> { | @@ -87,10 +87,7 @@ class FriendTrendView extends GetView<FriendTrendController> { | ||
| 87 | } | 87 | } |
| 88 | 88 | ||
| 89 | class _FriendTrendTitle extends StatelessWidget { | 89 | class _FriendTrendTitle extends StatelessWidget { |
| 90 | - const _FriendTrendTitle({ | ||
| 91 | - required this.title, | ||
| 92 | - required this.avatarUrl, | ||
| 93 | - }); | 90 | + const _FriendTrendTitle({required this.title, required this.avatarUrl}); |
| 94 | 91 | ||
| 95 | final String title; | 92 | final String title; |
| 96 | final String? avatarUrl; | 93 | final String? avatarUrl; |
| @@ -29,6 +29,7 @@ class HealthTrendContent extends StatefulWidget { | @@ -29,6 +29,7 @@ class HealthTrendContent extends StatefulWidget { | ||
| 29 | required this.onTypeChanged, | 29 | required this.onTypeChanged, |
| 30 | required this.onQueryChanged, | 30 | required this.onQueryChanged, |
| 31 | this.queryForType, | 31 | this.queryForType, |
| 32 | + this.friendAvatarUrl, | ||
| 32 | }); | 33 | }); |
| 33 | 34 | ||
| 34 | final HealthReportQuery query; | 35 | final HealthReportQuery query; |
| @@ -41,6 +42,7 @@ class HealthTrendContent extends StatefulWidget { | @@ -41,6 +42,7 @@ class HealthTrendContent extends StatefulWidget { | ||
| 41 | final ValueChanged<int> onTypeChanged; | 42 | final ValueChanged<int> onTypeChanged; |
| 42 | final void Function(ReportPeriod period, DateTime date) onQueryChanged; | 43 | final void Function(ReportPeriod period, DateTime date) onQueryChanged; |
| 43 | final HealthReportQuery Function(int typeIndex)? queryForType; | 44 | final HealthReportQuery Function(int typeIndex)? queryForType; |
| 45 | + final String? friendAvatarUrl; | ||
| 44 | 46 | ||
| 45 | @override | 47 | @override |
| 46 | State<HealthTrendContent> createState() => _HealthTrendContentState(); | 48 | State<HealthTrendContent> createState() => _HealthTrendContentState(); |
| @@ -110,12 +112,14 @@ class _HealthTrendContentState extends State<HealthTrendContent> | @@ -110,12 +112,14 @@ class _HealthTrendContentState extends State<HealthTrendContent> | ||
| 110 | final vipInfo = preferences.vipInfo; | 112 | final vipInfo = preferences.vipInfo; |
| 111 | final isVip = vipInfo?.isVip ?? false; | 113 | final isVip = vipInfo?.isVip ?? false; |
| 112 | void openPurchase(String channelType) => Get.toNamed( | 114 | void openPurchase(String channelType) => Get.toNamed( |
| 113 | - Routes.PURCHASE, | ||
| 114 | - arguments: {IntentKeys.channelType: channelType}, | ||
| 115 | - ); | 115 | + Routes.PURCHASE, |
| 116 | + arguments: {IntentKeys.channelType: channelType}, | ||
| 117 | + ); | ||
| 116 | 118 | ||
| 117 | return HealthReportSubjectScope( | 119 | return HealthReportSubjectScope( |
| 118 | isSelf: widget.query.isSelf, | 120 | isSelf: widget.query.isSelf, |
| 121 | + friendUserId: widget.query.targetUserId, | ||
| 122 | + friendAvatarUrl: widget.friendAvatarUrl, | ||
| 119 | child: Column( | 123 | child: Column( |
| 120 | children: [ | 124 | children: [ |
| 121 | TrendTypeTabBar(tabController: _tabController), | 125 | TrendTypeTabBar(tabController: _tabController), |
| @@ -322,11 +326,11 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { | @@ -322,11 +326,11 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { | ||
| 322 | 326 | ||
| 323 | @override | 327 | @override |
| 324 | Widget build(BuildContext context) => HrvReportView( | 328 | Widget build(BuildContext context) => HrvReportView( |
| 325 | - logic: _logic, | ||
| 326 | - isVip: widget.isVip, | ||
| 327 | - onSubscribe: widget.onSubscribe, | ||
| 328 | - loadingIndicator: const _HealthTrendLoadingIndicator(), | ||
| 329 | - ); | 329 | + logic: _logic, |
| 330 | + isVip: widget.isVip, | ||
| 331 | + onSubscribe: widget.onSubscribe, | ||
| 332 | + loadingIndicator: const _HealthTrendLoadingIndicator(), | ||
| 333 | + ); | ||
| 330 | } | 334 | } |
| 331 | 335 | ||
| 332 | class _ActivityBurnTrendSection extends StatefulWidget { | 336 | class _ActivityBurnTrendSection extends StatefulWidget { |
| @@ -422,12 +426,12 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { | @@ -422,12 +426,12 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { | ||
| 422 | 426 | ||
| 423 | @override | 427 | @override |
| 424 | Widget build(BuildContext context) => ActivityBurnReportView( | 428 | Widget build(BuildContext context) => ActivityBurnReportView( |
| 425 | - logic: _logic, | ||
| 426 | - isVip: widget.isVip, | ||
| 427 | - onSubscribe: widget.onSubscribe, | ||
| 428 | - loadingIndicator: const _HealthTrendLoadingIndicator(), | ||
| 429 | - tooltipDismissDelayMs: 4000, | ||
| 430 | - ); | 429 | + logic: _logic, |
| 430 | + isVip: widget.isVip, | ||
| 431 | + onSubscribe: widget.onSubscribe, | ||
| 432 | + loadingIndicator: const _HealthTrendLoadingIndicator(), | ||
| 433 | + tooltipDismissDelayMs: 4000, | ||
| 434 | + ); | ||
| 431 | } | 435 | } |
| 432 | 436 | ||
| 433 | class _SleepTrendSection extends StatefulWidget { | 437 | class _SleepTrendSection extends StatefulWidget { |
| @@ -462,9 +466,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | @@ -462,9 +466,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | ||
| 462 | @override | 466 | @override |
| 463 | void initState() { | 467 | void initState() { |
| 464 | super.initState(); | 468 | super.initState(); |
| 465 | - _logic = SleepReportLogic( | ||
| 466 | - initialTargetUserId: widget.query.targetUserId, | ||
| 467 | - ); | 469 | + _logic = SleepReportLogic(initialTargetUserId: widget.query.targetUserId); |
| 468 | _appReviewPromptLogic = AppReviewPromptLogic( | 470 | _appReviewPromptLogic = AppReviewPromptLogic( |
| 469 | storage: Get.find<LocalStorage>(), | 471 | storage: Get.find<LocalStorage>(), |
| 470 | userIdProvider: () => Get.find<UserStateService>().userId, | 472 | userIdProvider: () => Get.find<UserStateService>().userId, |
| @@ -527,14 +529,14 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | @@ -527,14 +529,14 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | ||
| 527 | 529 | ||
| 528 | @override | 530 | @override |
| 529 | Widget build(BuildContext context) => SleepReportView( | 531 | Widget build(BuildContext context) => SleepReportView( |
| 530 | - logic: _logic, | ||
| 531 | - isVip: widget.isVip, | ||
| 532 | - onSubscribe: widget.onSubscribe, | ||
| 533 | - onReportViewed: widget.isSelected | ||
| 534 | - ? _appReviewPromptLogic.recordPromptEligibility | ||
| 535 | - : null, | ||
| 536 | - loadingIndicator: const _HealthTrendLoadingIndicator(), | ||
| 537 | - ); | 532 | + logic: _logic, |
| 533 | + isVip: widget.isVip, | ||
| 534 | + onSubscribe: widget.onSubscribe, | ||
| 535 | + onReportViewed: widget.isSelected | ||
| 536 | + ? _appReviewPromptLogic.recordPromptEligibility | ||
| 537 | + : null, | ||
| 538 | + loadingIndicator: const _HealthTrendLoadingIndicator(), | ||
| 539 | + ); | ||
| 538 | } | 540 | } |
| 539 | 541 | ||
| 540 | class _KeepAliveWrapper extends StatefulWidget { | 542 | class _KeepAliveWrapper extends StatefulWidget { |
| 1 | import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart'; | 1 | import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart'; |
| 2 | import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; | 2 | import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; |
| 3 | +import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | ||
| 3 | import 'package:doublefeel_flutter/core/network/api/theme_api.dart'; | 4 | import 'package:doublefeel_flutter/core/network/api/theme_api.dart'; |
| 4 | import 'package:doublefeel_flutter/core/network/api/user_api.dart'; | 5 | import 'package:doublefeel_flutter/core/network/api/user_api.dart'; |
| 5 | import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; | 6 | import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; |
| 7 | +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | ||
| 6 | import 'package:get/get.dart'; | 8 | import 'package:get/get.dart'; |
| 7 | 9 | ||
| 8 | import '../../friends/controllers/friends_controller.dart'; | 10 | import '../../friends/controllers/friends_controller.dart'; |
| 11 | +import '../../pk/controllers/pk_controller.dart'; | ||
| 9 | import '../controllers/home_controller.dart'; | 12 | import '../controllers/home_controller.dart'; |
| 10 | import '../controllers/app_home_controller.dart'; | 13 | import '../controllers/app_home_controller.dart'; |
| 11 | import '../controllers/trend/trend_controller.dart'; | 14 | import '../controllers/trend/trend_controller.dart'; |
| @@ -13,6 +16,14 @@ import '../controllers/trend/trend_controller.dart'; | @@ -13,6 +16,14 @@ import '../controllers/trend/trend_controller.dart'; | ||
| 13 | class HomeBinding extends Bindings { | 16 | class HomeBinding extends Bindings { |
| 14 | @override | 17 | @override |
| 15 | void dependencies() { | 18 | void dependencies() { |
| 19 | + Get.put<FriendsController>(FriendsController()); | ||
| 20 | + Get.put<PkController>( | ||
| 21 | + PkController( | ||
| 22 | + Get.find<FriendApi>(), | ||
| 23 | + Get.find<UserPreferencesStorage>(), | ||
| 24 | + Get.find<HealthApi>(), | ||
| 25 | + ), | ||
| 26 | + ); | ||
| 16 | Get.put<HomeController>(HomeController()); | 27 | Get.put<HomeController>(HomeController()); |
| 17 | Get.put<MyController>( | 28 | Get.put<MyController>( |
| 18 | MyController( | 29 | MyController( |
| @@ -22,7 +33,6 @@ class HomeBinding extends Bindings { | @@ -22,7 +33,6 @@ class HomeBinding extends Bindings { | ||
| 22 | ), | 33 | ), |
| 23 | ); | 34 | ); |
| 24 | Get.put<TrendController>(TrendController(Get.find<FriendApi>())); | 35 | Get.put<TrendController>(TrendController(Get.find<FriendApi>())); |
| 25 | - Get.put<FriendsController>(FriendsController()); | ||
| 26 | Get.put<AppHomeController>(AppHomeController()); | 36 | Get.put<AppHomeController>(AppHomeController()); |
| 27 | } | 37 | } |
| 28 | } | 38 | } |
| 1 | +import '../../pk/controllers/pk_controller.dart'; | ||
| 1 | import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart'; | 2 | import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart'; |
| 2 | import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart'; | 3 | import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart'; |
| 3 | import 'package:doublefeel_flutter/core/constants/flutter_bridge_method_name.dart'; | 4 | import 'package:doublefeel_flutter/core/constants/flutter_bridge_method_name.dart'; |
| @@ -23,23 +24,47 @@ import 'trend/trend_controller.dart'; | @@ -23,23 +24,47 @@ import 'trend/trend_controller.dart'; | ||
| 23 | 24 | ||
| 24 | enum TrendEntrySource { bottomTab, jump } | 25 | enum TrendEntrySource { bottomTab, jump } |
| 25 | 26 | ||
| 27 | +enum HomeTab { today, trend, friends, pk, my } | ||
| 28 | + | ||
| 26 | class HomeController extends GetxController { | 29 | class HomeController extends GetxController { |
| 27 | - static const trendTabIndex = 1; | ||
| 28 | - static const friendsTabIndex = 2; | ||
| 29 | static const _mainChannel = MethodChannel('doublefeel_flutter_main_channel'); | 30 | static const _mainChannel = MethodChannel('doublefeel_flutter_main_channel'); |
| 30 | 31 | ||
| 31 | final LocalStorage _storage = Get.find<LocalStorage>(); | 32 | final LocalStorage _storage = Get.find<LocalStorage>(); |
| 32 | final UserStateService userStateService = Get.find<UserStateService>(); | 33 | final UserStateService userStateService = Get.find<UserStateService>(); |
| 33 | final AppPlatformHostApi _platformHostApi = AppPlatformHostApi(); | 34 | final AppPlatformHostApi _platformHostApi = AppPlatformHostApi(); |
| 34 | 35 | ||
| 35 | - /// 当前选中的底部 tab 索引 | ||
| 36 | - final selectedIndex = 0.obs; | 36 | + /// 当前选中的底部 Tab;展示位置由 [visibleTabs] 动态计算。 |
| 37 | + final selectedTab = HomeTab.today.obs; | ||
| 37 | final lastFlutterUrl = ''.obs; | 38 | final lastFlutterUrl = ''.obs; |
| 38 | var isFromOnboard = false; | 39 | var isFromOnboard = false; |
| 40 | + late final Worker _friendsWorker; | ||
| 41 | + | ||
| 42 | + bool get hasFriends => Get.find<FriendsController>().friends.isNotEmpty; | ||
| 43 | + | ||
| 44 | + List<HomeTab> get visibleTabs => [ | ||
| 45 | + HomeTab.today, | ||
| 46 | + HomeTab.trend, | ||
| 47 | + HomeTab.friends, | ||
| 48 | + if (hasFriends) HomeTab.pk, | ||
| 49 | + HomeTab.my, | ||
| 50 | + ]; | ||
| 51 | + | ||
| 52 | + int get currentSelectedIndex => visibleTabs.indexOf(selectedTab.value); | ||
| 39 | 53 | ||
| 40 | @override | 54 | @override |
| 41 | void onInit() { | 55 | void onInit() { |
| 42 | super.onInit(); | 56 | super.onInit(); |
| 57 | + _friendsWorker = ever( | ||
| 58 | + Get.find<FriendsController>().friends, | ||
| 59 | + (_) { | ||
| 60 | + // If the last friend is deleted while PK is open, PK is removed from | ||
| 61 | + // the tab bar. Return to the Friends tab instead of changing the | ||
| 62 | + // meaning of the current index to My. | ||
| 63 | + if (!hasFriends && selectedTab.value == HomeTab.pk) { | ||
| 64 | + selectedTab.value = HomeTab.friends; | ||
| 65 | + } | ||
| 66 | + }, | ||
| 67 | + ); | ||
| 43 | _mainChannel.setMethodCallHandler(_handleNativeMethodCall); | 68 | _mainChannel.setMethodCallHandler(_handleNativeMethodCall); |
| 44 | isFromOnboard = Get.arguments?['isFromOnboard'] ?? false; | 69 | isFromOnboard = Get.arguments?['isFromOnboard'] ?? false; |
| 45 | SchedulerBinding.instance.addPostFrameCallback((_) async { | 70 | SchedulerBinding.instance.addPostFrameCallback((_) async { |
| @@ -61,6 +86,7 @@ class HomeController extends GetxController { | @@ -61,6 +86,7 @@ class HomeController extends GetxController { | ||
| 61 | 86 | ||
| 62 | @override | 87 | @override |
| 63 | void onClose() { | 88 | void onClose() { |
| 89 | + _friendsWorker.dispose(); | ||
| 64 | _mainChannel.setMethodCallHandler(null); | 90 | _mainChannel.setMethodCallHandler(null); |
| 65 | super.onClose(); | 91 | super.onClose(); |
| 66 | } | 92 | } |
| @@ -141,33 +167,41 @@ class HomeController extends GetxController { | @@ -141,33 +167,41 @@ class HomeController extends GetxController { | ||
| 141 | int index, { | 167 | int index, { |
| 142 | TrendEntrySource trendEntrySource = TrendEntrySource.bottomTab, | 168 | TrendEntrySource trendEntrySource = TrendEntrySource.bottomTab, |
| 143 | }) { | 169 | }) { |
| 144 | - if (selectedIndex.value == index) return; | ||
| 145 | - selectedIndex.value = index; | 170 | + final tabs = visibleTabs; |
| 171 | + if (index < 0 || index >= tabs.length) return; | ||
| 172 | + selectTab(tabs[index], trendEntrySource: trendEntrySource); | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + void selectTab( | ||
| 176 | + HomeTab tab, { | ||
| 177 | + TrendEntrySource trendEntrySource = TrendEntrySource.bottomTab, | ||
| 178 | + }) { | ||
| 179 | + if (!visibleTabs.contains(tab) || selectedTab.value == tab) return; | ||
| 180 | + selectedTab.value = tab; | ||
| 146 | 181 | ||
| 147 | WidgetsBinding.instance.addPostFrameCallback((_) { | 182 | WidgetsBinding.instance.addPostFrameCallback((_) { |
| 148 | - if (selectedIndex.value != index) return; | 183 | + if (selectedTab.value != tab) return; |
| 149 | 184 | ||
| 150 | final trendController = Get.find<TrendController>(); | 185 | final trendController = Get.find<TrendController>(); |
| 151 | final friendsController = Get.find<FriendsController>(); | 186 | final friendsController = Get.find<FriendsController>(); |
| 152 | 187 | ||
| 153 | - if (index == trendTabIndex && | 188 | + if (tab == HomeTab.trend && |
| 154 | trendEntrySource == TrendEntrySource.bottomTab) { | 189 | trendEntrySource == TrendEntrySource.bottomTab) { |
| 155 | trendController.selectSelf(refresh: false); | 190 | trendController.selectSelf(refresh: false); |
| 156 | } | 191 | } |
| 157 | 192 | ||
| 158 | trendController.setPageVisible( | 193 | trendController.setPageVisible( |
| 159 | - index == trendTabIndex, | 194 | + tab == HomeTab.trend, |
| 160 | resetQueryOnShow: trendEntrySource == TrendEntrySource.bottomTab, | 195 | resetQueryOnShow: trendEntrySource == TrendEntrySource.bottomTab, |
| 161 | ); | 196 | ); |
| 162 | - friendsController.setPageVisible(index == friendsTabIndex); | ||
| 163 | - | ||
| 164 | - switch (index) { | ||
| 165 | - case 0: | ||
| 166 | - Get.find<AppHomeController>().refreshOnShow(); | ||
| 167 | - break; | ||
| 168 | - case 3: | ||
| 169 | - ta.track('enter_doublefeel_my_page'); | ||
| 170 | - break; | 197 | + friendsController.setPageVisible(tab == HomeTab.friends); |
| 198 | + | ||
| 199 | + if (tab == HomeTab.today) { | ||
| 200 | + Get.find<AppHomeController>().refreshOnShow(); | ||
| 201 | + } else if (tab == HomeTab.pk) { | ||
| 202 | + Get.find<PkController>().refreshData(); | ||
| 203 | + } else if (tab == HomeTab.my) { | ||
| 204 | + ta.track('enter_doublefeel_my_page'); | ||
| 171 | } | 205 | } |
| 172 | }); | 206 | }); |
| 173 | } | 207 | } |
| @@ -182,7 +216,7 @@ class HomeController extends GetxController { | @@ -182,7 +216,7 @@ class HomeController extends GetxController { | ||
| 182 | if (fromTodayTab) { | 216 | if (fromTodayTab) { |
| 183 | Get.find<TrendController>().selectSelf(); | 217 | Get.find<TrendController>().selectSelf(); |
| 184 | } | 218 | } |
| 185 | - changeTab(trendTabIndex, trendEntrySource: TrendEntrySource.jump); | 219 | + selectTab(HomeTab.trend, trendEntrySource: TrendEntrySource.jump); |
| 186 | } | 220 | } |
| 187 | 221 | ||
| 188 | int _dateKey(DateTime date) => | 222 | int _dateKey(DateTime date) => |
| @@ -256,7 +290,7 @@ class HomeController extends GetxController { | @@ -256,7 +290,7 @@ class HomeController extends GetxController { | ||
| 256 | void _switchHomeTab(String? tab, Map<String, String> params) { | 290 | void _switchHomeTab(String? tab, Map<String, String> params) { |
| 257 | switch (tab) { | 291 | switch (tab) { |
| 258 | case 'today': | 292 | case 'today': |
| 259 | - changeTab(0); | 293 | + selectTab(HomeTab.today); |
| 260 | final scrollToLatestHrv = | 294 | final scrollToLatestHrv = |
| 261 | params['scroll_to_latest_hrv']?.toLowerCase() == 'true'; | 295 | params['scroll_to_latest_hrv']?.toLowerCase() == 'true'; |
| 262 | if (scrollToLatestHrv) { | 296 | if (scrollToLatestHrv) { |
| @@ -271,14 +305,17 @@ class HomeController extends GetxController { | @@ -271,14 +305,17 @@ class HomeController extends GetxController { | ||
| 271 | if (trendType != null) { | 305 | if (trendType != null) { |
| 272 | openTrend(trendType, fromTodayTab: false); | 306 | openTrend(trendType, fromTodayTab: false); |
| 273 | } else { | 307 | } else { |
| 274 | - changeTab(trendTabIndex, trendEntrySource: TrendEntrySource.jump); | 308 | + selectTab(HomeTab.trend, trendEntrySource: TrendEntrySource.jump); |
| 275 | } | 309 | } |
| 276 | break; | 310 | break; |
| 277 | case 'friends': | 311 | case 'friends': |
| 278 | - changeTab(friendsTabIndex); | 312 | + selectTab(HomeTab.friends); |
| 313 | + break; | ||
| 314 | + case 'pk': | ||
| 315 | + selectTab(HomeTab.pk); | ||
| 279 | break; | 316 | break; |
| 280 | case 'my': | 317 | case 'my': |
| 281 | - changeTab(3); | 318 | + selectTab(HomeTab.my); |
| 282 | break; | 319 | break; |
| 283 | default: | 320 | default: |
| 284 | break; | 321 | break; |
| @@ -9,18 +9,27 @@ import '../widgets/df_tab_bar.dart'; | @@ -9,18 +9,27 @@ import '../widgets/df_tab_bar.dart'; | ||
| 9 | import '../../friends/views/friends_tab.dart'; | 9 | import '../../friends/views/friends_tab.dart'; |
| 10 | import 'tabs/home_trend_view.dart'; | 10 | import 'tabs/home_trend_view.dart'; |
| 11 | import 'tabs/app_home_tab.dart'; | 11 | import 'tabs/app_home_tab.dart'; |
| 12 | +import 'tabs/home_pk_view.dart'; | ||
| 12 | import 'tabs/my_tab.dart'; | 13 | import 'tabs/my_tab.dart'; |
| 13 | 14 | ||
| 14 | class HomePage extends GetView<HomeController> { | 15 | class HomePage extends GetView<HomeController> { |
| 15 | const HomePage({super.key}); | 16 | const HomePage({super.key}); |
| 16 | 17 | ||
| 17 | - static const _tabs = [ | 18 | + static const _tabsWithoutPk = [ |
| 18 | AppHomeTab(), | 19 | AppHomeTab(), |
| 19 | HomeTrendView(), | 20 | HomeTrendView(), |
| 20 | FriendsTab(), | 21 | FriendsTab(), |
| 21 | MyTab(), | 22 | MyTab(), |
| 22 | ]; | 23 | ]; |
| 23 | 24 | ||
| 25 | + static const _tabsWithPk = [ | ||
| 26 | + AppHomeTab(), | ||
| 27 | + HomeTrendView(), | ||
| 28 | + FriendsTab(), | ||
| 29 | + HomePkView(), | ||
| 30 | + MyTab(), | ||
| 31 | + ]; | ||
| 32 | + | ||
| 24 | @override | 33 | @override |
| 25 | Widget build(BuildContext context) { | 34 | Widget build(BuildContext context) { |
| 26 | return _HomeReviewPromptGate( | 35 | return _HomeReviewPromptGate( |
| @@ -30,18 +39,17 @@ class HomePage extends GetView<HomeController> { | @@ -30,18 +39,17 @@ class HomePage extends GetView<HomeController> { | ||
| 30 | extendBody: true, // 让内容延伸到 bottomNavigationBar 下方 | 39 | extendBody: true, // 让内容延伸到 bottomNavigationBar 下方 |
| 31 | extendBodyBehindAppBar: true, | 40 | extendBodyBehindAppBar: true, |
| 32 | 41 | ||
| 33 | - body: Obx( | ||
| 34 | - () => IndexedStack( | ||
| 35 | - index: controller.selectedIndex.value, | ||
| 36 | - children: _tabs, | ||
| 37 | - ), | ||
| 38 | - ), | ||
| 39 | - bottomNavigationBar: Obx( | ||
| 40 | - () => DfTabBar( | ||
| 41 | - selectedIndex: controller.selectedIndex.value, | ||
| 42 | - onTap: (index) => controller.changeTab(index), | ||
| 43 | - ), | ||
| 44 | - ), | 42 | + body: Obx(() { |
| 43 | + final hasFriends = controller.hasFriends; | ||
| 44 | + final tabs = hasFriends ? _tabsWithPk : _tabsWithoutPk; | ||
| 45 | + final selectedIndex = controller.currentSelectedIndex; | ||
| 46 | + return IndexedStack(index: selectedIndex, children: tabs); | ||
| 47 | + }), | ||
| 48 | + bottomNavigationBar: Obx(() => DfTabBar( | ||
| 49 | + selectedIndex: controller.currentSelectedIndex, | ||
| 50 | + showPk: controller.hasFriends, | ||
| 51 | + onTap: controller.changeTab, | ||
| 52 | + )), | ||
| 45 | ), | 53 | ), |
| 46 | ); | 54 | ); |
| 47 | } | 55 | } |
| 1 | +import '../../controllers/home_controller.dart'; | ||
| 2 | +import 'package:flutter/material.dart'; | ||
| 3 | +import 'package:get/get.dart'; | ||
| 4 | +import '../../../pk/controllers/pk_controller.dart'; | ||
| 5 | +import '../../../pk/views/pk_view.dart'; | ||
| 6 | + | ||
| 7 | +class HomePkView extends GetView<PkController> { | ||
| 8 | + const HomePkView({super.key}); | ||
| 9 | + @override | ||
| 10 | + Widget build(BuildContext context) => Obx(() { | ||
| 11 | + if (controller.selectedFriend.value == null) { | ||
| 12 | + return ColoredBox( | ||
| 13 | + color: const Color(0xFFF5F2FF), | ||
| 14 | + child: Center( | ||
| 15 | + child: controller.isLoading.value | ||
| 16 | + ? const CircularProgressIndicator() | ||
| 17 | + : Column( | ||
| 18 | + mainAxisSize: MainAxisSize.min, | ||
| 19 | + children: [ | ||
| 20 | + Text( | ||
| 21 | + controller.loadFailed.value | ||
| 22 | + ? '加载失败,请重试' | ||
| 23 | + : '添加好友后即可查看健康PK', | ||
| 24 | + ), | ||
| 25 | + const SizedBox(height: 12), | ||
| 26 | + TextButton( | ||
| 27 | + onPressed: controller.refreshData, | ||
| 28 | + child: const Text('刷新'), | ||
| 29 | + ), | ||
| 30 | + ], | ||
| 31 | + ), | ||
| 32 | + ), | ||
| 33 | + ); | ||
| 34 | + } | ||
| 35 | + return PkView( | ||
| 36 | + data: controller.data, | ||
| 37 | + friendUserId: controller.selectedFriend.value?.friendUserId, | ||
| 38 | + loadFailed: controller.loadFailed.value || | ||
| 39 | + (controller.isPartnerSelected && controller.pkLoadFailed.value), | ||
| 40 | + onBack: () => Get.find<HomeController>().selectTab(HomeTab.today), | ||
| 41 | + onRefresh: controller.refreshData, | ||
| 42 | + onSwitchFriend: controller.showFriendListBottomSheet, | ||
| 43 | + ); | ||
| 44 | + }); | ||
| 45 | +} |
| 1 | -import 'package:cached_network_image/cached_network_image.dart'; | ||
| 2 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | 1 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; |
| 3 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 2 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 4 | import 'package:flutter/material.dart'; | 3 | import 'package:flutter/material.dart'; |
| @@ -15,10 +14,7 @@ class HomeTrendView extends GetView<TrendController> { | @@ -15,10 +14,7 @@ class HomeTrendView extends GetView<TrendController> { | ||
| 15 | return const _TrendPageBackground( | 14 | return const _TrendPageBackground( |
| 16 | child: Column( | 15 | child: Column( |
| 17 | children: [ | 16 | children: [ |
| 18 | - SafeArea( | ||
| 19 | - bottom: false, | ||
| 20 | - child: _HomeTrendHeader(), | ||
| 21 | - ), | 17 | + SafeArea(bottom: false, child: _HomeTrendHeader()), |
| 22 | Expanded(child: _HomeTrendBody()), | 18 | Expanded(child: _HomeTrendBody()), |
| 23 | ], | 19 | ], |
| 24 | ), | 20 | ), |
| @@ -39,6 +35,7 @@ class _HomeTrendBody extends GetView<TrendController> { | @@ -39,6 +35,7 @@ class _HomeTrendBody extends GetView<TrendController> { | ||
| 39 | refreshToken: controller.refreshToken.value, | 35 | refreshToken: controller.refreshToken.value, |
| 40 | onTypeChanged: controller.changeType, | 36 | onTypeChanged: controller.changeType, |
| 41 | onQueryChanged: controller.changeQuery, | 37 | onQueryChanged: controller.changeQuery, |
| 38 | + friendAvatarUrl: controller.targetFriendInfo.value?.avatar, | ||
| 42 | ), | 39 | ), |
| 43 | ); | 40 | ); |
| 44 | } | 41 | } |
| @@ -121,10 +118,7 @@ class _TrendHeaderAvatar extends StatelessWidget { | @@ -121,10 +118,7 @@ class _TrendHeaderAvatar extends StatelessWidget { | ||
| 121 | height: 36, | 118 | height: 36, |
| 122 | decoration: ShapeDecoration( | 119 | decoration: ShapeDecoration( |
| 123 | image: imageUrl?.isNotEmpty == true | 120 | image: imageUrl?.isNotEmpty == true |
| 124 | - ? DecorationImage( | ||
| 125 | - image: NetworkImage(imageUrl!), | ||
| 126 | - fit: BoxFit.cover, | ||
| 127 | - ) | 121 | + ? DecorationImage(image: NetworkImage(imageUrl!), fit: BoxFit.cover) |
| 128 | : null, | 122 | : null, |
| 129 | shape: RoundedRectangleBorder( | 123 | shape: RoundedRectangleBorder( |
| 130 | side: const BorderSide(width: 0.78, color: Colors.white), | 124 | side: const BorderSide(width: 0.78, color: Colors.white), |
| @@ -139,11 +133,7 @@ class _TrendHeaderAvatar extends StatelessWidget { | @@ -139,11 +133,7 @@ class _TrendHeaderAvatar extends StatelessWidget { | ||
| 139 | return GestureDetector( | 133 | return GestureDetector( |
| 140 | behavior: HitTestBehavior.opaque, | 134 | behavior: HitTestBehavior.opaque, |
| 141 | onTap: onTap, | 135 | onTap: onTap, |
| 142 | - child: SizedBox( | ||
| 143 | - width: 44, | ||
| 144 | - height: 44, | ||
| 145 | - child: Center(child: avatar), | ||
| 146 | - ), | 136 | + child: SizedBox(width: 44, height: 44, child: Center(child: avatar)), |
| 147 | ); | 137 | ); |
| 148 | } | 138 | } |
| 149 | } | 139 | } |
| @@ -153,11 +143,7 @@ class _AvatarFallback extends StatelessWidget { | @@ -153,11 +143,7 @@ class _AvatarFallback extends StatelessWidget { | ||
| 153 | 143 | ||
| 154 | @override | 144 | @override |
| 155 | Widget build(BuildContext context) { | 145 | Widget build(BuildContext context) { |
| 156 | - return const Icon( | ||
| 157 | - Icons.person_rounded, | ||
| 158 | - color: Colors.white, | ||
| 159 | - size: 22, | ||
| 160 | - ); | 146 | + return const Icon(Icons.person_rounded, color: Colors.white, size: 22); |
| 161 | } | 147 | } |
| 162 | } | 148 | } |
| 163 | 149 |
| @@ -4,11 +4,13 @@ import 'package:flutter/material.dart'; | @@ -4,11 +4,13 @@ import 'package:flutter/material.dart'; | ||
| 4 | 4 | ||
| 5 | class DfTabBar extends StatelessWidget { | 5 | class DfTabBar extends StatelessWidget { |
| 6 | final int selectedIndex; | 6 | final int selectedIndex; |
| 7 | + final bool showPk; | ||
| 7 | final ValueChanged<int> onTap; | 8 | final ValueChanged<int> onTap; |
| 8 | 9 | ||
| 9 | const DfTabBar({ | 10 | const DfTabBar({ |
| 10 | super.key, | 11 | super.key, |
| 11 | required this.selectedIndex, | 12 | required this.selectedIndex, |
| 13 | + required this.showPk, | ||
| 12 | required this.onTap, | 14 | required this.onTap, |
| 13 | }); | 15 | }); |
| 14 | 16 | ||
| @@ -19,6 +21,7 @@ class DfTabBar extends StatelessWidget { | @@ -19,6 +21,7 @@ class DfTabBar extends StatelessWidget { | ||
| 19 | 'assets/images/tabbar/icon_today.png', | 21 | 'assets/images/tabbar/icon_today.png', |
| 20 | 'assets/images/tabbar/icon_trend.png', | 22 | 'assets/images/tabbar/icon_trend.png', |
| 21 | 'assets/images/tabbar/icon_friends.png', | 23 | 'assets/images/tabbar/icon_friends.png', |
| 24 | + 'assets/images/tabbar/icon_pk.png', | ||
| 22 | 'assets/images/tabbar/icon_my.png', | 25 | 'assets/images/tabbar/icon_my.png', |
| 23 | ]; | 26 | ]; |
| 24 | 27 | ||
| @@ -26,6 +29,7 @@ class DfTabBar extends StatelessWidget { | @@ -26,6 +29,7 @@ class DfTabBar extends StatelessWidget { | ||
| 26 | 'assets/images/tabbar/icon_today_selected.png', | 29 | 'assets/images/tabbar/icon_today_selected.png', |
| 27 | 'assets/images/tabbar/icon_trend_selected.png', | 30 | 'assets/images/tabbar/icon_trend_selected.png', |
| 28 | 'assets/images/tabbar/icon_friends_selected.png', | 31 | 'assets/images/tabbar/icon_friends_selected.png', |
| 32 | + 'assets/images/tabbar/icon_pk_selected.png', | ||
| 29 | 'assets/images/tabbar/icon_my_selected.png', | 33 | 'assets/images/tabbar/icon_my_selected.png', |
| 30 | ]; | 34 | ]; |
| 31 | 35 | ||
| @@ -36,8 +40,10 @@ class DfTabBar extends StatelessWidget { | @@ -36,8 +40,10 @@ class DfTabBar extends StatelessWidget { | ||
| 36 | l10n.tabToday, | 40 | l10n.tabToday, |
| 37 | l10n.tabTrend, | 41 | l10n.tabTrend, |
| 38 | l10n.tabFriends, | 42 | l10n.tabFriends, |
| 43 | + 'PK', | ||
| 39 | l10n.tabMy, | 44 | l10n.tabMy, |
| 40 | ]; | 45 | ]; |
| 46 | + final tabIndexes = showPk ? List.generate(5, (index) => index) : [0, 1, 2, 4]; | ||
| 41 | 47 | ||
| 42 | return SafeArea( | 48 | return SafeArea( |
| 43 | top: false, | 49 | top: false, |
| @@ -60,7 +66,7 @@ class DfTabBar extends StatelessWidget { | @@ -60,7 +66,7 @@ class DfTabBar extends StatelessWidget { | ||
| 60 | padding: const EdgeInsets.all(4), | 66 | padding: const EdgeInsets.all(4), |
| 61 | child: LayoutBuilder( | 67 | child: LayoutBuilder( |
| 62 | builder: (context, constraints) { | 68 | builder: (context, constraints) { |
| 63 | - final tabWidth = constraints.maxWidth / _tabIcons.length; | 69 | + final tabWidth = constraints.maxWidth / tabIndexes.length; |
| 64 | return Stack( | 70 | return Stack( |
| 65 | clipBehavior: Clip.none, | 71 | clipBehavior: Clip.none, |
| 66 | children: [ | 72 | children: [ |
| @@ -80,27 +86,33 @@ class DfTabBar extends StatelessWidget { | @@ -80,27 +86,33 @@ class DfTabBar extends StatelessWidget { | ||
| 80 | ), | 86 | ), |
| 81 | ), | 87 | ), |
| 82 | Row( | 88 | Row( |
| 83 | - children: List.generate(_tabIcons.length, (i) { | ||
| 84 | - final isSelected = selectedIndex == i; | 89 | + children: List.generate(tabIndexes.length, (index) { |
| 90 | + final tabIndex = tabIndexes[index]; | ||
| 91 | + final isSelected = selectedIndex == index; | ||
| 85 | return Expanded( | 92 | return Expanded( |
| 86 | child: RepaintBoundary( | 93 | child: RepaintBoundary( |
| 87 | child: GestureDetector( | 94 | child: GestureDetector( |
| 88 | - onTap: () => onTap(i), | 95 | + onTap: () => onTap(index), |
| 89 | behavior: HitTestBehavior.opaque, | 96 | behavior: HitTestBehavior.opaque, |
| 90 | child: Column( | 97 | child: Column( |
| 91 | mainAxisAlignment: MainAxisAlignment.center, | 98 | mainAxisAlignment: MainAxisAlignment.center, |
| 92 | children: [ | 99 | children: [ |
| 93 | Image.asset( | 100 | Image.asset( |
| 94 | isSelected | 101 | isSelected |
| 95 | - ? _selectedTabIcons[i] | ||
| 96 | - : _tabIcons[i], | 102 | + ? _selectedTabIcons[tabIndex] |
| 103 | + : _tabIcons[tabIndex], | ||
| 104 | + color: tabIndex == 3 | ||
| 105 | + ? (isSelected | ||
| 106 | + ? context.colors.primary | ||
| 107 | + : const Color(0xFF0F0F11)) | ||
| 108 | + : null, | ||
| 97 | width: 24, | 109 | width: 24, |
| 98 | height: 24, | 110 | height: 24, |
| 99 | gaplessPlayback: true, | 111 | gaplessPlayback: true, |
| 100 | ), | 112 | ), |
| 101 | const SizedBox(height: 0.5), | 113 | const SizedBox(height: 0.5), |
| 102 | Text( | 114 | Text( |
| 103 | - tabLabels[i], | 115 | + tabLabels[tabIndex], |
| 104 | style: TextStyle( | 116 | style: TextStyle( |
| 105 | fontSize: 9, | 117 | fontSize: 9, |
| 106 | fontWeight: FontWeight.w800, | 118 | fontWeight: FontWeight.w800, |
| @@ -13,8 +13,10 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { | @@ -13,8 +13,10 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { | ||
| 13 | required this.selectedFriend, | 13 | required this.selectedFriend, |
| 14 | required this.onSelectSelf, | 14 | required this.onSelectSelf, |
| 15 | required this.onSelectFriend, | 15 | required this.onSelectFriend, |
| 16 | + this.showSelf = true, | ||
| 16 | }); | 17 | }); |
| 17 | 18 | ||
| 19 | + final bool showSelf; | ||
| 18 | final ScrollController scrollController; | 20 | final ScrollController scrollController; |
| 19 | final RxList<FriendItem> friendsList; | 21 | final RxList<FriendItem> friendsList; |
| 20 | final Rxn<FriendItem> selectedFriend; | 22 | final Rxn<FriendItem> selectedFriend; |
| @@ -77,7 +79,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { | @@ -77,7 +79,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { | ||
| 77 | .meUserInfo; | 79 | .meUserInfo; |
| 78 | final selfNickname = self?.nickname?.trim(); | 80 | final selfNickname = self?.nickname?.trim(); |
| 79 | final currentSelectedId = selectedFriend.value?.friendUserId; | 81 | final currentSelectedId = selectedFriend.value?.friendUserId; |
| 80 | - final itemCount = friendsList.length + 1; | 82 | + final itemCount = friendsList.length + (showSelf ? 1 : 0); |
| 81 | 83 | ||
| 82 | return ListView.separated( | 84 | return ListView.separated( |
| 83 | controller: scrollController, | 85 | controller: scrollController, |
| @@ -85,7 +87,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { | @@ -85,7 +87,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { | ||
| 85 | itemCount: itemCount, | 87 | itemCount: itemCount, |
| 86 | separatorBuilder: (_, __) => const SizedBox(height: 8), | 88 | separatorBuilder: (_, __) => const SizedBox(height: 8), |
| 87 | itemBuilder: (context, index) { | 89 | itemBuilder: (context, index) { |
| 88 | - if (index == 0) { | 90 | + if (showSelf && index == 0) { |
| 89 | return _BottomSheetUserRow( | 91 | return _BottomSheetUserRow( |
| 90 | name: selfNickname?.isNotEmpty == true | 92 | name: selfNickname?.isNotEmpty == true |
| 91 | ? selfNickname! | 93 | ? selfNickname! |
| @@ -102,7 +104,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { | @@ -102,7 +104,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { | ||
| 102 | ); | 104 | ); |
| 103 | } | 105 | } |
| 104 | 106 | ||
| 105 | - final friend = friendsList[index - 1]; | 107 | + final friend = friendsList[index - (showSelf ? 1 : 0)]; |
| 106 | final name = _friendName(context, friend); | 108 | final name = _friendName(context, friend); |
| 107 | final nickname = friend.friendNickname?.trim(); | 109 | final nickname = friend.friendNickname?.trim(); |
| 108 | return _BottomSheetUserRow( | 110 | return _BottomSheetUserRow( |
| 1 | +import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/r.dart'; | ||
| 4 | +import 'package:flutter/material.dart'; | ||
| 5 | + | ||
| 6 | +import '../models/hrv_report_models.dart'; | ||
| 7 | +import 'hrv_distribution_bar.dart'; | ||
| 8 | + | ||
| 9 | +class HrvStressDistributionCard extends StatelessWidget { | ||
| 10 | + const HrvStressDistributionCard({ | ||
| 11 | + super.key, | ||
| 12 | + required this.report, | ||
| 13 | + required this.title, | ||
| 14 | + this.showExample = false, | ||
| 15 | + this.trailing, | ||
| 16 | + this.footer, | ||
| 17 | + this.onTap, | ||
| 18 | + }); | ||
| 19 | + | ||
| 20 | + final HrvPeriodReport report; | ||
| 21 | + final String title; | ||
| 22 | + final bool showExample; | ||
| 23 | + final Widget? trailing; | ||
| 24 | + final Widget? footer; | ||
| 25 | + final VoidCallback? onTap; | ||
| 26 | + | ||
| 27 | + @override | ||
| 28 | + Widget build(BuildContext context) { | ||
| 29 | + final content = Container( | ||
| 30 | + width: double.infinity, | ||
| 31 | + padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), | ||
| 32 | + decoration: BoxDecoration( | ||
| 33 | + color: Colors.white, | ||
| 34 | + borderRadius: BorderRadius.circular(16), | ||
| 35 | + ), | ||
| 36 | + child: Column( | ||
| 37 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 38 | + children: [ | ||
| 39 | + _Title( | ||
| 40 | + title: title, | ||
| 41 | + showExample: showExample, | ||
| 42 | + trailing: trailing, | ||
| 43 | + ), | ||
| 44 | + const SizedBox(height: 20), | ||
| 45 | + Row( | ||
| 46 | + crossAxisAlignment: CrossAxisAlignment.end, | ||
| 47 | + children: [ | ||
| 48 | + Expanded( | ||
| 49 | + child: Column( | ||
| 50 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 51 | + children: [ | ||
| 52 | + Text( | ||
| 53 | + context.l10n.hrvValidDays, | ||
| 54 | + style: const TextStyle( | ||
| 55 | + color: Color(0xFF78787D), | ||
| 56 | + fontSize: 12, | ||
| 57 | + ), | ||
| 58 | + ), | ||
| 59 | + const SizedBox(height: 2), | ||
| 60 | + _ValueWithUnit( | ||
| 61 | + value: '${report.validDays}', | ||
| 62 | + unit: context.l10n.hrvDistributionDayUnit, | ||
| 63 | + ), | ||
| 64 | + const SizedBox(height: 28), | ||
| 65 | + _DistributionGrid(report: report), | ||
| 66 | + ], | ||
| 67 | + ), | ||
| 68 | + ), | ||
| 69 | + _DistributionBar(report: report), | ||
| 70 | + const SizedBox(width: 16), | ||
| 71 | + ], | ||
| 72 | + ), | ||
| 73 | + if (footer != null) ...[ | ||
| 74 | + const SizedBox(height: 20), | ||
| 75 | + const Divider(height: 1, color: Color(0xFFF3F3F3)), | ||
| 76 | + const SizedBox(height: 20), | ||
| 77 | + footer!, | ||
| 78 | + ], | ||
| 79 | + ], | ||
| 80 | + ), | ||
| 81 | + ); | ||
| 82 | + if (onTap == null) return content; | ||
| 83 | + | ||
| 84 | + return Stack( | ||
| 85 | + children: [ | ||
| 86 | + content, | ||
| 87 | + Positioned.fill( | ||
| 88 | + child: GestureDetector( | ||
| 89 | + behavior: HitTestBehavior.opaque, | ||
| 90 | + onTap: onTap, | ||
| 91 | + ), | ||
| 92 | + ), | ||
| 93 | + ], | ||
| 94 | + ); | ||
| 95 | + } | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +class _Title extends StatelessWidget { | ||
| 99 | + const _Title({ | ||
| 100 | + required this.title, | ||
| 101 | + required this.showExample, | ||
| 102 | + this.trailing, | ||
| 103 | + }); | ||
| 104 | + | ||
| 105 | + final String title; | ||
| 106 | + final bool showExample; | ||
| 107 | + final Widget? trailing; | ||
| 108 | + | ||
| 109 | + @override | ||
| 110 | + Widget build(BuildContext context) => Row( | ||
| 111 | + crossAxisAlignment: CrossAxisAlignment.center, | ||
| 112 | + children: [ | ||
| 113 | + Text(title, | ||
| 114 | + style: | ||
| 115 | + const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), | ||
| 116 | + if (showExample) | ||
| 117 | + Image.asset( | ||
| 118 | + R.assetsImagesTrendProIcon, | ||
| 119 | + width: 46.w, | ||
| 120 | + height: 13.h, | ||
| 121 | + fit: BoxFit.contain, | ||
| 122 | + ), | ||
| 123 | + if (trailing != null) ...[ | ||
| 124 | + const Spacer(), | ||
| 125 | + trailing!, | ||
| 126 | + ], | ||
| 127 | + ], | ||
| 128 | + ); | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +class _DistributionGrid extends StatelessWidget { | ||
| 132 | + const _DistributionGrid({required this.report}); | ||
| 133 | + | ||
| 134 | + final HrvPeriodReport report; | ||
| 135 | + | ||
| 136 | + @override | ||
| 137 | + Widget build(BuildContext context) => LayoutBuilder( | ||
| 138 | + builder: (context, constraints) { | ||
| 139 | + const spacing = 14.0; | ||
| 140 | + final itemWidth = (constraints.maxWidth - spacing) / 2; | ||
| 141 | + return Wrap( | ||
| 142 | + spacing: spacing, | ||
| 143 | + runSpacing: 19, | ||
| 144 | + children: [ | ||
| 145 | + for (final level in HrvStressLevel.values) | ||
| 146 | + SizedBox( | ||
| 147 | + width: itemWidth, | ||
| 148 | + child: _DistributionItem(level: level, report: report), | ||
| 149 | + ), | ||
| 150 | + ], | ||
| 151 | + ); | ||
| 152 | + }, | ||
| 153 | + ); | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +class _DistributionItem extends StatelessWidget { | ||
| 157 | + const _DistributionItem({required this.level, required this.report}); | ||
| 158 | + | ||
| 159 | + final HrvStressLevel level; | ||
| 160 | + final HrvPeriodReport report; | ||
| 161 | + | ||
| 162 | + @override | ||
| 163 | + Widget build(BuildContext context) { | ||
| 164 | + final count = report.countFor(level); | ||
| 165 | + final percent = | ||
| 166 | + report.validDays == 0 ? 0 : (count / report.validDays * 100).round(); | ||
| 167 | + return Column( | ||
| 168 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 169 | + children: [ | ||
| 170 | + Row( | ||
| 171 | + children: [ | ||
| 172 | + Container( | ||
| 173 | + width: 12, | ||
| 174 | + height: 12, | ||
| 175 | + decoration: BoxDecoration( | ||
| 176 | + color: Color(level.colorValue), | ||
| 177 | + shape: BoxShape.circle, | ||
| 178 | + ), | ||
| 179 | + ), | ||
| 180 | + const SizedBox(width: 4), | ||
| 181 | + Expanded( | ||
| 182 | + child: Text( | ||
| 183 | + level.label, | ||
| 184 | + maxLines: 1, | ||
| 185 | + overflow: TextOverflow.ellipsis, | ||
| 186 | + style: const TextStyle( | ||
| 187 | + fontSize: 12, | ||
| 188 | + fontWeight: FontWeight.w500, | ||
| 189 | + height: 1.2, | ||
| 190 | + ), | ||
| 191 | + ), | ||
| 192 | + ), | ||
| 193 | + ], | ||
| 194 | + ), | ||
| 195 | + const SizedBox(height: 8), | ||
| 196 | + if (report.hasData) | ||
| 197 | + Row( | ||
| 198 | + crossAxisAlignment: CrossAxisAlignment.end, | ||
| 199 | + children: [ | ||
| 200 | + Text( | ||
| 201 | + '$count', | ||
| 202 | + style: const TextStyle( | ||
| 203 | + color: Color(0xFF0F0F11), | ||
| 204 | + fontSize: 26, | ||
| 205 | + fontWeight: FontWeight.w500, | ||
| 206 | + height: 1, | ||
| 207 | + ), | ||
| 208 | + ), | ||
| 209 | + Padding( | ||
| 210 | + padding: const EdgeInsets.only(left: 2, bottom: 2), | ||
| 211 | + child: Text( | ||
| 212 | + context.l10n.hrvDistributionDayUnit, | ||
| 213 | + style: const TextStyle( | ||
| 214 | + color: Color(0xFF0F0F11), | ||
| 215 | + fontSize: 12, | ||
| 216 | + fontWeight: FontWeight.w500, | ||
| 217 | + ), | ||
| 218 | + ), | ||
| 219 | + ), | ||
| 220 | + ], | ||
| 221 | + ) | ||
| 222 | + else | ||
| 223 | + Text( | ||
| 224 | + context.l10n.hrvDistributionAwaitingData, | ||
| 225 | + style: const TextStyle( | ||
| 226 | + color: Color(0xFF78787D), | ||
| 227 | + fontSize: 15, | ||
| 228 | + fontWeight: FontWeight.w500, | ||
| 229 | + ), | ||
| 230 | + ), | ||
| 231 | + const SizedBox(height: 1), | ||
| 232 | + Text( | ||
| 233 | + '$percent%', | ||
| 234 | + style: const TextStyle( | ||
| 235 | + color: Color(0xFF78787D), | ||
| 236 | + fontSize: 12, | ||
| 237 | + height: 1.2, | ||
| 238 | + ), | ||
| 239 | + ), | ||
| 240 | + ], | ||
| 241 | + ); | ||
| 242 | + } | ||
| 243 | +} | ||
| 244 | + | ||
| 245 | +class _ValueWithUnit extends StatelessWidget { | ||
| 246 | + const _ValueWithUnit({required this.value, required this.unit}); | ||
| 247 | + | ||
| 248 | + final String value; | ||
| 249 | + final String unit; | ||
| 250 | + | ||
| 251 | + @override | ||
| 252 | + Widget build(BuildContext context) => Row( | ||
| 253 | + crossAxisAlignment: CrossAxisAlignment.end, | ||
| 254 | + children: [ | ||
| 255 | + Text( | ||
| 256 | + value, | ||
| 257 | + style: const TextStyle( | ||
| 258 | + fontSize: 26, fontWeight: FontWeight.w600, height: 1), | ||
| 259 | + ), | ||
| 260 | + Padding( | ||
| 261 | + padding: const EdgeInsets.only(left: 3, bottom: 2), | ||
| 262 | + child: Text(unit, style: const TextStyle(fontSize: 12)), | ||
| 263 | + ), | ||
| 264 | + ], | ||
| 265 | + ); | ||
| 266 | +} | ||
| 267 | + | ||
| 268 | +class _DistributionBar extends StatelessWidget { | ||
| 269 | + const _DistributionBar({required this.report}); | ||
| 270 | + | ||
| 271 | + final HrvPeriodReport report; | ||
| 272 | + | ||
| 273 | + @override | ||
| 274 | + Widget build(BuildContext context) { | ||
| 275 | + final segments = [ | ||
| 276 | + for (final level in HrvStressLevel.values) | ||
| 277 | + if (report.countFor(level) > 0) | ||
| 278 | + HrvDistributionBarSegment( | ||
| 279 | + color: Color(level.colorValue), | ||
| 280 | + flex: report.countFor(level), | ||
| 281 | + ), | ||
| 282 | + ]; | ||
| 283 | + return SizedBox( | ||
| 284 | + width: 42, | ||
| 285 | + height: 220, | ||
| 286 | + child: IgnorePointer( | ||
| 287 | + child: HrvDistributionBar( | ||
| 288 | + segments: report.hasData ? segments : const [], | ||
| 289 | + backgroundColor: const Color(0xFFF3F3F3), | ||
| 290 | + radius: 7, | ||
| 291 | + ), | ||
| 292 | + ), | ||
| 293 | + ); | ||
| 294 | + } | ||
| 295 | +} |
| @@ -4,14 +4,16 @@ import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | @@ -4,14 +4,16 @@ import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | ||
| 4 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 4 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 5 | import 'package:fl_chart/fl_chart.dart'; | 5 | import 'package:fl_chart/fl_chart.dart'; |
| 6 | import 'package:flutter/material.dart'; | 6 | import 'package:flutter/material.dart'; |
| 7 | +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart'; | ||
| 7 | 8 | ||
| 8 | import '../../../../core/utils/app_time_formatter.dart'; | 9 | import '../../../../core/utils/app_time_formatter.dart'; |
| 9 | import '../../../../r.dart'; | 10 | import '../../../../r.dart'; |
| 11 | +import '../../../widget/friend_interact_click_view.dart'; | ||
| 10 | import '../../report_common/utils/report_localization.dart'; | 12 | import '../../report_common/utils/report_localization.dart'; |
| 11 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; | 13 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; |
| 12 | import '../../report_common/widgets/health_report_subject_scope.dart'; | 14 | import '../../report_common/widgets/health_report_subject_scope.dart'; |
| 13 | import '../models/hrv_report_models.dart'; | 15 | import '../models/hrv_report_models.dart'; |
| 14 | -import 'hrv_distribution_bar.dart'; | 16 | +import 'hrv_stress_distribution_card.dart'; |
| 15 | 17 | ||
| 16 | class HrvWeekReportView extends StatelessWidget { | 18 | class HrvWeekReportView extends StatelessWidget { |
| 17 | const HrvWeekReportView({ | 19 | const HrvWeekReportView({ |
| @@ -31,7 +33,8 @@ class HrvWeekReportView extends StatelessWidget { | @@ -31,7 +33,8 @@ class HrvWeekReportView extends StatelessWidget { | ||
| 31 | Widget build(BuildContext context) { | 33 | Widget build(BuildContext context) { |
| 32 | final now = DateTime.now(); | 34 | final now = DateTime.now(); |
| 33 | final today = DateTime(now.year, now.month, now.day); | 35 | final today = DateTime(now.year, now.month, now.day); |
| 34 | - final data = report ?? | 36 | + final data = |
| 37 | + report ?? | ||
| 35 | WeeklyHrvReport.empty( | 38 | WeeklyHrvReport.empty( |
| 36 | today.subtract(Duration(days: today.weekday - 1)), | 39 | today.subtract(Duration(days: today.weekday - 1)), |
| 37 | ); | 40 | ); |
| @@ -95,9 +98,28 @@ class _HrvPeriodReportView extends StatelessWidget { | @@ -95,9 +98,28 @@ class _HrvPeriodReportView extends StatelessWidget { | ||
| 95 | tooltipDismissDelayMs: tooltipDismissDelayMs, | 98 | tooltipDismissDelayMs: tooltipDismissDelayMs, |
| 96 | ), | 99 | ), |
| 97 | const SizedBox(height: 12), | 100 | const SizedBox(height: 12), |
| 98 | - _DistributionCard( | 101 | + HrvStressDistributionCard( |
| 99 | report: report, | 102 | report: report, |
| 103 | + title: showExample | ||
| 104 | + ? context.l10n.reportExampleTitle( | ||
| 105 | + HealthReportSubjectScope.titleOf( | ||
| 106 | + context, | ||
| 107 | + context.l10n.hrvStressDistribution, | ||
| 108 | + ), | ||
| 109 | + ) | ||
| 110 | + : HealthReportSubjectScope.titleOf( | ||
| 111 | + context, | ||
| 112 | + context.l10n.hrvStressDistribution, | ||
| 113 | + ), | ||
| 100 | showExample: showExample, | 114 | showExample: showExample, |
| 115 | + trailing: HealthReportSubjectScope.isViewingSelf(context) | ||
| 116 | + ? null | ||
| 117 | + : FriendInteractClickView( | ||
| 118 | + interactionType: isMonth | ||
| 119 | + ? FriendInteractionType.monthStressTrend | ||
| 120 | + : FriendInteractionType.weekStressTrend, | ||
| 121 | + ), | ||
| 122 | + footer: _ExtremeHrvSection(report: report), | ||
| 101 | onTap: showExample ? onSubscribe : null, | 123 | onTap: showExample ? onSubscribe : null, |
| 102 | ), | 124 | ), |
| 103 | ], | 125 | ], |
| @@ -134,6 +156,7 @@ class _DailyStressCard extends StatelessWidget { | @@ -134,6 +156,7 @@ class _DailyStressCard extends StatelessWidget { | ||
| 134 | context.l10n.hrvDailyStressTrend, | 156 | context.l10n.hrvDailyStressTrend, |
| 135 | ), | 157 | ), |
| 136 | showExample: showExample, | 158 | showExample: showExample, |
| 159 | + isMonth: isMonth, | ||
| 137 | ), | 160 | ), |
| 138 | const SizedBox(height: 18), | 161 | const SizedBox(height: 18), |
| 139 | SizedBox( | 162 | SizedBox( |
| @@ -311,11 +334,14 @@ class _HrvBarChartState extends State<_HrvBarChart> { | @@ -311,11 +334,14 @@ class _HrvBarChartState extends State<_HrvBarChart> { | ||
| 311 | ), | 334 | ), |
| 312 | titlesData: FlTitlesData( | 335 | titlesData: FlTitlesData( |
| 313 | topTitles: const AxisTitles( | 336 | topTitles: const AxisTitles( |
| 314 | - sideTitles: SideTitles(showTitles: false)), | 337 | + sideTitles: SideTitles(showTitles: false), |
| 338 | + ), | ||
| 315 | rightTitles: const AxisTitles( | 339 | rightTitles: const AxisTitles( |
| 316 | - sideTitles: SideTitles(showTitles: false)), | 340 | + sideTitles: SideTitles(showTitles: false), |
| 341 | + ), | ||
| 317 | leftTitles: const AxisTitles( | 342 | leftTitles: const AxisTitles( |
| 318 | - sideTitles: SideTitles(showTitles: false)), | 343 | + sideTitles: SideTitles(showTitles: false), |
| 344 | + ), | ||
| 319 | bottomTitles: AxisTitles( | 345 | bottomTitles: AxisTitles( |
| 320 | sideTitles: SideTitles( | 346 | sideTitles: SideTitles( |
| 321 | showTitles: true, | 347 | showTitles: true, |
| @@ -345,8 +371,9 @@ class _HrvBarChartState extends State<_HrvBarChart> { | @@ -345,8 +371,9 @@ class _HrvBarChartState extends State<_HrvBarChart> { | ||
| 345 | // Keep individual empty days selectable so their | 371 | // Keep individual empty days selectable so their |
| 346 | // localized date and empty-state message are visible. | 372 | // localized date and empty-state message are visible. |
| 347 | final nextIndex = touchedIndex; | 373 | final nextIndex = touchedIndex; |
| 348 | - final nextOffset = | ||
| 349 | - nextIndex == null ? null : response?.spot?.offset; | 374 | + final nextOffset = nextIndex == null |
| 375 | + ? null | ||
| 376 | + : response?.spot?.offset; | ||
| 350 | if (_touchedIndex != nextIndex || | 377 | if (_touchedIndex != nextIndex || |
| 351 | _touchedOffset != nextOffset) { | 378 | _touchedOffset != nextOffset) { |
| 352 | setState(() { | 379 | setState(() { |
| @@ -538,27 +565,38 @@ class _TrendMetric extends StatelessWidget { | @@ -538,27 +565,38 @@ class _TrendMetric extends StatelessWidget { | ||
| 538 | final isGoodChange = _isGoodChange(difference); | 565 | final isGoodChange = _isGoodChange(difference); |
| 539 | final comparison = hasData | 566 | final comparison = hasData |
| 540 | ? isMonth | 567 | ? isMonth |
| 541 | - ? _monthComparisonText(context, difference!) | ||
| 542 | - : _weekComparisonText(context, difference!) | 568 | + ? _monthComparisonText(context, difference!) |
| 569 | + : _weekComparisonText(context, difference!) | ||
| 543 | : _unavailableComparisonText(context); | 570 | : _unavailableComparisonText(context); |
| 544 | final comparisonColor = _comparisonColor(difference); | 571 | final comparisonColor = _comparisonColor(difference); |
| 545 | return Column( | 572 | return Column( |
| 546 | crossAxisAlignment: CrossAxisAlignment.start, | 573 | crossAxisAlignment: CrossAxisAlignment.start, |
| 547 | children: [ | 574 | children: [ |
| 548 | - Text(label, | ||
| 549 | - style: const TextStyle(color: Color(0xFF78787D), fontSize: 12)), | 575 | + Text( |
| 576 | + label, | ||
| 577 | + style: const TextStyle(color: Color(0xFF78787D), fontSize: 12), | ||
| 578 | + ), | ||
| 550 | const SizedBox(height: 3), | 579 | const SizedBox(height: 3), |
| 551 | Row( | 580 | Row( |
| 552 | crossAxisAlignment: CrossAxisAlignment.end, | 581 | crossAxisAlignment: CrossAxisAlignment.end, |
| 553 | children: [ | 582 | children: [ |
| 554 | - Text(context.l10n.hrvTrendDayValue(value), | ||
| 555 | - style: const TextStyle( | ||
| 556 | - fontSize: 26, fontWeight: FontWeight.w600, height: 1)), | 583 | + Text( |
| 584 | + context.l10n.hrvTrendDayValue(value), | ||
| 585 | + style: const TextStyle( | ||
| 586 | + fontSize: 26, | ||
| 587 | + fontWeight: FontWeight.w600, | ||
| 588 | + height: 1, | ||
| 589 | + ), | ||
| 590 | + ), | ||
| 557 | Padding( | 591 | Padding( |
| 558 | padding: const EdgeInsets.only(left: 4, bottom: 2), | 592 | padding: const EdgeInsets.only(left: 4, bottom: 2), |
| 559 | - child: Text(context.l10n.hrvTrendDayUnit, | ||
| 560 | - style: const TextStyle( | ||
| 561 | - fontSize: 12, fontWeight: FontWeight.w500)), | 593 | + child: Text( |
| 594 | + context.l10n.hrvTrendDayUnit, | ||
| 595 | + style: const TextStyle( | ||
| 596 | + fontSize: 12, | ||
| 597 | + fontWeight: FontWeight.w500, | ||
| 598 | + ), | ||
| 599 | + ), | ||
| 562 | ), | 600 | ), |
| 563 | ], | 601 | ], |
| 564 | ), | 602 | ), |
| @@ -591,16 +629,16 @@ class _TrendMetric extends StatelessWidget { | @@ -591,16 +629,16 @@ class _TrendMetric extends StatelessWidget { | ||
| 591 | return difference == 0 | 629 | return difference == 0 |
| 592 | ? context.l10n.hrvTrendSameAsLastWeek | 630 | ? context.l10n.hrvTrendSameAsLastWeek |
| 593 | : difference > 0 | 631 | : difference > 0 |
| 594 | - ? context.l10n.hrvTrendMoreDaysThanLastWeek(difference) | ||
| 595 | - : context.l10n.hrvTrendFewerDaysThanLastWeek(difference.abs()); | 632 | + ? context.l10n.hrvTrendMoreDaysThanLastWeek(difference) |
| 633 | + : context.l10n.hrvTrendFewerDaysThanLastWeek(difference.abs()); | ||
| 596 | } | 634 | } |
| 597 | 635 | ||
| 598 | String _monthComparisonText(BuildContext context, int difference) { | 636 | String _monthComparisonText(BuildContext context, int difference) { |
| 599 | return difference == 0 | 637 | return difference == 0 |
| 600 | ? context.l10n.hrvTrendSameAsLastMonth | 638 | ? context.l10n.hrvTrendSameAsLastMonth |
| 601 | : difference > 0 | 639 | : difference > 0 |
| 602 | - ? context.l10n.hrvTrendMoreDaysThanLastMonth(difference) | ||
| 603 | - : context.l10n.hrvTrendFewerDaysThanLastMonth(difference.abs()); | 640 | + ? context.l10n.hrvTrendMoreDaysThanLastMonth(difference) |
| 641 | + : context.l10n.hrvTrendFewerDaysThanLastMonth(difference.abs()); | ||
| 604 | } | 642 | } |
| 605 | 643 | ||
| 606 | String _unavailableComparisonText(BuildContext context) { | 644 | String _unavailableComparisonText(BuildContext context) { |
| @@ -662,11 +700,7 @@ class _StressAxisText extends StatelessWidget { | @@ -662,11 +700,7 @@ class _StressAxisText extends StatelessWidget { | ||
| 662 | return Text( | 700 | return Text( |
| 663 | text, | 701 | text, |
| 664 | maxLines: 1, | 702 | maxLines: 1, |
| 665 | - style: const TextStyle( | ||
| 666 | - color: Color(0xFFB0B0B6), | ||
| 667 | - fontSize: 10, | ||
| 668 | - height: 1, | ||
| 669 | - ), | 703 | + style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10, height: 1), |
| 670 | ); | 704 | ); |
| 671 | } | 705 | } |
| 672 | } | 706 | } |
| @@ -768,226 +802,22 @@ String _weekAxisLabel(BuildContext context, int weekday) { | @@ -768,226 +802,22 @@ String _weekAxisLabel(BuildContext context, int weekday) { | ||
| 768 | }; | 802 | }; |
| 769 | } | 803 | } |
| 770 | 804 | ||
| 771 | -class _DistributionCard extends StatelessWidget { | ||
| 772 | - const _DistributionCard({ | ||
| 773 | - required this.report, | ||
| 774 | - required this.showExample, | ||
| 775 | - this.onTap, | ||
| 776 | - }); | 805 | +class _ExtremeHrvSection extends StatelessWidget { |
| 806 | + const _ExtremeHrvSection({required this.report}); | ||
| 777 | 807 | ||
| 778 | final HrvPeriodReport report; | 808 | final HrvPeriodReport report; |
| 779 | - final bool showExample; | ||
| 780 | - final VoidCallback? onTap; | ||
| 781 | - static const _barRight = 25.0; | ||
| 782 | - static const _barWidth = 42.0; | ||
| 783 | 809 | ||
| 784 | @override | 810 | @override |
| 785 | - Widget build(BuildContext context) { | ||
| 786 | - return _Card( | ||
| 787 | - onTap: onTap, | ||
| 788 | - padding: EdgeInsets.zero, | ||
| 789 | - child: Padding( | ||
| 790 | - padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), | ||
| 791 | - child: Column( | ||
| 792 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 793 | - children: [ | ||
| 794 | - _topView(context), | ||
| 795 | - const Divider(height: 1, color: Color(0xFFF3F3F3)), | ||
| 796 | - const SizedBox(height: 20), | ||
| 797 | - Row( | ||
| 798 | - children: [ | ||
| 799 | - Expanded( | ||
| 800 | - child: _ExtremeHrv( | ||
| 801 | - title: context.l10n.hrvLowest, | ||
| 802 | - day: report.minDay, | ||
| 803 | - ), | ||
| 804 | - ), | ||
| 805 | - Expanded( | ||
| 806 | - child: _ExtremeHrv( | ||
| 807 | - title: context.l10n.hrvHighest, | ||
| 808 | - day: report.maxDay, | ||
| 809 | - ), | ||
| 810 | - ), | ||
| 811 | - ], | ||
| 812 | - ), | ||
| 813 | - ], | ||
| 814 | - ), | 811 | + Widget build(BuildContext context) => Row( |
| 812 | + children: [ | ||
| 813 | + Expanded( | ||
| 814 | + child: _ExtremeHrv(title: context.l10n.hrvLowest, day: report.minDay), | ||
| 815 | ), | 815 | ), |
| 816 | - ); | ||
| 817 | - } | ||
| 818 | - | ||
| 819 | - Widget _topView(BuildContext context) { | ||
| 820 | - return Stack( | ||
| 821 | - children: [ | ||
| 822 | - Positioned( | ||
| 823 | - child: Column( | ||
| 824 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 825 | - children: [ | ||
| 826 | - _ReportTitle( | ||
| 827 | - title: HealthReportSubjectScope.titleOf( | ||
| 828 | - context, | ||
| 829 | - context.l10n.hrvStressDistribution, | ||
| 830 | - ), | ||
| 831 | - showExample: showExample, | ||
| 832 | - fontSize: 16, | ||
| 833 | - ), | ||
| 834 | - const SizedBox(height: 20), | ||
| 835 | - Text( | ||
| 836 | - context.l10n.hrvValidDays, | ||
| 837 | - style: const TextStyle( | ||
| 838 | - color: Color(0xFF78787D), | ||
| 839 | - fontSize: 12, | ||
| 840 | - ), | ||
| 841 | - ), | ||
| 842 | - const SizedBox(height: 2), | ||
| 843 | - _ValueWithUnit( | ||
| 844 | - value: '${report.validDays}', | ||
| 845 | - unit: context.l10n.hrvDistributionDayUnit, | ||
| 846 | - ), | ||
| 847 | - const SizedBox(height: 28), | ||
| 848 | - SizedBox( | ||
| 849 | - width: 230, | ||
| 850 | - child: _DistributionGrid(report: report), | ||
| 851 | - ), | ||
| 852 | - const SizedBox(height: 20), | ||
| 853 | - ], | ||
| 854 | - )), | ||
| 855 | - Positioned( | ||
| 856 | - top: 0, | ||
| 857 | - width: _barWidth, | ||
| 858 | - right: _barRight, | ||
| 859 | - bottom: 25.h, | ||
| 860 | - child: Container( | ||
| 861 | - alignment: Alignment.topCenter, | ||
| 862 | - padding: EdgeInsets.only(top: 25.h), | ||
| 863 | - child: IgnorePointer(child: _DistributionBar(report: report))), | ||
| 864 | - ), | ||
| 865 | - ], | ||
| 866 | - ); | ||
| 867 | - } | ||
| 868 | -} | ||
| 869 | - | ||
| 870 | -class _DistributionGrid extends StatelessWidget { | ||
| 871 | - const _DistributionGrid({required this.report}); | ||
| 872 | - | ||
| 873 | - final HrvPeriodReport report; | ||
| 874 | - | ||
| 875 | - @override | ||
| 876 | - Widget build(BuildContext context) { | ||
| 877 | - return LayoutBuilder( | ||
| 878 | - builder: (context, constraints) { | ||
| 879 | - const spacing = 14.0; | ||
| 880 | - return Wrap( | ||
| 881 | - spacing: spacing, | ||
| 882 | - runSpacing: 19, | ||
| 883 | - children: [ | ||
| 884 | - for (final level in HrvStressLevel.values) | ||
| 885 | - SizedBox( | ||
| 886 | - width: 105, | ||
| 887 | - child: _DistributionItem(level: level, report: report), | ||
| 888 | - ), | ||
| 889 | - ], | ||
| 890 | - ); | ||
| 891 | - }, | ||
| 892 | - ); | ||
| 893 | - } | ||
| 894 | -} | ||
| 895 | - | ||
| 896 | -class _DistributionItem extends StatelessWidget { | ||
| 897 | - const _DistributionItem({required this.level, required this.report}); | ||
| 898 | - | ||
| 899 | - final HrvStressLevel level; | ||
| 900 | - final HrvPeriodReport report; | ||
| 901 | - | ||
| 902 | - @override | ||
| 903 | - Widget build(BuildContext context) { | ||
| 904 | - final count = report.countFor(level); | ||
| 905 | - final percent = | ||
| 906 | - report.validDays == 0 ? 0 : (count / report.validDays * 100).round(); | ||
| 907 | - return Column( | ||
| 908 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 909 | - children: [ | ||
| 910 | - Row(children: [ | ||
| 911 | - Container( | ||
| 912 | - width: 12, | ||
| 913 | - height: 12, | ||
| 914 | - decoration: BoxDecoration( | ||
| 915 | - color: Color(level.colorValue), shape: BoxShape.circle)), | ||
| 916 | - const SizedBox(width: 4), | ||
| 917 | - Expanded( | ||
| 918 | - child: Text( | ||
| 919 | - level.label, | ||
| 920 | - maxLines: 1, | ||
| 921 | - overflow: TextOverflow.ellipsis, | ||
| 922 | - style: const TextStyle( | ||
| 923 | - fontSize: 12, | ||
| 924 | - fontWeight: FontWeight.w500, | ||
| 925 | - height: 1.2, | ||
| 926 | - ), | ||
| 927 | - ), | ||
| 928 | - ), | ||
| 929 | - ]), | ||
| 930 | - const SizedBox(height: 8), | ||
| 931 | - if (report.hasData) | ||
| 932 | - Row( | ||
| 933 | - crossAxisAlignment: CrossAxisAlignment.end, | ||
| 934 | - children: [ | ||
| 935 | - Text('$count', | ||
| 936 | - style: const TextStyle( | ||
| 937 | - color: Color(0xFF0F0F11), | ||
| 938 | - fontSize: 26, | ||
| 939 | - fontWeight: FontWeight.w500, | ||
| 940 | - height: 1)), | ||
| 941 | - Padding( | ||
| 942 | - padding: const EdgeInsets.only(left: 2, bottom: 2), | ||
| 943 | - child: Text(context.l10n.hrvDistributionDayUnit, | ||
| 944 | - style: const TextStyle( | ||
| 945 | - color: Color(0xFF0F0F11), | ||
| 946 | - fontSize: 12, | ||
| 947 | - fontWeight: FontWeight.w500)), | ||
| 948 | - ), | ||
| 949 | - ], | ||
| 950 | - ) | ||
| 951 | - else | ||
| 952 | - Text(context.l10n.hrvDistributionAwaitingData, | ||
| 953 | - style: const TextStyle( | ||
| 954 | - color: Color(0xFF78787D), | ||
| 955 | - fontSize: 15, | ||
| 956 | - fontWeight: FontWeight.w500, | ||
| 957 | - height: 1.2)), | ||
| 958 | - const SizedBox(height: 1), | ||
| 959 | - Text('$percent%', | ||
| 960 | - style: const TextStyle( | ||
| 961 | - color: Color(0xFF78787D), | ||
| 962 | - fontSize: 12, | ||
| 963 | - height: 1.2, | ||
| 964 | - )), | ||
| 965 | - ], | ||
| 966 | - ); | ||
| 967 | - } | ||
| 968 | -} | ||
| 969 | - | ||
| 970 | -class _DistributionBar extends StatelessWidget { | ||
| 971 | - const _DistributionBar({required this.report}); | ||
| 972 | - | ||
| 973 | - final HrvPeriodReport report; | ||
| 974 | - | ||
| 975 | - @override | ||
| 976 | - Widget build(BuildContext context) { | ||
| 977 | - final segments = [ | ||
| 978 | - for (final level in HrvStressLevel.values) | ||
| 979 | - if (report.countFor(level) > 0) | ||
| 980 | - HrvDistributionBarSegment( | ||
| 981 | - color: Color(level.colorValue), | ||
| 982 | - flex: report.countFor(level), | ||
| 983 | - ), | ||
| 984 | - ]; | ||
| 985 | - return HrvDistributionBar( | ||
| 986 | - segments: report.hasData ? segments : const [], | ||
| 987 | - backgroundColor: const Color(0xFFF3F3F3), | ||
| 988 | - radius: 8, | ||
| 989 | - ); | ||
| 990 | - } | 816 | + Expanded( |
| 817 | + child: _ExtremeHrv(title: context.l10n.hrvHighest, day: report.maxDay), | ||
| 818 | + ), | ||
| 819 | + ], | ||
| 820 | + ); | ||
| 991 | } | 821 | } |
| 992 | 822 | ||
| 993 | class _ExtremeHrv extends StatelessWidget { | 823 | class _ExtremeHrv extends StatelessWidget { |
| @@ -1001,12 +831,15 @@ class _ExtremeHrv extends StatelessWidget { | @@ -1001,12 +831,15 @@ class _ExtremeHrv extends StatelessWidget { | ||
| 1001 | return Column( | 831 | return Column( |
| 1002 | crossAxisAlignment: CrossAxisAlignment.start, | 832 | crossAxisAlignment: CrossAxisAlignment.start, |
| 1003 | children: [ | 833 | children: [ |
| 1004 | - Text(title, | ||
| 1005 | - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), | 834 | + Text( |
| 835 | + title, | ||
| 836 | + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), | ||
| 837 | + ), | ||
| 1006 | const SizedBox(height: 5), | 838 | const SizedBox(height: 5), |
| 1007 | _ValueWithUnit( | 839 | _ValueWithUnit( |
| 1008 | - value: day == null ? '-' : '${day!.averageHrv!.round()}', | ||
| 1009 | - unit: 'ms'), | 840 | + value: day == null ? '-' : '${day!.averageHrv!.round()}', |
| 841 | + unit: 'ms', | ||
| 842 | + ), | ||
| 1010 | const SizedBox(height: 2), | 843 | const SizedBox(height: 2), |
| 1011 | Text( | 844 | Text( |
| 1012 | day == null | 845 | day == null |
| @@ -1039,9 +872,14 @@ class _ValueWithUnit extends StatelessWidget { | @@ -1039,9 +872,14 @@ class _ValueWithUnit extends StatelessWidget { | ||
| 1039 | return Row( | 872 | return Row( |
| 1040 | crossAxisAlignment: CrossAxisAlignment.end, | 873 | crossAxisAlignment: CrossAxisAlignment.end, |
| 1041 | children: [ | 874 | children: [ |
| 1042 | - Text(value, | ||
| 1043 | - style: const TextStyle( | ||
| 1044 | - fontSize: 26, fontWeight: FontWeight.w600, height: 1)), | 875 | + Text( |
| 876 | + value, | ||
| 877 | + style: const TextStyle( | ||
| 878 | + fontSize: 26, | ||
| 879 | + fontWeight: FontWeight.w600, | ||
| 880 | + height: 1, | ||
| 881 | + ), | ||
| 882 | + ), | ||
| 1045 | Padding( | 883 | Padding( |
| 1046 | padding: const EdgeInsets.only(left: 3, bottom: 2), | 884 | padding: const EdgeInsets.only(left: 3, bottom: 2), |
| 1047 | child: Text(unit, style: const TextStyle(fontSize: 12)), | 885 | child: Text(unit, style: const TextStyle(fontSize: 12)), |
| @@ -1055,20 +893,21 @@ class _ReportTitle extends StatelessWidget { | @@ -1055,20 +893,21 @@ class _ReportTitle extends StatelessWidget { | ||
| 1055 | const _ReportTitle({ | 893 | const _ReportTitle({ |
| 1056 | required this.title, | 894 | required this.title, |
| 1057 | required this.showExample, | 895 | required this.showExample, |
| 1058 | - this.fontSize = 15, | 896 | + required this.isMonth, |
| 1059 | }); | 897 | }); |
| 1060 | 898 | ||
| 1061 | final String title; | 899 | final String title; |
| 1062 | final bool showExample; | 900 | final bool showExample; |
| 1063 | - final double fontSize; | 901 | + final bool isMonth; |
| 1064 | 902 | ||
| 1065 | @override | 903 | @override |
| 1066 | Widget build(BuildContext context) { | 904 | Widget build(BuildContext context) { |
| 1067 | return Row( | 905 | return Row( |
| 906 | + crossAxisAlignment: CrossAxisAlignment.center, | ||
| 1068 | children: [ | 907 | children: [ |
| 1069 | Text( | 908 | Text( |
| 1070 | showExample ? context.l10n.reportExampleTitle(title) : title, | 909 | showExample ? context.l10n.reportExampleTitle(title) : title, |
| 1071 | - style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600), | 910 | + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), |
| 1072 | ), | 911 | ), |
| 1073 | if (showExample) ...[ | 912 | if (showExample) ...[ |
| 1074 | Image.asset( | 913 | Image.asset( |
| @@ -1078,6 +917,14 @@ class _ReportTitle extends StatelessWidget { | @@ -1078,6 +917,14 @@ class _ReportTitle extends StatelessWidget { | ||
| 1078 | fit: BoxFit.contain, | 917 | fit: BoxFit.contain, |
| 1079 | ), | 918 | ), |
| 1080 | ], | 919 | ], |
| 920 | + if (!HealthReportSubjectScope.isViewingSelf(context)) ...[ | ||
| 921 | + const Spacer(), | ||
| 922 | + FriendInteractClickView( | ||
| 923 | + interactionType: isMonth | ||
| 924 | + ? FriendInteractionType.monthStressTrend | ||
| 925 | + : FriendInteractionType.weekStressTrend, | ||
| 926 | + ), | ||
| 927 | + ], | ||
| 1081 | ], | 928 | ], |
| 1082 | ); | 929 | ); |
| 1083 | } | 930 | } |
| @@ -1100,7 +947,9 @@ class _Card extends StatelessWidget { | @@ -1100,7 +947,9 @@ class _Card extends StatelessWidget { | ||
| 1100 | width: double.infinity, | 947 | width: double.infinity, |
| 1101 | padding: padding, | 948 | padding: padding, |
| 1102 | decoration: BoxDecoration( | 949 | decoration: BoxDecoration( |
| 1103 | - color: Colors.white, borderRadius: BorderRadius.circular(16)), | 950 | + color: Colors.white, |
| 951 | + borderRadius: BorderRadius.circular(16), | ||
| 952 | + ), | ||
| 1104 | child: child, | 953 | child: child, |
| 1105 | ); | 954 | ); |
| 1106 | if (onTap == null) return content; | 955 | if (onTap == null) return content; |
| @@ -2,14 +2,16 @@ import 'dart:async'; | @@ -2,14 +2,16 @@ import 'dart:async'; | ||
| 2 | 2 | ||
| 3 | import 'package:fl_chart/fl_chart.dart'; | 3 | import 'package:fl_chart/fl_chart.dart'; |
| 4 | import 'package:flutter/material.dart'; | 4 | import 'package:flutter/material.dart'; |
| 5 | +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart'; | ||
| 5 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 6 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 6 | 7 | ||
| 8 | +import '../../../widget/friend_interact_click_view.dart'; | ||
| 7 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; | 9 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; |
| 8 | import '../../report_common/widgets/health_report_subject_scope.dart'; | 10 | import '../../report_common/widgets/health_report_subject_scope.dart'; |
| 9 | import '../../report_common/widgets/report_month_calendar_grid.dart'; | 11 | import '../../report_common/widgets/report_month_calendar_grid.dart'; |
| 10 | import '../../report_common/utils/report_localization.dart'; | 12 | import '../../report_common/utils/report_localization.dart'; |
| 11 | import '../models/hrv_report_models.dart'; | 13 | import '../models/hrv_report_models.dart'; |
| 12 | -import 'hrv_distribution_bar.dart'; | 14 | +import 'hrv_stress_distribution_card.dart'; |
| 13 | 15 | ||
| 14 | class HrvYearReportView extends StatelessWidget { | 16 | class HrvYearReportView extends StatelessWidget { |
| 15 | const HrvYearReportView({ | 17 | const HrvYearReportView({ |
| @@ -31,7 +33,18 @@ class HrvYearReportView extends StatelessWidget { | @@ -31,7 +33,18 @@ class HrvYearReportView extends StatelessWidget { | ||
| 31 | tooltipDismissDelayMs: tooltipDismissDelayMs, | 33 | tooltipDismissDelayMs: tooltipDismissDelayMs, |
| 32 | ), | 34 | ), |
| 33 | const SizedBox(height: 12), | 35 | const SizedBox(height: 12), |
| 34 | - _YearDistributionCard(report: data), | 36 | + HrvStressDistributionCard( |
| 37 | + report: data, | ||
| 38 | + title: HealthReportSubjectScope.titleOf( | ||
| 39 | + context, | ||
| 40 | + context.l10n.hrvStressDistribution, | ||
| 41 | + ), | ||
| 42 | + trailing: HealthReportSubjectScope.isViewingSelf(context) | ||
| 43 | + ? null | ||
| 44 | + : const FriendInteractClickView( | ||
| 45 | + interactionType: FriendInteractionType.yearStressTrend, | ||
| 46 | + ), | ||
| 47 | + ), | ||
| 35 | const SizedBox(height: 12), | 48 | const SizedBox(height: 12), |
| 36 | _DailyDistributionCard(report: data), | 49 | _DailyDistributionCard(report: data), |
| 37 | ], | 50 | ], |
| @@ -55,12 +68,11 @@ class _YearTrendCard extends StatelessWidget { | @@ -55,12 +68,11 @@ class _YearTrendCard extends StatelessWidget { | ||
| 55 | child: Column( | 68 | child: Column( |
| 56 | crossAxisAlignment: CrossAxisAlignment.start, | 69 | crossAxisAlignment: CrossAxisAlignment.start, |
| 57 | children: [ | 70 | children: [ |
| 58 | - Text( | ||
| 59 | - HealthReportSubjectScope.titleOf( | 71 | + _ReportTitle( |
| 72 | + title: HealthReportSubjectScope.titleOf( | ||
| 60 | context, | 73 | context, |
| 61 | context.l10n.hrvMonthlyStressTrend, | 74 | context.l10n.hrvMonthlyStressTrend, |
| 62 | ), | 75 | ), |
| 63 | - style: _titleStyle, | ||
| 64 | ), | 76 | ), |
| 65 | const SizedBox(height: 18), | 77 | const SizedBox(height: 18), |
| 66 | SizedBox( | 78 | SizedBox( |
| @@ -165,12 +177,14 @@ class _YearBarChartState extends State<_YearBarChart> { | @@ -165,12 +177,14 @@ class _YearBarChartState extends State<_YearBarChart> { | ||
| 165 | BarChartGroupData( | 177 | BarChartGroupData( |
| 166 | x: month, | 178 | x: month, |
| 167 | groupVertically: true, | 179 | groupVertically: true, |
| 168 | - showingTooltipIndicators: | ||
| 169 | - _touchedMonth == month ? [1] : [], | 180 | + showingTooltipIndicators: _touchedMonth == month |
| 181 | + ? [1] | ||
| 182 | + : [], | ||
| 170 | barRods: [ | 183 | barRods: [ |
| 171 | BarChartRodData( | 184 | BarChartRodData( |
| 172 | toY: _barHeight( | 185 | toY: _barHeight( |
| 173 | - widget.report.stressScoreForMonth(month)), | 186 | + widget.report.stressScoreForMonth(month), |
| 187 | + ), | ||
| 174 | width: 8, | 188 | width: 8, |
| 175 | color: _colorForMonth(month), | 189 | color: _colorForMonth(month), |
| 176 | borderRadius: const BorderRadius.vertical( | 190 | borderRadius: const BorderRadius.vertical( |
| @@ -192,10 +206,8 @@ class _YearBarChartState extends State<_YearBarChart> { | @@ -192,10 +206,8 @@ class _YearBarChartState extends State<_YearBarChart> { | ||
| 192 | show: true, | 206 | show: true, |
| 193 | drawVerticalLine: false, | 207 | drawVerticalLine: false, |
| 194 | horizontalInterval: 25, | 208 | horizontalInterval: 25, |
| 195 | - getDrawingHorizontalLine: (_) => const FlLine( | ||
| 196 | - color: _grid, | ||
| 197 | - dashArray: [2, 2], | ||
| 198 | - ), | 209 | + getDrawingHorizontalLine: (_) => |
| 210 | + const FlLine(color: _grid, dashArray: [2, 2]), | ||
| 199 | ), | 211 | ), |
| 200 | extraLinesData: ExtraLinesData( | 212 | extraLinesData: ExtraLinesData( |
| 201 | extraLinesOnTop: false, | 213 | extraLinesOnTop: false, |
| @@ -210,11 +222,14 @@ class _YearBarChartState extends State<_YearBarChart> { | @@ -210,11 +222,14 @@ class _YearBarChartState extends State<_YearBarChart> { | ||
| 210 | ), | 222 | ), |
| 211 | titlesData: FlTitlesData( | 223 | titlesData: FlTitlesData( |
| 212 | topTitles: const AxisTitles( | 224 | topTitles: const AxisTitles( |
| 213 | - sideTitles: SideTitles(showTitles: false)), | 225 | + sideTitles: SideTitles(showTitles: false), |
| 226 | + ), | ||
| 214 | rightTitles: const AxisTitles( | 227 | rightTitles: const AxisTitles( |
| 215 | - sideTitles: SideTitles(showTitles: false)), | 228 | + sideTitles: SideTitles(showTitles: false), |
| 229 | + ), | ||
| 216 | leftTitles: const AxisTitles( | 230 | leftTitles: const AxisTitles( |
| 217 | - sideTitles: SideTitles(showTitles: false)), | 231 | + sideTitles: SideTitles(showTitles: false), |
| 232 | + ), | ||
| 218 | bottomTitles: AxisTitles( | 233 | bottomTitles: AxisTitles( |
| 219 | sideTitles: SideTitles( | 234 | sideTitles: SideTitles( |
| 220 | showTitles: true, | 235 | showTitles: true, |
| @@ -241,11 +256,13 @@ class _YearBarChartState extends State<_YearBarChart> { | @@ -241,11 +256,13 @@ class _YearBarChartState extends State<_YearBarChart> { | ||
| 241 | event.localPosition?.dx, | 256 | event.localPosition?.dx, |
| 242 | constraints.maxWidth, | 257 | constraints.maxWidth, |
| 243 | ); | 258 | ); |
| 244 | - final hasTouchedData = month != null && | 259 | + final hasTouchedData = |
| 260 | + month != null && | ||
| 245 | widget.report.stressScoreForMonth(month) != null; | 261 | widget.report.stressScoreForMonth(month) != null; |
| 246 | final nextMonth = hasTouchedData ? month : null; | 262 | final nextMonth = hasTouchedData ? month : null; |
| 247 | - final nextOffset = | ||
| 248 | - nextMonth == null ? null : response?.spot?.offset; | 263 | + final nextOffset = nextMonth == null |
| 264 | + ? null | ||
| 265 | + : response?.spot?.offset; | ||
| 249 | if (_touchedMonth != nextMonth || | 266 | if (_touchedMonth != nextMonth || |
| 250 | _touchedOffset != nextOffset) { | 267 | _touchedOffset != nextOffset) { |
| 251 | setState(() { | 268 | setState(() { |
| @@ -302,8 +319,10 @@ class _YearBarChartState extends State<_YearBarChart> { | @@ -302,8 +319,10 @@ class _YearBarChartState extends State<_YearBarChart> { | ||
| 302 | ), | 319 | ), |
| 303 | ), | 320 | ), |
| 304 | if (!widget.report.hasData) | 321 | if (!widget.report.hasData) |
| 305 | - Text(context.l10n.hrvTrendAwaitingData, | ||
| 306 | - style: const TextStyle(color: _h3, fontSize: 11)), | 322 | + Text( |
| 323 | + context.l10n.hrvTrendAwaitingData, | ||
| 324 | + style: const TextStyle(color: _h3, fontSize: 11), | ||
| 325 | + ), | ||
| 307 | ], | 326 | ], |
| 308 | ); | 327 | ); |
| 309 | } | 328 | } |
| @@ -394,17 +413,15 @@ String _tooltipMonth(BuildContext context, int month) { | @@ -394,17 +413,15 @@ String _tooltipMonth(BuildContext context, int month) { | ||
| 394 | } | 413 | } |
| 395 | 414 | ||
| 396 | class _MonthExtreme extends StatelessWidget { | 415 | class _MonthExtreme extends StatelessWidget { |
| 397 | - const _MonthExtreme({ | ||
| 398 | - required this.label, | ||
| 399 | - required this.months, | ||
| 400 | - }); | 416 | + const _MonthExtreme({required this.label, required this.months}); |
| 401 | final String label; | 417 | final String label; |
| 402 | final List<int> months; | 418 | final List<int> months; |
| 403 | 419 | ||
| 404 | @override | 420 | @override |
| 405 | Widget build(BuildContext context) { | 421 | Widget build(BuildContext context) { |
| 406 | - final separator = | ||
| 407 | - Localizations.localeOf(context).languageCode == 'zh' ? ',' : ', '; | 422 | + final separator = Localizations.localeOf(context).languageCode == 'zh' |
| 423 | + ? ',' | ||
| 424 | + : ', '; | ||
| 408 | final value = months.isEmpty | 425 | final value = months.isEmpty |
| 409 | ? context.l10n.hrvEmptyMonthPlaceholder | 426 | ? context.l10n.hrvEmptyMonthPlaceholder |
| 410 | : months.map((month) => _tooltipMonth(context, month)).join(separator); | 427 | : months.map((month) => _tooltipMonth(context, month)).join(separator); |
| @@ -475,7 +492,8 @@ class _YearXAxisLabels extends StatelessWidget { | @@ -475,7 +492,8 @@ class _YearXAxisLabels extends StatelessWidget { | ||
| 475 | children: [ | 492 | children: [ |
| 476 | for (var index = 0; index < groupCount; index++) | 493 | for (var index = 0; index < groupCount; index++) |
| 477 | Positioned( | 494 | Positioned( |
| 478 | - left: constraints.maxWidth * ((index + 0.5) / groupCount) - | 495 | + left: |
| 496 | + constraints.maxWidth * ((index + 0.5) / groupCount) - | ||
| 479 | labelWidth / 2, | 497 | labelWidth / 2, |
| 480 | top: 6, | 498 | top: 6, |
| 481 | width: labelWidth, | 499 | width: labelWidth, |
| @@ -492,69 +510,6 @@ class _YearXAxisLabels extends StatelessWidget { | @@ -492,69 +510,6 @@ class _YearXAxisLabels extends StatelessWidget { | ||
| 492 | } | 510 | } |
| 493 | } | 511 | } |
| 494 | 512 | ||
| 495 | -class _YearDistributionCard extends StatelessWidget { | ||
| 496 | - const _YearDistributionCard({required this.report}); | ||
| 497 | - final YearlyHrvReport report; | ||
| 498 | - | ||
| 499 | - @override | ||
| 500 | - Widget build(BuildContext context) { | ||
| 501 | - return _Card( | ||
| 502 | - child: Column( | ||
| 503 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 504 | - children: [ | ||
| 505 | - Text( | ||
| 506 | - HealthReportSubjectScope.titleOf( | ||
| 507 | - context, | ||
| 508 | - context.l10n.hrvStressDistribution, | ||
| 509 | - ), | ||
| 510 | - style: _titleStyle, | ||
| 511 | - ), | ||
| 512 | - const SizedBox(height: 16), | ||
| 513 | - Row( | ||
| 514 | - crossAxisAlignment: CrossAxisAlignment.end, | ||
| 515 | - children: [ | ||
| 516 | - Expanded( | ||
| 517 | - child: Column( | ||
| 518 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 519 | - children: [ | ||
| 520 | - Text(context.l10n.hrvValidDays, | ||
| 521 | - style: const TextStyle(color: _h2, fontSize: 11)), | ||
| 522 | - const SizedBox(height: 3), | ||
| 523 | - _Number( | ||
| 524 | - value: '${report.validDays}', | ||
| 525 | - unit: context.l10n.hrvDistributionDayUnit, | ||
| 526 | - ), | ||
| 527 | - const SizedBox(height: 18), | ||
| 528 | - LayoutBuilder( | ||
| 529 | - builder: (context, constraints) { | ||
| 530 | - const spacing = 16.0; | ||
| 531 | - final itemWidth = (constraints.maxWidth - spacing) / 2; | ||
| 532 | - return Wrap( | ||
| 533 | - spacing: spacing, | ||
| 534 | - runSpacing: 13, | ||
| 535 | - children: [ | ||
| 536 | - for (final level in HrvStressLevel.values) | ||
| 537 | - SizedBox( | ||
| 538 | - width: itemWidth, | ||
| 539 | - child: _LevelItem(report: report, level: level), | ||
| 540 | - ), | ||
| 541 | - ], | ||
| 542 | - ); | ||
| 543 | - }, | ||
| 544 | - ), | ||
| 545 | - ], | ||
| 546 | - ), | ||
| 547 | - ), | ||
| 548 | - _StackedBar(report: report), | ||
| 549 | - const SizedBox(width: 20), | ||
| 550 | - ], | ||
| 551 | - ), | ||
| 552 | - ], | ||
| 553 | - ), | ||
| 554 | - ); | ||
| 555 | - } | ||
| 556 | -} | ||
| 557 | - | ||
| 558 | class _DailyDistributionCard extends StatelessWidget { | 513 | class _DailyDistributionCard extends StatelessWidget { |
| 559 | const _DailyDistributionCard({required this.report}); | 514 | const _DailyDistributionCard({required this.report}); |
| 560 | final YearlyHrvReport report; | 515 | final YearlyHrvReport report; |
| @@ -566,12 +521,12 @@ class _DailyDistributionCard extends StatelessWidget { | @@ -566,12 +521,12 @@ class _DailyDistributionCard extends StatelessWidget { | ||
| 566 | child: Column( | 521 | child: Column( |
| 567 | crossAxisAlignment: CrossAxisAlignment.start, | 522 | crossAxisAlignment: CrossAxisAlignment.start, |
| 568 | children: [ | 523 | children: [ |
| 569 | - Text( | ||
| 570 | - HealthReportSubjectScope.titleOf( | 524 | + _ReportTitle( |
| 525 | + title: HealthReportSubjectScope.titleOf( | ||
| 571 | context, | 526 | context, |
| 572 | context.l10n.hrvDailyStressDistribution, | 527 | context.l10n.hrvDailyStressDistribution, |
| 573 | ), | 528 | ), |
| 574 | - style: _titleStyle, | 529 | + trailingInset: 4, |
| 575 | ), | 530 | ), |
| 576 | const SizedBox(height: 20), | 531 | const SizedBox(height: 20), |
| 577 | GridView.builder( | 532 | GridView.builder( |
| @@ -606,18 +561,16 @@ class _DailyDistributionCard extends StatelessWidget { | @@ -606,18 +561,16 @@ class _DailyDistributionCard extends StatelessWidget { | ||
| 606 | } | 561 | } |
| 607 | 562 | ||
| 608 | ({List<HrvDayReport> mostStressed, List<HrvDayReport> mostRelaxed}) | 563 | ({List<HrvDayReport> mostStressed, List<HrvDayReport> mostRelaxed}) |
| 609 | - _dailyStressExtremeDays(YearlyHrvReport report) { | 564 | + _dailyStressExtremeDays(YearlyHrvReport report) { |
| 610 | final daysWithScore = report.days.where((day) { | 565 | final daysWithScore = report.days.where((day) { |
| 611 | return day.level != null && day.dailyComprehensiveStressScore != null; | 566 | return day.level != null && day.dailyComprehensiveStressScore != null; |
| 612 | }); | 567 | }); |
| 613 | - final mostStressed = daysWithScore | ||
| 614 | - .where((day) => _isStressedLevel(day.level!)) | ||
| 615 | - .toList() | ||
| 616 | - ..sort(_compareByDailyStressDescending); | ||
| 617 | - final mostRelaxed = daysWithScore | ||
| 618 | - .where((day) => !_isStressedLevel(day.level!)) | ||
| 619 | - .toList() | ||
| 620 | - ..sort(_compareByDailyStressAscending); | 568 | + final mostStressed = |
| 569 | + daysWithScore.where((day) => _isStressedLevel(day.level!)).toList() | ||
| 570 | + ..sort(_compareByDailyStressDescending); | ||
| 571 | + final mostRelaxed = | ||
| 572 | + daysWithScore.where((day) => !_isStressedLevel(day.level!)).toList() | ||
| 573 | + ..sort(_compareByDailyStressAscending); | ||
| 621 | 574 | ||
| 622 | return ( | 575 | return ( |
| 623 | mostStressed: _takeTwoAndSortByDate(mostStressed), | 576 | mostStressed: _takeTwoAndSortByDate(mostStressed), |
| @@ -626,14 +579,16 @@ class _DailyDistributionCard extends StatelessWidget { | @@ -626,14 +579,16 @@ class _DailyDistributionCard extends StatelessWidget { | ||
| 626 | } | 579 | } |
| 627 | 580 | ||
| 628 | int _compareByDailyStressDescending(HrvDayReport a, HrvDayReport b) { | 581 | int _compareByDailyStressDescending(HrvDayReport a, HrvDayReport b) { |
| 629 | - final valueComparison = b.dailyComprehensiveStressScore! | ||
| 630 | - .compareTo(a.dailyComprehensiveStressScore!); | 582 | + final valueComparison = b.dailyComprehensiveStressScore!.compareTo( |
| 583 | + a.dailyComprehensiveStressScore!, | ||
| 584 | + ); | ||
| 631 | return valueComparison == 0 ? a.date.compareTo(b.date) : valueComparison; | 585 | return valueComparison == 0 ? a.date.compareTo(b.date) : valueComparison; |
| 632 | } | 586 | } |
| 633 | 587 | ||
| 634 | int _compareByDailyStressAscending(HrvDayReport a, HrvDayReport b) { | 588 | int _compareByDailyStressAscending(HrvDayReport a, HrvDayReport b) { |
| 635 | - final valueComparison = a.dailyComprehensiveStressScore! | ||
| 636 | - .compareTo(b.dailyComprehensiveStressScore!); | 589 | + final valueComparison = a.dailyComprehensiveStressScore!.compareTo( |
| 590 | + b.dailyComprehensiveStressScore!, | ||
| 591 | + ); | ||
| 637 | return valueComparison == 0 ? a.date.compareTo(b.date) : valueComparison; | 592 | return valueComparison == 0 ? a.date.compareTo(b.date) : valueComparison; |
| 638 | } | 593 | } |
| 639 | 594 | ||
| @@ -661,8 +616,10 @@ class _MonthDots extends StatelessWidget { | @@ -661,8 +616,10 @@ class _MonthDots extends StatelessWidget { | ||
| 661 | return Column( | 616 | return Column( |
| 662 | crossAxisAlignment: CrossAxisAlignment.start, | 617 | crossAxisAlignment: CrossAxisAlignment.start, |
| 663 | children: [ | 618 | children: [ |
| 664 | - Text(_tooltipMonth(context, month), | ||
| 665 | - style: const TextStyle(fontSize: 8)), | 619 | + Text( |
| 620 | + _tooltipMonth(context, month), | ||
| 621 | + style: const TextStyle(fontSize: 8), | ||
| 622 | + ), | ||
| 666 | const SizedBox(height: 8), | 623 | const SizedBox(height: 8), |
| 667 | SizedBox( | 624 | SizedBox( |
| 668 | width: 80, | 625 | width: 80, |
| @@ -694,121 +651,51 @@ class _DateExtreme extends StatelessWidget { | @@ -694,121 +651,51 @@ class _DateExtreme extends StatelessWidget { | ||
| 694 | 651 | ||
| 695 | @override | 652 | @override |
| 696 | Widget build(BuildContext context) { | 653 | Widget build(BuildContext context) { |
| 697 | - final separator = | ||
| 698 | - Localizations.localeOf(context).languageCode == 'zh' ? ',' : ', '; | 654 | + final separator = Localizations.localeOf(context).languageCode == 'zh' |
| 655 | + ? ',' | ||
| 656 | + : ', '; | ||
| 699 | final value = days.isEmpty | 657 | final value = days.isEmpty |
| 700 | ? context.l10n.hrvEmptyMonthDayPlaceholder | 658 | ? context.l10n.hrvEmptyMonthDayPlaceholder |
| 701 | : days | 659 | : days |
| 702 | - .take(2) | ||
| 703 | - .map((day) => reportMonthDay(day.date, context: context)) | ||
| 704 | - .join(separator); | 660 | + .take(2) |
| 661 | + .map((day) => reportMonthDay(day.date, context: context)) | ||
| 662 | + .join(separator); | ||
| 705 | return Column( | 663 | return Column( |
| 706 | crossAxisAlignment: CrossAxisAlignment.start, | 664 | crossAxisAlignment: CrossAxisAlignment.start, |
| 707 | children: [ | 665 | children: [ |
| 708 | Text(label, style: const TextStyle(color: _h2, fontSize: 10)), | 666 | Text(label, style: const TextStyle(color: _h2, fontSize: 10)), |
| 709 | const SizedBox(height: 6), | 667 | const SizedBox(height: 6), |
| 710 | - Text(value, | ||
| 711 | - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), | ||
| 712 | - ], | ||
| 713 | - ); | ||
| 714 | - } | ||
| 715 | -} | ||
| 716 | - | ||
| 717 | -class _LevelItem extends StatelessWidget { | ||
| 718 | - const _LevelItem({required this.report, required this.level}); | ||
| 719 | - final YearlyHrvReport report; | ||
| 720 | - final HrvStressLevel level; | ||
| 721 | - | ||
| 722 | - @override | ||
| 723 | - Widget build(BuildContext context) { | ||
| 724 | - final count = report.countFor(level); | ||
| 725 | - final percent = | ||
| 726 | - report.validDays == 0 ? 0 : (count / report.validDays * 100).round(); | ||
| 727 | - return Column( | ||
| 728 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 729 | - children: [ | ||
| 730 | - Row(children: [ | ||
| 731 | - Container( | ||
| 732 | - width: 7, | ||
| 733 | - height: 7, | ||
| 734 | - decoration: BoxDecoration( | ||
| 735 | - color: Color(level.colorValue), shape: BoxShape.circle)), | ||
| 736 | - const SizedBox(width: 4), | ||
| 737 | - Text(level.label, | ||
| 738 | - style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w500)), | ||
| 739 | - ]), | ||
| 740 | - const SizedBox(height: 3), | ||
| 741 | - if (report.hasData) | ||
| 742 | - Row( | ||
| 743 | - crossAxisAlignment: CrossAxisAlignment.end, | ||
| 744 | - children: [ | ||
| 745 | - Text('$count', | ||
| 746 | - style: const TextStyle( | ||
| 747 | - color: _h1, | ||
| 748 | - fontSize: 26, | ||
| 749 | - fontWeight: FontWeight.w500, | ||
| 750 | - height: 1)), | ||
| 751 | - Padding( | ||
| 752 | - padding: const EdgeInsets.only(left: 2, bottom: 2), | ||
| 753 | - child: Text(context.l10n.hrvDistributionDayUnit, | ||
| 754 | - style: const TextStyle( | ||
| 755 | - color: _h1, fontSize: 12, fontWeight: FontWeight.w500)), | ||
| 756 | - ), | ||
| 757 | - ], | ||
| 758 | - ) | ||
| 759 | - else | ||
| 760 | - Text(context.l10n.hrvDistributionAwaitingData, | ||
| 761 | - style: const TextStyle( | ||
| 762 | - color: _h2, fontSize: 15, fontWeight: FontWeight.w500)), | ||
| 763 | - Text('$percent%', style: const TextStyle(color: _h2, fontSize: 9)), | 668 | + Text( |
| 669 | + value, | ||
| 670 | + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), | ||
| 671 | + ), | ||
| 764 | ], | 672 | ], |
| 765 | ); | 673 | ); |
| 766 | } | 674 | } |
| 767 | } | 675 | } |
| 768 | 676 | ||
| 769 | -class _StackedBar extends StatelessWidget { | ||
| 770 | - const _StackedBar({required this.report}); | ||
| 771 | - final YearlyHrvReport report; | ||
| 772 | - static const _height = 180.0; | 677 | +class _ReportTitle extends StatelessWidget { |
| 678 | + const _ReportTitle({required this.title, this.trailingInset = 0}); | ||
| 773 | 679 | ||
| 774 | - @override | ||
| 775 | - Widget build(BuildContext context) { | ||
| 776 | - final segments = [ | ||
| 777 | - for (final level in HrvStressLevel.values) | ||
| 778 | - if (report.countFor(level) > 0) | ||
| 779 | - HrvDistributionBarSegment( | ||
| 780 | - color: Color(level.colorValue), | ||
| 781 | - flex: report.countFor(level), | ||
| 782 | - ), | ||
| 783 | - ]; | ||
| 784 | - return SizedBox( | ||
| 785 | - width: 33, | ||
| 786 | - height: _height, | ||
| 787 | - child: HrvDistributionBar( | ||
| 788 | - segments: report.hasData ? segments : const [], | ||
| 789 | - backgroundColor: _grid, | ||
| 790 | - radius: 7, | ||
| 791 | - ), | ||
| 792 | - ); | ||
| 793 | - } | ||
| 794 | -} | 680 | + final String title; |
| 681 | + final double trailingInset; | ||
| 795 | 682 | ||
| 796 | -class _Number extends StatelessWidget { | ||
| 797 | - const _Number({required this.value, required this.unit}); | ||
| 798 | - final String value; | ||
| 799 | - final String unit; | ||
| 800 | @override | 683 | @override |
| 801 | Widget build(BuildContext context) => Row( | 684 | Widget build(BuildContext context) => Row( |
| 802 | - crossAxisAlignment: CrossAxisAlignment.end, | ||
| 803 | - children: [ | ||
| 804 | - Text(value, | ||
| 805 | - style: const TextStyle( | ||
| 806 | - fontSize: 25, fontWeight: FontWeight.w600, height: 1)), | ||
| 807 | - Padding( | ||
| 808 | - padding: const EdgeInsets.only(left: 3, bottom: 2), | ||
| 809 | - child: Text(unit, style: const TextStyle(fontSize: 10))), | ||
| 810 | - ], | ||
| 811 | - ); | 685 | + crossAxisAlignment: CrossAxisAlignment.center, |
| 686 | + children: [ | ||
| 687 | + Text(title, style: _titleStyle), | ||
| 688 | + if (!HealthReportSubjectScope.isViewingSelf(context)) ...[ | ||
| 689 | + const Spacer(), | ||
| 690 | + Padding( | ||
| 691 | + padding: EdgeInsets.only(right: trailingInset), | ||
| 692 | + child: const FriendInteractClickView( | ||
| 693 | + interactionType: FriendInteractionType.yearStressTrend, | ||
| 694 | + ), | ||
| 695 | + ), | ||
| 696 | + ], | ||
| 697 | + ], | ||
| 698 | + ); | ||
| 812 | } | 699 | } |
| 813 | 700 | ||
| 814 | class _Card extends StatelessWidget { | 701 | class _Card extends StatelessWidget { |
| @@ -820,12 +707,14 @@ class _Card extends StatelessWidget { | @@ -820,12 +707,14 @@ class _Card extends StatelessWidget { | ||
| 820 | final EdgeInsetsGeometry padding; | 707 | final EdgeInsetsGeometry padding; |
| 821 | @override | 708 | @override |
| 822 | Widget build(BuildContext context) => Container( | 709 | Widget build(BuildContext context) => Container( |
| 823 | - width: double.infinity, | ||
| 824 | - padding: padding, | ||
| 825 | - decoration: BoxDecoration( | ||
| 826 | - color: Colors.white, borderRadius: BorderRadius.circular(16)), | ||
| 827 | - child: child, | ||
| 828 | - ); | 710 | + width: double.infinity, |
| 711 | + padding: padding, | ||
| 712 | + decoration: BoxDecoration( | ||
| 713 | + color: Colors.white, | ||
| 714 | + borderRadius: BorderRadius.circular(16), | ||
| 715 | + ), | ||
| 716 | + child: child, | ||
| 717 | + ); | ||
| 829 | } | 718 | } |
| 830 | 719 | ||
| 831 | const _titleStyle = TextStyle(fontSize: 15, fontWeight: FontWeight.w600); | 720 | const _titleStyle = TextStyle(fontSize: 15, fontWeight: FontWeight.w600); |
| 1 | +import 'package:doublefeel_flutter/app/routes/app_pages.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | ||
| 4 | +import 'package:doublefeel_flutter/r.dart'; | ||
| 5 | +import 'package:flutter/material.dart'; | ||
| 6 | +import 'package:get/get.dart'; | ||
| 7 | + | ||
| 8 | +enum FriendInteractionAction { miss, stick, punch } | ||
| 9 | + | ||
| 10 | +/// Friend-interaction bottom sheet. It accepts independent callbacks so each | ||
| 11 | +/// host page can handle the three actions without coupling to a controller. | ||
| 12 | +class FriendInteractionDialog extends StatelessWidget { | ||
| 13 | + const FriendInteractionDialog({ | ||
| 14 | + super.key, | ||
| 15 | + required this.avatar, | ||
| 16 | + required this.steps, | ||
| 17 | + this.onAction, | ||
| 18 | + this.onMiss, | ||
| 19 | + this.onPoke, | ||
| 20 | + this.onPunch, | ||
| 21 | + }); | ||
| 22 | + | ||
| 23 | + final ImageProvider? avatar; | ||
| 24 | + final String steps; | ||
| 25 | + | ||
| 26 | + /// Compatibility callback for callers that prefer one typed action handler. | ||
| 27 | + final ValueChanged<FriendInteractionAction>? onAction; | ||
| 28 | + final VoidCallback? onMiss; | ||
| 29 | + final VoidCallback? onPoke; | ||
| 30 | + final VoidCallback? onPunch; | ||
| 31 | + | ||
| 32 | + static Future<T?> show<T>( | ||
| 33 | + BuildContext context, { | ||
| 34 | + required ImageProvider? avatar, | ||
| 35 | + required String steps, | ||
| 36 | + ValueChanged<FriendInteractionAction>? onAction, | ||
| 37 | + VoidCallback? onMiss, | ||
| 38 | + VoidCallback? onPoke, | ||
| 39 | + VoidCallback? onPunch, | ||
| 40 | + }) { | ||
| 41 | + return showModalBottomSheet<T>( | ||
| 42 | + context: context, | ||
| 43 | + isScrollControlled: true, | ||
| 44 | + backgroundColor: _surface, | ||
| 45 | + barrierColor: Colors.black.withValues(alpha: .7), | ||
| 46 | + shape: const RoundedRectangleBorder( | ||
| 47 | + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), | ||
| 48 | + ), | ||
| 49 | + builder: (_) => FriendInteractionDialog( | ||
| 50 | + avatar: avatar, | ||
| 51 | + steps: steps, | ||
| 52 | + onAction: onAction, | ||
| 53 | + onMiss: onMiss, | ||
| 54 | + onPoke: onPoke, | ||
| 55 | + onPunch: onPunch, | ||
| 56 | + ), | ||
| 57 | + ); | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + static const _surface = Color(0xFFF5F4FF); | ||
| 61 | + | ||
| 62 | + @override | ||
| 63 | + Widget build(BuildContext context) { | ||
| 64 | + final isVip = | ||
| 65 | + Get.find<UserPreferencesStorage>().preferences.value.vipInfo?.isVip == | ||
| 66 | + true; | ||
| 67 | + return Material( | ||
| 68 | + color: Colors.transparent, | ||
| 69 | + child: SafeArea( | ||
| 70 | + top: false, | ||
| 71 | + child: SizedBox( | ||
| 72 | + height: 333, | ||
| 73 | + child: Stack( | ||
| 74 | + clipBehavior: Clip.none, | ||
| 75 | + children: [ | ||
| 76 | + Positioned( | ||
| 77 | + top: -77, | ||
| 78 | + left: 22, | ||
| 79 | + child: Image.asset( | ||
| 80 | + 'assets/images/interact/ic_friend_interact.png', | ||
| 81 | + width: 98, | ||
| 82 | + height: 106, | ||
| 83 | + ), | ||
| 84 | + ), | ||
| 85 | + Positioned( | ||
| 86 | + top: -24, | ||
| 87 | + left: 28, | ||
| 88 | + child: Transform.rotate( | ||
| 89 | + angle: -0.08, | ||
| 90 | + child: Container( | ||
| 91 | + padding: const EdgeInsets.symmetric( | ||
| 92 | + horizontal: 18, | ||
| 93 | + vertical: 8, | ||
| 94 | + ), | ||
| 95 | + decoration: BoxDecoration( | ||
| 96 | + color: const Color(0xFFA084EF), | ||
| 97 | + borderRadius: BorderRadius.circular(12), | ||
| 98 | + ), | ||
| 99 | + child: Text( | ||
| 100 | + context.l10n.interactActionSheetInvitation, | ||
| 101 | + style: const TextStyle( | ||
| 102 | + color: Colors.white, | ||
| 103 | + fontSize: 18, | ||
| 104 | + fontWeight: FontWeight.w600, | ||
| 105 | + ), | ||
| 106 | + ), | ||
| 107 | + ), | ||
| 108 | + ), | ||
| 109 | + ), | ||
| 110 | + Positioned( | ||
| 111 | + top: 17, | ||
| 112 | + right: 28, | ||
| 113 | + child: IconButton( | ||
| 114 | + onPressed: () => Navigator.of(context).pop(), | ||
| 115 | + icon: const Icon(Icons.close, color: Color(0xFF845EEE)), | ||
| 116 | + iconSize: 20, | ||
| 117 | + padding: EdgeInsets.zero, | ||
| 118 | + constraints: const BoxConstraints.tightFor( | ||
| 119 | + width: 20, | ||
| 120 | + height: 20, | ||
| 121 | + ), | ||
| 122 | + ), | ||
| 123 | + ), | ||
| 124 | + Positioned(top: 42, left: 0, right: 0, child: _buildAvatar()), | ||
| 125 | + Positioned( | ||
| 126 | + top: 143, | ||
| 127 | + left: 0, | ||
| 128 | + right: 0, | ||
| 129 | + child: Text( | ||
| 130 | + context.l10n.interactTodaySteps, | ||
| 131 | + textAlign: TextAlign.center, | ||
| 132 | + style: const TextStyle( | ||
| 133 | + color: Color(0xFF0F0F11), | ||
| 134 | + fontSize: 16, | ||
| 135 | + ), | ||
| 136 | + ), | ||
| 137 | + ), | ||
| 138 | + Positioned( | ||
| 139 | + top: 163, | ||
| 140 | + left: 0, | ||
| 141 | + right: 0, | ||
| 142 | + child: RichText( | ||
| 143 | + textAlign: TextAlign.center, | ||
| 144 | + text: TextSpan( | ||
| 145 | + children: [ | ||
| 146 | + TextSpan( | ||
| 147 | + text: steps, | ||
| 148 | + style: const TextStyle( | ||
| 149 | + color: Color(0xFF845EEE), | ||
| 150 | + fontSize: 28, | ||
| 151 | + fontWeight: FontWeight.w600, | ||
| 152 | + ), | ||
| 153 | + ), | ||
| 154 | + TextSpan( | ||
| 155 | + text: context.l10n.interactStepsUnit, | ||
| 156 | + style: const TextStyle( | ||
| 157 | + color: Color(0xFF845EEE), | ||
| 158 | + fontSize: 16, | ||
| 159 | + ), | ||
| 160 | + ), | ||
| 161 | + ], | ||
| 162 | + ), | ||
| 163 | + ), | ||
| 164 | + ), | ||
| 165 | + Positioned( | ||
| 166 | + top: 213, | ||
| 167 | + left: 0, | ||
| 168 | + right: 0, | ||
| 169 | + child: FriendInteractActionButtonsView( | ||
| 170 | + isVip: isVip, | ||
| 171 | + onMiss: _handleMiss, | ||
| 172 | + onPoke: _handlePoke, | ||
| 173 | + onPunch: _handlePunch, | ||
| 174 | + ), | ||
| 175 | + ), | ||
| 176 | + ], | ||
| 177 | + ), | ||
| 178 | + ), | ||
| 179 | + ), | ||
| 180 | + ); | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + Widget _buildAvatar() => Center( | ||
| 184 | + child: Container( | ||
| 185 | + width: 90, | ||
| 186 | + height: 90, | ||
| 187 | + clipBehavior: Clip.antiAlias, | ||
| 188 | + decoration: BoxDecoration( | ||
| 189 | + shape: BoxShape.circle, | ||
| 190 | + border: Border.all(color: Colors.white, width: 2), | ||
| 191 | + ), | ||
| 192 | + child: avatar == null | ||
| 193 | + ? const Icon(Icons.person, color: Color(0xFF908B91)) | ||
| 194 | + : Image(image: avatar!, fit: BoxFit.cover), | ||
| 195 | + ), | ||
| 196 | + ); | ||
| 197 | + | ||
| 198 | + void _handleMiss() { | ||
| 199 | + onAction?.call(FriendInteractionAction.miss); | ||
| 200 | + onMiss?.call(); | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + void _handlePoke() { | ||
| 204 | + onAction?.call(FriendInteractionAction.stick); | ||
| 205 | + onPoke?.call(); | ||
| 206 | + } | ||
| 207 | + | ||
| 208 | + void _handlePunch() { | ||
| 209 | + onAction?.call(FriendInteractionAction.punch); | ||
| 210 | + onPunch?.call(); | ||
| 211 | + } | ||
| 212 | +} | ||
| 213 | + | ||
| 214 | +/// Reusable three-action group for any friend-interaction entry point. | ||
| 215 | +/// | ||
| 216 | +/// Miss and Punch are Pro actions. Their callbacks can therefore open a | ||
| 217 | +/// membership flow, while Poke can be handled directly by the host page. | ||
| 218 | +class FriendInteractActionButtonsView extends StatelessWidget { | ||
| 219 | + const FriendInteractActionButtonsView({ | ||
| 220 | + super.key, | ||
| 221 | + required this.isVip, | ||
| 222 | + this.onMiss, | ||
| 223 | + this.onPoke, | ||
| 224 | + this.onPunch, | ||
| 225 | + }); | ||
| 226 | + | ||
| 227 | + final bool isVip; | ||
| 228 | + final VoidCallback? onMiss; | ||
| 229 | + final VoidCallback? onPoke; | ||
| 230 | + final VoidCallback? onPunch; | ||
| 231 | + | ||
| 232 | + @override | ||
| 233 | + Widget build(BuildContext context) => Row( | ||
| 234 | + mainAxisAlignment: MainAxisAlignment.center, | ||
| 235 | + crossAxisAlignment: CrossAxisAlignment.end, | ||
| 236 | + children: [ | ||
| 237 | + _FriendInteractActionButton( | ||
| 238 | + label: context.l10n.interactActionMiss, | ||
| 239 | + iconAsset: 'assets/images/interact/ic_friend_interact_miss.png', | ||
| 240 | + showProBadge: !isVip, | ||
| 241 | + onTap: isVip ? onMiss : _openPurchase, | ||
| 242 | + ), | ||
| 243 | + const SizedBox(width: 12), | ||
| 244 | + _FriendInteractActionButton( | ||
| 245 | + label: context.l10n.interactActionStick, | ||
| 246 | + iconAsset: 'assets/images/interact/ic_friend_interact_poke.png', | ||
| 247 | + isPrimary: true, | ||
| 248 | + onTap: onPoke, | ||
| 249 | + ), | ||
| 250 | + const SizedBox(width: 12), | ||
| 251 | + _FriendInteractActionButton( | ||
| 252 | + label: context.l10n.interactActionPunch, | ||
| 253 | + iconAsset: 'assets/images/interact/ic_friend_interact_punch.png', | ||
| 254 | + showProBadge: !isVip, | ||
| 255 | + onTap: isVip ? onPunch : _openPurchase, | ||
| 256 | + ), | ||
| 257 | + ], | ||
| 258 | + ); | ||
| 259 | + | ||
| 260 | + void _openPurchase() => Get.toNamed(Routes.PURCHASE); | ||
| 261 | +} | ||
| 262 | + | ||
| 263 | +class _FriendInteractActionButton extends StatelessWidget { | ||
| 264 | + const _FriendInteractActionButton({ | ||
| 265 | + required this.label, | ||
| 266 | + required this.iconAsset, | ||
| 267 | + required this.onTap, | ||
| 268 | + this.isPrimary = false, | ||
| 269 | + this.showProBadge = false, | ||
| 270 | + }); | ||
| 271 | + | ||
| 272 | + final String label; | ||
| 273 | + final String iconAsset; | ||
| 274 | + final VoidCallback? onTap; | ||
| 275 | + final bool isPrimary; | ||
| 276 | + final bool showProBadge; | ||
| 277 | + | ||
| 278 | + @override | ||
| 279 | + Widget build(BuildContext context) { | ||
| 280 | + final iconSize = isPrimary ? 68.0 : 52.0; | ||
| 281 | + final buttonSize = isPrimary ? const Size(116, 78) : const Size(90, 56); | ||
| 282 | + final buttonTop = isPrimary ? 34.0 : 30.0; | ||
| 283 | + | ||
| 284 | + return SizedBox( | ||
| 285 | + width: buttonSize.width, | ||
| 286 | + height: isPrimary ? 112 : 93, | ||
| 287 | + child: Stack( | ||
| 288 | + clipBehavior: Clip.none, | ||
| 289 | + alignment: Alignment.topCenter, | ||
| 290 | + children: [ | ||
| 291 | + Positioned( | ||
| 292 | + top: buttonTop, | ||
| 293 | + child: Material( | ||
| 294 | + color: Colors.transparent, | ||
| 295 | + child: InkWell( | ||
| 296 | + onTap: onTap, | ||
| 297 | + borderRadius: BorderRadius.circular(isPrimary ? 24 : 16), | ||
| 298 | + child: Ink( | ||
| 299 | + width: buttonSize.width, | ||
| 300 | + height: buttonSize.height, | ||
| 301 | + decoration: BoxDecoration( | ||
| 302 | + color: const Color(0xFF896CDC), | ||
| 303 | + borderRadius: BorderRadius.circular(isPrimary ? 24 : 16), | ||
| 304 | + ), | ||
| 305 | + child: Align( | ||
| 306 | + alignment: Alignment.bottomCenter, | ||
| 307 | + child: Padding( | ||
| 308 | + padding: EdgeInsets.only(bottom: isPrimary ? 12 : 9), | ||
| 309 | + child: Text( | ||
| 310 | + label, | ||
| 311 | + style: TextStyle( | ||
| 312 | + color: Colors.white, | ||
| 313 | + fontSize: isPrimary ? 20 : 16, | ||
| 314 | + fontWeight: FontWeight.w600, | ||
| 315 | + height: 1, | ||
| 316 | + ), | ||
| 317 | + ), | ||
| 318 | + ), | ||
| 319 | + ), | ||
| 320 | + ), | ||
| 321 | + ), | ||
| 322 | + ), | ||
| 323 | + ), | ||
| 324 | + Image.asset(iconAsset, width: iconSize, height: iconSize), | ||
| 325 | + if (showProBadge) | ||
| 326 | + Positioned( | ||
| 327 | + left: 21, | ||
| 328 | + top: 43, | ||
| 329 | + child: Image.asset( | ||
| 330 | + R.assetsImagesTrendProIcon, | ||
| 331 | + width: 46, | ||
| 332 | + height: 13, | ||
| 333 | + fit: BoxFit.contain, | ||
| 334 | + ), | ||
| 335 | + ), | ||
| 336 | + ], | ||
| 337 | + ), | ||
| 338 | + ); | ||
| 339 | + } | ||
| 340 | +} |
lib/app/modules/pk/README.md
0 → 100644
| 1 | +# PK 页面 | ||
| 2 | + | ||
| 3 | +- `PkView` 是独立内容 View,可嵌入主页或单独放入 Scaffold。传入 `PkData`、`onSwitchFriend`、`onBack`、`onRefresh` 和可选 `onPoke`。 | ||
| 4 | +- `HomePkView` / `PkController` 管理主页入口与好友选择,复用趋势页好友弹层(`showSelf: false`)。默认不改变趋势页的选择。 | ||
| 5 | +- `PkData.result` 根据双方积分决定胜、负、平;`monthlyResult` 根据胜利天数独立决定。缺失值不视为平局,月历缺失日期显示空圈。 | ||
| 6 | +- 已接入 `HealthApi.getCurrentMonthPkData`:绑定另一半的双方积分、步数、活动消耗、站立小时数、胜利天数及每日积分。左侧映射 `pair_user_*`,右侧映射 `user_*`;默认选中另一半。当前接口没有好友参数,普通好友只显示好友接口提供的健康数据,不套用另一半的战绩。 | ||
| 7 | +- 请求结果按绑定对象及加载日期隔离,月历过滤跨月、未来及不完整记录。站立时间沿用现有活动报告的小时单位。 | ||
| 8 | +- `onPoke` 暂未接入:现有互动接口只有绑定双方通用动作,尚未确认 PK 数据互动参数及任意好友的目标参数,主页按钮仍禁用。 | ||
| 9 | +- Figma 导出图片保存在 `assets/images/pk/`,不依赖临时远程资源。`preview_friend` / `preview_self` 仅供设计测试使用。 | ||
| 10 | + | ||
| 11 | +设计状态测试:`flutter test test/app/modules/pk/pk_view_test.dart`。 | ||
| 12 | +可选设置 `PK_FONT_PATH` 为本机中文字体文件、`PK_CAPTURE_DIR` 为截图目录,并加 `--update-goldens`,导出三种状态的预览图。 |
| 1 | +import 'dart:async'; | ||
| 2 | +import 'package:flutter/material.dart'; | ||
| 3 | +import 'package:get/get.dart'; | ||
| 4 | +import '../../../../core/network/api/friend_api.dart'; | ||
| 5 | +import '../../../../core/result/app_result.dart'; | ||
| 6 | +import '../../../../data/local/user_preferences_storage.dart'; | ||
| 7 | +import '../../../../data/models/friend/friend_models.dart'; | ||
| 8 | +import '../../home/widgets/trend/trend_friend_select_bottom_sheet.dart'; | ||
| 9 | +import '../models/pk_data.dart'; | ||
| 10 | +import '../models/pk_data_mapper.dart'; | ||
| 11 | +import '../../../../core/network/api/health_api.dart'; | ||
| 12 | +import '../../../../data/models/health/health_models.dart'; | ||
| 13 | + | ||
| 14 | +class PkController extends GetxController { | ||
| 15 | + PkController(this._friendApi, this._preferences, this._healthApi); | ||
| 16 | + final HealthApi _healthApi; | ||
| 17 | + final pkLoadFailed = false.obs; | ||
| 18 | + final _partnerPk = Rxn<PkCurrentMonthData>(); | ||
| 19 | + int? _pkPartnerId; | ||
| 20 | + DateTime? _pkLoadedAt; | ||
| 21 | + final FriendApi _friendApi; | ||
| 22 | + final UserPreferencesStorage _preferences; | ||
| 23 | + final friends = <FriendItem>[].obs; | ||
| 24 | + final selectedFriend = Rxn<FriendItem>(); | ||
| 25 | + final selfHealth = Rxn<FriendHealthData>(); | ||
| 26 | + final isLoading = false.obs; | ||
| 27 | + final loadFailed = false.obs; | ||
| 28 | + | ||
| 29 | + bool get isPartnerSelected => | ||
| 30 | + selectedFriend.value?.friendUserId != null && | ||
| 31 | + selectedFriend.value?.friendUserId == | ||
| 32 | + _preferences.preferences.value.partnerUserInfo?.id; | ||
| 33 | + | ||
| 34 | + PkData get data { | ||
| 35 | + final friend = selectedFriend.value; | ||
| 36 | + final me = _preferences.preferences.value.meUserInfo; | ||
| 37 | + final remark = friend?.remarkName?.trim(); | ||
| 38 | + final base = PkData( | ||
| 39 | + month: DateTime.now(), | ||
| 40 | + left: PkPlayer( | ||
| 41 | + name: remark?.isNotEmpty == true | ||
| 42 | + ? remark! | ||
| 43 | + : friend?.friendNickname ?? '好友', | ||
| 44 | + subtitle: remark?.isNotEmpty == true | ||
| 45 | + ? '(${friend?.friendNickname ?? '好友'})' | ||
| 46 | + : '', | ||
| 47 | + avatarUrl: friend?.avatar, | ||
| 48 | + steps: friend?.healthData?.totalSteps, | ||
| 49 | + calories: friend?.healthData?.totalMove, | ||
| 50 | + ), | ||
| 51 | + right: PkPlayer( | ||
| 52 | + name: me?.nickname ?? '我', | ||
| 53 | + subtitle: '(我)', | ||
| 54 | + avatarUrl: me?.avatar, | ||
| 55 | + steps: selfHealth.value?.totalSteps, | ||
| 56 | + calories: selfHealth.value?.totalMove, | ||
| 57 | + ), | ||
| 58 | + ); | ||
| 59 | + final now = DateTime.now(); | ||
| 60 | + final loaded = _pkLoadedAt; | ||
| 61 | + final pk = _partnerPk.value; | ||
| 62 | + if (pk != null && | ||
| 63 | + _pkPartnerId != null && | ||
| 64 | + friend?.friendUserId == _pkPartnerId && | ||
| 65 | + _preferences.preferences.value.partnerUserInfo?.id == _pkPartnerId && | ||
| 66 | + loaded?.year == now.year && | ||
| 67 | + loaded?.month == now.month && | ||
| 68 | + loaded?.day == now.day) { | ||
| 69 | + return mapPartnerPkData( | ||
| 70 | + pk, | ||
| 71 | + partner: base.left, | ||
| 72 | + self: base.right, | ||
| 73 | + now: now, | ||
| 74 | + ); | ||
| 75 | + } | ||
| 76 | + return base; | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + Future<void> refreshData() async { | ||
| 80 | + if (isLoading.value) return; | ||
| 81 | + isLoading.value = true; | ||
| 82 | + try { | ||
| 83 | + final partner = _preferences.preferences.value.partnerUserInfo; | ||
| 84 | + final requestedAt = DateTime.now(); | ||
| 85 | + final (result, pkResult) = await ( | ||
| 86 | + _friendApi.friendList(true, withSelfHealthData: true), | ||
| 87 | + partner?.id == null | ||
| 88 | + ? Future<AppResult<PkCurrentMonthData>?>.value(null) | ||
| 89 | + : _healthApi.getCurrentMonthPkData(), | ||
| 90 | + ).wait; | ||
| 91 | + if (isClosed) return; | ||
| 92 | + switch (result) { | ||
| 93 | + case AppSuccess(:final data): | ||
| 94 | + final id = selectedFriend.value?.friendUserId; | ||
| 95 | + friends.assignAll( | ||
| 96 | + data.list.where((friend) => friend.friendUserId != null), | ||
| 97 | + ); | ||
| 98 | + if (partner?.id != null && | ||
| 99 | + !friends.any((friend) => friend.friendUserId == partner!.id)) { | ||
| 100 | + friends.insert( | ||
| 101 | + 0, | ||
| 102 | + FriendItem( | ||
| 103 | + friendUserId: partner!.id, | ||
| 104 | + friendNickname: partner.nickname, | ||
| 105 | + avatar: partner.avatar, | ||
| 106 | + ), | ||
| 107 | + ); | ||
| 108 | + } | ||
| 109 | + selectedFriend.value = | ||
| 110 | + friends.firstWhereOrNull((friend) => friend.friendUserId == id) ?? | ||
| 111 | + friends.firstWhereOrNull( | ||
| 112 | + (friend) => friend.friendUserId == partner?.id, | ||
| 113 | + ) ?? | ||
| 114 | + friends.firstOrNull; | ||
| 115 | + selfHealth.value = data.healthData; | ||
| 116 | + loadFailed.value = false; | ||
| 117 | + case AppFailure(): | ||
| 118 | + loadFailed.value = true; | ||
| 119 | + } | ||
| 120 | + if (selectedFriend.value == null && | ||
| 121 | + partner?.id != null && | ||
| 122 | + _preferences.preferences.value.partnerUserInfo?.id == partner?.id) { | ||
| 123 | + final item = FriendItem( | ||
| 124 | + friendUserId: partner!.id, | ||
| 125 | + friendNickname: partner.nickname, | ||
| 126 | + avatar: partner.avatar, | ||
| 127 | + ); | ||
| 128 | + if (!friends.any((friend) => friend.friendUserId == partner.id)) { | ||
| 129 | + friends.insert(0, item); | ||
| 130 | + } | ||
| 131 | + selectedFriend.value = item; | ||
| 132 | + } | ||
| 133 | + if (_preferences.preferences.value.partnerUserInfo?.id == partner?.id) { | ||
| 134 | + switch (pkResult) { | ||
| 135 | + case AppSuccess<PkCurrentMonthData>(:final data): | ||
| 136 | + _pkPartnerId = partner?.id; | ||
| 137 | + _pkLoadedAt = requestedAt; | ||
| 138 | + _partnerPk.value = data; | ||
| 139 | + pkLoadFailed.value = false; | ||
| 140 | + case AppFailure<PkCurrentMonthData>(): | ||
| 141 | + pkLoadFailed.value = true; | ||
| 142 | + case null: | ||
| 143 | + _partnerPk.value = null; | ||
| 144 | + _pkPartnerId = null; | ||
| 145 | + pkLoadFailed.value = false; | ||
| 146 | + } | ||
| 147 | + } | ||
| 148 | + } finally { | ||
| 149 | + if (!isClosed) isLoading.value = false; | ||
| 150 | + } | ||
| 151 | + } | ||
| 152 | + | ||
| 153 | + void showFriendListBottomSheet() { | ||
| 154 | + unawaited(refreshData()); | ||
| 155 | + if (friends.isEmpty) return; | ||
| 156 | + Get.bottomSheet( | ||
| 157 | + DraggableScrollableSheet( | ||
| 158 | + maxChildSize: .6, | ||
| 159 | + initialChildSize: .6, | ||
| 160 | + expand: false, | ||
| 161 | + snap: true, | ||
| 162 | + builder: (context, scrollController) => TrendFriendSelectBottomSheet( | ||
| 163 | + scrollController: scrollController, | ||
| 164 | + friendsList: friends, | ||
| 165 | + selectedFriend: selectedFriend, | ||
| 166 | + onSelectSelf: () {}, | ||
| 167 | + showSelf: false, | ||
| 168 | + onSelectFriend: (friend) => selectedFriend.value = friend, | ||
| 169 | + ), | ||
| 170 | + ), | ||
| 171 | + barrierColor: Colors.black.withValues(alpha: .7), | ||
| 172 | + enableDrag: true, | ||
| 173 | + isScrollControlled: true, | ||
| 174 | + persistent: false, | ||
| 175 | + ); | ||
| 176 | + } | ||
| 177 | +} |
lib/app/modules/pk/models/pk_data.dart
0 → 100644
| 1 | +/// PK results are supplied by the caller; missing scores are not a draw. | ||
| 2 | +enum PkResult { win, loss, draw } | ||
| 3 | + | ||
| 4 | +class PkPlayer { | ||
| 5 | + const PkPlayer({ | ||
| 6 | + required this.name, | ||
| 7 | + this.subtitle = '', | ||
| 8 | + this.avatarUrl, | ||
| 9 | + this.avatarAsset, | ||
| 10 | + this.score, | ||
| 11 | + this.steps, | ||
| 12 | + this.calories, | ||
| 13 | + this.standingHours, | ||
| 14 | + this.winningDays, | ||
| 15 | + }); | ||
| 16 | + final String name; | ||
| 17 | + final String subtitle; | ||
| 18 | + final String? avatarUrl; | ||
| 19 | + final String? avatarAsset; | ||
| 20 | + final int? score, steps, calories, winningDays; | ||
| 21 | + final double? standingHours; | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +class PkDay { | ||
| 25 | + const PkDay(this.leftScore, this.rightScore); | ||
| 26 | + final int leftScore, rightScore; | ||
| 27 | + double get leftFraction => | ||
| 28 | + leftScore + rightScore == 0 ? 0.5 : leftScore / (leftScore + rightScore); | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +class PkData { | ||
| 32 | + const PkData({ | ||
| 33 | + required this.left, | ||
| 34 | + required this.right, | ||
| 35 | + required this.month, | ||
| 36 | + this.days = const {}, | ||
| 37 | + }); | ||
| 38 | + final PkPlayer left, right; | ||
| 39 | + final DateTime month; | ||
| 40 | + | ||
| 41 | + /// Day of month -> result. Missing days render as unrecorded. | ||
| 42 | + final Map<int, PkDay> days; | ||
| 43 | + PkResult? get result => compare(left.score, right.score); | ||
| 44 | + PkResult? get monthlyResult => compare(left.winningDays, right.winningDays); | ||
| 45 | + static PkResult? compare(int? left, int? right) { | ||
| 46 | + if (left == null || right == null) return null; | ||
| 47 | + return left > right | ||
| 48 | + ? PkResult.win | ||
| 49 | + : left < right | ||
| 50 | + ? PkResult.loss | ||
| 51 | + : PkResult.draw; | ||
| 52 | + } | ||
| 53 | +} |
| 1 | +import '../../../../data/models/health/health_models.dart'; | ||
| 2 | +import 'pk_data.dart'; | ||
| 3 | + | ||
| 4 | +/// The existing PK endpoint compares the signed-in user with their partner. | ||
| 5 | +/// Its partner fields belong on the left, user fields on the right. | ||
| 6 | +PkData mapPartnerPkData( | ||
| 7 | + PkCurrentMonthData source, { | ||
| 8 | + required PkPlayer partner, | ||
| 9 | + required PkPlayer self, | ||
| 10 | + required DateTime now, | ||
| 11 | +}) { | ||
| 12 | + final todayDate = pkRecordDate(source.todayRecord?.date); | ||
| 13 | + final today = todayDate == null || | ||
| 14 | + (todayDate.year == now.year && | ||
| 15 | + todayDate.month == now.month && | ||
| 16 | + todayDate.day == now.day) | ||
| 17 | + ? source.todayRecord | ||
| 18 | + : null; | ||
| 19 | + final days = <int, PkDay>{}; | ||
| 20 | + for (final record in source.monthRecordList ?? const <PkRecord>[]) { | ||
| 21 | + final date = pkRecordDate(record.date); | ||
| 22 | + if (date == null || | ||
| 23 | + date.year != now.year || | ||
| 24 | + date.month != now.month || | ||
| 25 | + date.day > now.day || | ||
| 26 | + record.partnerScore == null || | ||
| 27 | + record.userScore == null) { | ||
| 28 | + continue; | ||
| 29 | + } | ||
| 30 | + days[date.day] = PkDay(record.partnerScore!, record.userScore!); | ||
| 31 | + } | ||
| 32 | + if (today?.partnerScore != null && today?.userScore != null) { | ||
| 33 | + days[now.day] = PkDay(today!.partnerScore!, today.userScore!); | ||
| 34 | + } | ||
| 35 | + return PkData( | ||
| 36 | + month: DateTime(now.year, now.month), | ||
| 37 | + days: days, | ||
| 38 | + left: PkPlayer( | ||
| 39 | + name: partner.name, | ||
| 40 | + subtitle: partner.subtitle, | ||
| 41 | + avatarUrl: partner.avatarUrl, | ||
| 42 | + avatarAsset: partner.avatarAsset, | ||
| 43 | + score: today?.partnerScore, | ||
| 44 | + steps: today?.partnerSteps ?? partner.steps, | ||
| 45 | + calories: today?.partnerMove ?? partner.calories, | ||
| 46 | + standingHours: today?.partnerStand?.toDouble(), | ||
| 47 | + winningDays: source.partnerWinAmount, | ||
| 48 | + ), | ||
| 49 | + right: PkPlayer( | ||
| 50 | + name: self.name, | ||
| 51 | + subtitle: self.subtitle, | ||
| 52 | + avatarUrl: self.avatarUrl, | ||
| 53 | + avatarAsset: self.avatarAsset, | ||
| 54 | + score: today?.userScore, | ||
| 55 | + steps: today?.userSteps ?? self.steps, | ||
| 56 | + calories: today?.userMove ?? self.calories, | ||
| 57 | + standingHours: today?.userStand?.toDouble(), | ||
| 58 | + winningDays: source.userWinAmount, | ||
| 59 | + ), | ||
| 60 | + ); | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +/// Accept the date encodings used by health records: yyyyMMdd or Unix time. | ||
| 64 | +DateTime? pkRecordDate(int? value) { | ||
| 65 | + if (value == null || value <= 0) return null; | ||
| 66 | + if (value >= 10000101 && value <= 99991231) { | ||
| 67 | + final year = value ~/ 10000, month = value ~/ 100 % 100, day = value % 100; | ||
| 68 | + final date = DateTime(year, month, day); | ||
| 69 | + return date.year == year && date.month == month && date.day == day | ||
| 70 | + ? date | ||
| 71 | + : null; | ||
| 72 | + } | ||
| 73 | + if (value < 1000000000 || value > 8640000000000000) return null; | ||
| 74 | + return DateTime.fromMillisecondsSinceEpoch( | ||
| 75 | + value >= 1000000000000 ? value : value * 1000, | ||
| 76 | + ); | ||
| 77 | +} |
-
Please register or login to post a comment