From 971ff641174e20e52c0fd201bf25e39b41796158 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Thu, 29 Oct 2026 00:20:16 +0100 Subject: [PATCH] Stage 4: guided Player for both Modes and the Session summary - buildSegments lays a Plan out as holds with Transitions (get ready, switch sides, next round, next exercise) or sets with count-up Rests; Supersets alternate members and rest per round unless asked otherwise - SessionRunner drives segments against an injected clock: auto-advance with a chime, pause, skip, back, rep sets confirmed with logged reps and kg, timed sets started on demand, early end - Player screen: countdown ring, accent pulse in the last 3 s, Flip Animation, next-up cards, Rest Screen, mute toggle, screen kept on - Sessions table (schema v2 migration) keeps a Plan snapshot and Set Logs; nothing is saved when no hold or set was done - Summary with time, exercises or sets logged, and every Set Log - Synthesised quiet cue sounds (tools/sounds), status bar per Mode Co-Authored-By: Claude Opus 5.5 --- GLOSSARY.md | 8 +- assets/sounds/stretching_cue.wav | Bin 0 -> 114704 bytes assets/sounds/workout_cue.wav | Bin 0 -> 5336 bytes lib/main.dart | 4 + lib/src/app.dart | 10 + lib/src/database/database.dart | 48 +- lib/src/database/database.g.dart | 756 +++++++++++++++++- lib/src/design/app_theme.dart | 2 + lib/src/history/session_repository.dart | 86 ++ lib/src/localization/app_en.arb | 92 ++- lib/src/localization/app_localizations.dart | 259 +++++- .../localization/app_localizations_en.dart | 205 +++-- lib/src/plans/views/plan_editor_view.dart | 37 +- lib/src/plans/views/plans_view.dart | 7 + lib/src/player/player_effects.dart | 36 + lib/src/player/segments.dart | 151 ++++ lib/src/player/session_runner.dart | 241 ++++++ lib/src/player/views/player_view.dart | 729 +++++++++++++++++ lib/src/player/views/summary_view.dart | 159 ++++ lib/src/settings/settings_controller.dart | 15 + pubspec.lock | 138 +++- pubspec.yaml | 4 + test/history/session_repository_test.dart | 58 ++ test/player/segments_test.dart | 119 +++ test/player/session_runner_test.dart | 158 ++++ test/support/test_app.dart | 18 + test/views/player_view_test.dart | 148 ++++ tools/sounds/make_cues.py | 61 ++ 28 files changed, 3465 insertions(+), 84 deletions(-) create mode 100644 assets/sounds/stretching_cue.wav create mode 100644 assets/sounds/workout_cue.wav create mode 100644 lib/src/history/session_repository.dart create mode 100644 lib/src/player/player_effects.dart create mode 100644 lib/src/player/segments.dart create mode 100644 lib/src/player/session_runner.dart create mode 100644 lib/src/player/views/player_view.dart create mode 100644 lib/src/player/views/summary_view.dart create mode 100644 test/history/session_repository_test.dart create mode 100644 test/player/segments_test.dart create mode 100644 test/player/session_runner_test.dart create mode 100644 test/views/player_view_test.dart create mode 100644 tools/sounds/make_cues.py diff --git a/GLOSSARY.md b/GLOSSARY.md index 34b978b..53c10a3 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -55,14 +55,16 @@ In Workout Mode, the screen after "Set done": a count-up timer ("Resting 1:12") button. Nothing counts down. Timed sets start only when the user taps Start. **Transition**: -In Stretching Mode, a short card between rounds, sides ("Switch sides") and Exercises, after which the -next hold starts automatically. Length set per Plan, 5 s by default. +In Stretching Mode, a short card before the first hold ("Get ready") and between rounds, sides +("Switch sides") and Exercises, after which the next hold starts automatically. Its length is the +Plan's "pause between holds", 5 s by default; 0 s means no Transitions. ## Sessions **Session**: One run-through of a Plan in the Player. It stores a snapshot of the Plan, so later edits to the Plan -never change it. A Session is **complete** or **incomplete** (ended early). +never change it. A Session is **complete** (reached the end) or **incomplete** (ended early). A Session +in which no hold or set was actually done is not saved at all. _Avoid_: Workout, run **Player**: diff --git a/assets/sounds/stretching_cue.wav b/assets/sounds/stretching_cue.wav new file mode 100644 index 0000000000000000000000000000000000000000..2ce5b4b4ae3eaae299d532626bfa33f31beb2c3e GIT binary patch literal 114704 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

$I*v=XG+F>Z4IWROh~MJn)*%Z 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 R*wA-AD^>mV$tQz@z^KM7pAX(T7S? zLJ>roAV@EQbd(~}4G=;C>6wz5x%Zy^zDa1Vx9*x@5zg;^dw=^o=Va02g|xJj2}G~A zeXZkwp`&WV5|O}D>ouYdYlswzrFSz%Wvs!P&Sn>l68k0Ws(vZ;okT5pg4^ZZuT0_E z;sr~pzAOBb(Zg8k{h+`s5LYkdOup9Q#--a6{rl8(yVo-~Wn8tQ)cnM?@!v#mcQ3Xs zQQ9gVvdIZN(&!MF=j~ARPJVRmOWD5Mow+BAZ-ypO71zPobIE6_T}+u?wSi|(%zWo8 zYXfzmvO^?tGno?_6ZCr9l=R8pa`S4ABd_o6($e&hUp(pjJ3c9KYVzRZ?8K+z21h;P zXl0$CW{J)0mHo^zeS0v?n_uM0@1A!(cj2u@g*NXC`Y?5nt4X|;I3r0UO-k^_EOZaH zH`fj-S(L{stY_A@v^S2^&2!c3f1R zW485(nkRNc>!O@tcGo)u5BkzeyB1{@EH9W)G_@=$_=$|y?5-bT-ixp2sT03G<_-4| z`&zA?Izh~aR*{@-X6RFbFZxSLyBGgem|Iv{e8pQU)RZ%{EN4>8sJQEK`(t}V*LGdB zvE?_VqZm$G`GTBg4$*f9m-^?F)hxMI)V%nWQjfoWIFC1J>zwaI4~g9xJ0vD1D%$C= z4YUkUQbi|P!l&gZbG)7t4EpPNxg=CPwq#yer$9A*CS_^EojapO#mtF$DmurN;OJz{ zR#TJ`dXg6NVL8tHM2`-=64>iKU22vrDm~)uT^`b-1#1nQ$x&0IM@A<`opm;~FVqI9 zXT%YzNppEGw7${Xgw_VS`R12}OE;J0_}&igF{TNR^^#+RJ2Prfl-+&B(a)w^ggRHO zL{ew)HaXf{r4J3+%CGo(dvBKQ_7?a%h5pCP71gY>9H(8syNA2ou8@71^#e<$@`m^X zS^tnX$uZ`3eNm`i`Fs9E-x2R--?c#XaGZQrNwGF}G;{e~GhDTtb?xO^vD!^}RE(ei zkLGo9g88R@BDA%GGqK>79XcXEmnYrSk=;B4et?R>$}-}a_9QB@U5y(yoE z@@hHJ%+}3NOmJ9Wlz*^)Y~btQJUx}SD^mN|t~p0IFF8K4Z?Yb@B&qwv1?o)K_&wzC zV>6^T4!;{L2qXs@1lpDN3bimkYjY4*-hqV*7+jftmzdgnFp>|Fkr1TVvr~#egw|JTS+^lEJ z4Ld`Z%G1j~E#Da25Pr#QOZC(h+DO|w_L25ow*OdtmSJiQrJk5Z57ANX#hLPJ^Eu%H%hSeLV$^>G(=hVT0qzOvJF}PJ4gVP0 z9?S^-9xMx;(VNR-)KPs^+iQK#_M&Z$HAAynwkgZSWqOO8w3}Z7waOe})X@W>+M(;g zo}nq>SB!a5h!@pIwL#Vr>jA69dc*R)Iz$;Rc2O6cV%xbZFO@%=pBg>&=fkr?Z-p*~ zZ2DoNKsFZXs$og6PPTTiPS-|Q+NyPxc48T|A%iz_I^5W4t}v$Qv%|MSr$TRpKh~R> z&+u!aqk7!3PxDw$Xfc}IQlR9C6!9rFB|rbnoq2^kWbQFG>VJmQ!q0{O4Eyx|H7D_C z(Mnxp8Lq9>-qtcLE7k9n4I)5esXmqPI!@zN@{D=WxTbsb@53v@t@S=efw_bKE1Ib9 zTWV=9YCcOVOLw)GGD-YR{pn%k=_l^UKglf9XG9yX>b|f;U!m_YTFVk%CTgfHE$1x7 zmM<;;s8Xq_yeziUYm`K}yox&@z4>O0+0vM&chm>yno-r9F00c<5v4jTpIg4QJY|`s zZc?(uqaqU-jHPV;f!p&&88jQ11B}!9m-=qKuQ9|tB-3c82q_oT&X$grtLoG0aOEeV zi8<7oTy&L}f%;WiWe4*MBg(j}#~GW9t){|*Xurr-epEF}xjI)ZQkp6g#Z4Lq2Q|9F zOF`|F9@)p-ZloEtjJ}32O|u2hq=Vv^GC}=aU8kn0tCg_mf%9S@k}K#EF9Eer*2H;p z-I!>+YpgQT&DL@NXM)+)Qd`8;Y&(~;!NLQ99>y!_bYD%Vf zL~Nx2)R>~k%cnr4^Hn)W`ppIAF!LkxtT|XV;V8OAKZqvEcxAHkqEaBn3#(X4y{H~J zsFY9g0`9`Oa;$VC_urc9O{@Gy_T)NLOp8RK@`}P>d@E;(MlB)iHn^0-XH><3dRy(t!mwc=~hS0swVG=*NE+T_NWaf^>) z%5QT5AC;fU!Sa3hmoNL_=Od|zx`?Ub3o%x-6$Wjhk@P%OhwnP)fm)0cJC)DNC332q zB{yTj<2cCUFt0XZs2DBY6m>;5Ey2!jK`CUT5a)vWj{9>R&XJqsLb+U?mbLkF*5L9D zYAkw*-r_mo7AG*PH>e3!!QKk;O+Lck^FV%#OXcr!quebE<+Gg0@$?b>Lp4QvJavVS zw$Wtj1~1|N1p4c>wR{M`#+IBCAM+dqFx%U!$v5IMLkr{WHKfKrQ1@+>R5uToy}} zoAP)h`f*T4DM(JCh#Rz(W}>U+@WKho59%ndfM@AklWnYU6873Be1)6QRN7DZ6heki zp}z6dof;ucb|k}BL9OHuk;H~jOW+2$%NAf-&!Ddz@Z%O;p*{2s4W%@k(;nnqg35;0 zDxQM1E#Q6~ZpDN6NA_}Sc=!vQrVDhO*3)dtz&%nMr>90H{xutkTFukBAGhTvxH)#( zc;3P~KTnfr4IQLov=j6FkY2}xt5Y;t?on%aHhv4}!fkNEy~Q(mA1l?{~x3(gWuV3(Gy~ByU46pMm&@ z=O8DCF^}r#t3UjhgwH_K_bk=LIqgK=nJcwU@eZv03hvKFf9r5Q2Dt`2%fQ|pLmyCI z)b}Lr5f65;0xDEd;c4E5G|WdwbFn|S@HsZQ7Iwwk^getTfcdt^DP0}8u-&6>;GEyf zYq9oHE@CI%-I}`M%^uoX2#SMl&d1!cmk5}W|%u$y-v9~&_3{kU(-*bVAg ztnY>!q&+=_eU*aq!hVk`MGh`tuk693H-kNbUF=5+>*5yZ2rdozdjj4h;ij{~!Ah+N zbrHA9Zy>f~hy20U;ZZE87Vx73K2Ou*R0F4A#Ebh>E??q5`FE)8L4RlP7K7v9Msuh< zkDi*NzK772v%bbbO4o}$88~Tg4V(BFD=2ezLqjy0TYzaksBB z+{@YU;{upVm{lS6oDI~&aIQY4RulDkFyV+7;+`92T!2hvfw+hrl7oa9YzOra{CEVP z>ZmUcT}8aOPkBMzgdbP%T*DN~Ff9kDRH)QKovElVreea8TtpHdao&rXa#2|p$ZT}w zV@BdVsOw=c)p5ckpex562k%owP`Ck|>(DHOgM>GGkcNlAq+-7Dn3WA){9CICyD$ej zH$WFdT~tt2L8L;d3S4*H^Wv_S4=Zn@#vCZ!MlM1~h70dcf?5i`J*dxl&x;4Ce5mB& zQ-Ch@J5(aPONLS$T(9gZ;$S5eLiZ(DeGAWR?0g>_QlS-x^+|9q0rj~nCS2LoUCNKD z3Nh*1U`ybIiSx;YwF%%7P-&#T`(7~SVxWr%RRp2{-(_5mpH@;HFdop6`tD9RlBY^4 zfVIU?E5x@KZ((HDiMhsuiNl|f`m{T_c#y(>WnQ?o&ZfzKW+DUdTI?3tXkr zgIzsH1u8f{RCV~SRA{-NOV literal 0 HcmV?d00001 diff --git a/lib/main.dart b/lib/main.dart index 2b5123b..dd6fb2e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,9 @@ import 'package:provider/provider.dart'; import 'src/app.dart'; import 'src/database/database.dart'; import 'src/exercises/exercise_library.dart'; +import 'src/history/session_repository.dart'; import 'src/plans/plan_repository.dart'; +import 'src/player/player_effects.dart'; import 'src/settings/settings_controller.dart'; void main() async { @@ -24,6 +26,8 @@ void main() async { ChangeNotifierProvider.value(value: settings), Provider.value(value: library), Provider.value(value: PlanRepository(db)), + Provider.value(value: SessionRepository(db)), + Provider(create: (_) => DevicePlayerEffects()), ], child: const ZenXerciseApp(), ), diff --git a/lib/src/app.dart b/lib/src/app.dart index b39b842..5b8b7fb 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:provider/provider.dart'; @@ -9,6 +10,8 @@ import 'home/home_view.dart'; import 'localization/app_localizations.dart'; import 'plans/views/exercise_picker_view.dart'; import 'plans/views/plan_editor_view.dart'; +import 'player/views/player_view.dart'; +import 'player/views/summary_view.dart'; import 'settings/settings_controller.dart'; class ZenXerciseApp extends StatelessWidget { @@ -32,12 +35,19 @@ class ZenXerciseApp extends StatelessWidget { ], supportedLocales: AppLocalizations.supportedLocales, onGenerateTitle: (context) => AppLocalizations.of(context).appTitle, + // Status bar icons follow the Mode's ground on every screen, with or without an app bar. + builder: (context, child) => AnnotatedRegion( + value: style.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, + child: child!, + ), onGenerateRoute: (settings) => MaterialPageRoute( settings: settings, builder: (context) => switch (settings.name) { ExerciseDetailView.routeName => ExerciseDetailView(exerciseId: settings.arguments! as String), PlanEditorView.routeName => PlanEditorView(planId: settings.arguments! as String), ExercisePickerView.routeName => ExercisePickerView(planId: settings.arguments! as String), + PlayerView.routeName => PlayerView(planId: settings.arguments! as String), + SummaryView.routeName => SummaryView(sessionId: settings.arguments! as String), _ => const HomeView(), }, ), diff --git a/lib/src/database/database.dart b/lib/src/database/database.dart index 32be934..94dc447 100644 --- a/lib/src/database/database.dart +++ b/lib/src/database/database.dart @@ -1,6 +1,9 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; import '../plans/models/plan.dart'; +import '../player/session_runner.dart'; part 'database.g.dart'; @@ -32,10 +35,51 @@ class PlanItemsConverter extends TypeConverter, String> { String toSql(List value) => PlanItem.listToJson(value); } -@DriftDatabase(tables: [Plans]) +/// One row per finished Session, complete or ended early. It keeps a snapshot +/// of the Plan, so editing or deleting the Plan never changes History. +@DataClassName('SessionRow') +class Sessions extends Table { + TextColumn get id => text()(); + TextColumn get mode => text()(); + + /// The Plan it ran, if that still matters; no foreign key, so deleting the Plan keeps its Sessions. + TextColumn get planId => text()(); + TextColumn get planName => text()(); + TextColumn get items => text().map(const PlanItemsConverter())(); + DateTimeColumn get startedAt => dateTime()(); + DateTimeColumn get endedAt => dateTime()(); + IntColumn get durationSeconds => integer()(); + BoolColumn get complete => boolean()(); + TextColumn get setLogs => text().map(const SetLogsConverter())(); + + @override + Set get primaryKey => {id}; +} + +class SetLogsConverter extends TypeConverter, String> { + const SetLogsConverter(); + + @override + List fromSql(String fromDb) => [ + for (final l in jsonDecode(fromDb) as List) SetLog.fromJson((l as Map).cast()), + ]; + + @override + String toSql(List value) => jsonEncode([for (final l in value) l.toJson()]); +} + +@DriftDatabase(tables: [Plans, Sessions]) class AppDatabase extends _$AppDatabase { AppDatabase(super.e); @override - int get schemaVersion => 1; + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (m, from, to) async { + // 2: Sessions. + if (from < 2) await m.createTable(sessions); + }, + ); } diff --git a/lib/src/database/database.g.dart b/lib/src/database/database.g.dart index f1544bb..094c4f8 100644 --- a/lib/src/database/database.g.dart +++ b/lib/src/database/database.g.dart @@ -399,14 +399,536 @@ class PlansCompanion extends UpdateCompanion { } } +class $SessionsTable extends Sessions with TableInfo<$SessionsTable, SessionRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SessionsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _modeMeta = const VerificationMeta('mode'); + @override + late final GeneratedColumn mode = GeneratedColumn( + 'mode', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _planIdMeta = const VerificationMeta('planId'); + @override + late final GeneratedColumn planId = GeneratedColumn( + 'plan_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _planNameMeta = const VerificationMeta('planName'); + @override + late final GeneratedColumn planName = GeneratedColumn( + 'plan_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + late final GeneratedColumnWithTypeConverter, String> items = GeneratedColumn( + 'items', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($SessionsTable.$converteritems); + static const VerificationMeta _startedAtMeta = const VerificationMeta('startedAt'); + @override + late final GeneratedColumn startedAt = GeneratedColumn( + 'started_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _endedAtMeta = const VerificationMeta('endedAt'); + @override + late final GeneratedColumn endedAt = GeneratedColumn( + 'ended_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _durationSecondsMeta = const VerificationMeta('durationSeconds'); + @override + late final GeneratedColumn durationSeconds = GeneratedColumn( + 'duration_seconds', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _completeMeta = const VerificationMeta('complete'); + @override + late final GeneratedColumn complete = GeneratedColumn( + 'complete', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("complete" IN (0, 1))'), + ); + @override + late final GeneratedColumnWithTypeConverter, String> setLogs = GeneratedColumn( + 'set_logs', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($SessionsTable.$convertersetLogs); + @override + List get $columns => [ + id, + mode, + planId, + planName, + items, + startedAt, + endedAt, + durationSeconds, + complete, + setLogs, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sessions'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('mode')) { + context.handle(_modeMeta, mode.isAcceptableOrUnknown(data['mode']!, _modeMeta)); + } else if (isInserting) { + context.missing(_modeMeta); + } + if (data.containsKey('plan_id')) { + context.handle(_planIdMeta, planId.isAcceptableOrUnknown(data['plan_id']!, _planIdMeta)); + } else if (isInserting) { + context.missing(_planIdMeta); + } + if (data.containsKey('plan_name')) { + context.handle(_planNameMeta, planName.isAcceptableOrUnknown(data['plan_name']!, _planNameMeta)); + } else if (isInserting) { + context.missing(_planNameMeta); + } + if (data.containsKey('started_at')) { + context.handle(_startedAtMeta, startedAt.isAcceptableOrUnknown(data['started_at']!, _startedAtMeta)); + } else if (isInserting) { + context.missing(_startedAtMeta); + } + if (data.containsKey('ended_at')) { + context.handle(_endedAtMeta, endedAt.isAcceptableOrUnknown(data['ended_at']!, _endedAtMeta)); + } else if (isInserting) { + context.missing(_endedAtMeta); + } + if (data.containsKey('duration_seconds')) { + context.handle( + _durationSecondsMeta, + durationSeconds.isAcceptableOrUnknown(data['duration_seconds']!, _durationSecondsMeta), + ); + } else if (isInserting) { + context.missing(_durationSecondsMeta); + } + if (data.containsKey('complete')) { + context.handle(_completeMeta, complete.isAcceptableOrUnknown(data['complete']!, _completeMeta)); + } else if (isInserting) { + context.missing(_completeMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + SessionRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SessionRow( + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!, + mode: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}mode'])!, + planId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}plan_id'])!, + planName: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}plan_name'])!, + items: $SessionsTable.$converteritems.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}items'])!, + ), + startedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}started_at'])!, + endedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}ended_at'])!, + durationSeconds: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}duration_seconds'])!, + complete: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}complete'])!, + setLogs: $SessionsTable.$convertersetLogs.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}set_logs'])!, + ), + ); + } + + @override + $SessionsTable createAlias(String alias) { + return $SessionsTable(attachedDatabase, alias); + } + + static TypeConverter, String> $converteritems = const PlanItemsConverter(); + static TypeConverter, String> $convertersetLogs = const SetLogsConverter(); +} + +class SessionRow extends DataClass implements Insertable { + final String id; + final String mode; + + /// The Plan it ran, if that still matters; no foreign key, so deleting the Plan keeps its Sessions. + final String planId; + final String planName; + final List items; + final DateTime startedAt; + final DateTime endedAt; + final int durationSeconds; + final bool complete; + final List setLogs; + const SessionRow({ + required this.id, + required this.mode, + required this.planId, + required this.planName, + required this.items, + required this.startedAt, + required this.endedAt, + required this.durationSeconds, + required this.complete, + required this.setLogs, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['mode'] = Variable(mode); + map['plan_id'] = Variable(planId); + map['plan_name'] = Variable(planName); + { + map['items'] = Variable($SessionsTable.$converteritems.toSql(items)); + } + map['started_at'] = Variable(startedAt); + map['ended_at'] = Variable(endedAt); + map['duration_seconds'] = Variable(durationSeconds); + map['complete'] = Variable(complete); + { + map['set_logs'] = Variable($SessionsTable.$convertersetLogs.toSql(setLogs)); + } + return map; + } + + SessionsCompanion toCompanion(bool nullToAbsent) { + return SessionsCompanion( + id: Value(id), + mode: Value(mode), + planId: Value(planId), + planName: Value(planName), + items: Value(items), + startedAt: Value(startedAt), + endedAt: Value(endedAt), + durationSeconds: Value(durationSeconds), + complete: Value(complete), + setLogs: Value(setLogs), + ); + } + + factory SessionRow.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SessionRow( + id: serializer.fromJson(json['id']), + mode: serializer.fromJson(json['mode']), + planId: serializer.fromJson(json['planId']), + planName: serializer.fromJson(json['planName']), + items: serializer.fromJson>(json['items']), + startedAt: serializer.fromJson(json['startedAt']), + endedAt: serializer.fromJson(json['endedAt']), + durationSeconds: serializer.fromJson(json['durationSeconds']), + complete: serializer.fromJson(json['complete']), + setLogs: serializer.fromJson>(json['setLogs']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'mode': serializer.toJson(mode), + 'planId': serializer.toJson(planId), + 'planName': serializer.toJson(planName), + 'items': serializer.toJson>(items), + 'startedAt': serializer.toJson(startedAt), + 'endedAt': serializer.toJson(endedAt), + 'durationSeconds': serializer.toJson(durationSeconds), + 'complete': serializer.toJson(complete), + 'setLogs': serializer.toJson>(setLogs), + }; + } + + SessionRow copyWith({ + String? id, + String? mode, + String? planId, + String? planName, + List? items, + DateTime? startedAt, + DateTime? endedAt, + int? durationSeconds, + bool? complete, + List? setLogs, + }) => SessionRow( + id: id ?? this.id, + mode: mode ?? this.mode, + planId: planId ?? this.planId, + planName: planName ?? this.planName, + items: items ?? this.items, + startedAt: startedAt ?? this.startedAt, + endedAt: endedAt ?? this.endedAt, + durationSeconds: durationSeconds ?? this.durationSeconds, + complete: complete ?? this.complete, + setLogs: setLogs ?? this.setLogs, + ); + SessionRow copyWithCompanion(SessionsCompanion data) { + return SessionRow( + id: data.id.present ? data.id.value : this.id, + mode: data.mode.present ? data.mode.value : this.mode, + planId: data.planId.present ? data.planId.value : this.planId, + planName: data.planName.present ? data.planName.value : this.planName, + items: data.items.present ? data.items.value : this.items, + startedAt: data.startedAt.present ? data.startedAt.value : this.startedAt, + endedAt: data.endedAt.present ? data.endedAt.value : this.endedAt, + durationSeconds: data.durationSeconds.present ? data.durationSeconds.value : this.durationSeconds, + complete: data.complete.present ? data.complete.value : this.complete, + setLogs: data.setLogs.present ? data.setLogs.value : this.setLogs, + ); + } + + @override + String toString() { + return (StringBuffer('SessionRow(') + ..write('id: $id, ') + ..write('mode: $mode, ') + ..write('planId: $planId, ') + ..write('planName: $planName, ') + ..write('items: $items, ') + ..write('startedAt: $startedAt, ') + ..write('endedAt: $endedAt, ') + ..write('durationSeconds: $durationSeconds, ') + ..write('complete: $complete, ') + ..write('setLogs: $setLogs') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, mode, planId, planName, items, startedAt, endedAt, durationSeconds, complete, setLogs); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SessionRow && + other.id == this.id && + other.mode == this.mode && + other.planId == this.planId && + other.planName == this.planName && + other.items == this.items && + other.startedAt == this.startedAt && + other.endedAt == this.endedAt && + other.durationSeconds == this.durationSeconds && + other.complete == this.complete && + other.setLogs == this.setLogs); +} + +class SessionsCompanion extends UpdateCompanion { + final Value id; + final Value mode; + final Value planId; + final Value planName; + final Value> items; + final Value startedAt; + final Value endedAt; + final Value durationSeconds; + final Value complete; + final Value> setLogs; + final Value rowid; + const SessionsCompanion({ + this.id = const Value.absent(), + this.mode = const Value.absent(), + this.planId = const Value.absent(), + this.planName = const Value.absent(), + this.items = const Value.absent(), + this.startedAt = const Value.absent(), + this.endedAt = const Value.absent(), + this.durationSeconds = const Value.absent(), + this.complete = const Value.absent(), + this.setLogs = const Value.absent(), + this.rowid = const Value.absent(), + }); + SessionsCompanion.insert({ + required String id, + required String mode, + required String planId, + required String planName, + required List items, + required DateTime startedAt, + required DateTime endedAt, + required int durationSeconds, + required bool complete, + required List setLogs, + this.rowid = const Value.absent(), + }) : id = Value(id), + mode = Value(mode), + planId = Value(planId), + planName = Value(planName), + items = Value(items), + startedAt = Value(startedAt), + endedAt = Value(endedAt), + durationSeconds = Value(durationSeconds), + complete = Value(complete), + setLogs = Value(setLogs); + static Insertable custom({ + Expression? id, + Expression? mode, + Expression? planId, + Expression? planName, + Expression? items, + Expression? startedAt, + Expression? endedAt, + Expression? durationSeconds, + Expression? complete, + Expression? setLogs, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (mode != null) 'mode': mode, + if (planId != null) 'plan_id': planId, + if (planName != null) 'plan_name': planName, + if (items != null) 'items': items, + if (startedAt != null) 'started_at': startedAt, + if (endedAt != null) 'ended_at': endedAt, + if (durationSeconds != null) 'duration_seconds': durationSeconds, + if (complete != null) 'complete': complete, + if (setLogs != null) 'set_logs': setLogs, + if (rowid != null) 'rowid': rowid, + }); + } + + SessionsCompanion copyWith({ + Value? id, + Value? mode, + Value? planId, + Value? planName, + Value>? items, + Value? startedAt, + Value? endedAt, + Value? durationSeconds, + Value? complete, + Value>? setLogs, + Value? rowid, + }) { + return SessionsCompanion( + id: id ?? this.id, + mode: mode ?? this.mode, + planId: planId ?? this.planId, + planName: planName ?? this.planName, + items: items ?? this.items, + startedAt: startedAt ?? this.startedAt, + endedAt: endedAt ?? this.endedAt, + durationSeconds: durationSeconds ?? this.durationSeconds, + complete: complete ?? this.complete, + setLogs: setLogs ?? this.setLogs, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (mode.present) { + map['mode'] = Variable(mode.value); + } + if (planId.present) { + map['plan_id'] = Variable(planId.value); + } + if (planName.present) { + map['plan_name'] = Variable(planName.value); + } + if (items.present) { + map['items'] = Variable($SessionsTable.$converteritems.toSql(items.value)); + } + if (startedAt.present) { + map['started_at'] = Variable(startedAt.value); + } + if (endedAt.present) { + map['ended_at'] = Variable(endedAt.value); + } + if (durationSeconds.present) { + map['duration_seconds'] = Variable(durationSeconds.value); + } + if (complete.present) { + map['complete'] = Variable(complete.value); + } + if (setLogs.present) { + map['set_logs'] = Variable($SessionsTable.$convertersetLogs.toSql(setLogs.value)); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SessionsCompanion(') + ..write('id: $id, ') + ..write('mode: $mode, ') + ..write('planId: $planId, ') + ..write('planName: $planName, ') + ..write('items: $items, ') + ..write('startedAt: $startedAt, ') + ..write('endedAt: $endedAt, ') + ..write('durationSeconds: $durationSeconds, ') + ..write('complete: $complete, ') + ..write('setLogs: $setLogs, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); late final $PlansTable plans = $PlansTable(this); + late final $SessionsTable sessions = $SessionsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [plans]; + List get allSchemaEntities => [plans, sessions]; } typedef $$PlansTableCreateCompanionBuilder = @@ -596,9 +1118,241 @@ typedef $$PlansTableProcessedTableManager = PlanRow, PrefetchHooks Function() >; +typedef $$SessionsTableCreateCompanionBuilder = + SessionsCompanion Function({ + required String id, + required String mode, + required String planId, + required String planName, + required List items, + required DateTime startedAt, + required DateTime endedAt, + required int durationSeconds, + required bool complete, + required List setLogs, + Value rowid, + }); +typedef $$SessionsTableUpdateCompanionBuilder = + SessionsCompanion Function({ + Value id, + Value mode, + Value planId, + Value planName, + Value> items, + Value startedAt, + Value endedAt, + Value durationSeconds, + Value complete, + Value> setLogs, + Value rowid, + }); + +class $$SessionsTableFilterComposer extends Composer<_$AppDatabase, $SessionsTable> { + $$SessionsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get mode => $composableBuilder(column: $table.mode, builder: (column) => ColumnFilters(column)); + + ColumnFilters get planId => + $composableBuilder(column: $table.planId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get planName => + $composableBuilder(column: $table.planName, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get items => + $composableBuilder(column: $table.items, builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get startedAt => + $composableBuilder(column: $table.startedAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get endedAt => + $composableBuilder(column: $table.endedAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get durationSeconds => + $composableBuilder(column: $table.durationSeconds, builder: (column) => ColumnFilters(column)); + + ColumnFilters get complete => + $composableBuilder(column: $table.complete, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get setLogs => + $composableBuilder(column: $table.setLogs, builder: (column) => ColumnWithTypeConverterFilters(column)); +} + +class $$SessionsTableOrderingComposer extends Composer<_$AppDatabase, $SessionsTable> { + $$SessionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get mode => + $composableBuilder(column: $table.mode, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get planId => + $composableBuilder(column: $table.planId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get planName => + $composableBuilder(column: $table.planName, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get items => + $composableBuilder(column: $table.items, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get startedAt => + $composableBuilder(column: $table.startedAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get endedAt => + $composableBuilder(column: $table.endedAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get durationSeconds => + $composableBuilder(column: $table.durationSeconds, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get complete => + $composableBuilder(column: $table.complete, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get setLogs => + $composableBuilder(column: $table.setLogs, builder: (column) => ColumnOrderings(column)); +} + +class $$SessionsTableAnnotationComposer extends Composer<_$AppDatabase, $SessionsTable> { + $$SessionsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get mode => $composableBuilder(column: $table.mode, builder: (column) => column); + + GeneratedColumn get planId => $composableBuilder(column: $table.planId, builder: (column) => column); + + GeneratedColumn get planName => $composableBuilder(column: $table.planName, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get items => + $composableBuilder(column: $table.items, builder: (column) => column); + + GeneratedColumn get startedAt => $composableBuilder(column: $table.startedAt, builder: (column) => column); + + GeneratedColumn get endedAt => $composableBuilder(column: $table.endedAt, builder: (column) => column); + + GeneratedColumn get durationSeconds => + $composableBuilder(column: $table.durationSeconds, builder: (column) => column); + + GeneratedColumn get complete => $composableBuilder(column: $table.complete, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get setLogs => + $composableBuilder(column: $table.setLogs, builder: (column) => column); +} + +class $$SessionsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $SessionsTable, + SessionRow, + $$SessionsTableFilterComposer, + $$SessionsTableOrderingComposer, + $$SessionsTableAnnotationComposer, + $$SessionsTableCreateCompanionBuilder, + $$SessionsTableUpdateCompanionBuilder, + (SessionRow, BaseReferences<_$AppDatabase, $SessionsTable, SessionRow>), + SessionRow, + PrefetchHooks Function() + > { + $$SessionsTableTableManager(_$AppDatabase db, $SessionsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$SessionsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$SessionsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$SessionsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value mode = const Value.absent(), + Value planId = const Value.absent(), + Value planName = const Value.absent(), + Value> items = const Value.absent(), + Value startedAt = const Value.absent(), + Value endedAt = const Value.absent(), + Value durationSeconds = const Value.absent(), + Value complete = const Value.absent(), + Value> setLogs = const Value.absent(), + Value rowid = const Value.absent(), + }) => SessionsCompanion( + id: id, + mode: mode, + planId: planId, + planName: planName, + items: items, + startedAt: startedAt, + endedAt: endedAt, + durationSeconds: durationSeconds, + complete: complete, + setLogs: setLogs, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String mode, + required String planId, + required String planName, + required List items, + required DateTime startedAt, + required DateTime endedAt, + required int durationSeconds, + required bool complete, + required List setLogs, + Value rowid = const Value.absent(), + }) => SessionsCompanion.insert( + id: id, + mode: mode, + planId: planId, + planName: planName, + items: items, + startedAt: startedAt, + endedAt: endedAt, + durationSeconds: durationSeconds, + complete: complete, + setLogs: setLogs, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SessionsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $SessionsTable, + SessionRow, + $$SessionsTableFilterComposer, + $$SessionsTableOrderingComposer, + $$SessionsTableAnnotationComposer, + $$SessionsTableCreateCompanionBuilder, + $$SessionsTableUpdateCompanionBuilder, + (SessionRow, BaseReferences<_$AppDatabase, $SessionsTable, SessionRow>), + SessionRow, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; $AppDatabaseManager(this._db); $$PlansTableTableManager get plans => $$PlansTableTableManager(_db, _db.plans); + $$SessionsTableTableManager get sessions => $$SessionsTableTableManager(_db, _db.sessions); } diff --git a/lib/src/design/app_theme.dart b/lib/src/design/app_theme.dart index affee07..a041f59 100644 --- a/lib/src/design/app_theme.dart +++ b/lib/src/design/app_theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../modes/mode.dart'; import 'zen_style.dart'; @@ -53,6 +54,7 @@ class AppTheme { labelLarge: text.labelLarge?.copyWith(fontWeight: FontWeight.w700), ), appBarTheme: AppBarTheme( + systemOverlayStyle: workout ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, backgroundColor: s.background, surfaceTintColor: Colors.transparent, foregroundColor: s.ink, diff --git a/lib/src/history/session_repository.dart b/lib/src/history/session_repository.dart new file mode 100644 index 0000000..dfe71c9 --- /dev/null +++ b/lib/src/history/session_repository.dart @@ -0,0 +1,86 @@ +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import '../database/database.dart'; +import '../modes/mode.dart'; +import '../plans/models/plan.dart'; +import '../player/session_runner.dart'; + +/// A Session as History shows it. +class Session { + const Session({ + required this.id, + required this.mode, + required this.planId, + required this.planName, + required this.items, + required this.startedAt, + required this.endedAt, + required this.duration, + required this.complete, + required this.setLogs, + }); + + final String id; + final Mode mode; + final String planId; + final String planName; + + /// The Plan's Steps and Supersets as they were when the Session ran. + final List items; + final DateTime startedAt; + final DateTime endedAt; + final Duration duration; + final bool complete; + final List setLogs; +} + +/// Finished Sessions, per Mode. +class SessionRepository { + SessionRepository(this._db); + + final AppDatabase _db; + + Future save(SessionResult result) async { + final row = SessionsCompanion.insert( + id: const Uuid().v4(), + mode: result.plan.mode.name, + planId: result.plan.id, + planName: result.plan.name, + items: result.plan.items, + startedAt: result.startedAt, + endedAt: result.endedAt, + durationSeconds: result.duration.inSeconds, + complete: result.complete, + setLogs: result.setLogs, + ); + await _db.into(_db.sessions).insert(row); + return (await get(row.id.value))!; + } + + Future get(String id) async { + final row = await (_db.select(_db.sessions)..where((s) => s.id.equals(id))).getSingleOrNull(); + return row == null ? null : _toSession(row); + } + + /// A Mode's Sessions, the latest first. + Stream> watchSessions(Mode mode) { + final query = _db.select(_db.sessions) + ..where((s) => s.mode.equals(mode.name)) + ..orderBy([(s) => OrderingTerm.desc(s.endedAt)]); + return query.watch().map((rows) => rows.map(_toSession).toList()); + } + + static Session _toSession(SessionRow row) => Session( + id: row.id, + mode: Mode.fromName(row.mode), + planId: row.planId, + planName: row.planName, + items: row.items, + startedAt: row.startedAt, + endedAt: row.endedAt, + duration: Duration(seconds: row.durationSeconds), + complete: row.complete, + setLogs: row.setLogs, + ); +} diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index a26aa32..ac4ea6c 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -237,5 +237,95 @@ } }, "addToPlan": "Add to plan", - "addToPlanTitle": "Add to which plan?" + "addToPlanTitle": "Add to which plan?", + "start": "Start", + "startPlan": "Start", + "playerClose": "End session", + "mute": "Mute sounds", + "unmute": "Turn sounds on", + "pause": "Pause", + "resume": "Resume", + "skip": "Skip", + "previous": "Previous", + "paused": "Paused", + "progress": "{done} of {total}", + "@progress": { + "placeholders": { + "done": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "transitionGetReady": "Get ready", + "transitionSwitchSides": "Switch sides", + "transitionNextRound": "Round {round}", + "@transitionNextRound": { + "placeholders": { + "round": { + "type": "int" + } + } + }, + "transitionNextExercise": "Next up", + "sideLeft": "Left side", + "sideRight": "Right side", + "roundOf": "Round {round} of {rounds}", + "@roundOf": { + "placeholders": { + "round": { + "type": "int" + }, + "rounds": { + "type": "int" + } + } + }, + "setOf": "Set {set} of {sets}", + "@setOf": { + "placeholders": { + "set": { + "type": "int" + }, + "sets": { + "type": "int" + } + } + }, + "setDone": "Set done", + "doneEarly": "Done early", + "saveSet": "Save set", + "logSetTitle": "How did it go?", + "restTitle": "Rest", + "restNext": "Next", + "startNextSet": "Start next set", + "endSessionTitle": "End this session?", + "endSessionBody": "What you did so far is saved as unfinished. Only finished sessions count toward your streak.", + "keepGoing": "Keep going", + "endSession": "End session", + "summaryComplete": "Session complete", + "summaryIncomplete": "Ended early", + "summaryDuration": "Time", + "summaryExercises": "Exercises", + "summarySets": "Sets logged", + "summaryLogTitle": "What you did", + "setLogLine": "Set {set}", + "@setLogLine": { + "placeholders": { + "set": { + "type": "int" + } + } + }, + "durationMinutes": "{minutes, plural, =0{under a minute} =1{1 min} other{{minutes} min}}", + "@durationMinutes": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "logWeight": "Add a weight" } diff --git a/lib/src/localization/app_localizations.dart b/lib/src/localization/app_localizations.dart index 6278034..c70b0e9 100644 --- a/lib/src/localization/app_localizations.dart +++ b/lib/src/localization/app_localizations.dart @@ -61,8 +61,7 @@ import 'app_localizations_en.dart'; /// be consistent with the languages listed in the AppLocalizations.supportedLocales /// property. abstract class AppLocalizations { - AppLocalizations(String locale) - : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -70,8 +69,7 @@ abstract class AppLocalizations { return Localizations.of(context, AppLocalizations)!; } - static const LocalizationsDelegate delegate = - _AppLocalizationsDelegate(); + static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); /// A list of this localizations delegate along with the default localizations /// delegates. @@ -83,13 +81,12 @@ abstract class AppLocalizations { /// Additional delegates can be added by appending to this list in /// MaterialApp. This list does not have to be used at all if a custom list /// of delegates is preferred or required. - static const List> localizationsDelegates = - >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; + static const List> localizationsDelegates = >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; /// A list of this localizations delegate's supported locales. static const List supportedLocales = [Locale('en')]; @@ -609,10 +606,243 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Add to which plan?'** String get addToPlanTitle; + + /// No description provided for @start. + /// + /// In en, this message translates to: + /// **'Start'** + String get start; + + /// No description provided for @startPlan. + /// + /// In en, this message translates to: + /// **'Start'** + String get startPlan; + + /// No description provided for @playerClose. + /// + /// In en, this message translates to: + /// **'End session'** + String get playerClose; + + /// No description provided for @mute. + /// + /// In en, this message translates to: + /// **'Mute sounds'** + String get mute; + + /// No description provided for @unmute. + /// + /// In en, this message translates to: + /// **'Turn sounds on'** + String get unmute; + + /// No description provided for @pause. + /// + /// In en, this message translates to: + /// **'Pause'** + String get pause; + + /// No description provided for @resume. + /// + /// In en, this message translates to: + /// **'Resume'** + String get resume; + + /// No description provided for @skip. + /// + /// In en, this message translates to: + /// **'Skip'** + String get skip; + + /// No description provided for @previous. + /// + /// In en, this message translates to: + /// **'Previous'** + String get previous; + + /// No description provided for @paused. + /// + /// In en, this message translates to: + /// **'Paused'** + String get paused; + + /// No description provided for @progress. + /// + /// In en, this message translates to: + /// **'{done} of {total}'** + String progress(int done, int total); + + /// No description provided for @transitionGetReady. + /// + /// In en, this message translates to: + /// **'Get ready'** + String get transitionGetReady; + + /// No description provided for @transitionSwitchSides. + /// + /// In en, this message translates to: + /// **'Switch sides'** + String get transitionSwitchSides; + + /// No description provided for @transitionNextRound. + /// + /// In en, this message translates to: + /// **'Round {round}'** + String transitionNextRound(int round); + + /// No description provided for @transitionNextExercise. + /// + /// In en, this message translates to: + /// **'Next up'** + String get transitionNextExercise; + + /// No description provided for @sideLeft. + /// + /// In en, this message translates to: + /// **'Left side'** + String get sideLeft; + + /// No description provided for @sideRight. + /// + /// In en, this message translates to: + /// **'Right side'** + String get sideRight; + + /// No description provided for @roundOf. + /// + /// In en, this message translates to: + /// **'Round {round} of {rounds}'** + String roundOf(int round, int rounds); + + /// No description provided for @setOf. + /// + /// In en, this message translates to: + /// **'Set {set} of {sets}'** + String setOf(int set, int sets); + + /// No description provided for @setDone. + /// + /// In en, this message translates to: + /// **'Set done'** + String get setDone; + + /// No description provided for @doneEarly. + /// + /// In en, this message translates to: + /// **'Done early'** + String get doneEarly; + + /// No description provided for @saveSet. + /// + /// In en, this message translates to: + /// **'Save set'** + String get saveSet; + + /// No description provided for @logSetTitle. + /// + /// In en, this message translates to: + /// **'How did it go?'** + String get logSetTitle; + + /// No description provided for @restTitle. + /// + /// In en, this message translates to: + /// **'Rest'** + String get restTitle; + + /// No description provided for @restNext. + /// + /// In en, this message translates to: + /// **'Next'** + String get restNext; + + /// No description provided for @startNextSet. + /// + /// In en, this message translates to: + /// **'Start next set'** + String get startNextSet; + + /// No description provided for @endSessionTitle. + /// + /// In en, this message translates to: + /// **'End this session?'** + String get endSessionTitle; + + /// No description provided for @endSessionBody. + /// + /// In en, this message translates to: + /// **'What you did so far is saved as unfinished. Only finished sessions count toward your streak.'** + String get endSessionBody; + + /// No description provided for @keepGoing. + /// + /// In en, this message translates to: + /// **'Keep going'** + String get keepGoing; + + /// No description provided for @endSession. + /// + /// In en, this message translates to: + /// **'End session'** + String get endSession; + + /// No description provided for @summaryComplete. + /// + /// In en, this message translates to: + /// **'Session complete'** + String get summaryComplete; + + /// No description provided for @summaryIncomplete. + /// + /// In en, this message translates to: + /// **'Ended early'** + String get summaryIncomplete; + + /// No description provided for @summaryDuration. + /// + /// In en, this message translates to: + /// **'Time'** + String get summaryDuration; + + /// No description provided for @summaryExercises. + /// + /// In en, this message translates to: + /// **'Exercises'** + String get summaryExercises; + + /// No description provided for @summarySets. + /// + /// In en, this message translates to: + /// **'Sets logged'** + String get summarySets; + + /// No description provided for @summaryLogTitle. + /// + /// In en, this message translates to: + /// **'What you did'** + String get summaryLogTitle; + + /// No description provided for @setLogLine. + /// + /// In en, this message translates to: + /// **'Set {set}'** + String setLogLine(int set); + + /// No description provided for @durationMinutes. + /// + /// In en, this message translates to: + /// **'{minutes, plural, =0{under a minute} =1{1 min} other{{minutes} min}}'** + String durationMinutes(int minutes); + + /// No description provided for @logWeight. + /// + /// In en, this message translates to: + /// **'Add a weight'** + String get logWeight; } -class _AppLocalizationsDelegate - extends LocalizationsDelegate { +class _AppLocalizationsDelegate extends LocalizationsDelegate { const _AppLocalizationsDelegate(); @override @@ -621,8 +851,7 @@ class _AppLocalizationsDelegate } @override - bool isSupported(Locale locale) => - ['en'].contains(locale.languageCode); + bool isSupported(Locale locale) => ['en'].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; diff --git a/lib/src/localization/app_localizations_en.dart b/lib/src/localization/app_localizations_en.dart index 7ea8745..a77ec24 100644 --- a/lib/src/localization/app_localizations_en.dart +++ b/lib/src/localization/app_localizations_en.dart @@ -38,10 +38,8 @@ class AppLocalizationsEn extends AppLocalizations { @override String plansEmptyBody(String mode) { String _temp0 = intl.Intl.selectLogic(mode, { - 'stretching': - 'Put together a stretch routine from the library and it will wait for you here.', - 'other': - 'Put together a workout from the library and it will wait for you here.', + 'stretching': 'Put together a stretch routine from the library and it will wait for you here.', + 'other': 'Put together a workout from the library and it will wait for you here.', }); return '$_temp0'; } @@ -51,22 +49,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String librarySubtitle(String mode, int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count stretches', - one: '1 stretch', - ); - String _temp1 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count exercises', - one: '1 exercise', - ); - String _temp2 = intl.Intl.selectLogic(mode, { - 'stretching': '$_temp0', - 'other': '$_temp1', - }); + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count stretches', one: '1 stretch'); + String _temp1 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count exercises', one: '1 exercise'); + String _temp2 = intl.Intl.selectLogic(mode, {'stretching': '$_temp0', 'other': '$_temp1'}); return '$_temp2'; } @@ -77,8 +62,7 @@ class AppLocalizationsEn extends AppLocalizations { String get historyEmptyTitle => 'Nothing logged yet'; @override - String get historyEmptyBody => - 'Finished sessions show up here with their date and duration.'; + String get historyEmptyBody => 'Finished sessions show up here with their date and duration.'; @override String librarySubtitleFiltered(String mode, int shown, int total) { @@ -91,10 +75,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String searchHint(String mode) { - String _temp0 = intl.Intl.selectLogic(mode, { - 'stretching': 'Search stretches', - 'other': 'Search exercises', - }); + String _temp0 = intl.Intl.selectLogic(mode, {'stretching': 'Search stretches', 'other': 'Search exercises'}); return '$_temp0'; } @@ -129,8 +110,7 @@ class AppLocalizationsEn extends AppLocalizations { String get noMatchesTitle => 'No matches'; @override - String get noMatchesBody => - 'Try fewer words, or clear the filters to see everything again.'; + String get noMatchesBody => 'Try fewer words, or clear the filters to see everything again.'; @override String get detailMainMuscles => 'Main muscles'; @@ -167,10 +147,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String planNameHint(String mode) { - String _temp0 = intl.Intl.selectLogic(mode, { - 'stretching': 'e.g. Morning stretch', - 'other': 'e.g. Push day', - }); + String _temp0 = intl.Intl.selectLogic(mode, {'stretching': 'e.g. Morning stretch', 'other': 'e.g. Push day'}); return '$_temp0'; } @@ -218,8 +195,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get deletePlanBody => - 'The plan is removed. Sessions you already finished stay in your history.'; + String get deletePlanBody => 'The plan is removed. Sessions you already finished stay in your history.'; @override String planExerciseCount(int count) { @@ -269,12 +245,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String repsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count reps', - one: '1 rep', - ); + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count reps', one: '1 rep'); return '$_temp0'; } @@ -298,23 +269,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String roundsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count rounds', - one: '1 round', - ); + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count rounds', one: '1 round'); return '$_temp0'; } @override String setsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count sets', - one: '1 set', - ); + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count sets', one: '1 set'); return '$_temp0'; } @@ -355,8 +316,7 @@ class AppLocalizationsEn extends AppLocalizations { String get restBetweenLabel => 'Rest between exercises'; @override - String get restBetweenHint => - 'When off, you go straight to the next exercise and rest after each round.'; + String get restBetweenHint => 'When off, you go straight to the next exercise and rest after each round.'; @override String get supersetWithNext => 'Superset with next'; @@ -385,4 +345,141 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addToPlanTitle => 'Add to which plan?'; + + @override + String get start => 'Start'; + + @override + String get startPlan => 'Start'; + + @override + String get playerClose => 'End session'; + + @override + String get mute => 'Mute sounds'; + + @override + String get unmute => 'Turn sounds on'; + + @override + String get pause => 'Pause'; + + @override + String get resume => 'Resume'; + + @override + String get skip => 'Skip'; + + @override + String get previous => 'Previous'; + + @override + String get paused => 'Paused'; + + @override + String progress(int done, int total) { + return '$done of $total'; + } + + @override + String get transitionGetReady => 'Get ready'; + + @override + String get transitionSwitchSides => 'Switch sides'; + + @override + String transitionNextRound(int round) { + return 'Round $round'; + } + + @override + String get transitionNextExercise => 'Next up'; + + @override + String get sideLeft => 'Left side'; + + @override + String get sideRight => 'Right side'; + + @override + String roundOf(int round, int rounds) { + return 'Round $round of $rounds'; + } + + @override + String setOf(int set, int sets) { + return 'Set $set of $sets'; + } + + @override + String get setDone => 'Set done'; + + @override + String get doneEarly => 'Done early'; + + @override + String get saveSet => 'Save set'; + + @override + String get logSetTitle => 'How did it go?'; + + @override + String get restTitle => 'Rest'; + + @override + String get restNext => 'Next'; + + @override + String get startNextSet => 'Start next set'; + + @override + String get endSessionTitle => 'End this session?'; + + @override + String get endSessionBody => + 'What you did so far is saved as unfinished. Only finished sessions count toward your streak.'; + + @override + String get keepGoing => 'Keep going'; + + @override + String get endSession => 'End session'; + + @override + String get summaryComplete => 'Session complete'; + + @override + String get summaryIncomplete => 'Ended early'; + + @override + String get summaryDuration => 'Time'; + + @override + String get summaryExercises => 'Exercises'; + + @override + String get summarySets => 'Sets logged'; + + @override + String get summaryLogTitle => 'What you did'; + + @override + String setLogLine(int set) { + return 'Set $set'; + } + + @override + String durationMinutes(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes min', + one: '1 min', + zero: 'under a minute', + ); + return '$_temp0'; + } + + @override + String get logWeight => 'Add a weight'; } diff --git a/lib/src/plans/views/plan_editor_view.dart b/lib/src/plans/views/plan_editor_view.dart index 341d06c..cb2d977 100644 --- a/lib/src/plans/views/plan_editor_view.dart +++ b/lib/src/plans/views/plan_editor_view.dart @@ -11,6 +11,7 @@ import '../../modes/mode.dart'; import '../models/plan.dart'; import '../plan_labels.dart'; import '../plan_repository.dart'; +import '../../player/views/player_view.dart'; import 'exercise_picker_view.dart'; import 'plans_view.dart'; import 'step_editor_sheet.dart'; @@ -50,7 +51,6 @@ class _Editor extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - final bottomInset = MediaQuery.paddingOf(context).bottom; final addButton = FilledButton.icon( onPressed: () => _addExercises(context), icon: const Icon(Icons.add_rounded), @@ -78,7 +78,7 @@ class _Editor extends StatelessWidget { ) : ReorderableListView.builder( buildDefaultDragHandles: false, - padding: EdgeInsets.only(bottom: 96 + bottomInset), + padding: const EdgeInsets.only(bottom: 24), header: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -111,12 +111,35 @@ class _Editor extends StatelessWidget { }, ), ), - floatingActionButton: plan.items.isEmpty + bottomNavigationBar: plan.items.isEmpty ? null - : FloatingActionButton.extended( - onPressed: () => _addExercises(context), - icon: const Icon(Icons.add_rounded), - label: Text(l10n.addExercises), + : SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + children: [ + SizedBox( + height: 52, + child: OutlinedButton.icon( + onPressed: () => _addExercises(context), + icon: const Icon(Icons.add_rounded), + label: Text(l10n.addExercises), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 52, + child: FilledButton.icon( + onPressed: () => startPlan(context, plan), + icon: const Icon(Icons.play_arrow_rounded), + label: Text(l10n.startPlan), + ), + ), + ), + ], + ), + ), ), ); } diff --git a/lib/src/plans/views/plans_view.dart b/lib/src/plans/views/plans_view.dart index 5cb3459..e82a790 100644 --- a/lib/src/plans/views/plans_view.dart +++ b/lib/src/plans/views/plans_view.dart @@ -10,6 +10,7 @@ import '../../settings/settings_controller.dart'; import '../models/plan.dart'; import '../plan_labels.dart'; import '../plan_repository.dart'; +import '../../player/views/player_view.dart'; import 'plan_dialogs.dart'; import 'plan_editor_view.dart'; @@ -98,6 +99,12 @@ class PlanCard extends StatelessWidget { ], ), ), + if (plan.items.isNotEmpty) + IconButton.filled( + tooltip: l10n.startPlan, + onPressed: () => startPlan(context, plan), + icon: const Icon(Icons.play_arrow_rounded), + ), PlanMenu(plan: plan), ], ), diff --git a/lib/src/player/player_effects.dart b/lib/src/player/player_effects.dart new file mode 100644 index 0000000..b47d028 --- /dev/null +++ b/lib/src/player/player_effects.dart @@ -0,0 +1,36 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../modes/mode.dart'; + +/// What the Player does to the device: the Mode's chime and keeping the +/// screen on. Tests swap in a silent version. +abstract interface class PlayerEffects { + Future chime(Mode mode); + + Future keepScreenOn(bool on); +} + +class DevicePlayerEffects implements PlayerEffects { + DevicePlayerEffects() { + // No audio focus: the chime plays over the user's music instead of pausing it. + _player.setAudioContext( + AudioContext( + android: const AudioContextAndroid( + audioFocus: AndroidAudioFocus.none, + usageType: AndroidUsageType.assistanceSonification, + contentType: AndroidContentType.sonification, + ), + ), + ); + _player.setReleaseMode(ReleaseMode.stop); + } + + final _player = AudioPlayer(); + + @override + Future chime(Mode mode) => _player.play(AssetSource('sounds/${mode.name}_cue.wav')); + + @override + Future keepScreenOn(bool on) => WakelockPlus.toggle(enable: on); +} diff --git a/lib/src/player/segments.dart b/lib/src/player/segments.dart new file mode 100644 index 0000000..20c878c --- /dev/null +++ b/lib/src/player/segments.dart @@ -0,0 +1,151 @@ +import '../modes/mode.dart'; +import '../plans/models/plan.dart'; + +/// One screen of the Player, in the order a Session runs through them. +sealed class Segment { + const Segment(); + + /// The Exercise this segment is about, or null for Transitions and Rests. + String? get exerciseId => null; +} + +enum Side { left, right } + +/// A Stretching hold, counting down and moving on by itself. +class HoldSegment extends Segment { + const HoldSegment({ + required this.exerciseId, + required this.seconds, + required this.side, + required this.round, + required this.rounds, + }); + + @override + final String exerciseId; + final int seconds; + + /// Null when the Step is not held on both sides. + final Side? side; + final int round; + final int rounds; +} + +enum TransitionKind { getReady, switchSides, nextRound, nextExercise } + +/// A short Stretching card between holds; the next hold starts when it ends. +class TransitionSegment extends Segment { + const TransitionSegment({required this.kind, required this.seconds}); + + final TransitionKind kind; + final int seconds; +} + +/// One Workout set. A rep set ends with "Set done"; a timed set counts down +/// once started. +class SetSegment extends Segment { + const SetSegment({ + required this.exerciseId, + required this.target, + required this.weightKg, + required this.set, + required this.sets, + }); + + @override + final String exerciseId; + final WorkoutTarget target; + final double? weightKg; + + /// This set's number and the total, counted per Step or Superset round. + final int set; + final int sets; +} + +/// The Workout Rest Screen: counts up until the user starts the next set. +class RestSegment extends Segment { + const RestSegment(); +} + +/// Lays a Plan out as the Player will run it. +List buildSegments(Plan plan) => switch (plan.mode) { + Mode.stretching => _stretching(plan), + Mode.workout => _workout(plan), +}; + +List _stretching(Plan plan) { + final segments = []; + final pause = plan.transitionSeconds; + void transition(TransitionKind kind) { + if (pause > 0) segments.add(TransitionSegment(kind: kind, seconds: pause)); + } + + for (final (i, step) in plan.items.whereType().indexed) { + for (var round = 1; round <= step.rounds; round++) { + final sides = step.bothSides ? Side.values : const [null]; + for (final (s, side) in sides.indexed) { + transition(switch ((i, round, s)) { + (0, 1, 0) => TransitionKind.getReady, + (_, 1, 0) => TransitionKind.nextExercise, + (_, _, 0) => TransitionKind.nextRound, + _ => TransitionKind.switchSides, + }); + segments.add( + HoldSegment( + exerciseId: step.exerciseId, + seconds: step.holdSeconds, + side: side, + round: round, + rounds: step.rounds, + ), + ); + } + } + } + return segments; +} + +List _workout(Plan plan) { + final segments = []; + // A Rest goes before every set but the first; within a Superset round only if it asks for one. + void add(SetSegment set, {required bool restBefore}) { + if (segments.isNotEmpty && restBefore) segments.add(const RestSegment()); + segments.add(set); + } + + for (final item in plan.items) { + switch (item) { + case WorkoutStep(): + for (var set = 1; set <= item.sets; set++) { + add( + SetSegment( + exerciseId: item.exerciseId, + target: item.target, + weightKg: item.weightKg, + set: set, + sets: item.sets, + ), + restBefore: true, + ); + } + case Superset(): + for (var round = 1; round <= item.sets; round++) { + for (final (m, member) in item.members.indexed) { + add( + SetSegment( + exerciseId: member.exerciseId, + target: member.target, + weightKg: member.weightKg, + set: round, + sets: item.sets, + ), + restBefore: m == 0 || item.restBetween, + ); + } + } + case StretchingStep(): + break; + } + } + return segments; +} diff --git a/lib/src/player/session_runner.dart b/lib/src/player/session_runner.dart new file mode 100644 index 0000000..3f4c98e --- /dev/null +++ b/lib/src/player/session_runner.dart @@ -0,0 +1,241 @@ +import 'package:flutter/foundation.dart'; + +import '../plans/models/plan.dart'; +import 'segments.dart'; + +/// The reps or time and weight actually done for one Workout set. +@immutable +class SetLog { + const SetLog({required this.exerciseId, required this.set, this.reps, this.seconds, this.weightKg}); + + final String exerciseId; + final int set; + final int? reps; + final int? seconds; + final double? weightKg; + + Map toJson() => { + 'exerciseId': exerciseId, + 'set': set, + 'reps': reps, + 'seconds': seconds, + 'weightKg': weightKg, + }; + + factory SetLog.fromJson(Map json) => SetLog( + exerciseId: json['exerciseId']! as String, + set: json['set']! as int, + reps: json['reps'] as int?, + seconds: json['seconds'] as int?, + weightKg: (json['weightKg'] as num?)?.toDouble(), + ); +} + +/// What a finished Session hands over to History. +class SessionResult { + const SessionResult({ + required this.plan, + required this.startedAt, + required this.endedAt, + required this.duration, + required this.complete, + required this.setLogs, + }); + + final Plan plan; + final DateTime startedAt; + final DateTime endedAt; + + /// Time spent, without pauses. + final Duration duration; + final bool complete; + final List setLogs; +} + +/// Runs a Plan's segments against a clock the caller supplies: holds, +/// Transitions and started timed sets end by themselves on [tick]; rep sets +/// and Rests wait for the user. [onCue] fires whenever a countdown ends. +class SessionRunner extends ChangeNotifier { + SessionRunner(this.plan, {required DateTime startedAt, this.onCue}) + : segments = buildSegments(plan), + _startedAt = startedAt { + _enter(0, startedAt); + } + + final Plan plan; + final List segments; + final VoidCallback? onCue; + final DateTime _startedAt; + + var _index = 0; + DateTime? _segmentStart; + DateTime? _pausedAt; + var _pausedInSegment = Duration.zero; + var _pausedTotal = Duration.zero; + final _logs = {}; + var _finished = false; + var _complete = false; + var _progress = false; + DateTime? _endedAt; + + int get index => _index; + Segment get current => segments[_index]; + Segment? get next => _index + 1 < segments.length ? segments[_index + 1] : null; + bool get isPaused => _pausedAt != null; + bool get isFinished => _finished; + bool get isComplete => _complete; + + /// Whether a hold or set has been done, so ending early is worth saving. + bool get hasProgress => _progress; + + /// A timed set that has not been started yet. + bool get isWaitingToStart => current is SetSegment && (current as SetSegment).target.isTimed && _segmentStart == null; + + /// The countdown's length for holds, Transitions and timed sets; null otherwise. + Duration? get countdown => switch (current) { + HoldSegment(:final seconds) || TransitionSegment(:final seconds) => Duration(seconds: seconds), + SetSegment(:final target) when target.isTimed => Duration(seconds: target.seconds!), + _ => null, + }; + + /// Time on the current segment so far, without pauses. + Duration elapsed(DateTime now) { + final start = _segmentStart; + if (start == null) return Duration.zero; + final until = _pausedAt ?? now; + return until.difference(start) - _pausedInSegment; + } + + Duration remaining(DateTime now) { + final length = countdown ?? Duration.zero; + final left = length - elapsed(now); + return left.isNegative ? Duration.zero : left; + } + + void tick(DateTime now) { + if (_finished || isPaused || _segmentStart == null) return; + final length = countdown; + if (length == null || elapsed(now) < length) return; + final segment = current; + if (segment is HoldSegment) _progress = true; + if (segment is SetSegment) { + _progress = true; + _logs[_index] = SetLog( + exerciseId: segment.exerciseId, + set: segment.set, + seconds: segment.target.seconds, + weightKg: segment.weightKg, + ); + } + onCue?.call(); + _advance(now); + } + + void pause(DateTime now) { + if (isPaused || _finished) return; + _pausedAt = now; + notifyListeners(); + } + + void resume(DateTime now) { + final pausedAt = _pausedAt; + if (pausedAt == null) return; + final paused = now.difference(pausedAt); + _pausedInSegment += paused; + _pausedTotal += paused; + _pausedAt = null; + notifyListeners(); + } + + /// Logs a rep set as done (or a timed set ended early) and moves on. + void completeSet({int? reps, int? seconds, double? weightKg, required DateTime now}) { + final segment = current as SetSegment; + _logs[_index] = SetLog( + exerciseId: segment.exerciseId, + set: segment.set, + reps: segment.target.isTimed ? null : reps, + seconds: segment.target.isTimed ? (seconds ?? segment.target.seconds) : null, + weightKg: weightKg, + ); + _progress = true; + _advance(now); + } + + void startTimer(DateTime now) { + if (!isWaitingToStart) return; + _segmentStart = now; + notifyListeners(); + } + + /// Leaves the Rest Screen for the next set. + void startNext(DateTime now) => _advance(now); + + /// Moves on without logging anything. + void skip(DateTime now) { + if (_finished) return; + _resumeQuietly(now); + _advance(now); + } + + /// Returns to the start of the previous hold or set, passing over + /// Transitions and Rests; on the first one, restarts it. + void back(DateTime now) { + if (_finished) return; + _resumeQuietly(now); + var target = _index - 1; + while (target > 0 && (segments[target] is TransitionSegment || segments[target] is RestSegment)) { + target--; + } + if (target < 0) target = 0; + _enter(target, now); + notifyListeners(); + } + + void end(DateTime now) { + if (_finished) return; + _resumeQuietly(now); + _finish(now, complete: false); + } + + SessionResult result(DateTime now) { + final endedAt = _endedAt ?? now; + return SessionResult( + plan: plan, + startedAt: _startedAt, + endedAt: endedAt, + duration: endedAt.difference(_startedAt) - _pausedTotal, + complete: _complete, + setLogs: [for (final i in _logs.keys.toList()..sort()) _logs[i]!], + ); + } + + void _resumeQuietly(DateTime now) { + if (_pausedAt != null) { + _pausedTotal += now.difference(_pausedAt!); + _pausedAt = null; + } + } + + void _advance(DateTime now) { + if (_index + 1 >= segments.length) { + _finish(now, complete: true); + } else { + _enter(_index + 1, now); + notifyListeners(); + } + } + + /// Holds, Transitions and Rests start their clock on arrival; sets wait. + void _enter(int index, DateTime now) { + _index = index; + _pausedInSegment = Duration.zero; + _segmentStart = segments[index] is SetSegment ? null : now; + } + + void _finish(DateTime now, {required bool complete}) { + _finished = true; + _complete = complete; + _endedAt = now; + notifyListeners(); + } +} diff --git a/lib/src/player/views/player_view.dart b/lib/src/player/views/player_view.dart new file mode 100644 index 0000000..dfd895e --- /dev/null +++ b/lib/src/player/views/player_view.dart @@ -0,0 +1,729 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../design/number_stepper.dart'; +import '../../design/zen_style.dart'; +import '../../exercises/exercise_library.dart'; +import '../../exercises/views/flip_animation.dart'; +import '../../exercises/views/library_view.dart'; +import '../../history/session_repository.dart'; +import '../../localization/app_localizations.dart'; +import '../../plans/models/plan.dart'; +import '../../plans/plan_labels.dart'; +import '../../plans/plan_repository.dart'; +import '../../settings/settings_controller.dart'; +import '../player_effects.dart'; +import '../segments.dart'; +import '../session_runner.dart'; +import 'summary_view.dart'; + +/// Runs a Plan one segment at a time: Stretching holds and Transitions count +/// down and move on by themselves; Workout sets wait for "Set done" and the +/// Rest Screen counts up. The screen stays on while it is open. +class PlayerView extends StatefulWidget { + const PlayerView({super.key, required this.planId}); + + static const routeName = '/play'; + + final String planId; + + @override + State createState() => _PlayerViewState(); +} + +class _PlayerViewState extends State { + SessionRunner? _runner; + Timer? _ticker; + var _leaving = false; + late final PlayerEffects _effects = context.read(); + + @override + void initState() { + super.initState(); + _effects.keepScreenOn(true); + _start(); + } + + Future _start() async { + final plan = await context.read().get(widget.planId); + if (plan == null || !mounted) return; + final runner = SessionRunner(plan, startedAt: clock.now(), onCue: _chime)..addListener(_onChanged); + setState(() => _runner = runner); + _ticker = Timer.periodic(const Duration(milliseconds: 200), (_) { + runner.tick(clock.now()); + if (mounted) setState(() {}); + }); + } + + void _chime() { + final runner = _runner; + if (runner != null && !context.read().muted) _effects.chime(runner.plan.mode); + } + + void _onChanged() { + if (_runner!.isFinished) _leave(); + } + + /// Saves the Session and shows its summary. A Session in which no hold or + /// set was actually done (ended at once, or skipped through) is not worth + /// keeping and just closes the Player. + Future _leave() async { + if (_leaving) return; + _leaving = true; + _ticker?.cancel(); + final runner = _runner!; + final navigator = Navigator.of(context); + if (!runner.hasProgress) { + navigator.pop(); + return; + } + final session = await context.read().save(runner.result(clock.now())); + navigator.pushReplacementNamed(SummaryView.routeName, arguments: session.id); + } + + Future _confirmEnd() async { + final runner = _runner; + if (runner == null) { + Navigator.of(context).pop(); + return; + } + final l10n = AppLocalizations.of(context); + final end = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.endSessionTitle), + content: Text(l10n.endSessionBody), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(l10n.keepGoing)), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(l10n.endSession)), + ], + ), + ); + if (end ?? false) runner.end(clock.now()); + } + + @override + void dispose() { + _ticker?.cancel(); + _runner?.dispose(); + _effects.keepScreenOn(false); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final runner = _runner; + final s = ZenStyle.of(context); + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && !_leaving) _confirmEnd(); + }, + child: Scaffold( + body: runner == null + ? const SizedBox.shrink() + : Stack( + children: [ + _Pulse(on: _pulsing(runner), color: s.accent), + SafeArea( + child: Column( + children: [ + _TopBar(runner: runner, onClose: _confirmEnd), + Expanded( + child: AnimatedSwitcher( + duration: s.motion, + switchInCurve: s.curve, + switchOutCurve: s.curve, + child: KeyedSubtree(key: ValueKey(runner.index), child: _content(runner)), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: _Controls(runner: runner), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// The last three seconds of a running countdown. + bool _pulsing(SessionRunner runner) { + if (runner.countdown == null || runner.isPaused || runner.isWaitingToStart) return false; + final left = runner.remaining(clock.now()); + return left > Duration.zero && left <= const Duration(seconds: 3) && (left.inMilliseconds ~/ 500).isEven; + } + + Widget _content(SessionRunner runner) => switch (runner.current) { + final HoldSegment hold => _HoldContent(runner: runner, hold: hold), + final TransitionSegment transition => _TransitionContent(runner: runner, transition: transition), + final SetSegment set => _SetContent(runner: runner, set: set), + RestSegment() => _RestContent(runner: runner), + }; +} + +class _Pulse extends StatelessWidget { + const _Pulse({required this.on, required this.color}); + + final bool on; + final Color color; + + @override + Widget build(BuildContext context) => Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + opacity: on ? 0.16 : 0, + duration: const Duration(milliseconds: 300), + child: ColoredBox(color: color), + ), + ), + ); +} + +class _TopBar extends StatelessWidget { + const _TopBar({required this.runner, required this.onClose}); + + final SessionRunner runner; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + final settings = context.watch(); + final main = [ + for (final (i, seg) in runner.segments.indexed) + if (seg is HoldSegment || seg is SetSegment) i, + ]; + final done = main.where((i) => i < runner.index).length; + return Column( + children: [ + Row( + children: [ + IconButton(tooltip: l10n.playerClose, onPressed: onClose, icon: const Icon(Icons.close_rounded)), + Expanded( + child: Text( + runner.plan.name, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith(color: s.inkMuted), + ), + ), + IconButton( + tooltip: settings.muted ? l10n.unmute : l10n.mute, + onPressed: () => settings.updateMuted(!settings.muted), + icon: Icon(settings.muted ? Icons.volume_off_rounded : Icons.volume_up_rounded), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ClipRRect( + borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 3)), + child: LinearProgressIndicator( + value: main.isEmpty ? 0 : done / main.length, + minHeight: 6, + color: s.accent, + backgroundColor: s.surfaceSoft, + ), + ), + ), + ], + ); + } +} + +/// Image, name and a line of context above the segment's main figure. +class _ExerciseHeader extends StatelessWidget { + const _ExerciseHeader({required this.exerciseId, required this.caption}); + + final String exerciseId; + final String caption; + + @override + Widget build(BuildContext context) { + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + final exercise = context.read().byId(exerciseId); + return Column( + children: [ + if (exercise != null) + ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.28), + child: FlipAnimation(exercise: exercise), + ), + const SizedBox(height: 16), + Text( + exercise?.name ?? exerciseId, + textAlign: TextAlign.center, + style: text.headlineSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text(caption, style: text.bodyLarge?.copyWith(color: s.inkMuted)), + ], + ); + } +} + +class _HoldContent extends StatelessWidget { + const _HoldContent({required this.runner, required this.hold}); + + final SessionRunner runner; + final HoldSegment hold; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final caption = [ + if (hold.side != null) hold.side == Side.left ? l10n.sideLeft : l10n.sideRight, + if (hold.rounds > 1) l10n.roundOf(hold.round, hold.rounds), + ].join(' · '); + return _Centered( + children: [ + _ExerciseHeader(exerciseId: hold.exerciseId, caption: caption), + const SizedBox(height: 24), + _CountdownRing(runner: runner, size: 190), + ], + ); + } +} + +class _TransitionContent extends StatelessWidget { + const _TransitionContent({required this.runner, required this.transition}); + + final SessionRunner runner; + final TransitionSegment transition; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + final next = runner.next; + final title = switch (transition.kind) { + TransitionKind.getReady => l10n.transitionGetReady, + TransitionKind.switchSides => l10n.transitionSwitchSides, + TransitionKind.nextRound => l10n.transitionNextRound(next is HoldSegment ? next.round : 1), + TransitionKind.nextExercise => l10n.transitionNextExercise, + }; + final exercise = next?.exerciseId == null ? null : context.read().byId(next!.exerciseId!); + return _Centered( + children: [ + Text( + title, + style: text.displaySmall?.copyWith(color: s.accent), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + if (exercise != null) ...[ + ExerciseThumbnail(exercise: exercise, size: 120), + const SizedBox(height: 12), + Text(exercise.name, style: text.titleLarge, textAlign: TextAlign.center), + ], + const SizedBox(height: 28), + _CountdownRing(runner: runner, size: 120), + ], + ); + } +} + +class _SetContent extends StatelessWidget { + const _SetContent({required this.runner, required this.set}); + + final SessionRunner runner; + final SetSegment set; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + final target = [ + targetLabel(l10n, set.target), + if (set.weightKg != null) l10n.weightKg(kgNumber(set.weightKg!)), + ].join(' · '); + return _Centered( + children: [ + _ExerciseHeader(exerciseId: set.exerciseId, caption: l10n.setOf(set.set, set.sets)), + const SizedBox(height: 24), + if (set.target.isTimed && !runner.isWaitingToStart) + _CountdownRing(runner: runner, size: 170) + else + Text( + target, + style: text.displaySmall?.copyWith(color: s.accent), + textAlign: TextAlign.center, + ), + ], + ); + } +} + +class _RestContent extends StatelessWidget { + const _RestContent({required this.runner}); + + final SessionRunner runner; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + final next = runner.next; + final exercise = next is SetSegment ? context.read().byId(next.exerciseId) : null; + return _Centered( + children: [ + Text(l10n.restTitle, style: text.headlineMedium?.copyWith(color: s.inkMuted)), + const SizedBox(height: 8), + Text( + clockText(runner.elapsed(clock.now())), + style: text.displayLarge?.copyWith( + fontFamily: s.headingFont, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 32), + if (next is SetSegment && exercise != null) + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + ExerciseThumbnail(exercise: exercise, size: 64), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.restNext, style: text.bodySmall?.copyWith(color: s.inkMuted)), + Text(exercise.name, style: text.titleMedium, maxLines: 2, overflow: TextOverflow.ellipsis), + Text( + '${l10n.setOf(next.set, next.sets)} · ${targetLabel(l10n, next.target)}', + style: text.bodySmall?.copyWith(color: s.inkMuted), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _Centered extends StatelessWidget { + const _Centered({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) => Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column(mainAxisSize: MainAxisSize.min, children: children), + ), + ); +} + +/// "1:05". +String clockText(Duration d) => '${d.inMinutes}:${(d.inSeconds % 60).toString().padLeft(2, '0')}'; + +/// The current countdown as a draining ring with the seconds left inside. +class _CountdownRing extends StatelessWidget { + const _CountdownRing({required this.runner, required this.size}); + + final SessionRunner runner; + final double size; + + @override + Widget build(BuildContext context) { + final s = ZenStyle.of(context); + final total = runner.countdown ?? Duration.zero; + final left = runner.remaining(clock.now()); + final fraction = total == Duration.zero ? 0.0 : left.inMilliseconds / total.inMilliseconds; + final seconds = (left.inMilliseconds / 1000).ceil(); + return SizedBox.square( + dimension: size, + child: CustomPaint( + painter: _RingPainter( + fraction: fraction, + track: s.surfaceSoft, + colors: [s.accent, s.accentEnd], + round: s.controlRadius > 20, + ), + child: Center( + child: Text( + seconds >= 60 ? clockText(Duration(seconds: seconds)) : '$seconds', + style: TextStyle( + fontFamily: s.headingFont, + fontWeight: FontWeight.w800, + fontSize: size * 0.3, + color: runner.isPaused ? s.inkMuted : s.ink, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ), + ); + } +} + +class _RingPainter extends CustomPainter { + _RingPainter({required this.fraction, required this.track, required this.colors, required this.round}); + + final double fraction; + final Color track; + final List colors; + final bool round; + + @override + void paint(Canvas canvas, Size size) { + final stroke = size.width * 0.07; + final rect = Offset.zero & size; + final arc = rect.deflate(stroke / 2); + canvas.drawArc( + arc, + 0, + math.pi * 2, + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..color = track, + ); + if (fraction <= 0) return; + canvas.drawArc( + arc, + -math.pi / 2, + math.pi * 2 * fraction, + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = round ? StrokeCap.round : StrokeCap.butt + ..shader = SweepGradient( + colors: colors, + startAngle: -math.pi / 2, + endAngle: math.pi * 1.5, + transform: const GradientRotation(-math.pi / 2), + ).createShader(rect), + ); + } + + @override + bool shouldRepaint(_RingPainter old) => old.fraction != fraction || old.track != track || old.colors != colors; +} + +class _Controls extends StatelessWidget { + const _Controls({required this.runner}); + + final SessionRunner runner; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final segment = runner.current; + final back = IconButton.filledTonal( + tooltip: l10n.previous, + onPressed: () => runner.back(clock.now()), + icon: const Icon(Icons.skip_previous_rounded), + ); + final skip = IconButton.filledTonal( + tooltip: l10n.skip, + onPressed: () => runner.skip(clock.now()), + icon: const Icon(Icons.skip_next_rounded), + ); + final Widget main = switch (segment) { + RestSegment() => _BigButton(label: l10n.startNextSet, onPressed: () => runner.startNext(clock.now())), + SetSegment() when runner.isWaitingToStart => _BigButton( + label: l10n.start, + onPressed: () => runner.startTimer(clock.now()), + ), + final SetSegment set when !set.target.isTimed => _BigButton( + label: l10n.setDone, + onPressed: () => _logRepSet(context, set), + ), + _ => _PauseButton(runner: runner), + }; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (segment is SetSegment && segment.target.isTimed && !runner.isWaitingToStart) + TextButton( + onPressed: () => runner.completeSet( + seconds: runner.elapsed(clock.now()).inSeconds, + weightKg: segment.weightKg, + now: clock.now(), + ), + child: Text(l10n.doneEarly), + ), + Row( + children: [ + back, + const SizedBox(width: 16), + Expanded(child: Center(child: main)), + const SizedBox(width: 16), + if (segment is RestSegment) const SizedBox(width: 40) else skip, + ], + ), + ], + ); + } + + Future _logRepSet(BuildContext context, SetSegment set) async { + final logged = await showModalBottomSheet<(int, double?)>( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) => _SetLogSheet(set: set), + ); + if (logged != null && !runner.isFinished && runner.current == set) { + runner.completeSet(reps: logged.$1, weightKg: logged.$2, now: clock.now()); + } + } +} + +class _BigButton extends StatelessWidget { + const _BigButton({required this.label, required this.onPressed}); + + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final s = ZenStyle.of(context); + return SizedBox( + width: double.infinity, + height: 60, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: s.accentGradient, + borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 30)), + ), + child: FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.transparent, shadowColor: Colors.transparent), + onPressed: onPressed, + child: Text( + label, + style: TextStyle(fontFamily: s.headingFont, fontWeight: FontWeight.w800, fontSize: 20), + ), + ), + ), + ); + } +} + +class _PauseButton extends StatelessWidget { + const _PauseButton({required this.runner}); + + final SessionRunner runner; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + final paused = runner.isPaused; + return Semantics( + button: true, + label: paused ? l10n.resume : l10n.pause, + child: Material( + color: s.accent, + borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 36)), + child: InkWell( + borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 36)), + onTap: () => paused ? runner.resume(clock.now()) : runner.pause(clock.now()), + child: Tooltip( + message: paused ? l10n.resume : l10n.pause, + child: SizedBox.square( + dimension: 72, + child: Icon(paused ? Icons.play_arrow_rounded : Icons.pause_rounded, size: 40, color: s.onAccent), + ), + ), + ), + ), + ); + } +} + +/// Confirms a rep set: the target reps and weight come prefilled, so hitting +/// the target is a single tap. +class _SetLogSheet extends StatefulWidget { + const _SetLogSheet({required this.set}); + + final SetSegment set; + + @override + State<_SetLogSheet> createState() => _SetLogSheetState(); +} + +class _SetLogSheetState extends State<_SetLogSheet> { + late int _reps = widget.set.target.reps ?? 10; + late double? _kg = widget.set.weightKg; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final text = Theme.of(context).textTheme; + final kg = _kg; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.logSetTitle, style: text.titleLarge), + const SizedBox(height: 8), + NumberStepper( + label: l10n.stepReps, + value: '$_reps', + onDecrement: _reps > 0 ? () => setState(() => _reps--) : null, + onIncrement: _reps < 200 ? () => setState(() => _reps++) : null, + ), + if (kg != null) + NumberStepper( + label: l10n.stepWeight, + value: l10n.weightKg(kgNumber(kg)), + onDecrement: kg >= 2.5 ? () => setState(() => _kg = kg - 2.5) : null, + onIncrement: () => setState(() => _kg = kg + 2.5), + ) + else + Padding( + padding: const EdgeInsets.only(top: 8), + child: OutlinedButton.icon( + onPressed: () => setState(() => _kg = 20), + icon: const Icon(Icons.add_rounded), + label: Text(l10n.logWeight), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 52, + child: FilledButton(onPressed: () => Navigator.of(context).pop((_reps, _kg)), child: Text(l10n.saveSet)), + ), + ], + ), + ), + ); + } +} + +/// Lets the editor and the Plans list start a Plan, unless it has no Steps. +void startPlan(BuildContext context, Plan plan) { + if (plan.items.isEmpty) return; + Navigator.of(context).pushNamed(PlayerView.routeName, arguments: plan.id); +} diff --git a/lib/src/player/views/summary_view.dart b/lib/src/player/views/summary_view.dart new file mode 100644 index 0000000..ec6c973 --- /dev/null +++ b/lib/src/player/views/summary_view.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../design/zen_style.dart'; +import '../../exercises/exercise_library.dart'; +import '../../history/session_repository.dart'; +import '../../localization/app_localizations.dart'; +import '../../modes/mode.dart'; +import '../../plans/models/plan.dart'; +import '../../plans/plan_labels.dart'; +import '../session_runner.dart'; + +/// Shown when a Session ends: how long it took, what was done and, for +/// Workouts, every logged set. +class SummaryView extends StatelessWidget { + const SummaryView({super.key, required this.sessionId}); + + static const routeName = '/summary'; + + final String sessionId; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + return Scaffold( + body: FutureBuilder( + future: context.read().get(sessionId), + builder: (context, snapshot) { + final session = snapshot.data; + if (session == null) return const SizedBox.shrink(); + final exercises = { + for (final item in session.items) + ...switch (item) { + WorkoutStep(:final exerciseId) || StretchingStep(:final exerciseId) => [exerciseId], + Superset(:final members) => members.map((m) => m.exerciseId), + }, + }; + return SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 40, 20, 24), + children: [ + Icon( + session.complete ? Icons.check_circle_rounded : Icons.flag_rounded, + size: 56, + color: session.complete ? s.accent : s.inkMuted, + ), + const SizedBox(height: 16), + Text(session.complete ? l10n.summaryComplete : l10n.summaryIncomplete, style: text.headlineLarge), + const SizedBox(height: 4), + Text(session.planName, style: text.titleMedium?.copyWith(color: s.inkMuted)), + const SizedBox(height: 28), + Row( + children: [ + Expanded( + child: _Stat( + label: l10n.summaryDuration, + value: l10n.durationMinutes((session.duration.inSeconds / 60).round()), + ), + ), + const SizedBox(width: 10), + Expanded( + child: session.mode == Mode.workout + ? _Stat(label: l10n.summarySets, value: '${session.setLogs.length}') + : _Stat(label: l10n.summaryExercises, value: '${exercises.length}'), + ), + ], + ), + if (session.setLogs.isNotEmpty) ...[ + const SizedBox(height: 28), + Text(l10n.summaryLogTitle, style: text.titleMedium), + const SizedBox(height: 10), + for (final group in _byExercise(session.setLogs)) _LogGroup(logs: group), + ], + const SizedBox(height: 28), + SizedBox( + height: 52, + child: FilledButton(onPressed: () => Navigator.of(context).pop(), child: Text(l10n.done)), + ), + ], + ), + ); + }, + ), + ); + } + + /// Consecutive logs of the same Exercise together, as they were done. + static List> _byExercise(List logs) { + final groups = >[]; + for (final log in logs) { + if (groups.isNotEmpty && groups.last.first.exerciseId == log.exerciseId) { + groups.last.add(log); + } else { + groups.add([log]); + } + } + return groups; + } +} + +class _Stat extends StatelessWidget { + const _Stat({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(s.radius * 0.7)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: text.bodySmall?.copyWith(color: s.inkMuted)), + const SizedBox(height: 4), + Text(value, style: text.headlineSmall), + ], + ), + ); + } +} + +class _LogGroup extends StatelessWidget { + const _LogGroup({required this.logs}); + + final List logs; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final s = ZenStyle.of(context); + final text = Theme.of(context).textTheme; + final name = context.read().byId(logs.first.exerciseId)?.name ?? logs.first.exerciseId; + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: text.titleSmall), + for (final log in logs) + Text( + [ + l10n.setLogLine(log.set), + if (log.reps != null) l10n.repsCount(log.reps!), + if (log.seconds != null) l10n.secondsShort(log.seconds!), + if (log.weightKg != null) l10n.weightKg(kgNumber(log.weightKg!)), + ].join(' · '), + style: text.bodyMedium?.copyWith(color: s.inkMuted), + ), + ], + ), + ); + } +} diff --git a/lib/src/settings/settings_controller.dart b/lib/src/settings/settings_controller.dart index e2f0070..2d40e8f 100644 --- a/lib/src/settings/settings_controller.dart +++ b/lib/src/settings/settings_controller.dart @@ -6,11 +6,17 @@ import '../modes/mode.dart'; /// Stores the user's app preferences on the device. class SettingsService { static const _modeKey = 'mode'; + static const _mutedKey = 'muted'; /// The last-used Mode; Stretching on first launch. Future mode() async => Mode.fromName((await SharedPreferences.getInstance()).getString(_modeKey)); Future updateMode(Mode mode) async => (await SharedPreferences.getInstance()).setString(_modeKey, mode.name); + + /// Whether the Player's chime is muted; sound is on by default. + Future muted() async => (await SharedPreferences.getInstance()).getBool(_mutedKey) ?? false; + + Future updateMuted(bool muted) async => (await SharedPreferences.getInstance()).setBool(_mutedKey, muted); } class SettingsController extends ChangeNotifier { @@ -18,14 +24,23 @@ class SettingsController extends ChangeNotifier { final SettingsService _service; Mode _mode = Mode.stretching; + bool _muted = false; Mode get mode => _mode; + bool get muted => _muted; Future loadSettings() async { _mode = await _service.mode(); + _muted = await _service.muted(); notifyListeners(); } + Future updateMuted(bool muted) async { + _muted = muted; + notifyListeners(); + await _service.updateMuted(muted); + } + Future updateMode(Mode mode) async { if (mode == _mode) return; _mode = mode; diff --git a/pubspec.lock b/pubspec.lock index 1ddc957..75ec772 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 + url: "https://pub.dev" + source: hosted + version: "6.6.0" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + url: "https://pub.dev" + source: hosted + version: "6.4.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 + url: "https://pub.dev" + source: hosted + version: "5.2.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 + url: "https://pub.dev" + source: hosted + version: "4.3.0" boolean_selector: dependency: transitive description: @@ -122,7 +178,7 @@ packages: source: hosted version: "0.4.2" clock: - dependency: transitive + dependency: "direct main" description: name: clock sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b @@ -161,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" drift: dependency: "direct main" description: @@ -261,6 +325,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -389,6 +461,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -445,6 +533,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -650,6 +746,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -698,6 +802,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.dev" + source: hosted + version: "1.3.0" watcher: dependency: transitive description: @@ -730,6 +850,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -738,6 +866,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 33124e4..7fa59de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,8 @@ environment: sdk: ^3.8.1 dependencies: + audioplayers: ^6.1.0 + clock: ^1.1.1 drift: ^2.31.0 drift_flutter: ^0.2.8 flutter: @@ -17,6 +19,7 @@ dependencies: provider: ^6.1.5+1 shared_preferences: ^2.5.3 uuid: ^4.6.0 + wakelock_plus: ^1.4.0 dev_dependencies: flutter_test: @@ -33,6 +36,7 @@ flutter: assets: - assets/exercises/ - assets/exercises/images/ + - assets/sounds/ fonts: # Stretching Mode diff --git a/test/history/session_repository_test.dart b/test/history/session_repository_test.dart new file mode 100644 index 0000000..a010b66 --- /dev/null +++ b/test/history/session_repository_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zenxercise/src/database/database.dart'; +import 'package:zenxercise/src/history/session_repository.dart'; +import 'package:zenxercise/src/modes/mode.dart'; +import 'package:zenxercise/src/plans/models/plan.dart'; +import 'package:zenxercise/src/player/session_runner.dart'; + +import '../support/test_database.dart'; + +void main() { + late AppDatabase db; + late SessionRepository sessions; + + setUp(() { + db = testDatabase(); + sessions = SessionRepository(db); + }); + tearDown(() => db.close()); + + Plan plan(Mode mode) => Plan( + id: 'p1', + mode: mode, + name: 'Legs', + items: const [WorkoutStep(exerciseId: 'Squat', sets: 1, target: WorkoutTarget.reps(10))], + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + + SessionResult result(Mode mode, DateTime end, {bool complete = true}) => SessionResult( + plan: plan(mode), + startedAt: end.subtract(const Duration(minutes: 30)), + endedAt: end, + duration: const Duration(minutes: 28), + complete: complete, + setLogs: const [SetLog(exerciseId: 'Squat', set: 1, reps: 8, weightKg: 60)], + ); + + test('a saved Session keeps its Plan snapshot, logs and outcome', () async { + final saved = await sessions.save(result(Mode.workout, DateTime(2026, 10, 29, 19), complete: false)); + + expect(saved.planName, 'Legs'); + expect(saved.items.single, isA()); + expect(saved.duration, const Duration(minutes: 28)); + expect(saved.complete, isFalse); + expect(saved.setLogs.single.reps, 8); + expect(saved.setLogs.single.weightKg, 60); + }); + + test('Sessions are listed per Mode, the latest first', () async { + await sessions.save(result(Mode.workout, DateTime(2026, 10, 28))); + await sessions.save(result(Mode.workout, DateTime(2026, 10, 29))); + await sessions.save(result(Mode.stretching, DateTime(2026, 10, 30))); + + final workout = await sessions.watchSessions(Mode.workout).first; + + expect(workout.map((s) => s.endedAt.day), [29, 28]); + }); +} diff --git a/test/player/segments_test.dart b/test/player/segments_test.dart new file mode 100644 index 0000000..6d6172f --- /dev/null +++ b/test/player/segments_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zenxercise/src/modes/mode.dart'; +import 'package:zenxercise/src/plans/models/plan.dart'; +import 'package:zenxercise/src/player/segments.dart'; + +Plan _plan(Mode mode, List items, {int transition = 5}) => Plan( + id: 'p', + mode: mode, + name: 'P', + items: items, + transitionSeconds: transition, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), +); + +String _describe(Segment s) => switch (s) { + HoldSegment(:final exerciseId, :final side, :final round) => + 'hold $exerciseId${side == null ? '' : ' ${side.name}'} r$round', + TransitionSegment(:final kind, :final seconds) => '${kind.name} ${seconds}s', + SetSegment(:final exerciseId, :final set) => 'set $exerciseId $set', + RestSegment() => 'rest', +}; + +void main() { + group('Stretching', () { + test('holds every side and round with Transitions between, after a get-ready', () { + final segments = buildSegments( + _plan(Mode.stretching, const [ + StretchingStep(exerciseId: 'A', holdSeconds: 30, bothSides: true, rounds: 2), + StretchingStep(exerciseId: 'B', holdSeconds: 20, bothSides: false, rounds: 1), + ]), + ); + + expect(segments.map(_describe), [ + 'getReady 5s', + 'hold A left r1', + 'switchSides 5s', + 'hold A right r1', + 'nextRound 5s', + 'hold A left r2', + 'switchSides 5s', + 'hold A right r2', + 'nextExercise 5s', + 'hold B r1', + ]); + }); + + test('a pause of 0 s means no Transitions at all', () { + final segments = buildSegments( + _plan(Mode.stretching, const [ + StretchingStep(exerciseId: 'A', holdSeconds: 30, bothSides: true, rounds: 1), + ], transition: 0), + ); + + expect(segments.map(_describe), ['hold A left r1', 'hold A right r1']); + }); + }); + + group('Workout', () { + test('each set is followed by a Rest, except the very last', () { + final segments = buildSegments( + _plan(Mode.workout, const [ + WorkoutStep(exerciseId: 'A', sets: 2, target: WorkoutTarget.reps(10)), + WorkoutStep(exerciseId: 'B', sets: 1, target: WorkoutTarget.seconds(30)), + ]), + ); + + expect(segments.map(_describe), ['set A 1', 'rest', 'set A 2', 'rest', 'set B 1']); + }); + + test('a Superset alternates its members and rests after each round only', () { + final segments = buildSegments( + _plan(Mode.workout, const [ + Superset( + sets: 2, + restBetween: false, + members: [ + SupersetMember(exerciseId: 'A', target: WorkoutTarget.reps(10)), + SupersetMember(exerciseId: 'B', target: WorkoutTarget.reps(12)), + ], + ), + ]), + ); + + expect(segments.map(_describe), ['set A 1', 'set B 1', 'rest', 'set A 2', 'set B 2']); + }); + + test('with rest between, a Superset also rests between its members', () { + final segments = buildSegments( + _plan(Mode.workout, const [ + Superset( + sets: 1, + restBetween: true, + members: [ + SupersetMember(exerciseId: 'A', target: WorkoutTarget.reps(10)), + SupersetMember(exerciseId: 'B', target: WorkoutTarget.reps(12)), + ], + ), + ]), + ); + + expect(segments.map(_describe), ['set A 1', 'rest', 'set B 1']); + }); + + test('a set carries its target, weight and position in the Step', () { + final set = + buildSegments( + _plan(Mode.workout, const [ + WorkoutStep(exerciseId: 'A', sets: 3, target: WorkoutTarget.reps(8), weightKg: 40), + ]), + )[2] + as SetSegment; + + expect(set.target, const WorkoutTarget.reps(8)); + expect(set.weightKg, 40); + expect((set.set, set.sets), (2, 3)); + }); + }); +} diff --git a/test/player/session_runner_test.dart b/test/player/session_runner_test.dart new file mode 100644 index 0000000..c7f4df3 --- /dev/null +++ b/test/player/session_runner_test.dart @@ -0,0 +1,158 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zenxercise/src/modes/mode.dart'; +import 'package:zenxercise/src/plans/models/plan.dart'; +import 'package:zenxercise/src/player/segments.dart'; +import 'package:zenxercise/src/player/session_runner.dart'; + +Plan _plan(Mode mode, List items, {int transition = 5}) => Plan( + id: 'p', + mode: mode, + name: 'Plan', + items: items, + transitionSeconds: transition, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), +); + +final t0 = DateTime(2026, 10, 29, 7); +DateTime at(int seconds) => t0.add(Duration(seconds: seconds)); + +void main() { + group('Stretching runs by itself', () { + late SessionRunner runner; + var cues = 0; + + setUp(() { + cues = 0; + runner = SessionRunner( + _plan(Mode.stretching, const [StretchingStep(exerciseId: 'A', holdSeconds: 30, bothSides: true, rounds: 1)]), + startedAt: t0, + onCue: () => cues++, + ); + }); + + test('counting down a get-ready, two holds and the switch between them', () { + expect(runner.current, isA()); + expect(runner.remaining(at(2)), const Duration(seconds: 3)); + + runner.tick(at(5)); + expect(runner.current, isA().having((h) => h.side, 'side', Side.left)); + + runner.tick(at(35)); + expect(runner.current, isA().having((t) => t.kind, 'kind', TransitionKind.switchSides)); + + runner.tick(at(40)); + runner.tick(at(70)); + expect(runner.isFinished, isTrue); + expect(runner.isComplete, isTrue); + expect(cues, 4); + }); + + test('pausing stops the clock', () { + runner.tick(at(5)); + runner.pause(at(10)); + runner.tick(at(100)); + expect(runner.current, isA()); + expect(runner.remaining(at(100)), const Duration(seconds: 25)); + + runner.resume(at(100)); + expect(runner.remaining(at(110)), const Duration(seconds: 15)); + }); + + test('skipping and going back move between holds, passing over Transitions', () { + runner.skip(at(1)); + expect(runner.current, isA().having((h) => h.side, 'side', Side.left)); + runner.skip(at(2)); + runner.skip(at(3)); + expect(runner.current, isA().having((h) => h.side, 'side', Side.right)); + + runner.back(at(4)); + expect(runner.current, isA().having((h) => h.side, 'side', Side.left)); + expect(runner.remaining(at(4)), const Duration(seconds: 30)); + }); + + test('there is progress worth saving only once a hold has ended', () { + expect(runner.hasProgress, isFalse); + runner.tick(at(5)); + // Skipping the left side is not progress; the right side starts after its switch at 15 s. + runner.skip(at(10)); + runner.tick(at(15)); + expect(runner.hasProgress, isFalse); + runner.tick(at(45)); + expect(runner.hasProgress, isTrue); + }); + + test('ending early finishes incomplete', () { + runner.end(at(12)); + + expect(runner.isFinished, isTrue); + expect(runner.isComplete, isFalse); + expect(runner.result(at(12)).duration, const Duration(seconds: 12)); + }); + }); + + group('Workout runs at the user\'s pace', () { + final plan = _plan(Mode.workout, const [ + WorkoutStep(exerciseId: 'Squat', sets: 2, target: WorkoutTarget.reps(10), weightKg: 60), + WorkoutStep(exerciseId: 'Plank', sets: 1, target: WorkoutTarget.seconds(30)), + ]); + + test('a rep set waits for "Set done", then the Rest counts up until the next set starts', () { + final runner = SessionRunner(plan, startedAt: t0); + + runner.tick(at(600)); + expect(runner.current, isA()); + + runner.completeSet(reps: 8, weightKg: 62.5, now: at(40)); + expect(runner.hasProgress, isTrue); + expect(runner.current, isA()); + expect(runner.elapsed(at(112)), const Duration(seconds: 72)); + + runner.startNext(at(120)); + expect(runner.current, isA().having((s) => s.set, 'set', 2)); + }); + + test('a timed set starts on demand, counts down and logs itself', () { + var cues = 0; + final runner = SessionRunner(plan, startedAt: t0, onCue: () => cues++); + runner.completeSet(reps: 10, weightKg: 60, now: at(30)); + runner.startNext(at(90)); + runner.completeSet(reps: 10, weightKg: 60, now: at(120)); + runner.startNext(at(180)); + + expect(runner.isWaitingToStart, isTrue); + runner.tick(at(500)); + expect(runner.current, isA()); + + runner.startTimer(at(500)); + runner.tick(at(530)); + + expect(runner.isFinished, isTrue); + expect(cues, 1); + expect(runner.result(at(530)).setLogs.map((l) => (l.exerciseId, l.set, l.reps, l.seconds, l.weightKg)), [ + ('Squat', 1, 10, null, 60.0), + ('Squat', 2, 10, null, 60.0), + ('Plank', 1, null, 30, null), + ]); + }); + + test('redoing a set after going back replaces its log', () { + final runner = SessionRunner(plan, startedAt: t0); + runner.completeSet(reps: 6, weightKg: 60, now: at(30)); + + runner.back(at(40)); + runner.completeSet(reps: 10, weightKg: 60, now: at(60)); + + expect(runner.result(at(60)).setLogs.single.reps, 10); + }); + + test('a skipped set is not logged', () { + final runner = SessionRunner(plan, startedAt: t0); + runner.skip(at(10)); + runner.skip(at(11)); + + expect(runner.current, isA().having((s) => s.set, 'set', 2)); + expect(runner.result(at(11)).setLogs, isEmpty); + }); + }); +} diff --git a/test/support/test_app.dart b/test/support/test_app.dart index 07881a2..86dd117 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -6,7 +6,9 @@ import 'package:zenxercise/src/app.dart'; import 'package:zenxercise/src/exercises/exercise.dart'; import 'package:zenxercise/src/exercises/exercise_library.dart'; import 'package:zenxercise/src/modes/mode.dart'; +import 'package:zenxercise/src/history/session_repository.dart'; import 'package:zenxercise/src/plans/plan_repository.dart'; +import 'package:zenxercise/src/player/player_effects.dart'; import 'package:zenxercise/src/settings/settings_controller.dart'; import 'test_database.dart'; @@ -48,6 +50,18 @@ final sampleLibrary = ExerciseLibrary([ exercise('Barbell_Squat', Mode.workout, primary: const ['quadriceps'], equipment: 'barbell'), ]); +/// Counts chimes instead of playing them and leaves the screen alone. +class FakePlayerEffects implements PlayerEffects { + final chimes = []; + bool screenOn = false; + + @override + Future chime(Mode mode) async => chimes.add(mode); + + @override + Future keepScreenOn(bool on) async => screenOn = on; +} + /// A Pixel-sized screen instead of the default 800×600, so the floating /// navigation bar does not cover what a test taps. void usePhoneScreen(WidgetTester tester) { @@ -62,6 +76,8 @@ Future pumpApp( ExerciseLibrary? library, Map prefs = const {}, PlanRepository? plans, + SessionRepository? sessions, + PlayerEffects? effects, }) async { SharedPreferences.setMockInitialValues(prefs); final settings = SettingsController(SettingsService()); @@ -74,6 +90,8 @@ Future pumpApp( ChangeNotifierProvider.value(value: settings), Provider.value(value: library ?? sampleLibrary), Provider.value(value: plans ?? PlanRepository(db)), + Provider.value(value: sessions ?? SessionRepository(db)), + Provider.value(value: effects ?? FakePlayerEffects()), ], child: const ZenXerciseApp(), ), diff --git a/test/views/player_view_test.dart b/test/views/player_view_test.dart new file mode 100644 index 0000000..8b54f0d --- /dev/null +++ b/test/views/player_view_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zenxercise/src/history/session_repository.dart'; +import 'package:zenxercise/src/modes/mode.dart'; +import 'package:zenxercise/src/plans/models/plan.dart'; +import 'package:zenxercise/src/plans/plan_repository.dart'; +import 'package:zenxercise/src/player/views/player_view.dart'; + +import '../support/test_app.dart'; +import '../support/test_database.dart'; + +void main() { + late PlanRepository plans; + late SessionRepository sessions; + late FakePlayerEffects effects; + + setUp(() { + final db = testDatabase(); + addTearDown(db.close); + plans = PlanRepository(db); + sessions = SessionRepository(db); + effects = FakePlayerEffects(); + }); + + Future startPlan(WidgetTester tester, Mode mode, List items) async { + final plan = await plans.create(mode, 'Routine'); + await plans.save(plan.copyWith(items: items)); + usePhoneScreen(tester); + await pumpApp(tester, plans: plans, sessions: sessions, effects: effects, prefs: {'mode': mode.name}); + await tester.tap(find.byTooltip('Start')); + await tester.pump(); + await tester.pump(); + } + + /// Drift's streams need real time to deliver, outside the test's fake clock. + Future> sessionsOf(WidgetTester tester, Mode mode) async => + (await tester.runAsync(() => sessions.watchSessions(mode).first))!; + + /// The Player's ticker never lets frames settle, so it is pumped for a fixed time. + Future settle(WidgetTester tester) async { + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + } + + Future wait(WidgetTester tester, int seconds) async { + for (var i = 0; i < seconds * 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + } + + testWidgets('a Stretching Session counts down, chimes, keeps the screen on and ends on its summary', (tester) async { + await startPlan(tester, Mode.stretching, const [ + StretchingStep(exerciseId: 'Calf_Stretch', holdSeconds: 10, bothSides: false, rounds: 1), + ]); + + expect(find.text('Get ready'), findsOneWidget); + expect(effects.screenOn, isTrue); + + // After the get-ready and the 0.7 s crossfade into the hold. + await wait(tester, 5); + await settle(tester); + expect(find.text('Calf Stretch'), findsOneWidget); + expect(find.text('Get ready'), findsNothing); + + await wait(tester, 10); + await settle(tester); + + expect(find.text('Session complete'), findsOneWidget); + expect(effects.chimes, [Mode.stretching, Mode.stretching]); + expect(effects.screenOn, isFalse); + expect((await sessionsOf(tester, Mode.stretching)).single.complete, isTrue); + }); + + testWidgets('a Workout set is confirmed with its logged reps, then rested and repeated', (tester) async { + await startPlan(tester, Mode.workout, const [ + WorkoutStep(exerciseId: 'Barbell_Squat', sets: 2, target: WorkoutTarget.reps(10), weightKg: 60), + ]); + + expect(find.text('Set 1 of 2'), findsOneWidget); + expect(find.text('10 reps · 60 kg'), findsOneWidget); + + await tester.tap(find.text('Set done')); + await settle(tester); + await tester.tap(find.byTooltip('Reps −')); + await tester.pump(); + await tester.tap(find.text('Save set')); + await settle(tester); + + expect(find.text('Rest'), findsOneWidget); + await wait(tester, 3); + expect(find.text('0:03'), findsOneWidget); + + await tester.tap(find.text('Start next set')); + await settle(tester); + await tester.tap(find.text('Set done')); + await settle(tester); + await tester.tap(find.text('Save set')); + await settle(tester); + + expect(find.text('Session complete'), findsOneWidget); + expect(find.text('Set 1 · 9 reps · 60 kg'), findsOneWidget); + expect(find.text('Set 2 · 10 reps · 60 kg'), findsOneWidget); + }); + + testWidgets('ending early after a set saves an unfinished Session', (tester) async { + await startPlan(tester, Mode.workout, const [ + WorkoutStep(exerciseId: 'Barbell_Squat', sets: 3, target: WorkoutTarget.reps(10)), + ]); + await tester.tap(find.text('Set done')); + await settle(tester); + await tester.tap(find.text('Save set')); + await settle(tester); + + await tester.tap(find.byTooltip('End session')); + await settle(tester); + await tester.tap(find.text('End session').last); + await settle(tester); + + expect(find.text('Ended early'), findsOneWidget); + expect((await sessionsOf(tester, Mode.workout)).single.complete, isFalse); + }); + + testWidgets('skipping through everything leaves no Session behind', (tester) async { + await startPlan(tester, Mode.workout, const [ + WorkoutStep(exerciseId: 'Barbell_Squat', sets: 1, target: WorkoutTarget.reps(10)), + ]); + + await tester.tap(find.byTooltip('Skip')); + await settle(tester); + + expect(find.byType(PlayerView), findsNothing); + expect(await sessionsOf(tester, Mode.workout), isEmpty); + }); + + testWidgets('ending before anything is done leaves no Session behind', (tester) async { + await startPlan(tester, Mode.workout, const [ + WorkoutStep(exerciseId: 'Barbell_Squat', sets: 3, target: WorkoutTarget.reps(10)), + ]); + + await tester.tap(find.byTooltip('End session')); + await settle(tester); + await tester.tap(find.text('End session').last); + await settle(tester); + + expect(find.byType(PlayerView), findsNothing); + expect(await sessionsOf(tester, Mode.workout), isEmpty); + }); +} diff --git a/tools/sounds/make_cues.py b/tools/sounds/make_cues.py new file mode 100644 index 0000000..b7d7091 --- /dev/null +++ b/tools/sounds/make_cues.py @@ -0,0 +1,61 @@ +"""Synthesises the Player's two cue sounds into assets/sounds/ (standard library only). + +- stretching_cue.wav: a soft singing bowl, slow attack, long gentle decay. +- workout_cue.wav: a short, muted wooden "tok". + +Both are kept quiet (peak about -12 dBFS) so they sit under the user's music. +""" + +import math +import random +import struct +import wave +from pathlib import Path + +RATE = 22050 + + +def write(path: Path, samples: list[float]) -> None: + peak = max(abs(s) for s in samples) or 1.0 + scale = 0.25 / peak # about -12 dBFS + with wave.open(str(path), "wb") as out: + out.setnchannels(1) + out.setsampwidth(2) + out.setframerate(RATE) + out.writeframes(b"".join(struct.pack(" list[float]: + # Inharmonic partials of a bowl, each fading at its own pace, with a slight beat. + partials = [(392.0, 1.0, 1.4), (393.2, 0.6, 1.5), (1062.0, 0.35, 0.8), (2010.0, 0.12, 0.45)] + samples = [] + for n in range(int(seconds * RATE)): + t = n / RATE + attack = min(1.0, t / 0.04) + value = sum(a * math.exp(-t / decay) * math.sin(2 * math.pi * f * t) for f, a, decay in partials) + samples.append(attack * value) + return samples + + +def wood_tok(seconds: float = 0.12) -> list[float]: + rng = random.Random(7) + samples = [] + for n in range(int(seconds * RATE)): + t = n / RATE + body = math.exp(-t / 0.018) * (math.sin(2 * math.pi * 820 * t) + 0.4 * math.sin(2 * math.pi * 1640 * t)) + click = math.exp(-t / 0.002) * (rng.random() * 2 - 1) * 0.3 + samples.append(body + click) + return samples + + +def main() -> None: + out = Path(__file__).resolve().parents[2] / "assets" / "sounds" + out.mkdir(parents=True, exist_ok=True) + write(out / "stretching_cue.wav", singing_bowl()) + write(out / "workout_cue.wav", wood_tok()) + for f in sorted(out.glob("*.wav")): + print(f"{f.name}: {f.stat().st_size / 1000:.0f} kB") + + +if __name__ == "__main__": + main()