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:
1 parent
bbdca95541
commit
971ff64117
28 files changed
+3465
-84
No files matched your search
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user