Stage 3: Plan editor with Steps, Supersets and reordering

- Plan model with immutable edits: add with Mode defaults, move, remove,
  Superset with next, take a member out, ungroup; Stretching length
  estimate from holds, sides, rounds and Transitions
- drift database (plans table, items as JSON, ADR 0005) and
  PlanRepository; Plans list per Mode with rename/duplicate/delete
- Editor: drag handles, step editor sheet (sets, reps or time, optional
  kg; hold, both sides, rounds; Superset sets and rest between),
  pause between holds for Stretching Plans
- Exercise picker reusing the Library browser; Add to plan from the
  Exercise detail page
- Theme: Mode-styled snackbar, FAB, menus, segmented buttons, switches

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-28 23:48:07 +01:00
1 parent 786dd52e30
commit bbdca95541
28 files changed
+3654 -11

No files matched your search

@@ -0,0 +1,18 @@
# ADR 0005 — A Plan's Steps and Supersets are stored as JSON
Status: Accepted (2026-10-29)
A Plan's items (Workout Steps, Stretching Steps and Supersets with their members) are stored as one
JSON column on the `plans` row, following CookIT's ADR 0002. A Plan is always loaded, edited and saved
as a whole, every edit is an immutable `Plan.withX` operation that can be unit-tested without a
database, and reordering or grouping into Supersets needs no position or group bookkeeping.
Plans are only listed per Mode, newest first.
## Consequences
- Items carry no ids; the editor keys rows by object identity, which is enough because every save
re-reads the Plan.
- Sessions will store a snapshot of the Plan's items (same JSON), so editing a Plan never changes
History.
- Querying across Plans by Exercise in SQL would need a table of its own; nothing needs it yet.
+5
View File
@@ -1,15 +1,19 @@
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'src/app.dart';
import 'src/database/database.dart';
import 'src/exercises/exercise_library.dart';
import 'src/plans/plan_repository.dart';
import 'src/settings/settings_controller.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
final db = AppDatabase(driftDatabase(name: 'zenxercise'));
final library = await ExerciseLibrary.load(rootBundle);
final settings = SettingsController(SettingsService());
await settings.loadSettings();
@@ -19,6 +23,7 @@ void main() async {
providers: [
ChangeNotifierProvider.value(value: settings),
Provider.value(value: library),
Provider.value(value: PlanRepository(db)),
],
child: const ZenXerciseApp(),
),
+4
View File
@@ -7,6 +7,8 @@ import 'design/zen_style.dart';
import 'exercises/views/exercise_detail_view.dart';
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 'settings/settings_controller.dart';
class ZenXerciseApp extends StatelessWidget {
@@ -34,6 +36,8 @@ class ZenXerciseApp extends StatelessWidget {
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),
_ => const HomeView(),
},
),
+41
View File
@@ -0,0 +1,41 @@
import 'package:drift/drift.dart';
import '../plans/models/plan.dart';
part 'database.g.dart';
/// One row per Plan. Its Steps and Supersets are only ever read and written
/// together with the Plan, so they are stored as JSON (see docs/adr/0005).
@DataClassName('PlanRow')
class Plans extends Table {
TextColumn get id => text()();
/// The Mode's name, `stretching` or `workout`.
TextColumn get mode => text()();
TextColumn get name => text()();
TextColumn get items => text().map(const PlanItemsConverter())();
IntColumn get transitionSeconds => integer().withDefault(const Constant(Plan.defaultTransitionSeconds))();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
class PlanItemsConverter extends TypeConverter<List<PlanItem>, String> {
const PlanItemsConverter();
@override
List<PlanItem> fromSql(String fromDb) => PlanItem.listFromJson(fromDb);
@override
String toSql(List<PlanItem> value) => PlanItem.listToJson(value);
}
@DriftDatabase(tables: [Plans])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
}
+604
View File
@@ -0,0 +1,604 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'database.dart';
// ignore_for_file: type=lint
class $PlansTable extends Plans with TableInfo<$PlansTable, PlanRow> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$PlansTable(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 _nameMeta = const VerificationMeta('name');
@override
late final GeneratedColumn<String> name = GeneratedColumn<String>(
'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>>($PlansTable.$converteritems);
static const VerificationMeta _transitionSecondsMeta = const VerificationMeta('transitionSeconds');
@override
late final GeneratedColumn<int> transitionSeconds = GeneratedColumn<int>(
'transition_seconds',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(Plan.defaultTransitionSeconds),
);
static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt');
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt');
@override
late final GeneratedColumn<DateTime> updatedAt = GeneratedColumn<DateTime>(
'updated_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [id, mode, name, items, transitionSeconds, createdAt, updatedAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'plans';
@override
VerificationContext validateIntegrity(Insertable<PlanRow> 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('name')) {
context.handle(_nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta));
} else if (isInserting) {
context.missing(_nameMeta);
}
if (data.containsKey('transition_seconds')) {
context.handle(
_transitionSecondsMeta,
transitionSeconds.isAcceptableOrUnknown(data['transition_seconds']!, _transitionSecondsMeta),
);
}
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
} else if (isInserting) {
context.missing(_createdAtMeta);
}
if (data.containsKey('updated_at')) {
context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
} else if (isInserting) {
context.missing(_updatedAtMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
PlanRow map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return PlanRow(
id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!,
mode: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}mode'])!,
name: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}name'])!,
items: $PlansTable.$converteritems.fromSql(
attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}items'])!,
),
transitionSeconds: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}transition_seconds'],
)!,
createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!,
);
}
@override
$PlansTable createAlias(String alias) {
return $PlansTable(attachedDatabase, alias);
}
static TypeConverter<List<PlanItem>, String> $converteritems = const PlanItemsConverter();
}
class PlanRow extends DataClass implements Insertable<PlanRow> {
final String id;
/// The Mode's name, `stretching` or `workout`.
final String mode;
final String name;
final List<PlanItem> items;
final int transitionSeconds;
final DateTime createdAt;
final DateTime updatedAt;
const PlanRow({
required this.id,
required this.mode,
required this.name,
required this.items,
required this.transitionSeconds,
required this.createdAt,
required this.updatedAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['mode'] = Variable<String>(mode);
map['name'] = Variable<String>(name);
{
map['items'] = Variable<String>($PlansTable.$converteritems.toSql(items));
}
map['transition_seconds'] = Variable<int>(transitionSeconds);
map['created_at'] = Variable<DateTime>(createdAt);
map['updated_at'] = Variable<DateTime>(updatedAt);
return map;
}
PlansCompanion toCompanion(bool nullToAbsent) {
return PlansCompanion(
id: Value(id),
mode: Value(mode),
name: Value(name),
items: Value(items),
transitionSeconds: Value(transitionSeconds),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
);
}
factory PlanRow.fromJson(Map<String, dynamic> json, {ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return PlanRow(
id: serializer.fromJson<String>(json['id']),
mode: serializer.fromJson<String>(json['mode']),
name: serializer.fromJson<String>(json['name']),
items: serializer.fromJson<List<PlanItem>>(json['items']),
transitionSeconds: serializer.fromJson<int>(json['transitionSeconds']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'mode': serializer.toJson<String>(mode),
'name': serializer.toJson<String>(name),
'items': serializer.toJson<List<PlanItem>>(items),
'transitionSeconds': serializer.toJson<int>(transitionSeconds),
'createdAt': serializer.toJson<DateTime>(createdAt),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
};
}
PlanRow copyWith({
String? id,
String? mode,
String? name,
List<PlanItem>? items,
int? transitionSeconds,
DateTime? createdAt,
DateTime? updatedAt,
}) => PlanRow(
id: id ?? this.id,
mode: mode ?? this.mode,
name: name ?? this.name,
items: items ?? this.items,
transitionSeconds: transitionSeconds ?? this.transitionSeconds,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
PlanRow copyWithCompanion(PlansCompanion data) {
return PlanRow(
id: data.id.present ? data.id.value : this.id,
mode: data.mode.present ? data.mode.value : this.mode,
name: data.name.present ? data.name.value : this.name,
items: data.items.present ? data.items.value : this.items,
transitionSeconds: data.transitionSeconds.present ? data.transitionSeconds.value : this.transitionSeconds,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
);
}
@override
String toString() {
return (StringBuffer('PlanRow(')
..write('id: $id, ')
..write('mode: $mode, ')
..write('name: $name, ')
..write('items: $items, ')
..write('transitionSeconds: $transitionSeconds, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, mode, name, items, transitionSeconds, createdAt, updatedAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is PlanRow &&
other.id == this.id &&
other.mode == this.mode &&
other.name == this.name &&
other.items == this.items &&
other.transitionSeconds == this.transitionSeconds &&
other.createdAt == this.createdAt &&
other.updatedAt == this.updatedAt);
}
class PlansCompanion extends UpdateCompanion<PlanRow> {
final Value<String> id;
final Value<String> mode;
final Value<String> name;
final Value<List<PlanItem>> items;
final Value<int> transitionSeconds;
final Value<DateTime> createdAt;
final Value<DateTime> updatedAt;
final Value<int> rowid;
const PlansCompanion({
this.id = const Value.absent(),
this.mode = const Value.absent(),
this.name = const Value.absent(),
this.items = const Value.absent(),
this.transitionSeconds = const Value.absent(),
this.createdAt = const Value.absent(),
this.updatedAt = const Value.absent(),
this.rowid = const Value.absent(),
});
PlansCompanion.insert({
required String id,
required String mode,
required String name,
required List<PlanItem> items,
this.transitionSeconds = const Value.absent(),
required DateTime createdAt,
required DateTime updatedAt,
this.rowid = const Value.absent(),
}) : id = Value(id),
mode = Value(mode),
name = Value(name),
items = Value(items),
createdAt = Value(createdAt),
updatedAt = Value(updatedAt);
static Insertable<PlanRow> custom({
Expression<String>? id,
Expression<String>? mode,
Expression<String>? name,
Expression<String>? items,
Expression<int>? transitionSeconds,
Expression<DateTime>? createdAt,
Expression<DateTime>? updatedAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (mode != null) 'mode': mode,
if (name != null) 'name': name,
if (items != null) 'items': items,
if (transitionSeconds != null) 'transition_seconds': transitionSeconds,
if (createdAt != null) 'created_at': createdAt,
if (updatedAt != null) 'updated_at': updatedAt,
if (rowid != null) 'rowid': rowid,
});
}
PlansCompanion copyWith({
Value<String>? id,
Value<String>? mode,
Value<String>? name,
Value<List<PlanItem>>? items,
Value<int>? transitionSeconds,
Value<DateTime>? createdAt,
Value<DateTime>? updatedAt,
Value<int>? rowid,
}) {
return PlansCompanion(
id: id ?? this.id,
mode: mode ?? this.mode,
name: name ?? this.name,
items: items ?? this.items,
transitionSeconds: transitionSeconds ?? this.transitionSeconds,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
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 (name.present) {
map['name'] = Variable<String>(name.value);
}
if (items.present) {
map['items'] = Variable<String>($PlansTable.$converteritems.toSql(items.value));
}
if (transitionSeconds.present) {
map['transition_seconds'] = Variable<int>(transitionSeconds.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (updatedAt.present) {
map['updated_at'] = Variable<DateTime>(updatedAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('PlansCompanion(')
..write('id: $id, ')
..write('mode: $mode, ')
..write('name: $name, ')
..write('items: $items, ')
..write('transitionSeconds: $transitionSeconds, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..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);
@override
Iterable<TableInfo<Table, Object?>> get allTables => allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [plans];
}
typedef $$PlansTableCreateCompanionBuilder =
PlansCompanion Function({
required String id,
required String mode,
required String name,
required List<PlanItem> items,
Value<int> transitionSeconds,
required DateTime createdAt,
required DateTime updatedAt,
Value<int> rowid,
});
typedef $$PlansTableUpdateCompanionBuilder =
PlansCompanion Function({
Value<String> id,
Value<String> mode,
Value<String> name,
Value<List<PlanItem>> items,
Value<int> transitionSeconds,
Value<DateTime> createdAt,
Value<DateTime> updatedAt,
Value<int> rowid,
});
class $$PlansTableFilterComposer extends Composer<_$AppDatabase, $PlansTable> {
$$PlansTableFilterComposer({
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 name => $composableBuilder(column: $table.name, builder: (column) => ColumnFilters(column));
ColumnWithTypeConverterFilters<List<PlanItem>, List<PlanItem>, String> get items =>
$composableBuilder(column: $table.items, builder: (column) => ColumnWithTypeConverterFilters(column));
ColumnFilters<int> get transitionSeconds =>
$composableBuilder(column: $table.transitionSeconds, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column));
}
class $$PlansTableOrderingComposer extends Composer<_$AppDatabase, $PlansTable> {
$$PlansTableOrderingComposer({
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 name =>
$composableBuilder(column: $table.name, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get items =>
$composableBuilder(column: $table.items, builder: (column) => ColumnOrderings(column));
ColumnOrderings<int> get transitionSeconds =>
$composableBuilder(column: $table.transitionSeconds, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column));
}
class $$PlansTableAnnotationComposer extends Composer<_$AppDatabase, $PlansTable> {
$$PlansTableAnnotationComposer({
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 name => $composableBuilder(column: $table.name, builder: (column) => column);
GeneratedColumnWithTypeConverter<List<PlanItem>, String> get items =>
$composableBuilder(column: $table.items, builder: (column) => column);
GeneratedColumn<int> get transitionSeconds =>
$composableBuilder(column: $table.transitionSeconds, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<DateTime> get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column);
}
class $$PlansTableTableManager
extends
RootTableManager<
_$AppDatabase,
$PlansTable,
PlanRow,
$$PlansTableFilterComposer,
$$PlansTableOrderingComposer,
$$PlansTableAnnotationComposer,
$$PlansTableCreateCompanionBuilder,
$$PlansTableUpdateCompanionBuilder,
(PlanRow, BaseReferences<_$AppDatabase, $PlansTable, PlanRow>),
PlanRow,
PrefetchHooks Function()
> {
$$PlansTableTableManager(_$AppDatabase db, $PlansTable table)
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () => $$PlansTableFilterComposer($db: db, $table: table),
createOrderingComposer: () => $$PlansTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () => $$PlansTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<String> id = const Value.absent(),
Value<String> mode = const Value.absent(),
Value<String> name = const Value.absent(),
Value<List<PlanItem>> items = const Value.absent(),
Value<int> transitionSeconds = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime> updatedAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => PlansCompanion(
id: id,
mode: mode,
name: name,
items: items,
transitionSeconds: transitionSeconds,
createdAt: createdAt,
updatedAt: updatedAt,
rowid: rowid,
),
createCompanionCallback:
({
required String id,
required String mode,
required String name,
required List<PlanItem> items,
Value<int> transitionSeconds = const Value.absent(),
required DateTime createdAt,
required DateTime updatedAt,
Value<int> rowid = const Value.absent(),
}) => PlansCompanion.insert(
id: id,
mode: mode,
name: name,
items: items,
transitionSeconds: transitionSeconds,
createdAt: createdAt,
updatedAt: updatedAt,
rowid: rowid,
),
withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$PlansTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$PlansTable,
PlanRow,
$$PlansTableFilterComposer,
$$PlansTableOrderingComposer,
$$PlansTableAnnotationComposer,
$$PlansTableCreateCompanionBuilder,
$$PlansTableUpdateCompanionBuilder,
(PlanRow, BaseReferences<_$AppDatabase, $PlansTable, PlanRow>),
PlanRow,
PrefetchHooks Function()
>;
class $AppDatabaseManager {
final _$AppDatabase _db;
$AppDatabaseManager(this._db);
$$PlansTableTableManager get plans => $$PlansTableTableManager(_db, _db.plans);
}
+39 -1
View File
@@ -107,7 +107,45 @@ class AppTheme {
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
insetPadding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
shape: RoundedRectangleBorder(borderRadius: control),
// Deep pine on the light ground; a lifted graphite with a hairline on the dark one.
backgroundColor: workout ? s.surfaceSoft : s.ink,
contentTextStyle: TextStyle(fontFamily: s.bodyFont, color: workout ? s.ink : s.background),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 16)),
side: workout ? BorderSide(color: s.line) : BorderSide.none,
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: s.accent,
foregroundColor: s.onAccent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 28))),
),
popupMenuTheme: PopupMenuThemeData(
color: s.surfaceSoft,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 16))),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected) ? s.accent : s.surfaceSoft,
),
foregroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected) ? s.onAccent : s.ink,
),
side: WidgetStatePropertyAll(BorderSide(color: s.line)),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 20))),
),
),
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected) ? s.onAccent : s.inkMuted,
),
trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected) ? s.accent : s.surfaceSoft,
),
),
);
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'zen_style.dart';
/// A labelled value with minus and plus buttons; a null callback disables its button.
class NumberStepper extends StatelessWidget {
const NumberStepper({
super.key,
required this.label,
required this.value,
required this.onDecrement,
required this.onIncrement,
this.hint,
});
final String label;
final String value;
final String? hint;
final VoidCallback? onDecrement;
final VoidCallback? onIncrement;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: text.titleSmall),
if (hint != null) Text(hint!, style: text.bodySmall?.copyWith(color: s.inkMuted)),
],
),
),
_StepButton(icon: Icons.remove_rounded, onTap: onDecrement, tooltip: '$label −'),
SizedBox(
width: 84,
child: Text(
value,
textAlign: TextAlign.center,
style: text.titleMedium?.copyWith(fontFeatures: const [FontFeature.tabularFigures()]),
),
),
_StepButton(icon: Icons.add_rounded, onTap: onIncrement, tooltip: '$label +'),
],
),
);
}
}
class _StepButton extends StatelessWidget {
const _StepButton({required this.icon, required this.onTap, required this.tooltip});
final IconData icon;
final VoidCallback? onTap;
final String tooltip;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final radius = BorderRadius.circular(s.controlRadius.clamp(0, 20));
return Material(
color: onTap == null ? s.surfaceSoft.withValues(alpha: 0.5) : s.surfaceSoft,
borderRadius: radius,
child: InkWell(
borderRadius: radius,
onTap: onTap,
child: Tooltip(
message: tooltip,
child: SizedBox.square(
dimension: 40,
child: Icon(icon, size: 20, color: onTap == null ? s.inkMuted.withValues(alpha: 0.5) : s.ink),
),
),
),
);
}
}
+10 -2
View File
@@ -4,11 +4,14 @@ import 'zen_style.dart';
/// A tab's big title and an optional line under it.
class PageHeader extends StatelessWidget {
const PageHeader({super.key, required this.title, this.subtitle});
const PageHeader({super.key, required this.title, this.subtitle, this.action});
final String title;
final String? subtitle;
/// A button beside the title, such as "New plan".
final Widget? action;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
@@ -18,7 +21,12 @@ class PageHeader extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: text.headlineLarge),
Row(
children: [
Expanded(child: Text(title, style: text.headlineLarge)),
if (action != null) ...[const SizedBox(width: 12), action!],
],
),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(subtitle!, style: text.bodyLarge?.copyWith(color: s.inkMuted)),
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../design/zen_style.dart';
import '../../localization/app_localizations.dart';
import '../../plans/views/add_to_plan_sheet.dart';
import '../exercise.dart';
import '../exercise_labels.dart';
import '../exercise_library.dart';
@@ -24,8 +25,21 @@ class ExerciseDetailView extends StatelessWidget {
final text = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: SizedBox(
height: 52,
child: FilledButton.icon(
onPressed: () => showAddToPlanSheet(context, exercise),
icon: const Icon(Icons.playlist_add_rounded),
label: Text(l10n.addToPlan),
),
),
),
),
body: ListView(
padding: EdgeInsets.fromLTRB(16, 0, 16, 32 + MediaQuery.paddingOf(context).bottom),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
children: [
FlipAnimation(exercise: exercise),
const SizedBox(height: 20),
+161 -1
View File
@@ -77,5 +77,165 @@
"detailType": "Type",
"equipmentNone": "None listed",
"flipStart": "Start position",
"flipEnd": "End position"
"flipEnd": "End position",
"newPlan": "New plan",
"newPlanTitle": "Name your plan",
"planNameHint": "{mode, select, stretching{e.g. Morning stretch} other{e.g. Push day}}",
"@planNameHint": {
"placeholders": {
"mode": {
"type": "String"
}
}
},
"create": "Create",
"cancel": "Cancel",
"save": "Save",
"rename": "Rename",
"duplicate": "Duplicate",
"delete": "Delete",
"remove": "Remove",
"edit": "Edit",
"done": "Done",
"moreActions": "More actions",
"renamePlanTitle": "Rename plan",
"duplicateName": "{name} (copy)",
"@duplicateName": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"deletePlanTitle": "Delete {name}?",
"@deletePlanTitle": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"deletePlanBody": "The plan is removed. Sessions you already finished stay in your history.",
"planExerciseCount": "{count, plural, =0{No exercises yet} =1{1 exercise} other{{count} exercises}}",
"@planExerciseCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"planDuration": "{minutes, plural, =0{under a minute} other{about {minutes} min}}",
"@planDuration": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"addExercises": "Add exercises",
"planEmptyTitle": "No exercises yet",
"planEmptyBody": "{mode, select, stretching{Add stretches from the library to build your routine.} other{Add exercises from the library to build your workout.}}",
"@planEmptyBody": {
"placeholders": {
"mode": {
"type": "String"
}
}
},
"transitionLabel": "Pause between holds",
"secondsShort": "{seconds} s",
"@secondsShort": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"repsCount": "{count, plural, =1{1 rep} other{{count} reps}}",
"@repsCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"setsTimes": "{sets} × {target}",
"@setsTimes": {
"placeholders": {
"sets": {
"type": "int"
},
"target": {
"type": "String"
}
}
},
"weightKg": "{kg} kg",
"@weightKg": {
"placeholders": {
"kg": {
"type": "String"
}
}
},
"holdSummary": "{seconds} s hold",
"@holdSummary": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"bothSidesShort": "both sides",
"roundsCount": "{count, plural, =1{1 round} other{{count} rounds}}",
"@roundsCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"setsCount": "{count, plural, =1{1 set} other{{count} sets}}",
"@setsCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"stepSets": "Sets",
"stepReps": "Reps",
"stepTime": "Time",
"stepWeight": "Weight",
"addWeight": "Add a target weight",
"removeWeight": "Remove weight",
"stepHold": "Hold",
"stepBothSides": "Both sides",
"stepBothSidesHint": "Left side first, then the right",
"stepRounds": "Rounds",
"supersetTitle": "Superset",
"restBetweenLabel": "Rest between exercises",
"restBetweenHint": "When off, you go straight to the next exercise and rest after each round.",
"supersetWithNext": "Superset with next",
"takeOutOfSuperset": "Take out of superset",
"ungroup": "Ungroup",
"dragToReorder": "Drag to reorder",
"added": "Added {name}",
"@added": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"addedToPlan": "Added to {plan}",
"@addedToPlan": {
"placeholders": {
"plan": {
"type": "String"
}
}
},
"addToPlan": "Add to plan",
"addToPlanTitle": "Add to which plan?"
}
+312
View File
@@ -297,6 +297,318 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'End position'**
String get flipEnd;
/// No description provided for @newPlan.
///
/// In en, this message translates to:
/// **'New plan'**
String get newPlan;
/// No description provided for @newPlanTitle.
///
/// In en, this message translates to:
/// **'Name your plan'**
String get newPlanTitle;
/// No description provided for @planNameHint.
///
/// In en, this message translates to:
/// **'{mode, select, stretching{e.g. Morning stretch} other{e.g. Push day}}'**
String planNameHint(String mode);
/// No description provided for @create.
///
/// In en, this message translates to:
/// **'Create'**
String get create;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancel;
/// No description provided for @save.
///
/// In en, this message translates to:
/// **'Save'**
String get save;
/// No description provided for @rename.
///
/// In en, this message translates to:
/// **'Rename'**
String get rename;
/// No description provided for @duplicate.
///
/// In en, this message translates to:
/// **'Duplicate'**
String get duplicate;
/// No description provided for @delete.
///
/// In en, this message translates to:
/// **'Delete'**
String get delete;
/// No description provided for @remove.
///
/// In en, this message translates to:
/// **'Remove'**
String get remove;
/// No description provided for @edit.
///
/// In en, this message translates to:
/// **'Edit'**
String get edit;
/// No description provided for @done.
///
/// In en, this message translates to:
/// **'Done'**
String get done;
/// No description provided for @moreActions.
///
/// In en, this message translates to:
/// **'More actions'**
String get moreActions;
/// No description provided for @renamePlanTitle.
///
/// In en, this message translates to:
/// **'Rename plan'**
String get renamePlanTitle;
/// No description provided for @duplicateName.
///
/// In en, this message translates to:
/// **'{name} (copy)'**
String duplicateName(String name);
/// No description provided for @deletePlanTitle.
///
/// In en, this message translates to:
/// **'Delete {name}?'**
String deletePlanTitle(String name);
/// No description provided for @deletePlanBody.
///
/// In en, this message translates to:
/// **'The plan is removed. Sessions you already finished stay in your history.'**
String get deletePlanBody;
/// No description provided for @planExerciseCount.
///
/// In en, this message translates to:
/// **'{count, plural, =0{No exercises yet} =1{1 exercise} other{{count} exercises}}'**
String planExerciseCount(int count);
/// No description provided for @planDuration.
///
/// In en, this message translates to:
/// **'{minutes, plural, =0{under a minute} other{about {minutes} min}}'**
String planDuration(int minutes);
/// No description provided for @addExercises.
///
/// In en, this message translates to:
/// **'Add exercises'**
String get addExercises;
/// No description provided for @planEmptyTitle.
///
/// In en, this message translates to:
/// **'No exercises yet'**
String get planEmptyTitle;
/// No description provided for @planEmptyBody.
///
/// In en, this message translates to:
/// **'{mode, select, stretching{Add stretches from the library to build your routine.} other{Add exercises from the library to build your workout.}}'**
String planEmptyBody(String mode);
/// No description provided for @transitionLabel.
///
/// In en, this message translates to:
/// **'Pause between holds'**
String get transitionLabel;
/// No description provided for @secondsShort.
///
/// In en, this message translates to:
/// **'{seconds} s'**
String secondsShort(int seconds);
/// No description provided for @repsCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 rep} other{{count} reps}}'**
String repsCount(int count);
/// No description provided for @setsTimes.
///
/// In en, this message translates to:
/// **'{sets} × {target}'**
String setsTimes(int sets, String target);
/// No description provided for @weightKg.
///
/// In en, this message translates to:
/// **'{kg} kg'**
String weightKg(String kg);
/// No description provided for @holdSummary.
///
/// In en, this message translates to:
/// **'{seconds} s hold'**
String holdSummary(int seconds);
/// No description provided for @bothSidesShort.
///
/// In en, this message translates to:
/// **'both sides'**
String get bothSidesShort;
/// No description provided for @roundsCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 round} other{{count} rounds}}'**
String roundsCount(int count);
/// No description provided for @setsCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 set} other{{count} sets}}'**
String setsCount(int count);
/// No description provided for @stepSets.
///
/// In en, this message translates to:
/// **'Sets'**
String get stepSets;
/// No description provided for @stepReps.
///
/// In en, this message translates to:
/// **'Reps'**
String get stepReps;
/// No description provided for @stepTime.
///
/// In en, this message translates to:
/// **'Time'**
String get stepTime;
/// No description provided for @stepWeight.
///
/// In en, this message translates to:
/// **'Weight'**
String get stepWeight;
/// No description provided for @addWeight.
///
/// In en, this message translates to:
/// **'Add a target weight'**
String get addWeight;
/// No description provided for @removeWeight.
///
/// In en, this message translates to:
/// **'Remove weight'**
String get removeWeight;
/// No description provided for @stepHold.
///
/// In en, this message translates to:
/// **'Hold'**
String get stepHold;
/// No description provided for @stepBothSides.
///
/// In en, this message translates to:
/// **'Both sides'**
String get stepBothSides;
/// No description provided for @stepBothSidesHint.
///
/// In en, this message translates to:
/// **'Left side first, then the right'**
String get stepBothSidesHint;
/// No description provided for @stepRounds.
///
/// In en, this message translates to:
/// **'Rounds'**
String get stepRounds;
/// No description provided for @supersetTitle.
///
/// In en, this message translates to:
/// **'Superset'**
String get supersetTitle;
/// No description provided for @restBetweenLabel.
///
/// In en, this message translates to:
/// **'Rest between exercises'**
String get restBetweenLabel;
/// No description provided for @restBetweenHint.
///
/// In en, this message translates to:
/// **'When off, you go straight to the next exercise and rest after each round.'**
String get restBetweenHint;
/// No description provided for @supersetWithNext.
///
/// In en, this message translates to:
/// **'Superset with next'**
String get supersetWithNext;
/// No description provided for @takeOutOfSuperset.
///
/// In en, this message translates to:
/// **'Take out of superset'**
String get takeOutOfSuperset;
/// No description provided for @ungroup.
///
/// In en, this message translates to:
/// **'Ungroup'**
String get ungroup;
/// No description provided for @dragToReorder.
///
/// In en, this message translates to:
/// **'Drag to reorder'**
String get dragToReorder;
/// No description provided for @added.
///
/// In en, this message translates to:
/// **'Added {name}'**
String added(String name);
/// No description provided for @addedToPlan.
///
/// In en, this message translates to:
/// **'Added to {plan}'**
String addedToPlan(String plan);
/// No description provided for @addToPlan.
///
/// In en, this message translates to:
/// **'Add to plan'**
String get addToPlan;
/// No description provided for @addToPlanTitle.
///
/// In en, this message translates to:
/// **'Add to which plan?'**
String get addToPlanTitle;
}
class _AppLocalizationsDelegate
@@ -158,4 +158,231 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get flipEnd => 'End position';
@override
String get newPlan => 'New plan';
@override
String get newPlanTitle => 'Name your plan';
@override
String planNameHint(String mode) {
String _temp0 = intl.Intl.selectLogic(mode, {
'stretching': 'e.g. Morning stretch',
'other': 'e.g. Push day',
});
return '$_temp0';
}
@override
String get create => 'Create';
@override
String get cancel => 'Cancel';
@override
String get save => 'Save';
@override
String get rename => 'Rename';
@override
String get duplicate => 'Duplicate';
@override
String get delete => 'Delete';
@override
String get remove => 'Remove';
@override
String get edit => 'Edit';
@override
String get done => 'Done';
@override
String get moreActions => 'More actions';
@override
String get renamePlanTitle => 'Rename plan';
@override
String duplicateName(String name) {
return '$name (copy)';
}
@override
String deletePlanTitle(String name) {
return 'Delete $name?';
}
@override
String get deletePlanBody =>
'The plan is removed. Sessions you already finished stay in your history.';
@override
String planExerciseCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count exercises',
one: '1 exercise',
zero: 'No exercises yet',
);
return '$_temp0';
}
@override
String planDuration(int minutes) {
String _temp0 = intl.Intl.pluralLogic(
minutes,
locale: localeName,
other: 'about $minutes min',
zero: 'under a minute',
);
return '$_temp0';
}
@override
String get addExercises => 'Add exercises';
@override
String get planEmptyTitle => 'No exercises yet';
@override
String planEmptyBody(String mode) {
String _temp0 = intl.Intl.selectLogic(mode, {
'stretching': 'Add stretches from the library to build your routine.',
'other': 'Add exercises from the library to build your workout.',
});
return '$_temp0';
}
@override
String get transitionLabel => 'Pause between holds';
@override
String secondsShort(int seconds) {
return '$seconds s';
}
@override
String repsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count reps',
one: '1 rep',
);
return '$_temp0';
}
@override
String setsTimes(int sets, String target) {
return '$sets × $target';
}
@override
String weightKg(String kg) {
return '$kg kg';
}
@override
String holdSummary(int seconds) {
return '$seconds s hold';
}
@override
String get bothSidesShort => 'both sides';
@override
String roundsCount(int count) {
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',
);
return '$_temp0';
}
@override
String get stepSets => 'Sets';
@override
String get stepReps => 'Reps';
@override
String get stepTime => 'Time';
@override
String get stepWeight => 'Weight';
@override
String get addWeight => 'Add a target weight';
@override
String get removeWeight => 'Remove weight';
@override
String get stepHold => 'Hold';
@override
String get stepBothSides => 'Both sides';
@override
String get stepBothSidesHint => 'Left side first, then the right';
@override
String get stepRounds => 'Rounds';
@override
String get supersetTitle => 'Superset';
@override
String get restBetweenLabel => 'Rest between exercises';
@override
String get restBetweenHint =>
'When off, you go straight to the next exercise and rest after each round.';
@override
String get supersetWithNext => 'Superset with next';
@override
String get takeOutOfSuperset => 'Take out of superset';
@override
String get ungroup => 'Ungroup';
@override
String get dragToReorder => 'Drag to reorder';
@override
String added(String name) {
return 'Added $name';
}
@override
String addedToPlan(String plan) {
return 'Added to $plan';
}
@override
String get addToPlan => 'Add to plan';
@override
String get addToPlanTitle => 'Add to which plan?';
}
+357
View File
@@ -0,0 +1,357 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../modes/mode.dart';
/// A user-created, ordered list of Steps and Supersets belonging to one Mode.
/// Every edit returns a new Plan.
@immutable
class Plan {
const Plan({
required this.id,
required this.mode,
required this.name,
required this.items,
this.transitionSeconds = defaultTransitionSeconds,
required this.createdAt,
required this.updatedAt,
});
static const defaultTransitionSeconds = 5;
final String id;
final Mode mode;
final String name;
final List<PlanItem> items;
/// Stretching only: the length of each Transition between holds.
final int transitionSeconds;
final DateTime createdAt;
final DateTime updatedAt;
/// Every Exercise in the Plan, counting each Superset member.
int get exerciseCount => items.fold(0, (n, item) => n + (item is Superset ? item.members.length : 1));
/// Holds plus the Transitions between them, for Stretching Plans; Workout
/// Plans run at the user's pace and have no estimate.
Duration? get estimatedDuration {
if (mode != Mode.stretching) return null;
var holds = 0;
var seconds = 0;
for (final step in items.whereType<StretchingStep>()) {
final count = step.rounds * (step.bothSides ? 2 : 1);
holds += count;
seconds += count * step.holdSeconds;
}
return Duration(seconds: seconds + (holds > 1 ? (holds - 1) * transitionSeconds : 0));
}
Plan copyWith({String? name, List<PlanItem>? items, int? transitionSeconds, DateTime? updatedAt}) => Plan(
id: id,
mode: mode,
name: name ?? this.name,
items: items ?? this.items,
transitionSeconds: transitionSeconds ?? this.transitionSeconds,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
/// Appends a Step with the Mode's defaults: 3 × 10 reps, or a 30 s hold on one side.
Plan withExerciseAdded(String exerciseId) => copyWith(
items: [
...items,
switch (mode) {
Mode.workout => WorkoutStep(exerciseId: exerciseId, sets: 3, target: const WorkoutTarget.reps(10)),
Mode.stretching => StretchingStep(exerciseId: exerciseId, holdSeconds: 30, bothSides: false, rounds: 1),
},
],
);
Plan withItemReplaced(int index, PlanItem item) => copyWith(items: [...items]..[index] = item);
Plan withItemRemoved(int index) => copyWith(items: [...items]..removeAt(index));
/// Moves an item the way a reorderable list reports it: [newIndex] counts
/// the moved item still in place.
Plan withItemMoved(int oldIndex, int newIndex) {
final list = [...items];
final item = list.removeAt(oldIndex);
list.insert(newIndex > oldIndex ? newIndex - 1 : newIndex, item);
return copyWith(items: list);
}
bool canSupersetWithNext(int index) =>
mode == Mode.workout &&
index + 1 < items.length &&
_members(items[index]) != null &&
_members(items[index + 1]) != null;
/// Groups the item with the one after it. The result keeps the first item's
/// set count (and rest choice, if it was already a Superset).
Plan withSupersetWithNext(int index) {
assert(canSupersetWithNext(index));
final first = items[index];
final merged = Superset(
sets: switch (first) {
WorkoutStep(:final sets) || Superset(:final sets) => sets,
StretchingStep() => throw StateError('Stretching Steps cannot form a Superset'),
},
restBetween: first is Superset && first.restBetween,
members: [..._members(first)!, ..._members(items[index + 1])!],
);
return copyWith(items: [...items]..replaceRange(index, index + 2, [merged]));
}
/// Takes one member out; it lands right after its Superset as a Step with the
/// Superset's set count. A Superset left with one member becomes a Step too.
Plan withMemberRemovedFromSuperset(int index, int memberIndex) {
final superset = items[index] as Superset;
final taken = superset.members[memberIndex].toStep(superset.sets);
final rest = [...superset.members]..removeAt(memberIndex);
final remaining = rest.length == 1 ? rest.single.toStep(superset.sets) : superset.copyWith(members: rest);
return copyWith(items: [...items]..replaceRange(index, index + 1, [remaining, taken]));
}
/// Turns a Superset back into one Step per member, each with its set count.
Plan withUngrouped(int index) {
final superset = items[index] as Superset;
return copyWith(
items: [...items]..replaceRange(index, index + 1, [for (final m in superset.members) m.toStep(superset.sets)]),
);
}
static List<SupersetMember>? _members(PlanItem item) => switch (item) {
WorkoutStep() => [item.toMember()],
Superset(:final members) => members,
StretchingStep() => null,
};
}
/// One entry of a Plan's list: a Step, or a Superset of Workout Steps.
sealed class PlanItem {
const PlanItem();
Map<String, Object?> toJson();
factory PlanItem.fromJson(Map<String, Object?> json) => switch (json['type']) {
'workout' => WorkoutStep.fromJson(json),
'stretching' => StretchingStep.fromJson(json),
'superset' => Superset.fromJson(json),
final type => throw FormatException('Unknown Plan item type $type'),
};
static String listToJson(List<PlanItem> items) => jsonEncode([for (final i in items) i.toJson()]);
static List<PlanItem> listFromJson(String json) => [
for (final i in jsonDecode(json) as List) PlanItem.fromJson((i as Map).cast<String, Object?>()),
];
}
/// What one Workout set asks for: a number of reps or a time.
@immutable
class WorkoutTarget {
const WorkoutTarget.reps(int this.reps) : seconds = null;
const WorkoutTarget.seconds(int this.seconds) : reps = null;
final int? reps;
final int? seconds;
bool get isTimed => seconds != null;
Map<String, Object?> toJson() => {'reps': reps, 'seconds': seconds};
factory WorkoutTarget.fromJson(Map<String, Object?> json) => json['seconds'] != null
? WorkoutTarget.seconds(json['seconds']! as int)
: WorkoutTarget.reps(json['reps']! as int);
@override
bool operator ==(Object other) => other is WorkoutTarget && other.reps == reps && other.seconds == seconds;
@override
int get hashCode => Object.hash(reps, seconds);
@override
String toString() => isTimed ? '${seconds}s' : '$reps reps';
}
@immutable
class WorkoutStep extends PlanItem {
const WorkoutStep({required this.exerciseId, required this.sets, required this.target, this.weightKg});
final String exerciseId;
final int sets;
final WorkoutTarget target;
/// Optional target weight in kg.
final double? weightKg;
WorkoutStep copyWith({int? sets, WorkoutTarget? target, double? Function()? weightKg}) => WorkoutStep(
exerciseId: exerciseId,
sets: sets ?? this.sets,
target: target ?? this.target,
weightKg: weightKg == null ? this.weightKg : weightKg(),
);
SupersetMember toMember() => SupersetMember(exerciseId: exerciseId, target: target, weightKg: weightKg);
@override
Map<String, Object?> toJson() => {
'type': 'workout',
'exerciseId': exerciseId,
'sets': sets,
...target.toJson(),
'weightKg': weightKg,
};
factory WorkoutStep.fromJson(Map<String, Object?> json) => WorkoutStep(
exerciseId: json['exerciseId']! as String,
sets: json['sets']! as int,
target: WorkoutTarget.fromJson(json),
weightKg: (json['weightKg'] as num?)?.toDouble(),
);
@override
bool operator ==(Object other) =>
other is WorkoutStep &&
other.exerciseId == exerciseId &&
other.sets == sets &&
other.target == target &&
other.weightKg == weightKg;
@override
int get hashCode => Object.hash(exerciseId, sets, target, weightKg);
@override
String toString() => 'WorkoutStep($exerciseId, $sets × $target, $weightKg kg)';
}
@immutable
class StretchingStep extends PlanItem {
const StretchingStep({
required this.exerciseId,
required this.holdSeconds,
required this.bothSides,
required this.rounds,
});
final String exerciseId;
final int holdSeconds;
/// Held on the left, then the right side, each for [holdSeconds].
final bool bothSides;
final int rounds;
StretchingStep copyWith({int? holdSeconds, bool? bothSides, int? rounds}) => StretchingStep(
exerciseId: exerciseId,
holdSeconds: holdSeconds ?? this.holdSeconds,
bothSides: bothSides ?? this.bothSides,
rounds: rounds ?? this.rounds,
);
@override
Map<String, Object?> toJson() => {
'type': 'stretching',
'exerciseId': exerciseId,
'holdSeconds': holdSeconds,
'bothSides': bothSides,
'rounds': rounds,
};
factory StretchingStep.fromJson(Map<String, Object?> json) => StretchingStep(
exerciseId: json['exerciseId']! as String,
holdSeconds: json['holdSeconds']! as int,
bothSides: json['bothSides']! as bool,
rounds: json['rounds']! as int,
);
@override
bool operator ==(Object other) =>
other is StretchingStep &&
other.exerciseId == exerciseId &&
other.holdSeconds == holdSeconds &&
other.bothSides == bothSides &&
other.rounds == rounds;
@override
int get hashCode => Object.hash(exerciseId, holdSeconds, bothSides, rounds);
@override
String toString() => 'StretchingStep($exerciseId, ${holdSeconds}s, both: $bothSides, × $rounds)';
}
/// Two or more Workout Steps performed alternately, one set of each in turn.
@immutable
class Superset extends PlanItem {
const Superset({required this.sets, required this.restBetween, required this.members});
final int sets;
/// Whether the Rest Screen also appears between members within a round, not
/// only after each round.
final bool restBetween;
final List<SupersetMember> members;
Superset copyWith({int? sets, bool? restBetween, List<SupersetMember>? members}) =>
Superset(sets: sets ?? this.sets, restBetween: restBetween ?? this.restBetween, members: members ?? this.members);
@override
Map<String, Object?> toJson() => {
'type': 'superset',
'sets': sets,
'restBetween': restBetween,
'members': [for (final m in members) m.toJson()],
};
factory Superset.fromJson(Map<String, Object?> json) => Superset(
sets: json['sets']! as int,
restBetween: json['restBetween']! as bool,
members: [for (final m in json['members']! as List) SupersetMember.fromJson((m as Map).cast<String, Object?>())],
);
@override
bool operator ==(Object other) =>
other is Superset && other.sets == sets && other.restBetween == restBetween && listEquals(other.members, members);
@override
int get hashCode => Object.hash(sets, restBetween, Object.hashAll(members));
@override
String toString() => 'Superset($sets sets, rest: $restBetween, $members)';
}
/// One Exercise inside a Superset: its own target and weight, the Superset's set count.
@immutable
class SupersetMember {
const SupersetMember({required this.exerciseId, required this.target, this.weightKg});
final String exerciseId;
final WorkoutTarget target;
final double? weightKg;
SupersetMember copyWith({WorkoutTarget? target, double? Function()? weightKg}) => SupersetMember(
exerciseId: exerciseId,
target: target ?? this.target,
weightKg: weightKg == null ? this.weightKg : weightKg(),
);
WorkoutStep toStep(int sets) => WorkoutStep(exerciseId: exerciseId, sets: sets, target: target, weightKg: weightKg);
Map<String, Object?> toJson() => {'exerciseId': exerciseId, ...target.toJson(), 'weightKg': weightKg};
factory SupersetMember.fromJson(Map<String, Object?> json) => SupersetMember(
exerciseId: json['exerciseId']! as String,
target: WorkoutTarget.fromJson(json),
weightKg: (json['weightKg'] as num?)?.toDouble(),
);
@override
bool operator ==(Object other) =>
other is SupersetMember && other.exerciseId == exerciseId && other.target == target && other.weightKg == weightKg;
@override
int get hashCode => Object.hash(exerciseId, target, weightKg);
@override
String toString() => 'SupersetMember($exerciseId, $target, $weightKg kg)';
}
+34
View File
@@ -0,0 +1,34 @@
import '../localization/app_localizations.dart';
import 'models/plan.dart';
/// A weight without a trailing ".0": 20, 12.5.
String kgNumber(double kg) => kg == kg.roundToDouble() ? kg.toInt().toString() : kg.toString();
String targetLabel(AppLocalizations l10n, WorkoutTarget target) =>
target.isTimed ? l10n.secondsShort(target.seconds!) : l10n.repsCount(target.reps!);
/// "3 × 10 reps · 20 kg", "45 s · 8 kg", "30 s hold · both sides · 2 rounds".
String workoutStepSummary(AppLocalizations l10n, WorkoutStep step) => [
l10n.setsTimes(step.sets, targetLabel(l10n, step.target)),
if (step.weightKg != null) l10n.weightKg(kgNumber(step.weightKg!)),
].join(' · ');
String memberSummary(AppLocalizations l10n, SupersetMember member) => [
targetLabel(l10n, member.target),
if (member.weightKg != null) l10n.weightKg(kgNumber(member.weightKg!)),
].join(' · ');
String stretchingStepSummary(AppLocalizations l10n, StretchingStep step) => [
l10n.holdSummary(step.holdSeconds),
if (step.bothSides) l10n.bothSidesShort,
if (step.rounds > 1) l10n.roundsCount(step.rounds),
].join(' · ');
/// "3 exercises · about 6 min" (the length only for Stretching Plans).
String planSummary(AppLocalizations l10n, Plan plan) {
final duration = plan.estimatedDuration;
return [
l10n.planExerciseCount(plan.exerciseCount),
if (duration != null && plan.exerciseCount > 0) l10n.planDuration((duration.inSeconds / 60).round()),
].join(' · ');
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../database/database.dart';
import '../modes/mode.dart';
import 'models/plan.dart';
/// The user's Plans, per Mode.
class PlanRepository {
PlanRepository(this._db, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now;
final AppDatabase _db;
final DateTime Function() _clock;
/// A Mode's Plans, the newest first.
Stream<List<Plan>> watchPlans(Mode mode) {
final query = _db.select(_db.plans)
..where((p) => p.mode.equals(mode.name))
..orderBy([(p) => OrderingTerm.desc(p.createdAt)]);
return query.watch().map((rows) => rows.map(_toPlan).toList());
}
Stream<Plan?> watchPlan(String id) => (_db.select(
_db.plans,
)..where((p) => p.id.equals(id))).watchSingleOrNull().map((r) => r == null ? null : _toPlan(r));
Future<Plan?> get(String id) async {
final row = await (_db.select(_db.plans)..where((p) => p.id.equals(id))).getSingleOrNull();
return row == null ? null : _toPlan(row);
}
Future<Plan> create(Mode mode, String name) async {
final now = _clock();
final plan = Plan(id: const Uuid().v4(), mode: mode, name: name, items: const [], createdAt: now, updatedAt: now);
await _db.into(_db.plans).insert(_toCompanion(plan));
return plan;
}
Future<void> save(Plan plan) =>
_db.into(_db.plans).insertOnConflictUpdate(_toCompanion(plan.copyWith(updatedAt: _clock())));
Future<Plan> duplicate(String id, String name) async {
final original = (await get(id))!;
final now = _clock();
final copy = Plan(
id: const Uuid().v4(),
mode: original.mode,
name: name,
items: original.items,
transitionSeconds: original.transitionSeconds,
createdAt: now,
updatedAt: now,
);
await _db.into(_db.plans).insert(_toCompanion(copy));
return copy;
}
Future<void> delete(String id) => (_db.delete(_db.plans)..where((p) => p.id.equals(id))).go();
static Plan _toPlan(PlanRow row) => Plan(
id: row.id,
mode: Mode.fromName(row.mode),
name: row.name,
items: row.items,
transitionSeconds: row.transitionSeconds,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
);
static PlansCompanion _toCompanion(Plan plan) => PlansCompanion.insert(
id: plan.id,
mode: plan.mode.name,
name: plan.name,
items: plan.items,
transitionSeconds: Value(plan.transitionSeconds),
createdAt: plan.createdAt,
updatedAt: plan.updatedAt,
);
}
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/zen_style.dart';
import '../../exercises/exercise.dart';
import '../../localization/app_localizations.dart';
import '../plan_labels.dart';
import '../plan_repository.dart';
import 'plan_dialogs.dart';
/// Lets the user pick one of the Exercise's Mode's Plans, or start a new one,
/// and adds the Exercise to its end.
Future<void> showAddToPlanSheet(BuildContext context, Exercise exercise) async {
final l10n = AppLocalizations.of(context);
final plans = context.read<PlanRepository>();
final messenger = ScaffoldMessenger.of(context);
final chosen = await showModalBottomSheet<_Choice>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) => _AddToPlanSheet(exercise: exercise),
);
if (chosen == null || !context.mounted) return;
var planId = chosen.planId;
if (planId == null) {
final name = await promptPlanName(context, mode: exercise.mode, title: l10n.newPlanTitle);
if (name == null) return;
planId = (await plans.create(exercise.mode, name)).id;
}
final plan = (await plans.get(planId))!;
await plans.save(plan.withExerciseAdded(exercise.id));
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.addedToPlan(plan.name))));
}
/// A Plan's id, or null for "New plan".
class _Choice {
const _Choice(this.planId);
final String? planId;
}
class _AddToPlanSheet extends StatelessWidget {
const _AddToPlanSheet({required this.exercise});
final Exercise exercise;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
return SafeArea(
child: StreamBuilder(
stream: context.read<PlanRepository>().watchPlans(exercise.mode),
builder: (context, snapshot) {
final plans = snapshot.data ?? const [];
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.7),
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 12),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Text(l10n.addToPlanTitle, style: text.titleLarge),
),
for (final plan in plans)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
title: Text(plan.name, style: text.titleSmall),
subtitle: Text(planSummary(l10n, plan), style: TextStyle(color: s.inkMuted)),
onTap: () => Navigator.of(context).pop(_Choice(plan.id)),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Icon(Icons.add_rounded, color: s.accent),
title: Text(l10n.newPlan, style: text.titleSmall?.copyWith(color: s.accent)),
onTap: () => Navigator.of(context).pop(const _Choice(null)),
),
],
),
);
},
),
);
}
}
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../exercises/exercise.dart';
import '../../exercises/exercise_library.dart';
import '../../exercises/views/exercise_browser.dart';
import '../../localization/app_localizations.dart';
import '../plan_repository.dart';
/// The Exercise Library of a Plan's Mode; each tap adds that Exercise to the
/// end of the Plan, so several can be added in one go.
class ExercisePickerView extends StatelessWidget {
const ExercisePickerView({super.key, required this.planId});
static const routeName = '/plan/add';
final String planId;
Future<void> _add(BuildContext context, Exercise exercise) async {
final plans = context.read<PlanRepository>();
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final plan = await plans.get(planId);
if (plan == null) return;
await plans.save(plan.withExerciseAdded(exercise.id));
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.added(exercise.name)), duration: const Duration(milliseconds: 1500)));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return StreamBuilder(
stream: context.read<PlanRepository>().watchPlan(planId),
builder: (context, snapshot) {
final plan = snapshot.data;
return Scaffold(
appBar: AppBar(
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(l10n.done))],
),
body: plan == null
? null
: ExerciseBrowser(
mode: plan.mode,
exercises: context.read<ExerciseLibrary>().forMode(plan.mode),
title: l10n.addExercises,
bottomPadding: 24 + MediaQuery.paddingOf(context).bottom,
onTap: (e) => _add(context, e),
),
);
},
);
}
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import '../../localization/app_localizations.dart';
import '../../modes/mode.dart';
/// Asks for a Plan name; null when cancelled.
Future<String?> promptPlanName(BuildContext context, {required Mode mode, String? initial, required String title}) =>
showDialog<String>(
context: context,
builder: (context) => _PlanNameDialog(mode: mode, initial: initial, title: title),
);
class _PlanNameDialog extends StatefulWidget {
const _PlanNameDialog({required this.mode, required this.initial, required this.title});
final Mode mode;
final String? initial;
final String title;
@override
State<_PlanNameDialog> createState() => _PlanNameDialogState();
}
class _PlanNameDialogState extends State<_PlanNameDialog> {
late final _name = TextEditingController(text: widget.initial);
@override
void dispose() {
_name.dispose();
super.dispose();
}
void _submit() {
final name = _name.text.trim();
if (name.isNotEmpty) Navigator.of(context).pop(name);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(widget.title),
content: TextField(
controller: _name,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(hintText: l10n.planNameHint(widget.mode.name)),
onSubmitted: (_) => _submit(),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(l10n.cancel)),
ListenableBuilder(
listenable: _name,
builder: (context, _) => FilledButton(
onPressed: _name.text.trim().isEmpty ? null : _submit,
child: Text(widget.initial == null ? l10n.create : l10n.save),
),
),
],
);
}
}
Future<bool> confirmDeletePlan(BuildContext context, String name) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deletePlanTitle(name)),
content: Text(l10n.deletePlanBody),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(l10n.cancel)),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(l10n.delete)),
],
),
);
return confirmed ?? false;
}
+340
View File
@@ -0,0 +1,340 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/number_stepper.dart';
import '../../design/page_header.dart';
import '../../design/zen_style.dart';
import '../../exercises/exercise_library.dart';
import '../../exercises/views/library_view.dart';
import '../../localization/app_localizations.dart';
import '../../modes/mode.dart';
import '../models/plan.dart';
import '../plan_labels.dart';
import '../plan_repository.dart';
import 'exercise_picker_view.dart';
import 'plans_view.dart';
import 'step_editor_sheet.dart';
/// Builds one Plan: its Steps and Supersets in order, each editable, movable
/// by its handle and removable. Every change is saved at once.
class PlanEditorView extends StatelessWidget {
const PlanEditorView({super.key, required this.planId});
static const routeName = '/plan';
final String planId;
@override
Widget build(BuildContext context) {
final plans = context.read<PlanRepository>();
return StreamBuilder(
stream: plans.watchPlan(planId),
builder: (context, snapshot) {
final plan = snapshot.data;
if (plan == null) return const Scaffold();
return _Editor(plan: plan, save: plans.save);
},
);
}
}
class _Editor extends StatelessWidget {
const _Editor({required this.plan, required this.save});
final Plan plan;
final Future<void> Function(Plan) save;
void _addExercises(BuildContext context) =>
Navigator.of(context).pushNamed(ExercisePickerView.routeName, arguments: plan.id);
@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),
label: Text(l10n.addExercises),
);
return Scaffold(
appBar: AppBar(
actions: [PlanMenu(plan: plan, onDeleted: () => Navigator.of(context).pop())],
),
body: plan.items.isEmpty
? ListView(
children: [
PageHeader(title: plan.name),
const SizedBox(height: 16),
EmptyState(
icon: Icons.add_task_rounded,
title: l10n.planEmptyTitle,
body: l10n.planEmptyBody(plan.mode.name),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Row(children: [addButton]),
),
],
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
padding: EdgeInsets.only(bottom: 96 + bottomInset),
header: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PageHeader(title: plan.name, subtitle: planSummary(l10n, plan)),
if (plan.mode == Mode.stretching)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 16, 12),
child: NumberStepper(
label: l10n.transitionLabel,
value: l10n.secondsShort(plan.transitionSeconds),
onDecrement: plan.transitionSeconds > 0
? () => save(plan.copyWith(transitionSeconds: plan.transitionSeconds - 1))
: null,
onIncrement: plan.transitionSeconds < 30
? () => save(plan.copyWith(transitionSeconds: plan.transitionSeconds + 1))
: null,
),
),
],
),
itemCount: plan.items.length,
onReorder: (from, to) => save(plan.withItemMoved(from, to)),
proxyDecorator: (child, _, _) => Material(color: Colors.transparent, elevation: 6, child: child),
itemBuilder: (context, i) => Padding(
key: ObjectKey(plan.items[i]),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
child: switch (plan.items[i]) {
final Superset superset => _SupersetCard(plan: plan, index: i, superset: superset, save: save),
final item => _StepTile(plan: plan, index: i, item: item, save: save),
},
),
),
floatingActionButton: plan.items.isEmpty
? null
: FloatingActionButton.extended(
onPressed: () => _addExercises(context),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addExercises),
),
);
}
}
/// A Workout or Stretching Step on its own.
class _StepTile extends StatelessWidget {
const _StepTile({required this.plan, required this.index, required this.item, required this.save});
final Plan plan;
final int index;
final PlanItem item;
final Future<void> Function(Plan) save;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final (exerciseId, summary) = switch (item) {
final WorkoutStep step => (step.exerciseId, workoutStepSummary(l10n, step)),
final StretchingStep step => (step.exerciseId, stretchingStepSummary(l10n, step)),
Superset() => throw StateError('Supersets have their own card'),
};
final name = _exerciseName(context, exerciseId);
return Card(
child: _ExerciseRow(
exerciseId: exerciseId,
name: name,
summary: summary,
dragIndex: index,
onTap: () => showStepEditor<PlanItem>(
context,
title: name,
value: item,
onChanged: (edited) => save(plan.withItemReplaced(index, edited)),
),
menu: [
if (plan.canSupersetWithNext(index))
_MenuAction(l10n.supersetWithNext, () => save(plan.withSupersetWithNext(index))),
_MenuAction(l10n.remove, () => save(plan.withItemRemoved(index))),
],
),
);
}
}
/// A Superset: a header with its set count and rest choice, its members
/// below, all held together by an accent bar on the left.
class _SupersetCard extends StatelessWidget {
const _SupersetCard({required this.plan, required this.index, required this.superset, required this.save});
final Plan plan;
final int index;
final Superset superset;
final Future<void> Function(Plan) save;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
return Card(
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4, decoration: BoxDecoration(gradient: s.accentGradient)),
Expanded(
child: Column(
children: [
InkWell(
onTap: () => showStepEditor<Superset>(
context,
title: l10n.supersetTitle,
value: superset,
onChanged: (edited) => save(plan.withItemReplaced(index, edited)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 0, 4),
child: Row(
children: [
Icon(Icons.link_rounded, size: 20, color: s.accent),
const SizedBox(width: 8),
Expanded(
child: Text(
'${l10n.supersetTitle} · ${l10n.setsCount(superset.sets)}',
style: text.titleSmall?.copyWith(color: s.accent),
),
),
_Menu([
if (plan.canSupersetWithNext(index))
_MenuAction(l10n.supersetWithNext, () => save(plan.withSupersetWithNext(index))),
_MenuAction(l10n.ungroup, () => save(plan.withUngrouped(index))),
_MenuAction(l10n.remove, () => save(plan.withItemRemoved(index))),
]),
_DragHandle(index: index),
],
),
),
),
for (final (m, member) in superset.members.indexed)
_ExerciseRow(
exerciseId: member.exerciseId,
name: _exerciseName(context, member.exerciseId),
summary: memberSummary(l10n, member),
onTap: () => showStepEditor<SupersetMember>(
context,
title: _exerciseName(context, member.exerciseId),
value: member,
onChanged: (edited) => save(
plan.withItemReplaced(index, superset.copyWith(members: [...superset.members]..[m] = edited)),
),
),
menu: [
_MenuAction(l10n.takeOutOfSuperset, () => save(plan.withMemberRemovedFromSuperset(index, m))),
],
),
],
),
),
],
),
),
);
}
}
String _exerciseName(BuildContext context, String exerciseId) =>
context.read<ExerciseLibrary>().byId(exerciseId)?.name ?? exerciseId;
class _ExerciseRow extends StatelessWidget {
const _ExerciseRow({
required this.exerciseId,
required this.name,
required this.summary,
required this.onTap,
required this.menu,
this.dragIndex,
});
final String exerciseId;
final String name;
final String summary;
final VoidCallback onTap;
final List<_MenuAction> menu;
/// Set for items of the Plan's own list, which can be dragged.
final int? dragIndex;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
final exercise = context.read<ExerciseLibrary>().byId(exerciseId);
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 0, 8),
child: Row(
children: [
if (exercise != null) ExerciseThumbnail(exercise: exercise, size: 52),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: text.titleSmall, maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
Text(summary, style: text.bodySmall?.copyWith(color: s.inkMuted)),
],
),
),
_Menu(menu),
if (dragIndex != null) _DragHandle(index: dragIndex!) else const SizedBox(width: 8),
],
),
),
);
}
}
class _DragHandle extends StatelessWidget {
const _DragHandle({required this.index});
final int index;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
return ReorderableDragStartListener(
index: index,
child: Tooltip(
message: AppLocalizations.of(context).dragToReorder,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Icon(Icons.drag_indicator_rounded, color: s.inkMuted),
),
),
);
}
}
class _MenuAction {
const _MenuAction(this.label, this.onSelected);
final String label;
final VoidCallback onSelected;
}
class _Menu extends StatelessWidget {
const _Menu(this.actions);
final List<_MenuAction> actions;
@override
Widget build(BuildContext context) => PopupMenuButton<int>(
tooltip: AppLocalizations.of(context).moreActions,
icon: Icon(Icons.more_vert_rounded, color: ZenStyle.of(context).inkMuted),
onSelected: (i) => actions[i].onSelected(),
itemBuilder: (context) => [for (final (i, a) in actions.indexed) PopupMenuItem(value: i, child: Text(a.label))],
);
}
+133 -6
View File
@@ -2,22 +2,149 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/page_header.dart';
import '../../design/zen_nav_bar.dart';
import '../../design/zen_style.dart';
import '../../localization/app_localizations.dart';
import '../../modes/mode.dart';
import '../../settings/settings_controller.dart';
import '../models/plan.dart';
import '../plan_labels.dart';
import '../plan_repository.dart';
import 'plan_dialogs.dart';
import 'plan_editor_view.dart';
/// The current Mode's Plans. Users build every Plan themselves.
/// The current Mode's Plans, newest first. Users build every Plan themselves.
class PlansView extends StatelessWidget {
const PlansView({super.key});
/// Asks for a name, creates the Plan and opens it in the editor.
static Future<void> createPlan(BuildContext context, Mode mode) async {
final l10n = AppLocalizations.of(context);
final plans = context.read<PlanRepository>();
final navigator = Navigator.of(context);
final name = await promptPlanName(context, mode: mode, title: l10n.newPlanTitle);
if (name == null) return;
final plan = await plans.create(mode, name);
await navigator.pushNamed(PlanEditorView.routeName, arguments: plan.id);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final mode = context.watch<SettingsController>().mode;
return ListView(
children: [
PageHeader(title: l10n.plansTitle),
const SizedBox(height: 24),
EmptyState(icon: Icons.playlist_add_rounded, title: l10n.plansEmptyTitle, body: l10n.plansEmptyBody(mode.name)),
final bottom = ZenNavBar.height + 32 + MediaQuery.paddingOf(context).bottom;
return StreamBuilder(
stream: context.read<PlanRepository>().watchPlans(mode),
builder: (context, snapshot) {
final plans = snapshot.data;
if (plans == null) return const SizedBox.shrink();
final newPlan = FilledButton.icon(
onPressed: () => createPlan(context, mode),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.newPlan),
);
return ListView(
padding: EdgeInsets.only(bottom: bottom),
children: [
PageHeader(title: l10n.plansTitle, action: plans.isEmpty ? null : newPlan),
if (plans.isEmpty) ...[
const SizedBox(height: 24),
EmptyState(
icon: Icons.playlist_add_rounded,
title: l10n.plansEmptyTitle,
body: l10n.plansEmptyBody(mode.name),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Row(children: [newPlan]),
),
] else
for (final plan in plans)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: PlanCard(plan: plan),
),
],
);
},
);
}
}
class PlanCard extends StatelessWidget {
const PlanCard({super.key, required this.plan});
final Plan plan;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
return Card(
child: InkWell(
onTap: () => Navigator.of(context).pushNamed(PlanEditorView.routeName, arguments: plan.id),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 4, 16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(plan.name, style: text.titleLarge, maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Text(planSummary(l10n, plan), style: text.bodyMedium?.copyWith(color: s.inkMuted)),
],
),
),
PlanMenu(plan: plan),
],
),
),
),
);
}
}
/// Rename, duplicate and delete, on a Plan card and in the editor.
class PlanMenu extends StatelessWidget {
const PlanMenu({super.key, required this.plan, this.onDeleted});
final Plan plan;
/// Called after the Plan is gone, e.g. to leave its editor.
final VoidCallback? onDeleted;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final plans = context.read<PlanRepository>();
return PopupMenuButton<String>(
tooltip: l10n.moreActions,
onSelected: (action) async {
switch (action) {
case 'rename':
final name = await promptPlanName(
context,
mode: plan.mode,
initial: plan.name,
title: l10n.renamePlanTitle,
);
if (name != null) await plans.save(plan.copyWith(name: name));
case 'duplicate':
await plans.duplicate(plan.id, l10n.duplicateName(plan.name));
case 'delete':
if (await confirmDeletePlan(context, plan.name)) {
await plans.delete(plan.id);
onDeleted?.call();
}
}
},
itemBuilder: (context) => [
PopupMenuItem(value: 'rename', child: Text(l10n.rename)),
PopupMenuItem(value: 'duplicate', child: Text(l10n.duplicate)),
PopupMenuItem(value: 'delete', child: Text(l10n.delete)),
],
);
}
+219
View File
@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import '../../design/number_stepper.dart';
import '../../design/zen_style.dart';
import '../../localization/app_localizations.dart';
import '../models/plan.dart';
import '../plan_labels.dart';
/// Edits a Step, a Superset or a Superset member in a bottom sheet. Every
/// change is handed to [onChanged] right away; there is no separate save.
Future<void> showStepEditor<T>(
BuildContext context, {
required String title,
required T value,
required ValueChanged<T> onChanged,
}) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _StepEditorSheet<T>(title: title, initial: value, onChanged: onChanged),
);
class _StepEditorSheet<T> extends StatefulWidget {
const _StepEditorSheet({required this.title, required this.initial, required this.onChanged});
final String title;
final T initial;
final ValueChanged<T> onChanged;
@override
State<_StepEditorSheet<T>> createState() => _StepEditorSheetState<T>();
}
class _StepEditorSheetState<T> extends State<_StepEditorSheet<T>> {
late T _value = widget.initial;
void _set(Object value) {
setState(() => _value = value as T);
widget.onChanged(_value);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final text = Theme.of(context).textTheme;
final value = _value;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.title, style: text.titleLarge),
const SizedBox(height: 12),
...switch (value) {
WorkoutStep() => [
_setsStepper(l10n, value.sets, (n) => _set(value.copyWith(sets: n))),
_TargetEditor(
target: value.target,
onChanged: (t) => _set(value.copyWith(target: t)),
),
_WeightEditor(
weightKg: value.weightKg,
onChanged: (kg) => _set(value.copyWith(weightKg: () => kg)),
),
],
SupersetMember() => [
_TargetEditor(
target: value.target,
onChanged: (t) => _set(value.copyWith(target: t)),
),
_WeightEditor(
weightKg: value.weightKg,
onChanged: (kg) => _set(value.copyWith(weightKg: () => kg)),
),
],
Superset() => [
_setsStepper(l10n, value.sets, (n) => _set(value.copyWith(sets: n))),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.restBetweenLabel, style: text.titleSmall),
subtitle: Text(l10n.restBetweenHint),
value: value.restBetween,
onChanged: (on) => _set(value.copyWith(restBetween: on)),
),
],
StretchingStep() => [
NumberStepper(
label: l10n.stepHold,
value: l10n.secondsShort(value.holdSeconds),
onDecrement: value.holdSeconds > 5
? () => _set(value.copyWith(holdSeconds: value.holdSeconds - 5))
: null,
onIncrement: value.holdSeconds < 300
? () => _set(value.copyWith(holdSeconds: value.holdSeconds + 5))
: null,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.stepBothSides, style: text.titleSmall),
subtitle: Text(l10n.stepBothSidesHint),
value: value.bothSides,
onChanged: (on) => _set(value.copyWith(bothSides: on)),
),
NumberStepper(
label: l10n.stepRounds,
value: '${value.rounds}',
onDecrement: value.rounds > 1 ? () => _set(value.copyWith(rounds: value.rounds - 1)) : null,
onIncrement: value.rounds < 10 ? () => _set(value.copyWith(rounds: value.rounds + 1)) : null,
),
],
_ => const <Widget>[],
},
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(onPressed: () => Navigator.of(context).pop(), child: Text(l10n.done)),
),
],
),
),
);
}
Widget _setsStepper(AppLocalizations l10n, int sets, ValueChanged<int> onChanged) => NumberStepper(
label: l10n.stepSets,
value: '$sets',
onDecrement: sets > 1 ? () => onChanged(sets - 1) : null,
onIncrement: sets < 20 ? () => onChanged(sets + 1) : null,
);
}
/// Reps or time per set, switching between the two with sensible starting values.
class _TargetEditor extends StatelessWidget {
const _TargetEditor({required this.target, required this.onChanged});
final WorkoutTarget target;
final ValueChanged<WorkoutTarget> onChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
SegmentedButton<bool>(
showSelectedIcon: false,
segments: [
ButtonSegment(value: false, label: Text(l10n.stepReps)),
ButtonSegment(value: true, label: Text(l10n.stepTime)),
],
selected: {target.isTimed},
onSelectionChanged: (timed) =>
onChanged(timed.single ? const WorkoutTarget.seconds(30) : const WorkoutTarget.reps(10)),
),
if (target.isTimed)
NumberStepper(
label: l10n.stepTime,
value: l10n.secondsShort(target.seconds!),
onDecrement: target.seconds! > 5 ? () => onChanged(WorkoutTarget.seconds(target.seconds! - 5)) : null,
onIncrement: target.seconds! < 600 ? () => onChanged(WorkoutTarget.seconds(target.seconds! + 5)) : null,
)
else
NumberStepper(
label: l10n.stepReps,
value: '${target.reps}',
onDecrement: target.reps! > 1 ? () => onChanged(WorkoutTarget.reps(target.reps! - 1)) : null,
onIncrement: target.reps! < 100 ? () => onChanged(WorkoutTarget.reps(target.reps! + 1)) : null,
),
],
);
}
}
/// An optional target weight in 2.5 kg steps.
class _WeightEditor extends StatelessWidget {
const _WeightEditor({required this.weightKg, required this.onChanged});
static const step = 2.5;
final double? weightKg;
final ValueChanged<double?> onChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final s = ZenStyle.of(context);
final kg = weightKg;
if (kg == null) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: OutlinedButton.icon(
onPressed: () => onChanged(20),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addWeight),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NumberStepper(
label: l10n.stepWeight,
value: l10n.weightKg(kgNumber(kg)),
onDecrement: kg >= step ? () => onChanged(kg - step) : null,
onIncrement: kg < 500 ? () => onChanged(kg + step) : null,
),
TextButton(
onPressed: () => onChanged(null),
style: TextButton.styleFrom(foregroundColor: s.inkMuted, padding: EdgeInsets.zero),
child: Text(l10n.removeWeight),
),
],
);
}
}
+384
View File
@@ -1,6 +1,30 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a
url: "https://pub.dev"
source: hosted
version: "88.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f"
url: "https://pub.dev"
source: hosted
version: "8.1.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -17,6 +41,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
build_config:
dependency: transitive
description:
name: build_config
sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d
url: "https://pub.dev"
source: hosted
version: "1.3.3"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78
url: "https://pub.dev"
source: hosted
version: "4.1.2"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7
url: "https://pub.dev"
source: hosted
version: "8.13.0"
characters:
dependency: transitive
description:
@@ -25,6 +97,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@@ -41,6 +137,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4
url: "https://pub.dev"
source: hosted
version: "3.0.8"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
url: "https://pub.dev"
source: hosted
version: "3.1.2"
drift:
dependency: "direct main"
description:
name: drift
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
url: "https://pub.dev"
source: hosted
version: "2.31.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
url: "https://pub.dev"
source: hosted
version: "2.31.0"
drift_flutter:
dependency: "direct main"
description:
name: drift_flutter
sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7
url: "https://pub.dev"
source: hosted
version: "0.2.8"
fake_async:
dependency: transitive
description:
@@ -65,6 +209,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -93,6 +245,38 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: "361668bdd71f0ae2dc6d17b75a593520f3c414af6534f9eed1aa72049dd71d18"
url: "https://pub.dev"
source: hosted
version: "3.2.3"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: "direct main"
description:
@@ -101,6 +285,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
io:
dependency: transitive
description:
name: io
sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
@@ -133,6 +333,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -157,6 +365,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.dev"
source: hosted
version: "2.1.0"
nested:
dependency: transitive
description:
@@ -165,6 +381,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
@@ -173,6 +397,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
url: "https://pub.dev"
source: hosted
version: "2.2.19"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
@@ -213,6 +461,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c"
url: "https://pub.dev"
source: hosted
version: "1.5.3"
provider:
dependency: "direct main"
description:
@@ -221,6 +477,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "9ac81c01ffd6a9cf9b2713288834fbaaf6aebde05ffdf02d380e68b8e06f27d8"
url: "https://pub.dev"
source: hosted
version: "1.7.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
shared_preferences:
dependency: "direct main"
description:
@@ -277,11 +557,35 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
source_span:
dependency: transitive
description:
@@ -290,6 +594,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sqlite3:
dependency: "direct dev"
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
sqlite3_flutter_libs:
dependency: transitive
description:
name: sqlite3_flutter_libs
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
url: "https://pub.dev"
source: hosted
version: "0.5.42"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
url: "https://pub.dev"
source: hosted
version: "0.43.1"
stack_trace:
dependency: transitive
description:
@@ -306,6 +634,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a
url: "https://pub.dev"
source: hosted
version: "2.1.2"
string_scanner:
dependency: transitive
description:
@@ -330,6 +666,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.4"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math:
dependency: transitive
description:
@@ -346,6 +698,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
@@ -354,6 +714,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
@@ -362,6 +738,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks:
dart: ">=3.8.1 <4.0.0"
flutter: ">=3.29.0"
+6
View File
@@ -7,6 +7,8 @@ environment:
sdk: ^3.8.1
dependencies:
drift: ^2.31.0
drift_flutter: ^0.2.8
flutter:
sdk: flutter
flutter_localizations:
@@ -14,11 +16,15 @@ dependencies:
intl: ^0.20.2
provider: ^6.1.5+1
shared_preferences: ^2.5.3
uuid: ^4.6.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
drift_dev: ^2.31.0
build_runner: ^2.15.1
sqlite3: ^2.9.4
flutter:
uses-material-design: true
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:zenxercise/src/database/database.dart';
import 'package:zenxercise/src/modes/mode.dart';
import 'package:zenxercise/src/plans/models/plan.dart';
import 'package:zenxercise/src/plans/plan_repository.dart';
import '../support/test_database.dart';
void main() {
late AppDatabase db;
late PlanRepository plans;
var now = DateTime(2026, 10, 1, 8);
setUp(() {
db = testDatabase();
now = DateTime(2026, 10, 1, 8);
plans = PlanRepository(db, clock: () => now);
});
tearDown(() => db.close());
test('a created Plan is empty, belongs to its Mode and is listed newest first', () async {
final morning = await plans.create(Mode.stretching, 'Morning');
now = now.add(const Duration(hours: 1));
final evening = await plans.create(Mode.stretching, 'Evening');
await plans.create(Mode.workout, 'Legs');
final listed = await plans.watchPlans(Mode.stretching).first;
expect(listed.map((p) => p.name), ['Evening', 'Morning']);
expect(morning.items, isEmpty);
expect(evening.transitionSeconds, Plan.defaultTransitionSeconds);
});
test('saving keeps the items and stamps the change time', () async {
final plan = await plans.create(Mode.workout, 'Push');
now = DateTime(2026, 10, 2);
await plans.save(plan.withExerciseAdded('Bench').withExerciseAdded('Dips').withSupersetWithNext(0));
final stored = (await plans.get(plan.id))!;
expect(stored.items.single, isA<Superset>());
expect(stored.updatedAt, DateTime(2026, 10, 2));
expect(stored.createdAt, plan.createdAt);
});
test('a duplicate copies the items under a new id and name', () async {
final plan = await plans.create(Mode.workout, 'Push');
await plans.save(plan.withExerciseAdded('Bench'));
final copy = await plans.duplicate(plan.id, 'Push (copy)');
expect(copy.id, isNot(plan.id));
expect(copy.items, (await plans.get(plan.id))!.items);
expect((await plans.watchPlans(Mode.workout).first), hasLength(2));
});
test('a deleted Plan is gone', () async {
final plan = await plans.create(Mode.workout, 'Push');
await plans.delete(plan.id);
expect(await plans.get(plan.id), isNull);
});
}
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:zenxercise/src/modes/mode.dart';
import 'package:zenxercise/src/plans/models/plan.dart';
Plan _plan(Mode mode, List<PlanItem> items) => Plan(
id: 'p1',
mode: mode,
name: 'Push day',
items: items,
createdAt: DateTime(2026, 10, 1),
updatedAt: DateTime(2026, 10, 1),
);
WorkoutStep _w(String id, {int sets = 3, WorkoutTarget target = const WorkoutTarget.reps(10), double? kg}) =>
WorkoutStep(exerciseId: id, sets: sets, target: target, weightKg: kg);
void main() {
group('adding Exercises', () {
test('a Workout Plan adds a Workout Step of 3 × 10 reps without weight', () {
final plan = _plan(Mode.workout, const []).withExerciseAdded('Bench_Press');
expect(plan.items.single, _w('Bench_Press'));
});
test('a Stretching Plan adds a single 30 s hold on one side', () {
final plan = _plan(Mode.stretching, const []).withExerciseAdded('Calf_Stretch');
expect(
plan.items.single,
const StretchingStep(exerciseId: 'Calf_Stretch', holdSeconds: 30, bothSides: false, rounds: 1),
);
});
});
test('moving follows the list convention of an index past the removed item', () {
final plan = _plan(Mode.workout, [_w('A'), _w('B'), _w('C')]);
expect(plan.withItemMoved(0, 3).items, [_w('B'), _w('C'), _w('A')]);
expect(plan.withItemMoved(2, 0).items, [_w('C'), _w('A'), _w('B')]);
expect(plan.withItemRemoved(1).items, [_w('A'), _w('C')]);
});
group('Supersets', () {
test('a Step grouped with the next keeps each target and takes the first set count', () {
final plan = _plan(Mode.workout, [
_w('A', sets: 4, kg: 20),
_w('B', sets: 2, target: const WorkoutTarget.seconds(45)),
_w('C'),
]).withSupersetWithNext(0);
expect(plan.items, [
const Superset(
sets: 4,
restBetween: false,
members: [
SupersetMember(exerciseId: 'A', target: WorkoutTarget.reps(10), weightKg: 20),
SupersetMember(exerciseId: 'B', target: WorkoutTarget.seconds(45)),
],
),
_w('C'),
]);
expect(plan.exerciseCount, 3);
});
test('grouping again with the next item grows the Superset, also when the next is a Superset', () {
final grown = _plan(Mode.workout, [_w('A'), _w('B'), _w('C')]).withSupersetWithNext(0).withSupersetWithNext(0);
expect((grown.items.single as Superset).members.map((m) => m.exerciseId), ['A', 'B', 'C']);
final merged = _plan(Mode.workout, [_w('A'), _w('B'), _w('C')]).withSupersetWithNext(1).withSupersetWithNext(0);
expect((merged.items.single as Superset).members.map((m) => m.exerciseId), ['A', 'B', 'C']);
});
test('only an item with a next item can be grouped', () {
final plan = _plan(Mode.workout, [_w('A'), _w('B')]);
expect(plan.canSupersetWithNext(0), isTrue);
expect(plan.canSupersetWithNext(1), isFalse);
expect(_plan(Mode.stretching, const []).withExerciseAdded('X').canSupersetWithNext(0), isFalse);
});
test('a member taken out lands right after its Superset with the Superset set count', () {
final plan = _plan(Mode.workout, [
_w('A'),
_w('B'),
_w('C'),
_w('D'),
]).withSupersetWithNext(0).withSupersetWithNext(0).withMemberRemovedFromSuperset(0, 0);
expect((plan.items[0] as Superset).members.map((m) => m.exerciseId), ['B', 'C']);
expect(plan.items[1], _w('A'));
expect(plan.items[2], _w('D'));
});
test('a Superset left with one member turns back into a Step', () {
final plan = _plan(Mode.workout, [
_w('A', sets: 5),
_w('B'),
]).withSupersetWithNext(0).withMemberRemovedFromSuperset(0, 1);
expect(plan.items, [_w('A', sets: 5), _w('B', sets: 5)]);
});
test('ungrouping gives every member its own Step with the Superset set count', () {
final plan = _plan(Mode.workout, [_w('A', sets: 4), _w('B')]).withSupersetWithNext(0).withUngrouped(0);
expect(plan.items, [_w('A', sets: 4), _w('B', sets: 4)]);
});
});
test('a Stretching Plan estimates its length from holds, sides, rounds and Transitions', () {
final plan = _plan(Mode.stretching, const [
StretchingStep(exerciseId: 'A', holdSeconds: 30, bothSides: true, rounds: 2),
StretchingStep(exerciseId: 'B', holdSeconds: 60, bothSides: false, rounds: 1),
]);
// Holds: 4 × 30 s + 60 s = 180 s; 5 holds have 4 Transitions of 5 s between them.
expect(plan.estimatedDuration, const Duration(seconds: 200));
expect(_plan(Mode.workout, [_w('A')]).estimatedDuration, isNull);
});
test('items survive a JSON round trip', () {
final items = [
_w('A', kg: 12.5),
const StretchingStep(exerciseId: 'B', holdSeconds: 45, bothSides: true, rounds: 3),
const Superset(
sets: 3,
restBetween: true,
members: [
SupersetMember(exerciseId: 'C', target: WorkoutTarget.seconds(30)),
SupersetMember(exerciseId: 'D', target: WorkoutTarget.reps(8), weightKg: 40),
],
),
];
expect(PlanItem.listFromJson(PlanItem.listToJson(items)), items);
});
}
+7
View File
@@ -6,8 +6,11 @@ import 'package:zenxercise/src/app.dart';
import 'package:zenxercise/src/exercises/exercise.dart';
import 'package:zenxercise/src/exercises/exercise_library.dart';
import 'package:zenxercise/src/modes/mode.dart';
import 'package:zenxercise/src/plans/plan_repository.dart';
import 'package:zenxercise/src/settings/settings_controller.dart';
import 'test_database.dart';
Exercise exercise(
String id,
Mode mode, {
@@ -58,15 +61,19 @@ Future<SettingsController> pumpApp(
WidgetTester tester, {
ExerciseLibrary? library,
Map<String, Object> prefs = const {},
PlanRepository? plans,
}) async {
SharedPreferences.setMockInitialValues(prefs);
final settings = SettingsController(SettingsService());
await settings.loadSettings();
final db = testDatabase();
addTearDown(db.close);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: settings),
Provider.value(value: library ?? sampleLibrary),
Provider.value(value: plans ?? PlanRepository(db)),
],
child: const ZenXerciseApp(),
),
+21
View File
@@ -0,0 +1,21 @@
import 'dart:ffi';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:sqlite3/open.dart';
import 'package:zenxercise/src/database/database.dart';
/// An in-memory database for tests. On Windows hosts the sqlite3 library comes
/// from ZENXERCISE_SQLITE_DLL if set, otherwise from the system's winsqlite3.dll.
AppDatabase testDatabase() {
if (Platform.isWindows) {
open.overrideFor(OperatingSystem.windows, () {
final custom = Platform.environment['ZENXERCISE_SQLITE_DLL'];
return DynamicLibrary.open(custom ?? 'winsqlite3.dll');
});
}
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
// Synchronous stream teardown keeps widget tests from hanging on pending timers.
return AppDatabase(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:zenxercise/src/modes/mode.dart';
import 'package:zenxercise/src/plans/models/plan.dart';
import 'package:zenxercise/src/plans/plan_repository.dart';
import '../support/test_app.dart';
import '../support/test_database.dart';
void main() {
late PlanRepository plans;
setUp(() {
final db = testDatabase();
addTearDown(db.close);
plans = PlanRepository(db);
});
/// A Workout Plan with two Squat Steps, open in the editor.
Future<Plan> openWorkoutPlan(WidgetTester tester) async {
final plan = await plans.create(Mode.workout, 'Legs');
await plans.save(plan.withExerciseAdded('Barbell_Squat').withExerciseAdded('Barbell_Squat'));
usePhoneScreen(tester);
await pumpApp(tester, plans: plans, prefs: {'mode': 'workout'});
await tester.tap(find.text('Legs'));
await tester.pumpAndSettle();
return plan;
}
testWidgets('a new Plan is named, filled from the picker and listed with its count', (tester) async {
usePhoneScreen(tester);
await pumpApp(tester, plans: plans);
await tester.tap(find.text('New plan'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Morning');
await tester.pump();
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
expect(find.text('Morning'), findsOneWidget);
expect(find.text('No exercises yet'), findsWidgets);
await tester.tap(find.text('Add exercises'));
await tester.pumpAndSettle();
await tester.tap(find.text('90/90 Hamstring'));
await tester.pumpAndSettle();
await tester.tap(find.text('Calf Stretch'));
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('90/90 Hamstring'), findsOneWidget);
expect(find.text('Calf Stretch'), findsOneWidget);
// Two 30 s holds and one 5 s Transition.
expect(find.text('2 exercises · about 1 min'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Morning'), findsOneWidget);
expect(find.text('2 exercises · about 1 min'), findsOneWidget);
});
testWidgets('editing a Step saves at once and shows in its summary', (tester) async {
final plan = await openWorkoutPlan(tester);
await tester.tap(find.text('Barbell Squat').first);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Sets +'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add a target weight'));
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('4 × 10 reps · 20 kg'), findsOneWidget);
expect((await plans.get(plan.id))!.items.first, isA<WorkoutStep>().having((s) => s.sets, 'sets', 4));
});
testWidgets('two Steps become a Superset and split again', (tester) async {
await openWorkoutPlan(tester);
// The first Step's menu; the Plan's own menu in the app bar comes last.
await tester.tap(find.byTooltip('More actions').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Superset with next'));
await tester.pumpAndSettle();
expect(find.text('Superset · 3 sets'), findsOneWidget);
expect(find.text('10 reps'), findsNWidgets(2));
await tester.tap(find.byTooltip('More actions').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Ungroup'));
await tester.pumpAndSettle();
expect(find.text('Superset · 3 sets'), findsNothing);
expect(find.text('3 × 10 reps'), findsNWidgets(2));
});
testWidgets('deleting the Plan from its editor returns to the list', (tester) async {
await openWorkoutPlan(tester);
await tester.tap(find.byTooltip('More actions').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('Legs'), findsNothing);
expect(find.text('No plans yet'), findsOneWidget);
});
testWidgets('an Exercise is added to a Plan from its detail page', (tester) async {
final plan = await plans.create(Mode.stretching, 'Evening');
usePhoneScreen(tester);
await pumpApp(tester, plans: plans);
await tester.tap(find.byTooltip('Library'));
await tester.pumpAndSettle();
await tester.tap(find.text('Calf Stretch'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add to plan'));
await tester.pumpAndSettle();
await tester.tap(find.text('Evening'));
await tester.pumpAndSettle();
expect(find.text('Added to Evening'), findsOneWidget);
expect((await plans.get(plan.id))!.items.single, isA<StretchingStep>());
});
}