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..cba800b 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 // ═════════════════════════════════════════════════════════════════════════════== @@ -893,6 +897,7 @@ 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); } @@ -991,7 +996,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 @@ -1014,8 +1021,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( @@ -1512,6 +1520,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 +1569,9 @@ class _WorkoutPageState extends State { if (mounted) setState(() {}); }); _reloadPlan(); + loadAutoRestTimer().then((enabled) { + if (mounted) setState(() => _autoRestTimer = enabled); + }); } Future _reloadPlan() async { @@ -1565,6 +1582,7 @@ class _WorkoutPageState extends State { @override void dispose() { workoutPlanRevision.removeListener(_reloadPlan); + _restTimer?.cancel(); super.dispose(); } @@ -1577,8 +1595,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 +1605,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) { @@ -1836,6 +1919,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,6 +2006,25 @@ 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( @@ -1916,6 +2038,9 @@ class _WorkoutPageState extends State { _done.containsKey('${_day}_${e.key}'), t, () => _toggle(_day, e.key), + parseRestSeconds(e.value.rest) == null + ? null + : () => _startRestTimer(e.value), ), ), ), @@ -1948,6 +2073,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 +2315,7 @@ class _WorkoutPageState extends State { bool done, WorkoutTheme t, VoidCallback tap, + VoidCallback? startTimer, ) { return Padding( padding: const EdgeInsets.only(bottom: 6), @@ -2234,14 +2519,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 +2587,7 @@ class _RecordPageState extends State { bool _loading = true; int _yearTotal = 0; int _yearTrainDays = 0; + int _loadGeneration = 0; @override void initState() { @@ -2278,49 +2596,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; }); } @@ -3867,7 +4152,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 +4186,7 @@ class _SettingsPageState extends State { List _weightHistory = []; List _measurements = []; String _trendTab = 'weight'; // weight or measurements + bool _autoRestTimer = true; @override void initState() { @@ -3912,11 +4198,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 +4214,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, + activeThumbColor: t.primary, + onChanged: (value) async { + setState(() => _autoRestTimer = value); + await saveAutoRestTimer(value); + }, + ), + ); + } + Widget _workoutPlanCard(WorkoutTheme t) { return Container( decoration: BoxDecoration( @@ -4015,15 +4342,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 +4374,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, 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..b38dab9 100755 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -62,19 +62,49 @@ 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); + tester + .widget(find.byKey(const Key('rest-timer-add'))) + .onPressed!(); + await tester.pump(); + expect(find.text('2:00'), findsOneWidget); + + tester + .widget(find.byKey(const Key('rest-timer-dismiss'))) + .onPressed!(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('rest-timer-panel')), findsNothing); }); }