From f8ad79cf1d18ae397b51bebfcb1ec7636f4e2558 Mon Sep 17 00:00:00 2001 From: YXX168 Date: Fri, 17 Jul 2026 00:52:18 +0800 Subject: [PATCH 1/3] feat: add smart rest timer and stabilize workout records --- .github/workflows/build.yml | 2 +- CHANGELOG.md | 17 + README.md | 17 +- lib/main.dart | 811 +++++++++++++----- lib/services/data_service.dart | 2 +- lib/services/record_summary_service.dart | 62 ++ lib/services/rest_timer_service.dart | 28 + pubspec.yaml | 2 +- .../services/record_summary_service_test.dart | 50 ++ test/services/rest_timer_service_test.dart | 25 + test/widget_test.dart | 49 +- 11 files changed, 808 insertions(+), 257 deletions(-) create mode 100644 lib/services/record_summary_service.dart create mode 100644 lib/services/rest_timer_service.dart create mode 100644 test/services/record_summary_service_test.dart create mode 100644 test/services/rest_timer_service_test.dart diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce4d3c5..0143c11 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,6 +46,6 @@ jobs: run: flutter build apk --release --target-platform android-arm64 - uses: actions/upload-artifact@v4 with: - name: BodyRecomp-${{ github.ref_name }}-arm64 + name: BodyRecomp-${{ github.run_number }}-arm64 path: build/app/outputs/flutter-apk/app-release.apk retention-days: 30 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d3cac7..cbb6876 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,21 @@ # BodyRecomp CHANGELOG +## v6.9.0 (2026-07-17) + +### 训练效率 +- 新增组间休息计时器,支持按训练计划自动启动、点击休息标签手动启动、增加 30 秒和提前结束。 +- 设置页新增自动休息计时开关,偏好保存在本地。 +- 完成当日全部动作后显示轻量完成反馈。 + +### 稳定性与视觉 +- 修复当前周数据在年度统计中重复累计的问题。 +- 修复快速切换月份时旧请求覆盖新月份数据的问题。 +- 修复切换主题后趋势图仍使用旧配色的问题。 +- 修复异步资料与主题加载可能在页面销毁后触发刷新的问题。 +- 修复 PR 构建产物名称包含斜杠时上传失败的问题。 + +### 测试 +- 新增训练统计、休息时间解析与计时偏好测试。 + ## v6.8.3 (2026-07-16) ### 日期切页流畅度修复 diff --git a/README.md b/README.md index 2a8d177..61feaa5 100755 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ## 功能 - 🏋️ 每周训练计划与动作完成记录 +- ⏱️ 自动/手动组间休息计时,可加时或提前结束 - 📊 月度训练热力图和年度统计 - 👤 个人资料与 BMI 信息 - ⚖️ 体重历史与趋势 @@ -12,15 +13,15 @@ - 💾 JSON 数据导入导出 - 🔒 SharedPreferences 本地持久化 -## v6.7.0 新功能 +## v6.9.0 新功能 -v6.7.0 在 v6.6.1 基础上新增: +v6.9.0 在既有训练与身体数据功能上新增: -- 动作训练日志的数据模型与本地存储,包括重量、组数、次数、RPE 和 Epley 1RM 估算。 -- 体重和身体围度记录的按日更新、查询与删除。 -- JSON 备份 schema v2 的校验、v6.6 备份迁移、导入前备份以及合并/覆盖导入。 -- 训练页周一至周日横向滑动切换的基础交互。 -- models、services、历史归档与横向滑动的单元/Widget 测试。 +- 完成动作后按计划自动启动组间休息计时,也可点击休息时间手动启动。 +- 计时器支持增加 30 秒、提前结束和完成震动反馈。 +- 完成整日训练后显示轻量完成反馈。 +- 修复年度统计重复累计、月份快速切换数据串页和趋势图主题配色未更新。 +- 增加休息时间解析、偏好持久化和训练统计回归测试。 ## 下载 @@ -37,7 +38,7 @@ flutter build apk --release ## 当前版本 -- `v6.7.0+72` +- `v6.9.0+78` ## 技术栈 diff --git a/lib/main.dart b/lib/main.dart index 40261e7..303ab1e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,9 +8,13 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'models/recomp_models.dart'; import 'services/data_service.dart'; import 'services/history_service.dart'; +import 'services/record_summary_service.dart'; +import 'services/rest_timer_service.dart'; import 'services/workout_plan_service.dart'; import 'widgets/horizontal_day_swipe.dart'; +const appVersion = '6.9.0'; + // ═══════════════════════════════════════════════════════════════════════════════ // THEME SYSTEM — v6.6: profile + chart + settings support // ═════════════════════════════════════════════════════════════════════════════== @@ -594,9 +598,9 @@ class _FadeScaleEntryState extends State @override Widget build(BuildContext context) => FadeTransition( - opacity: _fade, - child: ScaleTransition(scale: _scale, child: widget.child), - ); + opacity: _fade, + child: ScaleTransition(scale: _scale, child: widget.child), + ); } class PressScale extends StatefulWidget { @@ -893,17 +897,18 @@ class _ThemeStateState extends State { Future setTheme(AppTheme m) async { final p = await SharedPreferences.getInstance(); await p.setInt('recomp_theme_v6', m.index); + if (!mounted) return; HapticFeedback.selectionClick(); setState(() => _mode = m); } @override Widget build(BuildContext context) => ThemeInherited( - current: _mode, - theme: themes[_mode]!, - setTheme: setTheme, - child: widget.child, - ); + current: _mode, + theme: themes[_mode]!, + setTheme: setTheme, + child: widget.child, + ); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -962,10 +967,11 @@ class RecompApp extends StatelessWidget { useMaterial3: true, fontFamily: 'Inter', fontFamilyFallback: const ['sans-serif'], - textTheme: (dark - ? ThemeData.dark().textTheme - : ThemeData.light().textTheme) - .apply(fontFamily: 'Inter'), + textTheme: + (dark + ? ThemeData.dark().textTheme + : ThemeData.light().textTheme) + .apply(fontFamily: 'Inter'), ), home: const MainPage(), ); @@ -991,7 +997,9 @@ class _MainPageState extends State with TickerProviderStateMixin { @override void initState() { super.initState(); - loadProfile().then((p) => setState(() => _profile = p)); + loadProfile().then((p) { + if (mounted) setState(() => _profile = p); + }); } @override @@ -1001,11 +1009,12 @@ class _MainPageState extends State with TickerProviderStateMixin { final p = _profile; final bmi = p != null && p.heightCm > 0 ? (p.weightKg / ((p.heightCm / 100) * (p.heightCm / 100))) - .toStringAsFixed(1) + .toStringAsFixed(1) : '--'; final ageStr = (p != null && p.age > 0) ? '${p.age}岁' : '--'; - final heightStr = - (p != null && p.heightCm > 0) ? '${p.heightCm.toInt()}cm' : '--'; + final heightStr = (p != null && p.heightCm > 0) + ? '${p.heightCm.toInt()}cm' + : '--'; final weightStr = (p != null && p.weightKg > 0) ? '${p.weightKg}kg' : '--'; final statusText = '$ageStr · $heightStr · $weightStr · BMI $bmi'; final pages = [ @@ -1014,8 +1023,9 @@ class _MainPageState extends State with TickerProviderStateMixin { ProgressionPage(), RecordPage(), SettingsPage( - onProfileChanged: () => - loadProfile().then((pp) => setState(() => _profile = pp)), + onProfileChanged: () => loadProfile().then((profile) { + if (mounted) setState(() => _profile = profile); + }), ), ]; return AnnotatedRegion( @@ -1096,15 +1106,16 @@ class _MainPageState extends State with TickerProviderStateMixin { transitionBuilder: (child, anim) => FadeTransition( opacity: anim, child: SlideTransition( - position: Tween( - begin: const Offset(0.02, 0), - end: Offset.zero, - ).animate( - CurvedAnimation( - parent: anim, - curve: Curves.easeOutCubic, - ), - ), + position: + Tween( + begin: const Offset(0.02, 0), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: anim, + curve: Curves.easeOutCubic, + ), + ), child: child, ), ), @@ -1302,8 +1313,9 @@ class _MainPageState extends State with TickerProviderStateMixin { }, child: Container( decoration: BoxDecoration( - color: - sel ? mt.primary.withOpacity(0.06) : null, + color: sel + ? mt.primary.withOpacity(0.06) + : null, borderRadius: BorderRadius.circular(14), border: Border.all( color: sel ? mt.primary : t.border, @@ -1463,8 +1475,9 @@ class _DaySegmentedNav extends StatelessWidget { duration: const Duration(milliseconds: 220), style: TextStyle( fontSize: 11, - fontWeight: - sel ? FontWeight.w900 : FontWeight.w700, + fontWeight: sel + ? FontWeight.w900 + : FontWeight.w700, color: sel ? Colors.white : t.text2, letterSpacing: -0.2, ), @@ -1475,8 +1488,9 @@ class _DaySegmentedNav extends StatelessWidget { duration: const Duration(milliseconds: 220), style: TextStyle( fontSize: 7.5, - fontWeight: - sel ? FontWeight.w700 : FontWeight.w500, + fontWeight: sel + ? FontWeight.w700 + : FontWeight.w500, color: sel ? Colors.white.withOpacity(0.86) : t.text4, @@ -1512,6 +1526,12 @@ class _WorkoutPageState extends State { int _day = 0; Map _done = {}; List _plan = List.from(workoutDays); + Timer? _restTimer; + int _restTotal = 0; + int _restRemaining = 0; + String _restExercise = ''; + bool _autoRestTimer = true; + Future _saveQueue = Future.value(); @override void initState() { @@ -1555,6 +1575,9 @@ class _WorkoutPageState extends State { if (mounted) setState(() {}); }); _reloadPlan(); + loadAutoRestTimer().then((enabled) { + if (mounted) setState(() => _autoRestTimer = enabled); + }); } Future _reloadPlan() async { @@ -1565,6 +1588,7 @@ class _WorkoutPageState extends State { @override void dispose() { workoutPlanRevision.removeListener(_reloadPlan); + _restTimer?.cancel(); super.dispose(); } @@ -1577,8 +1601,9 @@ class _WorkoutPageState extends State { Future _toggle(int di, int ei) async { final k = '${di}_$ei'; + final completing = !_done.containsKey(k); setState(() { - if (_done.containsKey(k)) { + if (!completing) { _done.remove(k); HapticFeedback.lightImpact(); } else { @@ -1586,15 +1611,79 @@ class _WorkoutPageState extends State { HapticFeedback.mediumImpact(); } }); + if (completing && _autoRestTimer) { + _startRestTimer(_plan[di].exercises[ei]); + } + final snapshot = Map.from(_done); + final previous = _saveQueue; + _saveQueue = () async { + try { + await previous; + } catch (_) { + // A later snapshot should still be persisted after a transient error. + } + await _persistDone(snapshot); + }(); + await _saveQueue; + } + + Future _persistDone(Map done) async { final p = await SharedPreferences.getInstance(); - await p.setString('recomp_done_v6', jsonEncode(_done)); + await p.setString('recomp_done_v6', jsonEncode(done)); // 同步写入本周真实日期历史;取消勾选时也会移除当天记录。 final now = DateTime.now(); final currentWeek = isoWeekNumber(now); final currentYear = isoWeekYear(now); await p.setInt('recomp_done_v6_week', currentWeek); await p.setInt('recomp_done_v6_year', currentYear); - await saveWeekDoneToHistory(p, _done, currentYear, currentWeek); + await saveWeekDoneToHistory(p, done, currentYear, currentWeek); + } + + void _startRestTimer(Exercise exercise) { + final seconds = parseRestSeconds(exercise.rest); + if (seconds == null) return; + _restTimer?.cancel(); + setState(() { + _restTotal = seconds; + _restRemaining = seconds; + _restExercise = exercise.name; + }); + _runRestTicker(); + } + + void _runRestTicker() { + _restTimer?.cancel(); + _restTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + if (_restRemaining <= 1) { + _restTimer?.cancel(); + setState(() => _restRemaining = 0); + HapticFeedback.heavyImpact(); + SystemSound.play(SystemSoundType.click); + } else { + setState(() => _restRemaining--); + } + }); + } + + void _addRestTime() { + if (_restTotal == 0) return; + setState(() { + _restRemaining += 30; + if (_restRemaining > _restTotal) _restTotal = _restRemaining; + }); + if (_restTimer?.isActive != true) { + _runRestTicker(); + } + } + + void _dismissRestTimer() { + _restTimer?.cancel(); + setState(() { + _restTotal = 0; + _restRemaining = 0; + _restExercise = ''; + }); } int _cnt(int d) { @@ -1630,15 +1719,16 @@ class _WorkoutPageState extends State { transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: SlideTransition( - position: Tween( - begin: const Offset(0.02, 0), - end: Offset.zero, - ).animate( - CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ), - ), + position: + Tween( + begin: const Offset(0.02, 0), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + ), child: child, ), ), @@ -1783,46 +1873,46 @@ class _WorkoutPageState extends State { ), const SizedBox(height: 8), ...day.recoveryOptions!.asMap().entries.map( - (e) => FadeScaleEntry( - index: e.key + 2, - child: Card( - margin: const EdgeInsets.only(bottom: 8), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - child: Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: t.primary, - boxShadow: [ - BoxShadow( - color: t.primary.withOpacity(0.4), - blurRadius: 6, - ), - ], - ), - ), - const SizedBox(width: 12), - Text( - e.value, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: t.text2, + (e) => FadeScaleEntry( + index: e.key + 2, + child: Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: t.primary, + boxShadow: [ + BoxShadow( + color: t.primary.withOpacity(0.4), + blurRadius: 6, ), - ), - ], + ], + ), ), - ), + const SizedBox(width: 12), + Text( + e.value, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: t.text2, + ), + ), + ], ), ), ), + ), + ), ], ), ); @@ -1836,6 +1926,26 @@ class _WorkoutPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ FadeScaleEntry(child: _dayHdr(day, t), index: 0), + AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axisAlignment: -1, + child: child, + ), + ), + child: _restTotal > 0 + ? Padding( + key: const ValueKey('rest-timer-visible'), + padding: const EdgeInsets.only(top: 10), + child: _restTimerPanel(t), + ) + : const SizedBox.shrink(key: ValueKey('rest-timer-hidden')), + ), const SizedBox(height: 12), FadeScaleEntry( index: 1, @@ -1903,23 +2013,45 @@ class _WorkoutPageState extends State { ], ), ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 320), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.97, end: 1).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutBack), + ), + child: child, + ), + ), + child: total > 0 && done == total + ? Padding( + key: ValueKey('workout-complete-$_day'), + padding: const EdgeInsets.only(top: 12), + child: _workoutCompletePanel(day, t), + ) + : const SizedBox.shrink(key: ValueKey('workout-incomplete')), + ), const SizedBox(height: 14), ...day.exercises.asMap().entries.map( - (e) => FadeScaleEntry( - key: ValueKey('entry_${_day}_${e.key}'), - index: e.key, - delay: const Duration(milliseconds: 38), - child: RepaintBoundary( - child: _exCard( - e.value, - e.key + 1, - _done.containsKey('${_day}_${e.key}'), - t, - () => _toggle(_day, e.key), - ), - ), + (e) => FadeScaleEntry( + key: ValueKey('entry_${_day}_${e.key}'), + index: e.key, + delay: const Duration(milliseconds: 38), + child: RepaintBoundary( + child: _exCard( + e.value, + e.key + 1, + _done.containsKey('${_day}_${e.key}'), + t, + () => _toggle(_day, e.key), + parseRestSeconds(e.value.rest) == null + ? null + : () => _startRestTimer(e.value), ), ), + ), + ), if (day.circuitNote != null) FadeScaleEntry( index: day.exercises.length + 2, @@ -1948,6 +2080,165 @@ class _WorkoutPageState extends State { ); } + Widget _restTimerPanel(WorkoutTheme t) { + final finished = _restRemaining == 0; + final progress = _restTotal == 0 ? 0.0 : _restRemaining / _restTotal; + final minutes = _restRemaining ~/ 60; + final seconds = (_restRemaining % 60).toString().padLeft(2, '0'); + return Container( + key: const Key('rest-timer-panel'), + padding: const EdgeInsets.fromLTRB(14, 12, 10, 12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + t.primary.withOpacity(t.isDark ? 0.16 : 0.10), + t.accent.withOpacity(t.isDark ? 0.09 : 0.05), + ], + ), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: t.primary.withOpacity(0.22)), + boxShadow: [ + BoxShadow( + color: t.primary.withOpacity(t.isDark ? 0.13 : 0.07), + blurRadius: 18, + offset: const Offset(0, 7), + ), + ], + ), + child: Row( + children: [ + SizedBox( + width: 52, + height: 52, + child: Stack( + alignment: Alignment.center, + children: [ + TweenAnimationBuilder( + tween: Tween(end: progress), + duration: const Duration(milliseconds: 420), + curve: Curves.easeOutCubic, + builder: (_, value, __) => CircularProgressIndicator( + value: value, + strokeWidth: 4, + backgroundColor: t.border.withOpacity(0.72), + valueColor: AlwaysStoppedAnimation( + finished ? t.success : t.primary, + ), + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + child: finished + ? Icon( + Icons.check_rounded, + key: const ValueKey('rest-complete'), + color: t.success, + size: 25, + ) + : Text( + '$minutes:$seconds', + key: ValueKey(_restRemaining), + style: TextStyle( + color: t.text1, + fontSize: 12, + fontWeight: FontWeight.w900, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + finished ? '休息完成,可以继续' : '组间休息', + style: TextStyle( + color: finished ? t.success : t.text1, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 3), + Text( + _restExercise, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: t.text3, fontSize: 10.5), + ), + ], + ), + ), + IconButton( + key: const Key('rest-timer-add'), + tooltip: '增加 30 秒', + onPressed: _addRestTime, + icon: Icon(Icons.add_alarm_rounded, color: t.primary, size: 20), + ), + IconButton( + key: const Key('rest-timer-dismiss'), + tooltip: '结束计时', + onPressed: _dismissRestTimer, + icon: Icon(Icons.close_rounded, color: t.text3, size: 19), + ), + ], + ), + ); + } + + Widget _workoutCompletePanel(WorkoutDay day, WorkoutTheme t) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + t.success.withOpacity(t.isDark ? 0.14 : 0.09), + t.primary.withOpacity(t.isDark ? 0.08 : 0.04), + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: t.success.withOpacity(0.24)), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: t.success.withOpacity(0.13), + ), + child: Icon(Icons.emoji_events_rounded, color: t.success, size: 20), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${day.dayName}训练已完成', + style: TextStyle( + color: t.text1, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + '漂亮收尾,记得补水、拉伸并保证恢复。', + style: TextStyle(color: t.text3, fontSize: 10.5), + ), + ], + ), + ), + ], + ), + ); + } + Widget _dayHdr(WorkoutDay d, WorkoutTheme t) { return Card( child: Padding( @@ -2031,6 +2322,7 @@ class _WorkoutPageState extends State { bool done, WorkoutTheme t, VoidCallback tap, + VoidCallback? startTimer, ) { return Padding( padding: const EdgeInsets.only(bottom: 6), @@ -2046,14 +2338,14 @@ class _WorkoutPageState extends State { side: done ? BorderSide(color: t.success.withOpacity(0.22), width: 1) : (ex.isStar - ? BorderSide( - color: t.primary.withOpacity(0.26), - width: 1.2, - ) - : BorderSide( - color: t.border.withOpacity(0.9), - width: 0.8, - )), + ? BorderSide( + color: t.primary.withOpacity(0.26), + width: 1.2, + ) + : BorderSide( + color: t.border.withOpacity(0.9), + width: 0.8, + )), ), child: AnimatedPadding( duration: const Duration(milliseconds: 220), @@ -2234,14 +2526,46 @@ class _WorkoutPageState extends State { ), ), const SizedBox(height: 2), - Text( - ex.rest, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: done ? t.text4.withOpacity(0.72) : t.text4, + if (startTimer == null) + Text( + ex.rest, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: done ? t.text4.withOpacity(0.72) : t.text4, + ), + ) + else + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: startTimer, + child: Padding( + padding: const EdgeInsets.only(top: 2, bottom: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.timer_outlined, + size: 10, + color: done + ? t.text4.withOpacity(0.72) + : t.primary, + ), + const SizedBox(width: 2), + Text( + ex.rest, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: done + ? t.text4.withOpacity(0.72) + : t.primary, + ), + ), + ], + ), + ), ), - ), ], ), ], @@ -2270,6 +2594,7 @@ class _RecordPageState extends State { bool _loading = true; int _yearTotal = 0; int _yearTrainDays = 0; + int _loadGeneration = 0; @override void initState() { @@ -2278,49 +2603,16 @@ class _RecordPageState extends State { } Future _loadData() async { + final generation = ++_loadGeneration; + final year = _viewYear; + final month = _viewMonth; final p = await SharedPreferences.getInstance(); - final data = await loadMonthHistory(p, _viewYear, _viewMonth); - - // 计算年度统计 - int yearTotal = 0; - int yearTrainDays = 0; - for (int m = 1; m <= 12; m++) { - final mData = await loadMonthHistory(p, _viewYear, m); - yearTotal += mData.values.fold(0, (s, v) => s + v); - yearTrainDays += mData.length; - } - - // 叠加本周当前完成数(可能还没归档) - final now = DateTime.now(); - final curDone = p.getString('recomp_done_v6'); - if (curDone != null) { - try { - final done = jsonDecode(curDone) as Map; - if (now.year == _viewYear && now.month == _viewMonth) { - final currentWeek = - p.getInt('recomp_done_v6_week') ?? isoWeekNumber(now); - final currentYear = - p.getInt('recomp_done_v6_year') ?? isoWeekYear(now); - final dateCounts = countByActualDate(done, currentYear, currentWeek); - for (final entry in dateCounts.entries) { - if (entry.key.year == _viewYear && - entry.key.month == _viewMonth && - entry.value > 0) { - data[entry.key.day] = entry.value; - } - } - } - if (now.year == _viewYear) { - yearTotal += done.length; - } - } catch (_) {} - } - - if (mounted) { + final summary = await loadRecordSummary(p, year, month); + if (mounted && generation == _loadGeneration) { setState(() { - _monthData = data; - _yearTotal = yearTotal; - _yearTrainDays = yearTrainDays; + _monthData = summary.monthData; + _yearTotal = summary.yearTotal; + _yearTrainDays = summary.yearTrainDays; _loading = false; }); } @@ -2706,8 +2998,9 @@ class _RecordPageState extends State { '$day', style: TextStyle( fontSize: 10, - fontWeight: - isToday ? FontWeight.w800 : FontWeight.w600, + fontWeight: isToday + ? FontWeight.w800 + : FontWeight.w600, color: count == 0 ? (isToday ? t.primary : t.text4) : (intensity > 0.62 ? Colors.white : t.text1), @@ -2860,8 +3153,9 @@ class _RecordPageState extends State { cells.add(SizedBox(width: 4, height: 4)); } else { final rawCount = history[day.toString()]; - final count = - rawCount is int ? rawCount : int.tryParse('$rawCount') ?? 0; + final count = rawCount is int + ? rawCount + : int.tryParse('$rawCount') ?? 0; final intensity = count > 0 ? (count / 8).clamp(0.0, 1.0) : 0.0; cells.add( Container( @@ -3017,44 +3311,43 @@ class NutritionPage extends StatelessWidget { ), const SizedBox(height: 10), ...nutritionTips.asMap().entries.map( - (e) => FadeScaleEntry( - index: e.key + 5, - child: Card( - margin: const EdgeInsets.only(bottom: 6), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, + (e) => FadeScaleEntry( + index: e.key + 5, + child: Card( + margin: const EdgeInsets.only(bottom: 6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 3, + height: 14, + margin: const EdgeInsets.only(right: 10, top: 2), + decoration: BoxDecoration( + color: t.primary, + borderRadius: BorderRadius.circular(2), + ), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 3, - height: 14, - margin: - const EdgeInsets.only(right: 10, top: 2), - decoration: BoxDecoration( - color: t.primary, - borderRadius: BorderRadius.circular(2), - ), - ), - Expanded( - child: Text( - nutritionTips[e.key], - style: TextStyle( - fontSize: 12, - color: t.text2, - height: 1.6, - ), - ), + Expanded( + child: Text( + nutritionTips[e.key], + style: TextStyle( + fontSize: 12, + color: t.text2, + height: 1.6, ), - ], + ), ), - ), + ], ), ), ), + ), + ), const SizedBox(height: 8), ]), ), @@ -3350,32 +3643,32 @@ class ProgressionPage extends StatelessWidget { ('孤立动作', '侧平举 / 弯举 / 下压:每次 +0.5-1kg 或 +1-2次'), ('遇到瓶颈', '减重 10% 重新开始,或更换动作变式刺激新角度'), ].asMap().entries.map( - (i) => FadeScaleEntry( - index: i.key + 8, - child: Card( - margin: const EdgeInsets.only(bottom: 6), - child: ListTile( - dense: true, - title: Text( - i.value.$1, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: t.text1, - ), - ), - subtitle: Text( - i.value.$2, - style: TextStyle( - fontSize: 11, - color: t.text3, - height: 1.5, - ), - ), + (i) => FadeScaleEntry( + index: i.key + 8, + child: Card( + margin: const EdgeInsets.only(bottom: 6), + child: ListTile( + dense: true, + title: Text( + i.value.$1, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: t.text1, + ), + ), + subtitle: Text( + i.value.$2, + style: TextStyle( + fontSize: 11, + color: t.text3, + height: 1.5, ), ), ), ), + ), + ), const SizedBox(height: 8), ]), ), @@ -3444,13 +3737,13 @@ class ThemePage extends StatelessWidget { ), ] : sel - ? [ - BoxShadow( - color: mt.primary.withOpacity(0.3), - blurRadius: 10, - ), - ] - : null, + ? [ + BoxShadow( + color: mt.primary.withOpacity(0.3), + blurRadius: 10, + ), + ] + : null, ), child: Padding( padding: const EdgeInsets.symmetric( @@ -3480,14 +3773,13 @@ class ThemePage extends StatelessWidget { ), ] : sel - ? [ - BoxShadow( - color: - mt.primary.withOpacity(0.3), - blurRadius: 10, - ), - ] - : null, + ? [ + BoxShadow( + color: mt.primary.withOpacity(0.3), + blurRadius: 10, + ), + ] + : null, ), child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), @@ -3814,7 +4106,8 @@ class TrendChartPainter extends CustomPainter { final fillPath = Path(); final points = []; for (int i = 0; i < data.length; i++) { - final x = padding.left + + final x = + padding.left + (data.length == 1 ? chartW / 2 : (i / (data.length - 1)) * chartW); final y = padding.top + chartH - ((data[i].$2 - minV) / range) * chartH; points.add(Offset(x, y)); @@ -3867,7 +4160,7 @@ class TrendChartPainter extends CustomPainter { @override bool shouldRepaint(covariant TrendChartPainter oldDelegate) => - data != oldDelegate.data; + data != oldDelegate.data || theme != oldDelegate.theme; } class MiniTrendChart extends StatelessWidget { @@ -3901,6 +4194,7 @@ class _SettingsPageState extends State { List _weightHistory = []; List _measurements = []; String _trendTab = 'weight'; // weight or measurements + bool _autoRestTimer = true; @override void initState() { @@ -3912,11 +4206,13 @@ class _SettingsPageState extends State { final profile = await loadProfile(); final weights = await loadWeightHistory(); final measurements = await loadMeasurements(); + final autoRestTimer = await loadAutoRestTimer(); if (mounted) setState(() { _profile = profile; _weightHistory = weights; _measurements = measurements; + _autoRestTimer = autoRestTimer; }); } @@ -3926,6 +4222,45 @@ class _SettingsPageState extends State { widget.onProfileChanged(); } + Widget _trainingPreferencesCard(WorkoutTheme t) { + return Container( + decoration: BoxDecoration( + color: t.card, + borderRadius: BorderRadius.circular(16), + ), + child: SwitchListTile.adaptive( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + secondary: Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: t.accent.withOpacity(0.09), + borderRadius: BorderRadius.circular(13), + ), + child: Icon(Icons.timer_rounded, color: t.accent, size: 21), + ), + title: Text( + '完成动作后自动计时', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: t.text1, + ), + ), + subtitle: Text( + '按训练计划中的休息时间启动,可随时加 30 秒或结束', + style: TextStyle(fontSize: 11, color: t.text3), + ), + value: _autoRestTimer, + activeColor: t.primary, + onChanged: (value) async { + setState(() => _autoRestTimer = value); + await saveAutoRestTimer(value); + }, + ), + ); + } + Widget _workoutPlanCard(WorkoutTheme t) { return Container( decoration: BoxDecoration( @@ -4015,15 +4350,18 @@ class _SettingsPageState extends State { // ── 训练计划 ── FadeScaleEntry(index: 2, child: _workoutPlanCard(t)), const SizedBox(height: 12), + // ── 训练偏好 ── + FadeScaleEntry(index: 3, child: _trainingPreferencesCard(t)), + const SizedBox(height: 12), // ── 数据管理 ── - FadeScaleEntry(index: 3, child: _dataCard(t)), + FadeScaleEntry(index: 4, child: _dataCard(t)), const SizedBox(height: 12), // ── 主题设置 ── - FadeScaleEntry(index: 4, child: _themeCard(t)), + FadeScaleEntry(index: 5, child: _themeCard(t)), const SizedBox(height: 12), // ── 关于 ── FadeScaleEntry( - index: 5, + index: 6, child: Container( decoration: BoxDecoration( color: t.card, @@ -4044,7 +4382,7 @@ class _SettingsPageState extends State { ), const SizedBox(height: 12), Text( - 'Body Recomp v6.8.0', + 'Body Recomp v$appVersion', style: TextStyle( fontSize: 13, color: t.text2, @@ -4632,12 +4970,12 @@ class _SettingsPageState extends State { MiniTrendChart( data: weights.length > 30 ? weights - .sublist(weights.length - 30) - .map((e) => ('${e.date.month}/${e.date.day}', e.weightKg)) - .toList() + .sublist(weights.length - 30) + .map((e) => ('${e.date.month}/${e.date.day}', e.weightKg)) + .toList() : weights - .map((e) => ('${e.date.month}/${e.date.day}', e.weightKg)) - .toList(), + .map((e) => ('${e.date.month}/${e.date.day}', e.weightKg)) + .toList(), theme: t, ), if (weights.isNotEmpty) ...[ @@ -4654,7 +4992,8 @@ class _SettingsPageState extends State { '变化: ${weights.last.weightKg > weights[weights.length - 2].weightKg ? "+" : ""}${(weights.last.weightKg - weights[weights.length - 2].weightKg).toStringAsFixed(1)}kg', style: TextStyle( fontSize: 11, - color: weights.last.weightKg > + color: + weights.last.weightKg > weights[weights.length - 2].weightKg ? t.warning : t.success, @@ -4746,7 +5085,9 @@ class _SettingsPageState extends State { : waistData, theme: t, ), - ...entries.reversed.take(8).map( + ...entries.reversed + .take(8) + .map( (entry) => Padding( padding: const EdgeInsets.only(top: 8), child: Row( diff --git a/lib/services/data_service.dart b/lib/services/data_service.dart index 3c4d64b..01f29d1 100644 --- a/lib/services/data_service.dart +++ b/lib/services/data_service.dart @@ -5,7 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/recomp_models.dart'; const int currentSchemaVersion = 2; -const String appDataVersion = '6.7.0'; +const String appDataVersion = '6.9.0'; const String profileKey = 'recomp_profile_v6'; const String weightHistoryKey = 'recomp_weight_history_v6'; const String measurementHistoryKey = 'recomp_measurements_v6'; diff --git a/lib/services/record_summary_service.dart b/lib/services/record_summary_service.dart new file mode 100644 index 0000000..077d7cd --- /dev/null +++ b/lib/services/record_summary_service.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'history_service.dart'; + +class RecordSummary { + final Map monthData; + final int yearTotal; + final int yearTrainDays; + + const RecordSummary({ + required this.monthData, + required this.yearTotal, + required this.yearTrainDays, + }); +} + +Future loadRecordSummary( + SharedPreferences preferences, + int year, + int month, { + DateTime? now, +}) async { + final months = >{}; + for (var value = 1; value <= 12; value++) { + months[value] = await loadMonthHistory(preferences, year, value); + } + + final current = now ?? DateTime.now(); + final rawDone = preferences.getString('recomp_done_v6'); + final storedWeek = + preferences.getInt('recomp_done_v6_week') ?? isoWeekNumber(current); + final storedYear = + preferences.getInt('recomp_done_v6_year') ?? isoWeekYear(current); + if (rawDone != null) { + try { + final done = jsonDecode(rawDone) as Map; + final currentCounts = countByActualDate(done, storedYear, storedWeek); + for (final entry in currentCounts.entries) { + if (entry.key.year == year && entry.value > 0) { + months[entry.key.month]![entry.key.day] = entry.value; + } + } + } catch (_) { + // Malformed current-week data should not hide valid archived history. + } + } + + var yearTotal = 0; + var yearTrainDays = 0; + for (final data in months.values) { + yearTotal += data.values.fold(0, (sum, value) => sum + value); + yearTrainDays += data.length; + } + + return RecordSummary( + monthData: Map.from(months[month]!), + yearTotal: yearTotal, + yearTrainDays: yearTrainDays, + ); +} diff --git a/lib/services/rest_timer_service.dart b/lib/services/rest_timer_service.dart new file mode 100644 index 0000000..11d5ff5 --- /dev/null +++ b/lib/services/rest_timer_service.dart @@ -0,0 +1,28 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +const String autoRestTimerKey = 'recomp_auto_rest_timer_v1'; + +int? parseRestSeconds(String value) { + final normalized = value.trim().toLowerCase(); + if (normalized.isEmpty || normalized.contains('循环')) return null; + final match = RegExp(r'(\d+(?:\.\d+)?)').firstMatch(normalized); + if (match == null) return null; + final amount = double.tryParse(match.group(1)!); + if (amount == null || amount <= 0) return null; + final usesMinutes = normalized.contains('min') || normalized.contains('分钟'); + final seconds = (amount * (usesMinutes ? 60 : 1)).round(); + return seconds.clamp(1, 3600).toInt(); +} + +Future loadAutoRestTimer({SharedPreferences? preferences}) async { + final prefs = preferences ?? await SharedPreferences.getInstance(); + return prefs.getBool(autoRestTimerKey) ?? true; +} + +Future saveAutoRestTimer( + bool enabled, { + SharedPreferences? preferences, +}) async { + final prefs = preferences ?? await SharedPreferences.getInstance(); + await prefs.setBool(autoRestTimerKey, enabled); +} diff --git a/pubspec.yaml b/pubspec.yaml index 9c888e1..d727d38 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: body_recomp description: Body Recomposition workout tracker with animations and glassmorphism UI. publish_to: 'none' -version: 6.8.3+77 +version: 6.9.0+78 environment: sdk: '>=3.2.0 <4.0.0' diff --git a/test/services/record_summary_service_test.dart b/test/services/record_summary_service_test.dart new file mode 100644 index 0000000..8d0f732 --- /dev/null +++ b/test/services/record_summary_service_test.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'package:body_recomp/services/history_service.dart'; +import 'package:body_recomp/services/record_summary_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test( + 'current week replaces archived counts instead of double counting', + () async { + final now = DateTime(2026, 7, 16); + final year = isoWeekYear(now); + final week = isoWeekNumber(now); + final done = {'0_0': true, '0_1': true, '2_0': true}; + final preferences = await SharedPreferences.getInstance(); + await preferences.setString('recomp_done_v6', jsonEncode(done)); + await preferences.setInt('recomp_done_v6_year', year); + await preferences.setInt('recomp_done_v6_week', week); + await saveWeekDoneToHistory(preferences, done, year, week); + + final summary = await loadRecordSummary(preferences, 2026, 7, now: now); + + expect(summary.yearTotal, 3); + expect(summary.yearTrainDays, 2); + expect(summary.monthData.values.fold(0, (sum, value) => sum + value), 3); + }, + ); + + test( + 'current week fills record summary before history is archived', + () async { + final now = DateTime(2026, 7, 16); + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + 'recomp_done_v6', + jsonEncode({'1_0': true, '1_1': true}), + ); + await preferences.setInt('recomp_done_v6_year', isoWeekYear(now)); + await preferences.setInt('recomp_done_v6_week', isoWeekNumber(now)); + + final summary = await loadRecordSummary(preferences, 2026, 7, now: now); + + expect(summary.yearTotal, 2); + expect(summary.yearTrainDays, 1); + }, + ); +} diff --git a/test/services/rest_timer_service_test.dart b/test/services/rest_timer_service_test.dart new file mode 100644 index 0000000..34a955e --- /dev/null +++ b/test/services/rest_timer_service_test.dart @@ -0,0 +1,25 @@ +import 'package:body_recomp/services/rest_timer_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('rest parser supports seconds, ranges and minutes', () { + expect(parseRestSeconds('90s'), 90); + expect(parseRestSeconds('90-120s'), 90); + expect(parseRestSeconds('2min'), 120); + expect(parseRestSeconds('1.5 分钟'), 90); + expect(parseRestSeconds('循环'), isNull); + expect(parseRestSeconds(''), isNull); + }); + + test('auto rest timer preference defaults on and persists', () async { + final preferences = await SharedPreferences.getInstance(); + expect(await loadAutoRestTimer(preferences: preferences), isTrue); + + await saveAutoRestTimer(false, preferences: preferences); + + expect(await loadAutoRestTimer(preferences: preferences), isFalse); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 32f05c8..774f466 100755 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -33,8 +33,9 @@ void main() { await tester.tap(mondayTab); await tester.pumpAndSettle(); } - final activeDayIndex = - workoutDays[dayIndex].exercises.isEmpty ? 0 : dayIndex; + final activeDayIndex = workoutDays[dayIndex].exercises.isEmpty + ? 0 + : dayIndex; final exerciseTotal = workoutDays[activeDayIndex].exercises.length; final firstCard = find.byKey(ValueKey('exercise_card_${activeDayIndex}_0')); expect(firstCard, findsOneWidget); @@ -62,19 +63,45 @@ void main() { expect(find.text('0/$exerciseTotal'), findsOneWidget); }); - testWidgets('switching workout day uses a page transition and staged entries', - (WidgetTester tester) async { + testWidgets( + 'switching workout day uses a page transition and staged entries', + (WidgetTester tester) async { + await tester.pumpWidget(const ThemeState(child: RecompApp())); + await tester.pumpAndSettle(); + + expect(find.byType(AnimatedSwitcher), findsWidgets); + expect(find.byType(FadeScaleEntry), findsWidgets); + + final currentDay = DateTime.now().weekday - 1; + final nextDay = (currentDay + 1) % workoutDays.length; + await tester.tap(find.text(['一', '二', '三', '四', '五', '六', '日'][nextDay])); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(SlideTransition), findsWidgets); + }, + ); + + testWidgets('workout rest timer starts, extends and dismisses', ( + WidgetTester tester, + ) async { await tester.pumpWidget(const ThemeState(child: RecompApp())); await tester.pumpAndSettle(); - expect(find.byType(AnimatedSwitcher), findsWidgets); - expect(find.byType(FadeScaleEntry), findsWidgets); + await tester.tap(find.text('一')); + await tester.pumpAndSettle(); + final firstCard = find.byKey(const ValueKey('exercise_card_0_0')); + await tester.tap(firstCard); + await tester.pump(const Duration(milliseconds: 250)); - final currentDay = DateTime.now().weekday - 1; - final nextDay = (currentDay + 1) % workoutDays.length; - await tester.tap(find.text(['一', '二', '三', '四', '五', '六', '日'][nextDay])); - await tester.pump(const Duration(milliseconds: 100)); + expect(find.byKey(const Key('rest-timer-panel')), findsOneWidget); + expect(find.text('1:30'), findsOneWidget); - expect(find.byType(SlideTransition), findsWidgets); + await tester.tap(find.byKey(const Key('rest-timer-add'))); + await tester.pump(); + expect(find.text('2:00'), findsOneWidget); + + await tester.tap(find.byKey(const Key('rest-timer-dismiss'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('rest-timer-panel')), findsNothing); }); } From febb329a2f0529766646bdac67b5cabf9a3ccf76 Mon Sep 17 00:00:00 2001 From: YXX168 Date: Fri, 17 Jul 2026 00:55:52 +0800 Subject: [PATCH 2/3] style: align with package language formatter --- lib/main.dart | 369 ++++++++++++++++++++---------------------- test/widget_test.dart | 5 +- 2 files changed, 181 insertions(+), 193 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 303ab1e..40b2bfb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -598,9 +598,9 @@ class _FadeScaleEntryState extends State @override Widget build(BuildContext context) => FadeTransition( - opacity: _fade, - child: ScaleTransition(scale: _scale, child: widget.child), - ); + opacity: _fade, + child: ScaleTransition(scale: _scale, child: widget.child), + ); } class PressScale extends StatefulWidget { @@ -904,11 +904,11 @@ class _ThemeStateState extends State { @override Widget build(BuildContext context) => ThemeInherited( - current: _mode, - theme: themes[_mode]!, - setTheme: setTheme, - child: widget.child, - ); + current: _mode, + theme: themes[_mode]!, + setTheme: setTheme, + child: widget.child, + ); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -967,11 +967,10 @@ class RecompApp extends StatelessWidget { useMaterial3: true, fontFamily: 'Inter', fontFamilyFallback: const ['sans-serif'], - textTheme: - (dark - ? ThemeData.dark().textTheme - : ThemeData.light().textTheme) - .apply(fontFamily: 'Inter'), + textTheme: (dark + ? ThemeData.dark().textTheme + : ThemeData.light().textTheme) + .apply(fontFamily: 'Inter'), ), home: const MainPage(), ); @@ -1009,12 +1008,11 @@ class _MainPageState extends State with TickerProviderStateMixin { final p = _profile; final bmi = p != null && p.heightCm > 0 ? (p.weightKg / ((p.heightCm / 100) * (p.heightCm / 100))) - .toStringAsFixed(1) + .toStringAsFixed(1) : '--'; final ageStr = (p != null && p.age > 0) ? '${p.age}岁' : '--'; - final heightStr = (p != null && p.heightCm > 0) - ? '${p.heightCm.toInt()}cm' - : '--'; + final heightStr = + (p != null && p.heightCm > 0) ? '${p.heightCm.toInt()}cm' : '--'; final weightStr = (p != null && p.weightKg > 0) ? '${p.weightKg}kg' : '--'; final statusText = '$ageStr · $heightStr · $weightStr · BMI $bmi'; final pages = [ @@ -1106,16 +1104,15 @@ class _MainPageState extends State with TickerProviderStateMixin { transitionBuilder: (child, anim) => FadeTransition( opacity: anim, child: SlideTransition( - position: - Tween( - begin: const Offset(0.02, 0), - end: Offset.zero, - ).animate( - CurvedAnimation( - parent: anim, - curve: Curves.easeOutCubic, - ), - ), + position: Tween( + begin: const Offset(0.02, 0), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: anim, + curve: Curves.easeOutCubic, + ), + ), child: child, ), ), @@ -1313,9 +1310,8 @@ class _MainPageState extends State with TickerProviderStateMixin { }, child: Container( decoration: BoxDecoration( - color: sel - ? mt.primary.withOpacity(0.06) - : null, + color: + sel ? mt.primary.withOpacity(0.06) : null, borderRadius: BorderRadius.circular(14), border: Border.all( color: sel ? mt.primary : t.border, @@ -1475,9 +1471,8 @@ class _DaySegmentedNav extends StatelessWidget { duration: const Duration(milliseconds: 220), style: TextStyle( fontSize: 11, - fontWeight: sel - ? FontWeight.w900 - : FontWeight.w700, + fontWeight: + sel ? FontWeight.w900 : FontWeight.w700, color: sel ? Colors.white : t.text2, letterSpacing: -0.2, ), @@ -1488,9 +1483,8 @@ class _DaySegmentedNav extends StatelessWidget { duration: const Duration(milliseconds: 220), style: TextStyle( fontSize: 7.5, - fontWeight: sel - ? FontWeight.w700 - : FontWeight.w500, + fontWeight: + sel ? FontWeight.w700 : FontWeight.w500, color: sel ? Colors.white.withOpacity(0.86) : t.text4, @@ -1719,16 +1713,15 @@ class _WorkoutPageState extends State { transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: SlideTransition( - position: - Tween( - begin: const Offset(0.02, 0), - end: Offset.zero, - ).animate( - CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ), - ), + position: Tween( + begin: const Offset(0.02, 0), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + ), child: child, ), ), @@ -1873,46 +1866,46 @@ class _WorkoutPageState extends State { ), const SizedBox(height: 8), ...day.recoveryOptions!.asMap().entries.map( - (e) => FadeScaleEntry( - index: e.key + 2, - child: Card( - margin: const EdgeInsets.only(bottom: 8), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - child: Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: t.primary, - boxShadow: [ - BoxShadow( - color: t.primary.withOpacity(0.4), - blurRadius: 6, - ), - ], - ), + (e) => FadeScaleEntry( + index: e.key + 2, + child: Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, ), - const SizedBox(width: 12), - Text( - e.value, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: t.text2, - ), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: t.primary, + boxShadow: [ + BoxShadow( + color: t.primary.withOpacity(0.4), + blurRadius: 6, + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + e.value, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: t.text2, + ), + ), + ], ), - ], + ), ), ), ), - ), - ), ], ), ); @@ -2034,24 +2027,24 @@ class _WorkoutPageState extends State { ), const SizedBox(height: 14), ...day.exercises.asMap().entries.map( - (e) => FadeScaleEntry( - key: ValueKey('entry_${_day}_${e.key}'), - index: e.key, - delay: const Duration(milliseconds: 38), - child: RepaintBoundary( - child: _exCard( - e.value, - e.key + 1, - _done.containsKey('${_day}_${e.key}'), - t, - () => _toggle(_day, e.key), - parseRestSeconds(e.value.rest) == null - ? null - : () => _startRestTimer(e.value), + (e) => FadeScaleEntry( + key: ValueKey('entry_${_day}_${e.key}'), + index: e.key, + delay: const Duration(milliseconds: 38), + child: RepaintBoundary( + child: _exCard( + e.value, + e.key + 1, + _done.containsKey('${_day}_${e.key}'), + t, + () => _toggle(_day, e.key), + parseRestSeconds(e.value.rest) == null + ? null + : () => _startRestTimer(e.value), + ), + ), ), ), - ), - ), if (day.circuitNote != null) FadeScaleEntry( index: day.exercises.length + 2, @@ -2338,14 +2331,14 @@ class _WorkoutPageState extends State { side: done ? BorderSide(color: t.success.withOpacity(0.22), width: 1) : (ex.isStar - ? BorderSide( - color: t.primary.withOpacity(0.26), - width: 1.2, - ) - : BorderSide( - color: t.border.withOpacity(0.9), - width: 0.8, - )), + ? BorderSide( + color: t.primary.withOpacity(0.26), + width: 1.2, + ) + : BorderSide( + color: t.border.withOpacity(0.9), + width: 0.8, + )), ), child: AnimatedPadding( duration: const Duration(milliseconds: 220), @@ -2998,9 +2991,8 @@ class _RecordPageState extends State { '$day', style: TextStyle( fontSize: 10, - fontWeight: isToday - ? FontWeight.w800 - : FontWeight.w600, + fontWeight: + isToday ? FontWeight.w800 : FontWeight.w600, color: count == 0 ? (isToday ? t.primary : t.text4) : (intensity > 0.62 ? Colors.white : t.text1), @@ -3153,9 +3145,8 @@ class _RecordPageState extends State { cells.add(SizedBox(width: 4, height: 4)); } else { final rawCount = history[day.toString()]; - final count = rawCount is int - ? rawCount - : int.tryParse('$rawCount') ?? 0; + final count = + rawCount is int ? rawCount : int.tryParse('$rawCount') ?? 0; final intensity = count > 0 ? (count / 8).clamp(0.0, 1.0) : 0.0; cells.add( Container( @@ -3311,43 +3302,44 @@ class NutritionPage extends StatelessWidget { ), const SizedBox(height: 10), ...nutritionTips.asMap().entries.map( - (e) => FadeScaleEntry( - index: e.key + 5, - child: Card( - margin: const EdgeInsets.only(bottom: 6), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 3, - height: 14, - margin: const EdgeInsets.only(right: 10, top: 2), - decoration: BoxDecoration( - color: t.primary, - borderRadius: BorderRadius.circular(2), - ), + (e) => FadeScaleEntry( + index: e.key + 5, + child: Card( + margin: const EdgeInsets.only(bottom: 6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, ), - Expanded( - child: Text( - nutritionTips[e.key], - style: TextStyle( - fontSize: 12, - color: t.text2, - height: 1.6, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 3, + height: 14, + margin: + const EdgeInsets.only(right: 10, top: 2), + decoration: BoxDecoration( + color: t.primary, + borderRadius: BorderRadius.circular(2), + ), ), - ), + Expanded( + child: Text( + nutritionTips[e.key], + style: TextStyle( + fontSize: 12, + color: t.text2, + height: 1.6, + ), + ), + ), + ], ), - ], + ), ), ), ), - ), - ), const SizedBox(height: 8), ]), ), @@ -3643,32 +3635,32 @@ class ProgressionPage extends StatelessWidget { ('孤立动作', '侧平举 / 弯举 / 下压:每次 +0.5-1kg 或 +1-2次'), ('遇到瓶颈', '减重 10% 重新开始,或更换动作变式刺激新角度'), ].asMap().entries.map( - (i) => FadeScaleEntry( - index: i.key + 8, - child: Card( - margin: const EdgeInsets.only(bottom: 6), - child: ListTile( - dense: true, - title: Text( - i.value.$1, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: t.text1, - ), - ), - subtitle: Text( - i.value.$2, - style: TextStyle( - fontSize: 11, - color: t.text3, - height: 1.5, + (i) => FadeScaleEntry( + index: i.key + 8, + child: Card( + margin: const EdgeInsets.only(bottom: 6), + child: ListTile( + dense: true, + title: Text( + i.value.$1, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: t.text1, + ), + ), + subtitle: Text( + i.value.$2, + style: TextStyle( + fontSize: 11, + color: t.text3, + height: 1.5, + ), + ), ), ), ), ), - ), - ), const SizedBox(height: 8), ]), ), @@ -3737,13 +3729,13 @@ class ThemePage extends StatelessWidget { ), ] : sel - ? [ - BoxShadow( - color: mt.primary.withOpacity(0.3), - blurRadius: 10, - ), - ] - : null, + ? [ + BoxShadow( + color: mt.primary.withOpacity(0.3), + blurRadius: 10, + ), + ] + : null, ), child: Padding( padding: const EdgeInsets.symmetric( @@ -3773,13 +3765,14 @@ class ThemePage extends StatelessWidget { ), ] : sel - ? [ - BoxShadow( - color: mt.primary.withOpacity(0.3), - blurRadius: 10, - ), - ] - : null, + ? [ + BoxShadow( + color: + mt.primary.withOpacity(0.3), + blurRadius: 10, + ), + ] + : null, ), child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), @@ -4106,8 +4099,7 @@ class TrendChartPainter extends CustomPainter { final fillPath = Path(); final points = []; for (int i = 0; i < data.length; i++) { - final x = - padding.left + + final x = padding.left + (data.length == 1 ? chartW / 2 : (i / (data.length - 1)) * chartW); final y = padding.top + chartH - ((data[i].$2 - minV) / range) * chartH; points.add(Offset(x, y)); @@ -4970,12 +4962,12 @@ class _SettingsPageState extends State { MiniTrendChart( data: weights.length > 30 ? weights - .sublist(weights.length - 30) - .map((e) => ('${e.date.month}/${e.date.day}', e.weightKg)) - .toList() + .sublist(weights.length - 30) + .map((e) => ('${e.date.month}/${e.date.day}', e.weightKg)) + .toList() : weights - .map((e) => ('${e.date.month}/${e.date.day}', e.weightKg)) - .toList(), + .map((e) => ('${e.date.month}/${e.date.day}', e.weightKg)) + .toList(), theme: t, ), if (weights.isNotEmpty) ...[ @@ -4992,8 +4984,7 @@ class _SettingsPageState extends State { '变化: ${weights.last.weightKg > weights[weights.length - 2].weightKg ? "+" : ""}${(weights.last.weightKg - weights[weights.length - 2].weightKg).toStringAsFixed(1)}kg', style: TextStyle( fontSize: 11, - color: - weights.last.weightKg > + color: weights.last.weightKg > weights[weights.length - 2].weightKg ? t.warning : t.success, @@ -5085,9 +5076,7 @@ class _SettingsPageState extends State { : waistData, theme: t, ), - ...entries.reversed - .take(8) - .map( + ...entries.reversed.take(8).map( (entry) => Padding( padding: const EdgeInsets.only(top: 8), child: Row( diff --git a/test/widget_test.dart b/test/widget_test.dart index 774f466..beae6c3 100755 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -33,9 +33,8 @@ void main() { await tester.tap(mondayTab); await tester.pumpAndSettle(); } - final activeDayIndex = workoutDays[dayIndex].exercises.isEmpty - ? 0 - : dayIndex; + final activeDayIndex = + workoutDays[dayIndex].exercises.isEmpty ? 0 : dayIndex; final exerciseTotal = workoutDays[activeDayIndex].exercises.length; final firstCard = find.byKey(ValueKey('exercise_card_${activeDayIndex}_0')); expect(firstCard, findsOneWidget); From 197a7f567e97ec937200baf878b95337b946382e Mon Sep 17 00:00:00 2001 From: YXX168 Date: Fri, 17 Jul 2026 00:58:03 +0800 Subject: [PATCH 3/3] test: validate rest timer controls without viewport taps --- lib/main.dart | 2 +- test/widget_test.dart | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 40b2bfb..cba800b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4244,7 +4244,7 @@ class _SettingsPageState extends State { style: TextStyle(fontSize: 11, color: t.text3), ), value: _autoRestTimer, - activeColor: t.primary, + activeThumbColor: t.primary, onChanged: (value) async { setState(() => _autoRestTimer = value); await saveAutoRestTimer(value); diff --git a/test/widget_test.dart b/test/widget_test.dart index beae6c3..b38dab9 100755 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -95,11 +95,15 @@ void main() { expect(find.byKey(const Key('rest-timer-panel')), findsOneWidget); expect(find.text('1:30'), findsOneWidget); - await tester.tap(find.byKey(const Key('rest-timer-add'))); + tester + .widget(find.byKey(const Key('rest-timer-add'))) + .onPressed!(); await tester.pump(); expect(find.text('2:00'), findsOneWidget); - await tester.tap(find.byKey(const Key('rest-timer-dismiss'))); + tester + .widget(find.byKey(const Key('rest-timer-dismiss'))) + .onPressed!(); await tester.pumpAndSettle(); expect(find.byKey(const Key('rest-timer-panel')), findsNothing); });