Stage 4: guided Player for both Modes and the Session summary

- buildSegments lays a Plan out as holds with Transitions (get ready,
  switch sides, next round, next exercise) or sets with count-up Rests;
  Supersets alternate members and rest per round unless asked otherwise
- SessionRunner drives segments against an injected clock: auto-advance
  with a chime, pause, skip, back, rep sets confirmed with logged reps
  and kg, timed sets started on demand, early end
- Player screen: countdown ring, accent pulse in the last 3 s, Flip
  Animation, next-up cards, Rest Screen, mute toggle, screen kept on
- Sessions table (schema v2 migration) keeps a Plan snapshot and Set
  Logs; nothing is saved when no hold or set was done
- Summary with time, exercises or sets logged, and every Set Log
- Synthesised quiet cue sounds (tools/sounds), status bar per Mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-29 00:20:16 +01:00
1 parent bbdca95541
commit 971ff64117
28 files changed
+3465 -84

No files matched your search

+5 -3
View File
@@ -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. button. Nothing counts down. Timed sets start only when the user taps Start.
**Transition**: **Transition**:
In Stretching Mode, a short card between rounds, sides ("Switch sides") and Exercises, after which the In Stretching Mode, a short card before the first hold ("Get ready") and between rounds, sides
next hold starts automatically. Length set per Plan, 5 s by default. ("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 ## Sessions
**Session**: **Session**:
One run-through of a Plan in the Player. It stores a snapshot of the Plan, so later edits to the Plan 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 _Avoid_: Workout, run
**Player**: **Player**:
Binary file not shown.
Binary file not shown.
+4
View File
@@ -6,7 +6,9 @@ import 'package:provider/provider.dart';
import 'src/app.dart'; import 'src/app.dart';
import 'src/database/database.dart'; import 'src/database/database.dart';
import 'src/exercises/exercise_library.dart'; import 'src/exercises/exercise_library.dart';
import 'src/history/session_repository.dart';
import 'src/plans/plan_repository.dart'; import 'src/plans/plan_repository.dart';
import 'src/player/player_effects.dart';
import 'src/settings/settings_controller.dart'; import 'src/settings/settings_controller.dart';
void main() async { void main() async {
@@ -24,6 +26,8 @@ void main() async {
ChangeNotifierProvider.value(value: settings), ChangeNotifierProvider.value(value: settings),
Provider.value(value: library), Provider.value(value: library),
Provider.value(value: PlanRepository(db)), Provider.value(value: PlanRepository(db)),
Provider.value(value: SessionRepository(db)),
Provider<PlayerEffects>(create: (_) => DevicePlayerEffects()),
], ],
child: const ZenXerciseApp(), child: const ZenXerciseApp(),
), ),
+10
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -9,6 +10,8 @@ import 'home/home_view.dart';
import 'localization/app_localizations.dart'; import 'localization/app_localizations.dart';
import 'plans/views/exercise_picker_view.dart'; import 'plans/views/exercise_picker_view.dart';
import 'plans/views/plan_editor_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'; import 'settings/settings_controller.dart';
class ZenXerciseApp extends StatelessWidget { class ZenXerciseApp extends StatelessWidget {
@@ -32,12 +35,19 @@ class ZenXerciseApp extends StatelessWidget {
], ],
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle, 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<SystemUiOverlayStyle>(
value: style.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
child: child!,
),
onGenerateRoute: (settings) => MaterialPageRoute<Object?>( onGenerateRoute: (settings) => MaterialPageRoute<Object?>(
settings: settings, settings: settings,
builder: (context) => switch (settings.name) { builder: (context) => switch (settings.name) {
ExerciseDetailView.routeName => ExerciseDetailView(exerciseId: settings.arguments! as String), ExerciseDetailView.routeName => ExerciseDetailView(exerciseId: settings.arguments! as String),
PlanEditorView.routeName => PlanEditorView(planId: settings.arguments! as String), PlanEditorView.routeName => PlanEditorView(planId: settings.arguments! as String),
ExercisePickerView.routeName => ExercisePickerView(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(), _ => const HomeView(),
}, },
), ),
+46 -2
View File
@@ -1,6 +1,9 @@
import 'dart:convert';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import '../plans/models/plan.dart'; import '../plans/models/plan.dart';
import '../player/session_runner.dart';
part 'database.g.dart'; part 'database.g.dart';
@@ -32,10 +35,51 @@ class PlanItemsConverter extends TypeConverter<List<PlanItem>, String> {
String toSql(List<PlanItem> value) => PlanItem.listToJson(value); String toSql(List<PlanItem> 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<Column> get primaryKey => {id};
}
class SetLogsConverter extends TypeConverter<List<SetLog>, String> {
const SetLogsConverter();
@override
List<SetLog> fromSql(String fromDb) => [
for (final l in jsonDecode(fromDb) as List) SetLog.fromJson((l as Map).cast<String, Object?>()),
];
@override
String toSql(List<SetLog> value) => jsonEncode([for (final l in value) l.toJson()]);
}
@DriftDatabase(tables: [Plans, Sessions])
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase(super.e); AppDatabase(super.e);
@override @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);
},
);
} }
+755 -1
View File
@@ -399,14 +399,536 @@ class PlansCompanion extends UpdateCompanion<PlanRow> {
} }
} }
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<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _modeMeta = const VerificationMeta('mode');
@override
late final GeneratedColumn<String> mode = GeneratedColumn<String>(
'mode',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _planIdMeta = const VerificationMeta('planId');
@override
late final GeneratedColumn<String> planId = GeneratedColumn<String>(
'plan_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _planNameMeta = const VerificationMeta('planName');
@override
late final GeneratedColumn<String> planName = GeneratedColumn<String>(
'plan_name',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
@override
late final GeneratedColumnWithTypeConverter<List<PlanItem>, String> items = GeneratedColumn<String>(
'items',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
).withConverter<List<PlanItem>>($SessionsTable.$converteritems);
static const VerificationMeta _startedAtMeta = const VerificationMeta('startedAt');
@override
late final GeneratedColumn<DateTime> startedAt = GeneratedColumn<DateTime>(
'started_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
static const VerificationMeta _endedAtMeta = const VerificationMeta('endedAt');
@override
late final GeneratedColumn<DateTime> endedAt = GeneratedColumn<DateTime>(
'ended_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
static const VerificationMeta _durationSecondsMeta = const VerificationMeta('durationSeconds');
@override
late final GeneratedColumn<int> durationSeconds = GeneratedColumn<int>(
'duration_seconds',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
static const VerificationMeta _completeMeta = const VerificationMeta('complete');
@override
late final GeneratedColumn<bool> complete = GeneratedColumn<bool>(
'complete',
aliasedName,
false,
type: DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("complete" IN (0, 1))'),
);
@override
late final GeneratedColumnWithTypeConverter<List<SetLog>, String> setLogs = GeneratedColumn<String>(
'set_logs',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
).withConverter<List<SetLog>>($SessionsTable.$convertersetLogs);
@override
List<GeneratedColumn> 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<SessionRow> 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<GeneratedColumn> get $primaryKey => {id};
@override
SessionRow map(Map<String, dynamic> 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<List<PlanItem>, String> $converteritems = const PlanItemsConverter();
static TypeConverter<List<SetLog>, String> $convertersetLogs = const SetLogsConverter();
}
class SessionRow extends DataClass implements Insertable<SessionRow> {
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<PlanItem> items;
final DateTime startedAt;
final DateTime endedAt;
final int durationSeconds;
final bool complete;
final List<SetLog> 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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['mode'] = Variable<String>(mode);
map['plan_id'] = Variable<String>(planId);
map['plan_name'] = Variable<String>(planName);
{
map['items'] = Variable<String>($SessionsTable.$converteritems.toSql(items));
}
map['started_at'] = Variable<DateTime>(startedAt);
map['ended_at'] = Variable<DateTime>(endedAt);
map['duration_seconds'] = Variable<int>(durationSeconds);
map['complete'] = Variable<bool>(complete);
{
map['set_logs'] = Variable<String>($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<String, dynamic> json, {ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return SessionRow(
id: serializer.fromJson<String>(json['id']),
mode: serializer.fromJson<String>(json['mode']),
planId: serializer.fromJson<String>(json['planId']),
planName: serializer.fromJson<String>(json['planName']),
items: serializer.fromJson<List<PlanItem>>(json['items']),
startedAt: serializer.fromJson<DateTime>(json['startedAt']),
endedAt: serializer.fromJson<DateTime>(json['endedAt']),
durationSeconds: serializer.fromJson<int>(json['durationSeconds']),
complete: serializer.fromJson<bool>(json['complete']),
setLogs: serializer.fromJson<List<SetLog>>(json['setLogs']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'mode': serializer.toJson<String>(mode),
'planId': serializer.toJson<String>(planId),
'planName': serializer.toJson<String>(planName),
'items': serializer.toJson<List<PlanItem>>(items),
'startedAt': serializer.toJson<DateTime>(startedAt),
'endedAt': serializer.toJson<DateTime>(endedAt),
'durationSeconds': serializer.toJson<int>(durationSeconds),
'complete': serializer.toJson<bool>(complete),
'setLogs': serializer.toJson<List<SetLog>>(setLogs),
};
}
SessionRow copyWith({
String? id,
String? mode,
String? planId,
String? planName,
List<PlanItem>? items,
DateTime? startedAt,
DateTime? endedAt,
int? durationSeconds,
bool? complete,
List<SetLog>? 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<SessionRow> {
final Value<String> id;
final Value<String> mode;
final Value<String> planId;
final Value<String> planName;
final Value<List<PlanItem>> items;
final Value<DateTime> startedAt;
final Value<DateTime> endedAt;
final Value<int> durationSeconds;
final Value<bool> complete;
final Value<List<SetLog>> setLogs;
final Value<int> 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<PlanItem> items,
required DateTime startedAt,
required DateTime endedAt,
required int durationSeconds,
required bool complete,
required List<SetLog> 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<SessionRow> custom({
Expression<String>? id,
Expression<String>? mode,
Expression<String>? planId,
Expression<String>? planName,
Expression<String>? items,
Expression<DateTime>? startedAt,
Expression<DateTime>? endedAt,
Expression<int>? durationSeconds,
Expression<bool>? complete,
Expression<String>? setLogs,
Expression<int>? 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<String>? id,
Value<String>? mode,
Value<String>? planId,
Value<String>? planName,
Value<List<PlanItem>>? items,
Value<DateTime>? startedAt,
Value<DateTime>? endedAt,
Value<int>? durationSeconds,
Value<bool>? complete,
Value<List<SetLog>>? setLogs,
Value<int>? 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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (mode.present) {
map['mode'] = Variable<String>(mode.value);
}
if (planId.present) {
map['plan_id'] = Variable<String>(planId.value);
}
if (planName.present) {
map['plan_name'] = Variable<String>(planName.value);
}
if (items.present) {
map['items'] = Variable<String>($SessionsTable.$converteritems.toSql(items.value));
}
if (startedAt.present) {
map['started_at'] = Variable<DateTime>(startedAt.value);
}
if (endedAt.present) {
map['ended_at'] = Variable<DateTime>(endedAt.value);
}
if (durationSeconds.present) {
map['duration_seconds'] = Variable<int>(durationSeconds.value);
}
if (complete.present) {
map['complete'] = Variable<bool>(complete.value);
}
if (setLogs.present) {
map['set_logs'] = Variable<String>($SessionsTable.$convertersetLogs.toSql(setLogs.value));
}
if (rowid.present) {
map['rowid'] = Variable<int>(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 { abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e); _$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this); $AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $PlansTable plans = $PlansTable(this); late final $PlansTable plans = $PlansTable(this);
late final $SessionsTable sessions = $SessionsTable(this);
@override @override
Iterable<TableInfo<Table, Object?>> get allTables => allSchemaEntities.whereType<TableInfo<Table, Object?>>(); Iterable<TableInfo<Table, Object?>> get allTables => allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override @override
List<DatabaseSchemaEntity> get allSchemaEntities => [plans]; List<DatabaseSchemaEntity> get allSchemaEntities => [plans, sessions];
} }
typedef $$PlansTableCreateCompanionBuilder = typedef $$PlansTableCreateCompanionBuilder =
@@ -596,9 +1118,241 @@ typedef $$PlansTableProcessedTableManager =
PlanRow, PlanRow,
PrefetchHooks Function() PrefetchHooks Function()
>; >;
typedef $$SessionsTableCreateCompanionBuilder =
SessionsCompanion Function({
required String id,
required String mode,
required String planId,
required String planName,
required List<PlanItem> items,
required DateTime startedAt,
required DateTime endedAt,
required int durationSeconds,
required bool complete,
required List<SetLog> setLogs,
Value<int> rowid,
});
typedef $$SessionsTableUpdateCompanionBuilder =
SessionsCompanion Function({
Value<String> id,
Value<String> mode,
Value<String> planId,
Value<String> planName,
Value<List<PlanItem>> items,
Value<DateTime> startedAt,
Value<DateTime> endedAt,
Value<int> durationSeconds,
Value<bool> complete,
Value<List<SetLog>> setLogs,
Value<int> rowid,
});
class $$SessionsTableFilterComposer extends Composer<_$AppDatabase, $SessionsTable> {
$$SessionsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get mode => $composableBuilder(column: $table.mode, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get planId =>
$composableBuilder(column: $table.planId, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get planName =>
$composableBuilder(column: $table.planName, builder: (column) => ColumnFilters(column));
ColumnWithTypeConverterFilters<List<PlanItem>, List<PlanItem>, String> get items =>
$composableBuilder(column: $table.items, builder: (column) => ColumnWithTypeConverterFilters(column));
ColumnFilters<DateTime> get startedAt =>
$composableBuilder(column: $table.startedAt, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get endedAt =>
$composableBuilder(column: $table.endedAt, builder: (column) => ColumnFilters(column));
ColumnFilters<int> get durationSeconds =>
$composableBuilder(column: $table.durationSeconds, builder: (column) => ColumnFilters(column));
ColumnFilters<bool> get complete =>
$composableBuilder(column: $table.complete, builder: (column) => ColumnFilters(column));
ColumnWithTypeConverterFilters<List<SetLog>, List<SetLog>, 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<String> get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get mode =>
$composableBuilder(column: $table.mode, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get planId =>
$composableBuilder(column: $table.planId, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get planName =>
$composableBuilder(column: $table.planName, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get items =>
$composableBuilder(column: $table.items, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get startedAt =>
$composableBuilder(column: $table.startedAt, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get endedAt =>
$composableBuilder(column: $table.endedAt, builder: (column) => ColumnOrderings(column));
ColumnOrderings<int> get durationSeconds =>
$composableBuilder(column: $table.durationSeconds, builder: (column) => ColumnOrderings(column));
ColumnOrderings<bool> get complete =>
$composableBuilder(column: $table.complete, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> 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<String> get id => $composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get mode => $composableBuilder(column: $table.mode, builder: (column) => column);
GeneratedColumn<String> get planId => $composableBuilder(column: $table.planId, builder: (column) => column);
GeneratedColumn<String> get planName => $composableBuilder(column: $table.planName, builder: (column) => column);
GeneratedColumnWithTypeConverter<List<PlanItem>, String> get items =>
$composableBuilder(column: $table.items, builder: (column) => column);
GeneratedColumn<DateTime> get startedAt => $composableBuilder(column: $table.startedAt, builder: (column) => column);
GeneratedColumn<DateTime> get endedAt => $composableBuilder(column: $table.endedAt, builder: (column) => column);
GeneratedColumn<int> get durationSeconds =>
$composableBuilder(column: $table.durationSeconds, builder: (column) => column);
GeneratedColumn<bool> get complete => $composableBuilder(column: $table.complete, builder: (column) => column);
GeneratedColumnWithTypeConverter<List<SetLog>, 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<String> id = const Value.absent(),
Value<String> mode = const Value.absent(),
Value<String> planId = const Value.absent(),
Value<String> planName = const Value.absent(),
Value<List<PlanItem>> items = const Value.absent(),
Value<DateTime> startedAt = const Value.absent(),
Value<DateTime> endedAt = const Value.absent(),
Value<int> durationSeconds = const Value.absent(),
Value<bool> complete = const Value.absent(),
Value<List<SetLog>> setLogs = const Value.absent(),
Value<int> 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<PlanItem> items,
required DateTime startedAt,
required DateTime endedAt,
required int durationSeconds,
required bool complete,
required List<SetLog> setLogs,
Value<int> 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 { class $AppDatabaseManager {
final _$AppDatabase _db; final _$AppDatabase _db;
$AppDatabaseManager(this._db); $AppDatabaseManager(this._db);
$$PlansTableTableManager get plans => $$PlansTableTableManager(_db, _db.plans); $$PlansTableTableManager get plans => $$PlansTableTableManager(_db, _db.plans);
$$SessionsTableTableManager get sessions => $$SessionsTableTableManager(_db, _db.sessions);
} }
+2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../modes/mode.dart'; import '../modes/mode.dart';
import 'zen_style.dart'; import 'zen_style.dart';
@@ -53,6 +54,7 @@ class AppTheme {
labelLarge: text.labelLarge?.copyWith(fontWeight: FontWeight.w700), labelLarge: text.labelLarge?.copyWith(fontWeight: FontWeight.w700),
), ),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
systemOverlayStyle: workout ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
backgroundColor: s.background, backgroundColor: s.background,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
foregroundColor: s.ink, foregroundColor: s.ink,
+86
View File
@@ -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<PlanItem> items;
final DateTime startedAt;
final DateTime endedAt;
final Duration duration;
final bool complete;
final List<SetLog> setLogs;
}
/// Finished Sessions, per Mode.
class SessionRepository {
SessionRepository(this._db);
final AppDatabase _db;
Future<Session> 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<Session?> 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<List<Session>> 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,
);
}
+91 -1
View File
@@ -237,5 +237,95 @@
} }
}, },
"addToPlan": "Add to plan", "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"
} }
+244 -15
View File
@@ -61,8 +61,7 @@ import 'app_localizations_en.dart';
/// be consistent with the languages listed in the AppLocalizations.supportedLocales /// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property. /// property.
abstract class AppLocalizations { abstract class AppLocalizations {
AppLocalizations(String locale) AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName; final String localeName;
@@ -70,8 +69,7 @@ abstract class AppLocalizations {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!; return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
} }
static const LocalizationsDelegate<AppLocalizations> delegate = static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations /// A list of this localizations delegate along with the default localizations
/// delegates. /// delegates.
@@ -83,13 +81,12 @@ abstract class AppLocalizations {
/// Additional delegates can be added by appending to this list in /// 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 /// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required. /// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
<LocalizationsDelegate<dynamic>>[ delegate,
delegate, GlobalMaterialLocalizations.delegate,
GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, ];
];
/// A list of this localizations delegate's supported locales. /// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[Locale('en')]; static const List<Locale> supportedLocales = <Locale>[Locale('en')];
@@ -609,10 +606,243 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add to which plan?'** /// **'Add to which plan?'**
String get addToPlanTitle; 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 class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate(); const _AppLocalizationsDelegate();
@override @override
@@ -621,8 +851,7 @@ class _AppLocalizationsDelegate
} }
@override @override
bool isSupported(Locale locale) => bool isSupported(Locale locale) => <String>['en'].contains(locale.languageCode);
<String>['en'].contains(locale.languageCode);
@override @override
bool shouldReload(_AppLocalizationsDelegate old) => false; bool shouldReload(_AppLocalizationsDelegate old) => false;
+151 -54
View File
@@ -38,10 +38,8 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String plansEmptyBody(String mode) { String plansEmptyBody(String mode) {
String _temp0 = intl.Intl.selectLogic(mode, { String _temp0 = intl.Intl.selectLogic(mode, {
'stretching': 'stretching': 'Put together a stretch routine from the library and it will wait for you here.',
'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.',
'other':
'Put together a workout from the library and it will wait for you here.',
}); });
return '$_temp0'; return '$_temp0';
} }
@@ -51,22 +49,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String librarySubtitle(String mode, int count) { String librarySubtitle(String mode, int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count stretches', one: '1 stretch');
count, String _temp1 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count exercises', one: '1 exercise');
locale: localeName, String _temp2 = intl.Intl.selectLogic(mode, {'stretching': '$_temp0', 'other': '$_temp1'});
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'; return '$_temp2';
} }
@@ -77,8 +62,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get historyEmptyTitle => 'Nothing logged yet'; String get historyEmptyTitle => 'Nothing logged yet';
@override @override
String get historyEmptyBody => String get historyEmptyBody => 'Finished sessions show up here with their date and duration.';
'Finished sessions show up here with their date and duration.';
@override @override
String librarySubtitleFiltered(String mode, int shown, int total) { String librarySubtitleFiltered(String mode, int shown, int total) {
@@ -91,10 +75,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String searchHint(String mode) { String searchHint(String mode) {
String _temp0 = intl.Intl.selectLogic(mode, { String _temp0 = intl.Intl.selectLogic(mode, {'stretching': 'Search stretches', 'other': 'Search exercises'});
'stretching': 'Search stretches',
'other': 'Search exercises',
});
return '$_temp0'; return '$_temp0';
} }
@@ -129,8 +110,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get noMatchesTitle => 'No matches'; String get noMatchesTitle => 'No matches';
@override @override
String get noMatchesBody => String get noMatchesBody => 'Try fewer words, or clear the filters to see everything again.';
'Try fewer words, or clear the filters to see everything again.';
@override @override
String get detailMainMuscles => 'Main muscles'; String get detailMainMuscles => 'Main muscles';
@@ -167,10 +147,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String planNameHint(String mode) { String planNameHint(String mode) {
String _temp0 = intl.Intl.selectLogic(mode, { String _temp0 = intl.Intl.selectLogic(mode, {'stretching': 'e.g. Morning stretch', 'other': 'e.g. Push day'});
'stretching': 'e.g. Morning stretch',
'other': 'e.g. Push day',
});
return '$_temp0'; return '$_temp0';
} }
@@ -218,8 +195,7 @@ class AppLocalizationsEn extends AppLocalizations {
} }
@override @override
String get deletePlanBody => String get deletePlanBody => 'The plan is removed. Sessions you already finished stay in your history.';
'The plan is removed. Sessions you already finished stay in your history.';
@override @override
String planExerciseCount(int count) { String planExerciseCount(int count) {
@@ -269,12 +245,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String repsCount(int count) { String repsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count reps', one: '1 rep');
count,
locale: localeName,
other: '$count reps',
one: '1 rep',
);
return '$_temp0'; return '$_temp0';
} }
@@ -298,23 +269,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String roundsCount(int count) { String roundsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count rounds', one: '1 round');
count,
locale: localeName,
other: '$count rounds',
one: '1 round',
);
return '$_temp0'; return '$_temp0';
} }
@override @override
String setsCount(int count) { String setsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count sets', one: '1 set');
count,
locale: localeName,
other: '$count sets',
one: '1 set',
);
return '$_temp0'; return '$_temp0';
} }
@@ -355,8 +316,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get restBetweenLabel => 'Rest between exercises'; String get restBetweenLabel => 'Rest between exercises';
@override @override
String get restBetweenHint => String get restBetweenHint => 'When off, you go straight to the next exercise and rest after each round.';
'When off, you go straight to the next exercise and rest after each round.';
@override @override
String get supersetWithNext => 'Superset with next'; String get supersetWithNext => 'Superset with next';
@@ -385,4 +345,141 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get addToPlanTitle => 'Add to which plan?'; 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';
} }
+30 -7
View File
@@ -11,6 +11,7 @@ import '../../modes/mode.dart';
import '../models/plan.dart'; import '../models/plan.dart';
import '../plan_labels.dart'; import '../plan_labels.dart';
import '../plan_repository.dart'; import '../plan_repository.dart';
import '../../player/views/player_view.dart';
import 'exercise_picker_view.dart'; import 'exercise_picker_view.dart';
import 'plans_view.dart'; import 'plans_view.dart';
import 'step_editor_sheet.dart'; import 'step_editor_sheet.dart';
@@ -50,7 +51,6 @@ class _Editor extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final bottomInset = MediaQuery.paddingOf(context).bottom;
final addButton = FilledButton.icon( final addButton = FilledButton.icon(
onPressed: () => _addExercises(context), onPressed: () => _addExercises(context),
icon: const Icon(Icons.add_rounded), icon: const Icon(Icons.add_rounded),
@@ -78,7 +78,7 @@ class _Editor extends StatelessWidget {
) )
: ReorderableListView.builder( : ReorderableListView.builder(
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
padding: EdgeInsets.only(bottom: 96 + bottomInset), padding: const EdgeInsets.only(bottom: 24),
header: Column( header: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -111,12 +111,35 @@ class _Editor extends StatelessWidget {
}, },
), ),
), ),
floatingActionButton: plan.items.isEmpty bottomNavigationBar: plan.items.isEmpty
? null ? null
: FloatingActionButton.extended( : SafeArea(
onPressed: () => _addExercises(context), child: Padding(
icon: const Icon(Icons.add_rounded), padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
label: Text(l10n.addExercises), 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),
),
),
),
],
),
),
), ),
); );
} }
+7
View File
@@ -10,6 +10,7 @@ import '../../settings/settings_controller.dart';
import '../models/plan.dart'; import '../models/plan.dart';
import '../plan_labels.dart'; import '../plan_labels.dart';
import '../plan_repository.dart'; import '../plan_repository.dart';
import '../../player/views/player_view.dart';
import 'plan_dialogs.dart'; import 'plan_dialogs.dart';
import 'plan_editor_view.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), PlanMenu(plan: plan),
], ],
), ),
+36
View File
@@ -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<void> chime(Mode mode);
Future<void> 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<void> chime(Mode mode) => _player.play(AssetSource('sounds/${mode.name}_cue.wav'));
@override
Future<void> keepScreenOn(bool on) => WakelockPlus.toggle(enable: on);
}
+151
View File
@@ -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<Segment> buildSegments(Plan plan) => switch (plan.mode) {
Mode.stretching => _stretching(plan),
Mode.workout => _workout(plan),
};
List<Segment> _stretching(Plan plan) {
final segments = <Segment>[];
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<StretchingStep>().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<Segment> _workout(Plan plan) {
final segments = <Segment>[];
// 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;
}
+241
View File
@@ -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<String, Object?> toJson() => {
'exerciseId': exerciseId,
'set': set,
'reps': reps,
'seconds': seconds,
'weightKg': weightKg,
};
factory SetLog.fromJson(Map<String, Object?> 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<SetLog> 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<Segment> segments;
final VoidCallback? onCue;
final DateTime _startedAt;
var _index = 0;
DateTime? _segmentStart;
DateTime? _pausedAt;
var _pausedInSegment = Duration.zero;
var _pausedTotal = Duration.zero;
final _logs = <int, SetLog>{};
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();
}
}
+729
View File
@@ -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<PlayerView> createState() => _PlayerViewState();
}
class _PlayerViewState extends State<PlayerView> {
SessionRunner? _runner;
Timer? _ticker;
var _leaving = false;
late final PlayerEffects _effects = context.read<PlayerEffects>();
@override
void initState() {
super.initState();
_effects.keepScreenOn(true);
_start();
}
Future<void> _start() async {
final plan = await context.read<PlanRepository>().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<SettingsController>().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<void> _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<SessionRepository>().save(runner.result(clock.now()));
navigator.pushReplacementNamed(SummaryView.routeName, arguments: session.id);
}
Future<void> _confirmEnd() async {
final runner = _runner;
if (runner == null) {
Navigator.of(context).pop();
return;
}
final l10n = AppLocalizations.of(context);
final end = await showDialog<bool>(
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<SettingsController>();
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<ExerciseLibrary>().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<ExerciseLibrary>().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<ExerciseLibrary>().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<Widget> 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<Color> 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<void> _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);
}
+159
View File
@@ -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<SessionRepository>().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<List<SetLog>> _byExercise(List<SetLog> logs) {
final groups = <List<SetLog>>[];
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<SetLog> 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<ExerciseLibrary>().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),
),
],
),
);
}
}
+15
View File
@@ -6,11 +6,17 @@ import '../modes/mode.dart';
/// Stores the user's app preferences on the device. /// Stores the user's app preferences on the device.
class SettingsService { class SettingsService {
static const _modeKey = 'mode'; static const _modeKey = 'mode';
static const _mutedKey = 'muted';
/// The last-used Mode; Stretching on first launch. /// The last-used Mode; Stretching on first launch.
Future<Mode> mode() async => Mode.fromName((await SharedPreferences.getInstance()).getString(_modeKey)); Future<Mode> mode() async => Mode.fromName((await SharedPreferences.getInstance()).getString(_modeKey));
Future<void> updateMode(Mode mode) async => (await SharedPreferences.getInstance()).setString(_modeKey, mode.name); Future<void> updateMode(Mode mode) async => (await SharedPreferences.getInstance()).setString(_modeKey, mode.name);
/// Whether the Player's chime is muted; sound is on by default.
Future<bool> muted() async => (await SharedPreferences.getInstance()).getBool(_mutedKey) ?? false;
Future<void> updateMuted(bool muted) async => (await SharedPreferences.getInstance()).setBool(_mutedKey, muted);
} }
class SettingsController extends ChangeNotifier { class SettingsController extends ChangeNotifier {
@@ -18,14 +24,23 @@ class SettingsController extends ChangeNotifier {
final SettingsService _service; final SettingsService _service;
Mode _mode = Mode.stretching; Mode _mode = Mode.stretching;
bool _muted = false;
Mode get mode => _mode; Mode get mode => _mode;
bool get muted => _muted;
Future<void> loadSettings() async { Future<void> loadSettings() async {
_mode = await _service.mode(); _mode = await _service.mode();
_muted = await _service.muted();
notifyListeners(); notifyListeners();
} }
Future<void> updateMuted(bool muted) async {
_muted = muted;
notifyListeners();
await _service.updateMuted(muted);
}
Future<void> updateMode(Mode mode) async { Future<void> updateMode(Mode mode) async {
if (mode == _mode) return; if (mode == _mode) return;
_mode = mode; _mode = mode;
+137 -1
View File
@@ -33,6 +33,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -122,7 +178,7 @@ packages:
source: hosted source: hosted
version: "0.4.2" version: "0.4.2"
clock: clock:
dependency: transitive dependency: "direct main"
description: description:
name: clock name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
@@ -161,6 +217,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
dbus:
dependency: transitive
description:
name: dbus
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
url: "https://pub.dev"
source: hosted
version: "0.7.15"
drift: drift:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -261,6 +325,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" 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: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@@ -389,6 +461,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@@ -445,6 +533,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -650,6 +746,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -698,6 +802,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.0" 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: watcher:
dependency: transitive dependency: transitive
description: description:
@@ -730,6 +850,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -738,6 +866,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
+4
View File
@@ -7,6 +7,8 @@ environment:
sdk: ^3.8.1 sdk: ^3.8.1
dependencies: dependencies:
audioplayers: ^6.1.0
clock: ^1.1.1
drift: ^2.31.0 drift: ^2.31.0
drift_flutter: ^0.2.8 drift_flutter: ^0.2.8
flutter: flutter:
@@ -17,6 +19,7 @@ dependencies:
provider: ^6.1.5+1 provider: ^6.1.5+1
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
uuid: ^4.6.0 uuid: ^4.6.0
wakelock_plus: ^1.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -33,6 +36,7 @@ flutter:
assets: assets:
- assets/exercises/ - assets/exercises/
- assets/exercises/images/ - assets/exercises/images/
- assets/sounds/
fonts: fonts:
# Stretching Mode # Stretching Mode
+58
View File
@@ -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<WorkoutStep>());
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]);
});
}
+119
View File
@@ -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<PlanItem> 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));
});
});
}
+158
View File
@@ -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<PlanItem> 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<TransitionSegment>());
expect(runner.remaining(at(2)), const Duration(seconds: 3));
runner.tick(at(5));
expect(runner.current, isA<HoldSegment>().having((h) => h.side, 'side', Side.left));
runner.tick(at(35));
expect(runner.current, isA<TransitionSegment>().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<HoldSegment>());
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<HoldSegment>().having((h) => h.side, 'side', Side.left));
runner.skip(at(2));
runner.skip(at(3));
expect(runner.current, isA<HoldSegment>().having((h) => h.side, 'side', Side.right));
runner.back(at(4));
expect(runner.current, isA<HoldSegment>().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<SetSegment>());
runner.completeSet(reps: 8, weightKg: 62.5, now: at(40));
expect(runner.hasProgress, isTrue);
expect(runner.current, isA<RestSegment>());
expect(runner.elapsed(at(112)), const Duration(seconds: 72));
runner.startNext(at(120));
expect(runner.current, isA<SetSegment>().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<SetSegment>());
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<SetSegment>().having((s) => s.set, 'set', 2));
expect(runner.result(at(11)).setLogs, isEmpty);
});
});
}
+18
View File
@@ -6,7 +6,9 @@ import 'package:zenxercise/src/app.dart';
import 'package:zenxercise/src/exercises/exercise.dart'; import 'package:zenxercise/src/exercises/exercise.dart';
import 'package:zenxercise/src/exercises/exercise_library.dart'; import 'package:zenxercise/src/exercises/exercise_library.dart';
import 'package:zenxercise/src/modes/mode.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/plans/plan_repository.dart';
import 'package:zenxercise/src/player/player_effects.dart';
import 'package:zenxercise/src/settings/settings_controller.dart'; import 'package:zenxercise/src/settings/settings_controller.dart';
import 'test_database.dart'; import 'test_database.dart';
@@ -48,6 +50,18 @@ final sampleLibrary = ExerciseLibrary([
exercise('Barbell_Squat', Mode.workout, primary: const ['quadriceps'], equipment: 'barbell'), 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 = <Mode>[];
bool screenOn = false;
@override
Future<void> chime(Mode mode) async => chimes.add(mode);
@override
Future<void> keepScreenOn(bool on) async => screenOn = on;
}
/// A Pixel-sized screen instead of the default 800×600, so the floating /// A Pixel-sized screen instead of the default 800×600, so the floating
/// navigation bar does not cover what a test taps. /// navigation bar does not cover what a test taps.
void usePhoneScreen(WidgetTester tester) { void usePhoneScreen(WidgetTester tester) {
@@ -62,6 +76,8 @@ Future<SettingsController> pumpApp(
ExerciseLibrary? library, ExerciseLibrary? library,
Map<String, Object> prefs = const {}, Map<String, Object> prefs = const {},
PlanRepository? plans, PlanRepository? plans,
SessionRepository? sessions,
PlayerEffects? effects,
}) async { }) async {
SharedPreferences.setMockInitialValues(prefs); SharedPreferences.setMockInitialValues(prefs);
final settings = SettingsController(SettingsService()); final settings = SettingsController(SettingsService());
@@ -74,6 +90,8 @@ Future<SettingsController> pumpApp(
ChangeNotifierProvider.value(value: settings), ChangeNotifierProvider.value(value: settings),
Provider.value(value: library ?? sampleLibrary), Provider.value(value: library ?? sampleLibrary),
Provider.value(value: plans ?? PlanRepository(db)), Provider.value(value: plans ?? PlanRepository(db)),
Provider.value(value: sessions ?? SessionRepository(db)),
Provider<PlayerEffects>.value(value: effects ?? FakePlayerEffects()),
], ],
child: const ZenXerciseApp(), child: const ZenXerciseApp(),
), ),
+148
View File
@@ -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<void> startPlan(WidgetTester tester, Mode mode, List<PlanItem> 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<List<Session>> 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<void> settle(WidgetTester tester) async {
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
}
Future<void> 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);
});
}
+61
View File
@@ -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("<h", int(s * scale * 32767)) for s in samples))
def singing_bowl(seconds: float = 2.6) -> 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()