From bbdca955414d0b68acae6e80351242bb8d24e712 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 28 Oct 2026 23:48:07 +0100 Subject: [PATCH] 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 --- docs/adr/0005-plan-items-stored-as-json.md | 18 + lib/main.dart | 5 + lib/src/app.dart | 4 + lib/src/database/database.dart | 41 ++ lib/src/database/database.g.dart | 604 ++++++++++++++++++ lib/src/design/app_theme.dart | 40 +- lib/src/design/number_stepper.dart | 82 +++ lib/src/design/page_header.dart | 12 +- .../exercises/views/exercise_detail_view.dart | 16 +- lib/src/localization/app_en.arb | 162 ++++- lib/src/localization/app_localizations.dart | 312 +++++++++ .../localization/app_localizations_en.dart | 227 +++++++ lib/src/plans/models/plan.dart | 357 +++++++++++ lib/src/plans/plan_labels.dart | 34 + lib/src/plans/plan_repository.dart | 79 +++ lib/src/plans/views/add_to_plan_sheet.dart | 89 +++ lib/src/plans/views/exercise_picker_view.dart | 55 ++ lib/src/plans/views/plan_dialogs.dart | 78 +++ lib/src/plans/views/plan_editor_view.dart | 340 ++++++++++ lib/src/plans/views/plans_view.dart | 139 +++- lib/src/plans/views/step_editor_sheet.dart | 219 +++++++ pubspec.lock | 384 +++++++++++ pubspec.yaml | 6 + test/plans/plan_repository_test.dart | 64 ++ test/plans/plan_test.dart | 137 ++++ test/support/test_app.dart | 7 + test/support/test_database.dart | 21 + test/views/plan_editor_test.dart | 133 ++++ 28 files changed, 3654 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0005-plan-items-stored-as-json.md create mode 100644 lib/src/database/database.dart create mode 100644 lib/src/database/database.g.dart create mode 100644 lib/src/design/number_stepper.dart create mode 100644 lib/src/plans/models/plan.dart create mode 100644 lib/src/plans/plan_labels.dart create mode 100644 lib/src/plans/plan_repository.dart create mode 100644 lib/src/plans/views/add_to_plan_sheet.dart create mode 100644 lib/src/plans/views/exercise_picker_view.dart create mode 100644 lib/src/plans/views/plan_dialogs.dart create mode 100644 lib/src/plans/views/plan_editor_view.dart create mode 100644 lib/src/plans/views/step_editor_sheet.dart create mode 100644 test/plans/plan_repository_test.dart create mode 100644 test/plans/plan_test.dart create mode 100644 test/support/test_database.dart create mode 100644 test/views/plan_editor_test.dart diff --git a/docs/adr/0005-plan-items-stored-as-json.md b/docs/adr/0005-plan-items-stored-as-json.md new file mode 100644 index 0000000..7326cbc --- /dev/null +++ b/docs/adr/0005-plan-items-stored-as-json.md @@ -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. diff --git a/lib/main.dart b/lib/main.dart index 75a08a7..2b5123b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(), ), diff --git a/lib/src/app.dart b/lib/src/app.dart index ce9b2a3..b39b842 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -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(), }, ), diff --git a/lib/src/database/database.dart b/lib/src/database/database.dart new file mode 100644 index 0000000..32be934 --- /dev/null +++ b/lib/src/database/database.dart @@ -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 get primaryKey => {id}; +} + +class PlanItemsConverter extends TypeConverter, String> { + const PlanItemsConverter(); + + @override + List fromSql(String fromDb) => PlanItem.listFromJson(fromDb); + + @override + String toSql(List value) => PlanItem.listToJson(value); +} + +@DriftDatabase(tables: [Plans]) +class AppDatabase extends _$AppDatabase { + AppDatabase(super.e); + + @override + int get schemaVersion => 1; +} diff --git a/lib/src/database/database.g.dart b/lib/src/database/database.g.dart new file mode 100644 index 0000000..f1544bb --- /dev/null +++ b/lib/src/database/database.g.dart @@ -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 id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _modeMeta = const VerificationMeta('mode'); + @override + late final GeneratedColumn mode = GeneratedColumn( + 'mode', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + late final GeneratedColumnWithTypeConverter, String> items = GeneratedColumn( + 'items', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($PlansTable.$converteritems); + static const VerificationMeta _transitionSecondsMeta = const VerificationMeta('transitionSeconds'); + @override + late final GeneratedColumn transitionSeconds = GeneratedColumn( + '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 createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List 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 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 get $primaryKey => {id}; + @override + PlanRow map(Map 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, String> $converteritems = const PlanItemsConverter(); +} + +class PlanRow extends DataClass implements Insertable { + final String id; + + /// The Mode's name, `stretching` or `workout`. + final String mode; + final String name; + final List 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 toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['mode'] = Variable(mode); + map['name'] = Variable(name); + { + map['items'] = Variable($PlansTable.$converteritems.toSql(items)); + } + map['transition_seconds'] = Variable(transitionSeconds); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(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 json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PlanRow( + id: serializer.fromJson(json['id']), + mode: serializer.fromJson(json['mode']), + name: serializer.fromJson(json['name']), + items: serializer.fromJson>(json['items']), + transitionSeconds: serializer.fromJson(json['transitionSeconds']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'mode': serializer.toJson(mode), + 'name': serializer.toJson(name), + 'items': serializer.toJson>(items), + 'transitionSeconds': serializer.toJson(transitionSeconds), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + PlanRow copyWith({ + String? id, + String? mode, + String? name, + List? 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 { + final Value id; + final Value mode; + final Value name; + final Value> items; + final Value transitionSeconds; + final Value createdAt; + final Value updatedAt; + final Value 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 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 custom({ + Expression? id, + Expression? mode, + Expression? name, + Expression? items, + Expression? transitionSeconds, + Expression? createdAt, + Expression? updatedAt, + Expression? 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? id, + Value? mode, + Value? name, + Value>? items, + Value? transitionSeconds, + Value? createdAt, + Value? updatedAt, + Value? 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 toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (mode.present) { + map['mode'] = Variable(mode.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (items.present) { + map['items'] = Variable($PlansTable.$converteritems.toSql(items.value)); + } + if (transitionSeconds.present) { + map['transition_seconds'] = Variable(transitionSeconds.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(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> get allTables => allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [plans]; +} + +typedef $$PlansTableCreateCompanionBuilder = + PlansCompanion Function({ + required String id, + required String mode, + required String name, + required List items, + Value transitionSeconds, + required DateTime createdAt, + required DateTime updatedAt, + Value rowid, + }); +typedef $$PlansTableUpdateCompanionBuilder = + PlansCompanion Function({ + Value id, + Value mode, + Value name, + Value> items, + Value transitionSeconds, + Value createdAt, + Value updatedAt, + Value rowid, + }); + +class $$PlansTableFilterComposer extends Composer<_$AppDatabase, $PlansTable> { + $$PlansTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get mode => $composableBuilder(column: $table.mode, builder: (column) => ColumnFilters(column)); + + ColumnFilters get name => $composableBuilder(column: $table.name, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get items => + $composableBuilder(column: $table.items, builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get transitionSeconds => + $composableBuilder(column: $table.transitionSeconds, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters 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 get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get mode => + $composableBuilder(column: $table.mode, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get name => + $composableBuilder(column: $table.name, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get items => + $composableBuilder(column: $table.items, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get transitionSeconds => + $composableBuilder(column: $table.transitionSeconds, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings 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 get id => $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get mode => $composableBuilder(column: $table.mode, builder: (column) => column); + + GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get items => + $composableBuilder(column: $table.items, builder: (column) => column); + + GeneratedColumn get transitionSeconds => + $composableBuilder(column: $table.transitionSeconds, builder: (column) => column); + + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn 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 id = const Value.absent(), + Value mode = const Value.absent(), + Value name = const Value.absent(), + Value> items = const Value.absent(), + Value transitionSeconds = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value 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 items, + Value transitionSeconds = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value 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); +} diff --git a/lib/src/design/app_theme.dart b/lib/src/design/app_theme.dart index 82f1715..affee07 100644 --- a/lib/src/design/app_theme.dart +++ b/lib/src/design/app_theme.dart @@ -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, + ), ), ); } diff --git a/lib/src/design/number_stepper.dart b/lib/src/design/number_stepper.dart new file mode 100644 index 0000000..44437e7 --- /dev/null +++ b/lib/src/design/number_stepper.dart @@ -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), + ), + ), + ), + ); + } +} diff --git a/lib/src/design/page_header.dart b/lib/src/design/page_header.dart index 5fd3678..ae70e3b 100644 --- a/lib/src/design/page_header.dart +++ b/lib/src/design/page_header.dart @@ -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)), diff --git a/lib/src/exercises/views/exercise_detail_view.dart b/lib/src/exercises/views/exercise_detail_view.dart index 8fe1afb..f256e65 100644 --- a/lib/src/exercises/views/exercise_detail_view.dart +++ b/lib/src/exercises/views/exercise_detail_view.dart @@ -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), diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index a99b41c..a26aa32 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -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?" } diff --git a/lib/src/localization/app_localizations.dart b/lib/src/localization/app_localizations.dart index a805298..6278034 100644 --- a/lib/src/localization/app_localizations.dart +++ b/lib/src/localization/app_localizations.dart @@ -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 diff --git a/lib/src/localization/app_localizations_en.dart b/lib/src/localization/app_localizations_en.dart index 524b0fe..7ea8745 100644 --- a/lib/src/localization/app_localizations_en.dart +++ b/lib/src/localization/app_localizations_en.dart @@ -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?'; } diff --git a/lib/src/plans/models/plan.dart b/lib/src/plans/models/plan.dart new file mode 100644 index 0000000..788fd6b --- /dev/null +++ b/lib/src/plans/models/plan.dart @@ -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 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()) { + 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? 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? _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 toJson(); + + factory PlanItem.fromJson(Map 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 items) => jsonEncode([for (final i in items) i.toJson()]); + + static List listFromJson(String json) => [ + for (final i in jsonDecode(json) as List) PlanItem.fromJson((i as Map).cast()), + ]; +} + +/// 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 toJson() => {'reps': reps, 'seconds': seconds}; + + factory WorkoutTarget.fromJson(Map 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 toJson() => { + 'type': 'workout', + 'exerciseId': exerciseId, + 'sets': sets, + ...target.toJson(), + 'weightKg': weightKg, + }; + + factory WorkoutStep.fromJson(Map 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 toJson() => { + 'type': 'stretching', + 'exerciseId': exerciseId, + 'holdSeconds': holdSeconds, + 'bothSides': bothSides, + 'rounds': rounds, + }; + + factory StretchingStep.fromJson(Map 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 members; + + Superset copyWith({int? sets, bool? restBetween, List? members}) => + Superset(sets: sets ?? this.sets, restBetween: restBetween ?? this.restBetween, members: members ?? this.members); + + @override + Map toJson() => { + 'type': 'superset', + 'sets': sets, + 'restBetween': restBetween, + 'members': [for (final m in members) m.toJson()], + }; + + factory Superset.fromJson(Map 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())], + ); + + @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 toJson() => {'exerciseId': exerciseId, ...target.toJson(), 'weightKg': weightKg}; + + factory SupersetMember.fromJson(Map 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)'; +} diff --git a/lib/src/plans/plan_labels.dart b/lib/src/plans/plan_labels.dart new file mode 100644 index 0000000..e0c29de --- /dev/null +++ b/lib/src/plans/plan_labels.dart @@ -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(' · '); +} diff --git a/lib/src/plans/plan_repository.dart b/lib/src/plans/plan_repository.dart new file mode 100644 index 0000000..8eafd89 --- /dev/null +++ b/lib/src/plans/plan_repository.dart @@ -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> 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 watchPlan(String id) => (_db.select( + _db.plans, + )..where((p) => p.id.equals(id))).watchSingleOrNull().map((r) => r == null ? null : _toPlan(r)); + + Future 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 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 save(Plan plan) => + _db.into(_db.plans).insertOnConflictUpdate(_toCompanion(plan.copyWith(updatedAt: _clock()))); + + Future 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 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, + ); +} diff --git a/lib/src/plans/views/add_to_plan_sheet.dart b/lib/src/plans/views/add_to_plan_sheet.dart new file mode 100644 index 0000000..5db50f1 --- /dev/null +++ b/lib/src/plans/views/add_to_plan_sheet.dart @@ -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 showAddToPlanSheet(BuildContext context, Exercise exercise) async { + final l10n = AppLocalizations.of(context); + final plans = context.read(); + 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().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)), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/src/plans/views/exercise_picker_view.dart b/lib/src/plans/views/exercise_picker_view.dart new file mode 100644 index 0000000..2eae706 --- /dev/null +++ b/lib/src/plans/views/exercise_picker_view.dart @@ -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 _add(BuildContext context, Exercise exercise) async { + final plans = context.read(); + 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().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().forMode(plan.mode), + title: l10n.addExercises, + bottomPadding: 24 + MediaQuery.paddingOf(context).bottom, + onTap: (e) => _add(context, e), + ), + ); + }, + ); + } +} diff --git a/lib/src/plans/views/plan_dialogs.dart b/lib/src/plans/views/plan_dialogs.dart new file mode 100644 index 0000000..865b873 --- /dev/null +++ b/lib/src/plans/views/plan_dialogs.dart @@ -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 promptPlanName(BuildContext context, {required Mode mode, String? initial, required String title}) => + showDialog( + 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 confirmDeletePlan(BuildContext context, String name) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + 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; +} diff --git a/lib/src/plans/views/plan_editor_view.dart b/lib/src/plans/views/plan_editor_view.dart new file mode 100644 index 0000000..341d06c --- /dev/null +++ b/lib/src/plans/views/plan_editor_view.dart @@ -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(); + 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 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 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( + 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 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( + 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( + 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().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().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( + 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))], + ); +} diff --git a/lib/src/plans/views/plans_view.dart b/lib/src/plans/views/plans_view.dart index d2828cd..5cb3459 100644 --- a/lib/src/plans/views/plans_view.dart +++ b/lib/src/plans/views/plans_view.dart @@ -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 createPlan(BuildContext context, Mode mode) async { + final l10n = AppLocalizations.of(context); + final plans = context.read(); + 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().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().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(); + return PopupMenuButton( + 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)), ], ); } diff --git a/lib/src/plans/views/step_editor_sheet.dart b/lib/src/plans/views/step_editor_sheet.dart new file mode 100644 index 0000000..d2ed4a6 --- /dev/null +++ b/lib/src/plans/views/step_editor_sheet.dart @@ -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 showStepEditor( + BuildContext context, { + required String title, + required T value, + required ValueChanged onChanged, +}) => showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => _StepEditorSheet(title: title, initial: value, onChanged: onChanged), +); + +class _StepEditorSheet extends StatefulWidget { + const _StepEditorSheet({required this.title, required this.initial, required this.onChanged}); + + final String title; + final T initial; + final ValueChanged onChanged; + + @override + State<_StepEditorSheet> createState() => _StepEditorSheetState(); +} + +class _StepEditorSheetState extends State<_StepEditorSheet> { + 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 [], + }, + 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 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 onChanged; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + SegmentedButton( + 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 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), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 48530d0..1ddc957 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index 18f9838..33124e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/plans/plan_repository_test.dart b/test/plans/plan_repository_test.dart new file mode 100644 index 0000000..95a79d0 --- /dev/null +++ b/test/plans/plan_repository_test.dart @@ -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()); + 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); + }); +} diff --git a/test/plans/plan_test.dart b/test/plans/plan_test.dart new file mode 100644 index 0000000..74a7f9a --- /dev/null +++ b/test/plans/plan_test.dart @@ -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 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); + }); +} diff --git a/test/support/test_app.dart b/test/support/test_app.dart index e8fb8a8..07881a2 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -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 pumpApp( WidgetTester tester, { ExerciseLibrary? library, Map 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(), ), diff --git a/test/support/test_database.dart b/test/support/test_database.dart new file mode 100644 index 0000000..65b158e --- /dev/null +++ b/test/support/test_database.dart @@ -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)); +} diff --git a/test/views/plan_editor_test.dart b/test/views/plan_editor_test.dart new file mode 100644 index 0000000..18b2414 --- /dev/null +++ b/test/views/plan_editor_test.dart @@ -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 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().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()); + }); +}