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.
**Transition**:
In Stretching Mode, a short card between rounds, sides ("Switch sides") and Exercises, after which the
next hold starts automatically. Length set per Plan, 5 s by default.
In Stretching Mode, a short card before the first hold ("Get ready") and between rounds, sides
("Switch sides") and Exercises, after which the next hold starts automatically. Its length is the
Plan's "pause between holds", 5 s by default; 0 s means no Transitions.
## Sessions
**Session**:
One run-through of a Plan in the Player. It stores a snapshot of the Plan, so later edits to the Plan
never change it. A Session is **complete** or **incomplete** (ended early).
never change it. A Session is **complete** (reached the end) or **incomplete** (ended early). A Session
in which no hold or set was actually done is not saved at all.
_Avoid_: Workout, run
**Player**:
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/database/database.dart';
import 'src/exercises/exercise_library.dart';
import 'src/history/session_repository.dart';
import 'src/plans/plan_repository.dart';
import 'src/player/player_effects.dart';
import 'src/settings/settings_controller.dart';
void main() async {
@@ -24,6 +26,8 @@ void main() async {
ChangeNotifierProvider.value(value: settings),
Provider.value(value: library),
Provider.value(value: PlanRepository(db)),
Provider.value(value: SessionRepository(db)),
Provider<PlayerEffects>(create: (_) => DevicePlayerEffects()),
],
child: const ZenXerciseApp(),
),
+10
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
@@ -9,6 +10,8 @@ import 'home/home_view.dart';
import 'localization/app_localizations.dart';
import 'plans/views/exercise_picker_view.dart';
import 'plans/views/plan_editor_view.dart';
import 'player/views/player_view.dart';
import 'player/views/summary_view.dart';
import 'settings/settings_controller.dart';
class ZenXerciseApp extends StatelessWidget {
@@ -32,12 +35,19 @@ class ZenXerciseApp extends StatelessWidget {
],
supportedLocales: AppLocalizations.supportedLocales,
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
// Status bar icons follow the Mode's ground on every screen, with or without an app bar.
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
value: style.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
child: child!,
),
onGenerateRoute: (settings) => MaterialPageRoute<Object?>(
settings: settings,
builder: (context) => switch (settings.name) {
ExerciseDetailView.routeName => ExerciseDetailView(exerciseId: settings.arguments! as String),
PlanEditorView.routeName => PlanEditorView(planId: settings.arguments! as String),
ExercisePickerView.routeName => ExercisePickerView(planId: settings.arguments! as String),
PlayerView.routeName => PlayerView(planId: settings.arguments! as String),
SummaryView.routeName => SummaryView(sessionId: settings.arguments! as String),
_ => const HomeView(),
},
),
+46 -2
View File
@@ -1,6 +1,9 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import '../plans/models/plan.dart';
import '../player/session_runner.dart';
part 'database.g.dart';
@@ -32,10 +35,51 @@ class PlanItemsConverter extends TypeConverter<List<PlanItem>, String> {
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 {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
// 2: Sessions.
if (from < 2) await m.createTable(sessions);
},
);
}
+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 {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $PlansTable plans = $PlansTable(this);
late final $SessionsTable sessions = $SessionsTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables => allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [plans];
List<DatabaseSchemaEntity> get allSchemaEntities => [plans, sessions];
}
typedef $$PlansTableCreateCompanionBuilder =
@@ -596,9 +1118,241 @@ typedef $$PlansTableProcessedTableManager =
PlanRow,
PrefetchHooks Function()
>;
typedef $$SessionsTableCreateCompanionBuilder =
SessionsCompanion Function({
required String id,
required String mode,
required String planId,
required String planName,
required List<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 {
final _$AppDatabase _db;
$AppDatabaseManager(this._db);
$$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/services.dart';
import '../modes/mode.dart';
import 'zen_style.dart';
@@ -53,6 +54,7 @@ class AppTheme {
labelLarge: text.labelLarge?.copyWith(fontWeight: FontWeight.w700),
),
appBarTheme: AppBarTheme(
systemOverlayStyle: workout ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
backgroundColor: s.background,
surfaceTintColor: Colors.transparent,
foregroundColor: s.ink,
+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",
"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
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
@@ -70,8 +69,7 @@ abstract class AppLocalizations {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
}
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
@@ -83,13 +81,12 @@ abstract class AppLocalizations {
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[Locale('en')];
@@ -609,10 +606,243 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Add to which plan?'**
String get addToPlanTitle;
/// No description provided for @start.
///
/// In en, this message translates to:
/// **'Start'**
String get start;
/// No description provided for @startPlan.
///
/// In en, this message translates to:
/// **'Start'**
String get startPlan;
/// No description provided for @playerClose.
///
/// In en, this message translates to:
/// **'End session'**
String get playerClose;
/// No description provided for @mute.
///
/// In en, this message translates to:
/// **'Mute sounds'**
String get mute;
/// No description provided for @unmute.
///
/// In en, this message translates to:
/// **'Turn sounds on'**
String get unmute;
/// No description provided for @pause.
///
/// In en, this message translates to:
/// **'Pause'**
String get pause;
/// No description provided for @resume.
///
/// In en, this message translates to:
/// **'Resume'**
String get resume;
/// No description provided for @skip.
///
/// In en, this message translates to:
/// **'Skip'**
String get skip;
/// No description provided for @previous.
///
/// In en, this message translates to:
/// **'Previous'**
String get previous;
/// No description provided for @paused.
///
/// In en, this message translates to:
/// **'Paused'**
String get paused;
/// No description provided for @progress.
///
/// In en, this message translates to:
/// **'{done} of {total}'**
String progress(int done, int total);
/// No description provided for @transitionGetReady.
///
/// In en, this message translates to:
/// **'Get ready'**
String get transitionGetReady;
/// No description provided for @transitionSwitchSides.
///
/// In en, this message translates to:
/// **'Switch sides'**
String get transitionSwitchSides;
/// No description provided for @transitionNextRound.
///
/// In en, this message translates to:
/// **'Round {round}'**
String transitionNextRound(int round);
/// No description provided for @transitionNextExercise.
///
/// In en, this message translates to:
/// **'Next up'**
String get transitionNextExercise;
/// No description provided for @sideLeft.
///
/// In en, this message translates to:
/// **'Left side'**
String get sideLeft;
/// No description provided for @sideRight.
///
/// In en, this message translates to:
/// **'Right side'**
String get sideRight;
/// No description provided for @roundOf.
///
/// In en, this message translates to:
/// **'Round {round} of {rounds}'**
String roundOf(int round, int rounds);
/// No description provided for @setOf.
///
/// In en, this message translates to:
/// **'Set {set} of {sets}'**
String setOf(int set, int sets);
/// No description provided for @setDone.
///
/// In en, this message translates to:
/// **'Set done'**
String get setDone;
/// No description provided for @doneEarly.
///
/// In en, this message translates to:
/// **'Done early'**
String get doneEarly;
/// No description provided for @saveSet.
///
/// In en, this message translates to:
/// **'Save set'**
String get saveSet;
/// No description provided for @logSetTitle.
///
/// In en, this message translates to:
/// **'How did it go?'**
String get logSetTitle;
/// No description provided for @restTitle.
///
/// In en, this message translates to:
/// **'Rest'**
String get restTitle;
/// No description provided for @restNext.
///
/// In en, this message translates to:
/// **'Next'**
String get restNext;
/// No description provided for @startNextSet.
///
/// In en, this message translates to:
/// **'Start next set'**
String get startNextSet;
/// No description provided for @endSessionTitle.
///
/// In en, this message translates to:
/// **'End this session?'**
String get endSessionTitle;
/// No description provided for @endSessionBody.
///
/// In en, this message translates to:
/// **'What you did so far is saved as unfinished. Only finished sessions count toward your streak.'**
String get endSessionBody;
/// No description provided for @keepGoing.
///
/// In en, this message translates to:
/// **'Keep going'**
String get keepGoing;
/// No description provided for @endSession.
///
/// In en, this message translates to:
/// **'End session'**
String get endSession;
/// No description provided for @summaryComplete.
///
/// In en, this message translates to:
/// **'Session complete'**
String get summaryComplete;
/// No description provided for @summaryIncomplete.
///
/// In en, this message translates to:
/// **'Ended early'**
String get summaryIncomplete;
/// No description provided for @summaryDuration.
///
/// In en, this message translates to:
/// **'Time'**
String get summaryDuration;
/// No description provided for @summaryExercises.
///
/// In en, this message translates to:
/// **'Exercises'**
String get summaryExercises;
/// No description provided for @summarySets.
///
/// In en, this message translates to:
/// **'Sets logged'**
String get summarySets;
/// No description provided for @summaryLogTitle.
///
/// In en, this message translates to:
/// **'What you did'**
String get summaryLogTitle;
/// No description provided for @setLogLine.
///
/// In en, this message translates to:
/// **'Set {set}'**
String setLogLine(int set);
/// No description provided for @durationMinutes.
///
/// In en, this message translates to:
/// **'{minutes, plural, =0{under a minute} =1{1 min} other{{minutes} min}}'**
String durationMinutes(int minutes);
/// No description provided for @logWeight.
///
/// In en, this message translates to:
/// **'Add a weight'**
String get logWeight;
}
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
@@ -621,8 +851,7 @@ class _AppLocalizationsDelegate
}
@override
bool isSupported(Locale locale) =>
<String>['en'].contains(locale.languageCode);
bool isSupported(Locale locale) => <String>['en'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
+151 -54
View File
@@ -38,10 +38,8 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String plansEmptyBody(String mode) {
String _temp0 = intl.Intl.selectLogic(mode, {
'stretching':
'Put together a stretch routine from the library and it will wait for you here.',
'other':
'Put together a workout from the library and it will wait for you here.',
'stretching': 'Put together a stretch routine from the library and it will wait for you here.',
'other': 'Put together a workout from the library and it will wait for you here.',
});
return '$_temp0';
}
@@ -51,22 +49,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String librarySubtitle(String mode, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count stretches',
one: '1 stretch',
);
String _temp1 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count exercises',
one: '1 exercise',
);
String _temp2 = intl.Intl.selectLogic(mode, {
'stretching': '$_temp0',
'other': '$_temp1',
});
String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count stretches', one: '1 stretch');
String _temp1 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count exercises', one: '1 exercise');
String _temp2 = intl.Intl.selectLogic(mode, {'stretching': '$_temp0', 'other': '$_temp1'});
return '$_temp2';
}
@@ -77,8 +62,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get historyEmptyTitle => 'Nothing logged yet';
@override
String get historyEmptyBody =>
'Finished sessions show up here with their date and duration.';
String get historyEmptyBody => 'Finished sessions show up here with their date and duration.';
@override
String librarySubtitleFiltered(String mode, int shown, int total) {
@@ -91,10 +75,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String searchHint(String mode) {
String _temp0 = intl.Intl.selectLogic(mode, {
'stretching': 'Search stretches',
'other': 'Search exercises',
});
String _temp0 = intl.Intl.selectLogic(mode, {'stretching': 'Search stretches', 'other': 'Search exercises'});
return '$_temp0';
}
@@ -129,8 +110,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get noMatchesTitle => 'No matches';
@override
String get noMatchesBody =>
'Try fewer words, or clear the filters to see everything again.';
String get noMatchesBody => 'Try fewer words, or clear the filters to see everything again.';
@override
String get detailMainMuscles => 'Main muscles';
@@ -167,10 +147,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String planNameHint(String mode) {
String _temp0 = intl.Intl.selectLogic(mode, {
'stretching': 'e.g. Morning stretch',
'other': 'e.g. Push day',
});
String _temp0 = intl.Intl.selectLogic(mode, {'stretching': 'e.g. Morning stretch', 'other': 'e.g. Push day'});
return '$_temp0';
}
@@ -218,8 +195,7 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get deletePlanBody =>
'The plan is removed. Sessions you already finished stay in your history.';
String get deletePlanBody => 'The plan is removed. Sessions you already finished stay in your history.';
@override
String planExerciseCount(int count) {
@@ -269,12 +245,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String repsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count reps',
one: '1 rep',
);
String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count reps', one: '1 rep');
return '$_temp0';
}
@@ -298,23 +269,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String roundsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count rounds',
one: '1 round',
);
String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count rounds', one: '1 round');
return '$_temp0';
}
@override
String setsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count sets',
one: '1 set',
);
String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count sets', one: '1 set');
return '$_temp0';
}
@@ -355,8 +316,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get restBetweenLabel => 'Rest between exercises';
@override
String get restBetweenHint =>
'When off, you go straight to the next exercise and rest after each round.';
String get restBetweenHint => 'When off, you go straight to the next exercise and rest after each round.';
@override
String get supersetWithNext => 'Superset with next';
@@ -385,4 +345,141 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get addToPlanTitle => 'Add to which plan?';
@override
String get start => 'Start';
@override
String get startPlan => 'Start';
@override
String get playerClose => 'End session';
@override
String get mute => 'Mute sounds';
@override
String get unmute => 'Turn sounds on';
@override
String get pause => 'Pause';
@override
String get resume => 'Resume';
@override
String get skip => 'Skip';
@override
String get previous => 'Previous';
@override
String get paused => 'Paused';
@override
String progress(int done, int total) {
return '$done of $total';
}
@override
String get transitionGetReady => 'Get ready';
@override
String get transitionSwitchSides => 'Switch sides';
@override
String transitionNextRound(int round) {
return 'Round $round';
}
@override
String get transitionNextExercise => 'Next up';
@override
String get sideLeft => 'Left side';
@override
String get sideRight => 'Right side';
@override
String roundOf(int round, int rounds) {
return 'Round $round of $rounds';
}
@override
String setOf(int set, int sets) {
return 'Set $set of $sets';
}
@override
String get setDone => 'Set done';
@override
String get doneEarly => 'Done early';
@override
String get saveSet => 'Save set';
@override
String get logSetTitle => 'How did it go?';
@override
String get restTitle => 'Rest';
@override
String get restNext => 'Next';
@override
String get startNextSet => 'Start next set';
@override
String get endSessionTitle => 'End this session?';
@override
String get endSessionBody =>
'What you did so far is saved as unfinished. Only finished sessions count toward your streak.';
@override
String get keepGoing => 'Keep going';
@override
String get endSession => 'End session';
@override
String get summaryComplete => 'Session complete';
@override
String get summaryIncomplete => 'Ended early';
@override
String get summaryDuration => 'Time';
@override
String get summaryExercises => 'Exercises';
@override
String get summarySets => 'Sets logged';
@override
String get summaryLogTitle => 'What you did';
@override
String setLogLine(int set) {
return 'Set $set';
}
@override
String durationMinutes(int minutes) {
String _temp0 = intl.Intl.pluralLogic(
minutes,
locale: localeName,
other: '$minutes min',
one: '1 min',
zero: 'under a minute',
);
return '$_temp0';
}
@override
String get logWeight => 'Add a weight';
}
+30 -7
View File
@@ -11,6 +11,7 @@ import '../../modes/mode.dart';
import '../models/plan.dart';
import '../plan_labels.dart';
import '../plan_repository.dart';
import '../../player/views/player_view.dart';
import 'exercise_picker_view.dart';
import 'plans_view.dart';
import 'step_editor_sheet.dart';
@@ -50,7 +51,6 @@ class _Editor extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottomInset = MediaQuery.paddingOf(context).bottom;
final addButton = FilledButton.icon(
onPressed: () => _addExercises(context),
icon: const Icon(Icons.add_rounded),
@@ -78,7 +78,7 @@ class _Editor extends StatelessWidget {
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
padding: EdgeInsets.only(bottom: 96 + bottomInset),
padding: const EdgeInsets.only(bottom: 24),
header: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -111,12 +111,35 @@ class _Editor extends StatelessWidget {
},
),
),
floatingActionButton: plan.items.isEmpty
bottomNavigationBar: plan.items.isEmpty
? null
: FloatingActionButton.extended(
onPressed: () => _addExercises(context),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addExercises),
: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Row(
children: [
SizedBox(
height: 52,
child: OutlinedButton.icon(
onPressed: () => _addExercises(context),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addExercises),
),
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 52,
child: FilledButton.icon(
onPressed: () => startPlan(context, plan),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(l10n.startPlan),
),
),
),
],
),
),
),
);
}
+7
View File
@@ -10,6 +10,7 @@ import '../../settings/settings_controller.dart';
import '../models/plan.dart';
import '../plan_labels.dart';
import '../plan_repository.dart';
import '../../player/views/player_view.dart';
import 'plan_dialogs.dart';
import 'plan_editor_view.dart';
@@ -98,6 +99,12 @@ class PlanCard extends StatelessWidget {
],
),
),
if (plan.items.isNotEmpty)
IconButton.filled(
tooltip: l10n.startPlan,
onPressed: () => startPlan(context, plan),
icon: const Icon(Icons.play_arrow_rounded),
),
PlanMenu(plan: plan),
],
),
+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.
class SettingsService {
static const _modeKey = 'mode';
static const _mutedKey = 'muted';
/// The last-used Mode; Stretching on first launch.
Future<Mode> mode() async => Mode.fromName((await SharedPreferences.getInstance()).getString(_modeKey));
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 {
@@ -18,14 +24,23 @@ class SettingsController extends ChangeNotifier {
final SettingsService _service;
Mode _mode = Mode.stretching;
bool _muted = false;
Mode get mode => _mode;
bool get muted => _muted;
Future<void> loadSettings() async {
_mode = await _service.mode();
_muted = await _service.muted();
notifyListeners();
}
Future<void> updateMuted(bool muted) async {
_muted = muted;
notifyListeners();
await _service.updateMuted(muted);
}
Future<void> updateMode(Mode mode) async {
if (mode == _mode) return;
_mode = mode;
+137 -1
View File
@@ -33,6 +33,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70
url: "https://pub.dev"
source: hosted
version: "6.6.0"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91
url: "https://pub.dev"
source: hosted
version: "6.4.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9
url: "https://pub.dev"
source: hosted
version: "5.2.0"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734
url: "https://pub.dev"
source: hosted
version: "4.3.0"
boolean_selector:
dependency: transitive
description:
@@ -122,7 +178,7 @@ packages:
source: hosted
version: "0.4.2"
clock:
dependency: transitive
dependency: "direct main"
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
@@ -161,6 +217,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
dbus:
dependency: transitive
description:
name: dbus
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
url: "https://pub.dev"
source: hosted
version: "0.7.15"
drift:
dependency: "direct main"
description:
@@ -261,6 +325,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
@@ -389,6 +461,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: transitive
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:
@@ -445,6 +533,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -650,6 +746,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph:
dependency: transitive
description:
@@ -698,6 +802,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.0"
wakelock_plus:
dependency: "direct main"
description:
name: wakelock_plus
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
wakelock_plus_platform_interface:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
watcher:
dependency: transitive
description:
@@ -730,6 +850,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
@@ -738,6 +866,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
+4
View File
@@ -7,6 +7,8 @@ environment:
sdk: ^3.8.1
dependencies:
audioplayers: ^6.1.0
clock: ^1.1.1
drift: ^2.31.0
drift_flutter: ^0.2.8
flutter:
@@ -17,6 +19,7 @@ dependencies:
provider: ^6.1.5+1
shared_preferences: ^2.5.3
uuid: ^4.6.0
wakelock_plus: ^1.4.0
dev_dependencies:
flutter_test:
@@ -33,6 +36,7 @@ flutter:
assets:
- assets/exercises/
- assets/exercises/images/
- assets/sounds/
fonts:
# Stretching Mode
+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_library.dart';
import 'package:zenxercise/src/modes/mode.dart';
import 'package:zenxercise/src/history/session_repository.dart';
import 'package:zenxercise/src/plans/plan_repository.dart';
import 'package:zenxercise/src/player/player_effects.dart';
import 'package:zenxercise/src/settings/settings_controller.dart';
import 'test_database.dart';
@@ -48,6 +50,18 @@ final sampleLibrary = ExerciseLibrary([
exercise('Barbell_Squat', Mode.workout, primary: const ['quadriceps'], equipment: 'barbell'),
]);
/// Counts chimes instead of playing them and leaves the screen alone.
class FakePlayerEffects implements PlayerEffects {
final chimes = <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
/// navigation bar does not cover what a test taps.
void usePhoneScreen(WidgetTester tester) {
@@ -62,6 +76,8 @@ Future<SettingsController> pumpApp(
ExerciseLibrary? library,
Map<String, Object> prefs = const {},
PlanRepository? plans,
SessionRepository? sessions,
PlayerEffects? effects,
}) async {
SharedPreferences.setMockInitialValues(prefs);
final settings = SettingsController(SettingsService());
@@ -74,6 +90,8 @@ Future<SettingsController> pumpApp(
ChangeNotifierProvider.value(value: settings),
Provider.value(value: library ?? sampleLibrary),
Provider.value(value: plans ?? PlanRepository(db)),
Provider.value(value: sessions ?? SessionRepository(db)),
Provider<PlayerEffects>.value(value: effects ?? FakePlayerEffects()),
],
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()