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 0000000..2ce5b4b Binary files /dev/null and b/assets/sounds/stretching_cue.wav differ diff --git a/assets/sounds/workout_cue.wav b/assets/sounds/workout_cue.wav new file mode 100644 index 0000000..231078f Binary files /dev/null and b/assets/sounds/workout_cue.wav differ 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()