Stage 3: Plan editor with Steps, Supersets and reordering
- Plan model with immutable edits: add with Mode defaults, move, remove, Superset with next, take a member out, ungroup; Stretching length estimate from holds, sides, rounds and Transitions - drift database (plans table, items as JSON, ADR 0005) and PlanRepository; Plans list per Mode with rename/duplicate/delete - Editor: drag handles, step editor sheet (sets, reps or time, optional kg; hold, both sides, rounds; Superset sets and rest between), pause between holds for Stretching Plans - Exercise picker reusing the Library browser; Add to plan from the Exercise detail page - Theme: Mode-styled snackbar, FAB, menus, segmented buttons, switches Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
786dd52e30
commit
bbdca95541
28 files changed
+3654
-11
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
# ADR 0005 — A Plan's Steps and Supersets are stored as JSON
|
||||||
|
|
||||||
|
Status: Accepted (2026-10-29)
|
||||||
|
|
||||||
|
A Plan's items (Workout Steps, Stretching Steps and Supersets with their members) are stored as one
|
||||||
|
JSON column on the `plans` row, following CookIT's ADR 0002. A Plan is always loaded, edited and saved
|
||||||
|
as a whole, every edit is an immutable `Plan.withX` operation that can be unit-tested without a
|
||||||
|
database, and reordering or grouping into Supersets needs no position or group bookkeeping.
|
||||||
|
|
||||||
|
Plans are only listed per Mode, newest first.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Items carry no ids; the editor keys rows by object identity, which is enough because every save
|
||||||
|
re-reads the Plan.
|
||||||
|
- Sessions will store a snapshot of the Plan's items (same JSON), so editing a Plan never changes
|
||||||
|
History.
|
||||||
|
- Querying across Plans by Exercise in SQL would need a table of its own; nothing needs it yet.
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'src/app.dart';
|
import 'src/app.dart';
|
||||||
|
import 'src/database/database.dart';
|
||||||
import 'src/exercises/exercise_library.dart';
|
import 'src/exercises/exercise_library.dart';
|
||||||
|
import 'src/plans/plan_repository.dart';
|
||||||
import 'src/settings/settings_controller.dart';
|
import 'src/settings/settings_controller.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
|
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
|
||||||
|
|
||||||
|
final db = AppDatabase(driftDatabase(name: 'zenxercise'));
|
||||||
final library = await ExerciseLibrary.load(rootBundle);
|
final library = await ExerciseLibrary.load(rootBundle);
|
||||||
final settings = SettingsController(SettingsService());
|
final settings = SettingsController(SettingsService());
|
||||||
await settings.loadSettings();
|
await settings.loadSettings();
|
||||||
@@ -19,6 +23,7 @@ void main() async {
|
|||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider.value(value: settings),
|
ChangeNotifierProvider.value(value: settings),
|
||||||
Provider.value(value: library),
|
Provider.value(value: library),
|
||||||
|
Provider.value(value: PlanRepository(db)),
|
||||||
],
|
],
|
||||||
child: const ZenXerciseApp(),
|
child: const ZenXerciseApp(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import 'design/zen_style.dart';
|
|||||||
import 'exercises/views/exercise_detail_view.dart';
|
import 'exercises/views/exercise_detail_view.dart';
|
||||||
import 'home/home_view.dart';
|
import 'home/home_view.dart';
|
||||||
import 'localization/app_localizations.dart';
|
import 'localization/app_localizations.dart';
|
||||||
|
import 'plans/views/exercise_picker_view.dart';
|
||||||
|
import 'plans/views/plan_editor_view.dart';
|
||||||
import 'settings/settings_controller.dart';
|
import 'settings/settings_controller.dart';
|
||||||
|
|
||||||
class ZenXerciseApp extends StatelessWidget {
|
class ZenXerciseApp extends StatelessWidget {
|
||||||
@@ -34,6 +36,8 @@ class ZenXerciseApp extends StatelessWidget {
|
|||||||
settings: settings,
|
settings: settings,
|
||||||
builder: (context) => switch (settings.name) {
|
builder: (context) => switch (settings.name) {
|
||||||
ExerciseDetailView.routeName => ExerciseDetailView(exerciseId: settings.arguments! as String),
|
ExerciseDetailView.routeName => ExerciseDetailView(exerciseId: settings.arguments! as String),
|
||||||
|
PlanEditorView.routeName => PlanEditorView(planId: settings.arguments! as String),
|
||||||
|
ExercisePickerView.routeName => ExercisePickerView(planId: settings.arguments! as String),
|
||||||
_ => const HomeView(),
|
_ => const HomeView(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../plans/models/plan.dart';
|
||||||
|
|
||||||
|
part 'database.g.dart';
|
||||||
|
|
||||||
|
/// One row per Plan. Its Steps and Supersets are only ever read and written
|
||||||
|
/// together with the Plan, so they are stored as JSON (see docs/adr/0005).
|
||||||
|
@DataClassName('PlanRow')
|
||||||
|
class Plans extends Table {
|
||||||
|
TextColumn get id => text()();
|
||||||
|
|
||||||
|
/// The Mode's name, `stretching` or `workout`.
|
||||||
|
TextColumn get mode => text()();
|
||||||
|
TextColumn get name => text()();
|
||||||
|
TextColumn get items => text().map(const PlanItemsConverter())();
|
||||||
|
IntColumn get transitionSeconds => integer().withDefault(const Constant(Plan.defaultTransitionSeconds))();
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlanItemsConverter extends TypeConverter<List<PlanItem>, String> {
|
||||||
|
const PlanItemsConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<PlanItem> fromSql(String fromDb) => PlanItem.listFromJson(fromDb);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(List<PlanItem> value) => PlanItem.listToJson(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DriftDatabase(tables: [Plans])
|
||||||
|
class AppDatabase extends _$AppDatabase {
|
||||||
|
AppDatabase(super.e);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'database.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
class $PlansTable extends Plans with TableInfo<$PlansTable, PlanRow> {
|
||||||
|
@override
|
||||||
|
final GeneratedDatabase attachedDatabase;
|
||||||
|
final String? _alias;
|
||||||
|
$PlansTable(this.attachedDatabase, [this._alias]);
|
||||||
|
static const VerificationMeta _idMeta = const VerificationMeta('id');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> id = GeneratedColumn<String>(
|
||||||
|
'id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _modeMeta = const VerificationMeta('mode');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> mode = GeneratedColumn<String>(
|
||||||
|
'mode',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _nameMeta = const VerificationMeta('name');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> name = GeneratedColumn<String>(
|
||||||
|
'name',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumnWithTypeConverter<List<PlanItem>, String> items = GeneratedColumn<String>(
|
||||||
|
'items',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
).withConverter<List<PlanItem>>($PlansTable.$converteritems);
|
||||||
|
static const VerificationMeta _transitionSecondsMeta = const VerificationMeta('transitionSeconds');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<int> transitionSeconds = GeneratedColumn<int>(
|
||||||
|
'transition_seconds',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultValue: const Constant(Plan.defaultTransitionSeconds),
|
||||||
|
);
|
||||||
|
static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
|
||||||
|
'created_at',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.dateTime,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<DateTime> updatedAt = GeneratedColumn<DateTime>(
|
||||||
|
'updated_at',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.dateTime,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<GeneratedColumn> get $columns => [id, mode, name, items, transitionSeconds, createdAt, updatedAt];
|
||||||
|
@override
|
||||||
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
|
@override
|
||||||
|
String get actualTableName => $name;
|
||||||
|
static const String $name = 'plans';
|
||||||
|
@override
|
||||||
|
VerificationContext validateIntegrity(Insertable<PlanRow> instance, {bool isInserting = false}) {
|
||||||
|
final context = VerificationContext();
|
||||||
|
final data = instance.toColumns(true);
|
||||||
|
if (data.containsKey('id')) {
|
||||||
|
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_idMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('mode')) {
|
||||||
|
context.handle(_modeMeta, mode.isAcceptableOrUnknown(data['mode']!, _modeMeta));
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_modeMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('name')) {
|
||||||
|
context.handle(_nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta));
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_nameMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('transition_seconds')) {
|
||||||
|
context.handle(
|
||||||
|
_transitionSecondsMeta,
|
||||||
|
transitionSeconds.isAcceptableOrUnknown(data['transition_seconds']!, _transitionSecondsMeta),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.containsKey('created_at')) {
|
||||||
|
context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_createdAtMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('updated_at')) {
|
||||||
|
context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_updatedAtMeta);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<GeneratedColumn> get $primaryKey => {id};
|
||||||
|
@override
|
||||||
|
PlanRow map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||||
|
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||||
|
return PlanRow(
|
||||||
|
id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!,
|
||||||
|
mode: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}mode'])!,
|
||||||
|
name: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}name'])!,
|
||||||
|
items: $PlansTable.$converteritems.fromSql(
|
||||||
|
attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}items'])!,
|
||||||
|
),
|
||||||
|
transitionSeconds: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.int,
|
||||||
|
data['${effectivePrefix}transition_seconds'],
|
||||||
|
)!,
|
||||||
|
createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
|
||||||
|
updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
$PlansTable createAlias(String alias) {
|
||||||
|
return $PlansTable(attachedDatabase, alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
static TypeConverter<List<PlanItem>, String> $converteritems = const PlanItemsConverter();
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlanRow extends DataClass implements Insertable<PlanRow> {
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// The Mode's name, `stretching` or `workout`.
|
||||||
|
final String mode;
|
||||||
|
final String name;
|
||||||
|
final List<PlanItem> items;
|
||||||
|
final int transitionSeconds;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
const PlanRow({
|
||||||
|
required this.id,
|
||||||
|
required this.mode,
|
||||||
|
required this.name,
|
||||||
|
required this.items,
|
||||||
|
required this.transitionSeconds,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, Expression>{};
|
||||||
|
map['id'] = Variable<String>(id);
|
||||||
|
map['mode'] = Variable<String>(mode);
|
||||||
|
map['name'] = Variable<String>(name);
|
||||||
|
{
|
||||||
|
map['items'] = Variable<String>($PlansTable.$converteritems.toSql(items));
|
||||||
|
}
|
||||||
|
map['transition_seconds'] = Variable<int>(transitionSeconds);
|
||||||
|
map['created_at'] = Variable<DateTime>(createdAt);
|
||||||
|
map['updated_at'] = Variable<DateTime>(updatedAt);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlansCompanion toCompanion(bool nullToAbsent) {
|
||||||
|
return PlansCompanion(
|
||||||
|
id: Value(id),
|
||||||
|
mode: Value(mode),
|
||||||
|
name: Value(name),
|
||||||
|
items: Value(items),
|
||||||
|
transitionSeconds: Value(transitionSeconds),
|
||||||
|
createdAt: Value(createdAt),
|
||||||
|
updatedAt: Value(updatedAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PlanRow.fromJson(Map<String, dynamic> json, {ValueSerializer? serializer}) {
|
||||||
|
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||||
|
return PlanRow(
|
||||||
|
id: serializer.fromJson<String>(json['id']),
|
||||||
|
mode: serializer.fromJson<String>(json['mode']),
|
||||||
|
name: serializer.fromJson<String>(json['name']),
|
||||||
|
items: serializer.fromJson<List<PlanItem>>(json['items']),
|
||||||
|
transitionSeconds: serializer.fromJson<int>(json['transitionSeconds']),
|
||||||
|
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
||||||
|
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||||
|
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||||
|
return <String, dynamic>{
|
||||||
|
'id': serializer.toJson<String>(id),
|
||||||
|
'mode': serializer.toJson<String>(mode),
|
||||||
|
'name': serializer.toJson<String>(name),
|
||||||
|
'items': serializer.toJson<List<PlanItem>>(items),
|
||||||
|
'transitionSeconds': serializer.toJson<int>(transitionSeconds),
|
||||||
|
'createdAt': serializer.toJson<DateTime>(createdAt),
|
||||||
|
'updatedAt': serializer.toJson<DateTime>(updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PlanRow copyWith({
|
||||||
|
String? id,
|
||||||
|
String? mode,
|
||||||
|
String? name,
|
||||||
|
List<PlanItem>? items,
|
||||||
|
int? transitionSeconds,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) => PlanRow(
|
||||||
|
id: id ?? this.id,
|
||||||
|
mode: mode ?? this.mode,
|
||||||
|
name: name ?? this.name,
|
||||||
|
items: items ?? this.items,
|
||||||
|
transitionSeconds: transitionSeconds ?? this.transitionSeconds,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
PlanRow copyWithCompanion(PlansCompanion data) {
|
||||||
|
return PlanRow(
|
||||||
|
id: data.id.present ? data.id.value : this.id,
|
||||||
|
mode: data.mode.present ? data.mode.value : this.mode,
|
||||||
|
name: data.name.present ? data.name.value : this.name,
|
||||||
|
items: data.items.present ? data.items.value : this.items,
|
||||||
|
transitionSeconds: data.transitionSeconds.present ? data.transitionSeconds.value : this.transitionSeconds,
|
||||||
|
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
|
||||||
|
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('PlanRow(')
|
||||||
|
..write('id: $id, ')
|
||||||
|
..write('mode: $mode, ')
|
||||||
|
..write('name: $name, ')
|
||||||
|
..write('items: $items, ')
|
||||||
|
..write('transitionSeconds: $transitionSeconds, ')
|
||||||
|
..write('createdAt: $createdAt, ')
|
||||||
|
..write('updatedAt: $updatedAt')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(id, mode, name, items, transitionSeconds, createdAt, updatedAt);
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
(other is PlanRow &&
|
||||||
|
other.id == this.id &&
|
||||||
|
other.mode == this.mode &&
|
||||||
|
other.name == this.name &&
|
||||||
|
other.items == this.items &&
|
||||||
|
other.transitionSeconds == this.transitionSeconds &&
|
||||||
|
other.createdAt == this.createdAt &&
|
||||||
|
other.updatedAt == this.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlansCompanion extends UpdateCompanion<PlanRow> {
|
||||||
|
final Value<String> id;
|
||||||
|
final Value<String> mode;
|
||||||
|
final Value<String> name;
|
||||||
|
final Value<List<PlanItem>> items;
|
||||||
|
final Value<int> transitionSeconds;
|
||||||
|
final Value<DateTime> createdAt;
|
||||||
|
final Value<DateTime> updatedAt;
|
||||||
|
final Value<int> rowid;
|
||||||
|
const PlansCompanion({
|
||||||
|
this.id = const Value.absent(),
|
||||||
|
this.mode = const Value.absent(),
|
||||||
|
this.name = const Value.absent(),
|
||||||
|
this.items = const Value.absent(),
|
||||||
|
this.transitionSeconds = const Value.absent(),
|
||||||
|
this.createdAt = const Value.absent(),
|
||||||
|
this.updatedAt = const Value.absent(),
|
||||||
|
this.rowid = const Value.absent(),
|
||||||
|
});
|
||||||
|
PlansCompanion.insert({
|
||||||
|
required String id,
|
||||||
|
required String mode,
|
||||||
|
required String name,
|
||||||
|
required List<PlanItem> items,
|
||||||
|
this.transitionSeconds = const Value.absent(),
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
this.rowid = const Value.absent(),
|
||||||
|
}) : id = Value(id),
|
||||||
|
mode = Value(mode),
|
||||||
|
name = Value(name),
|
||||||
|
items = Value(items),
|
||||||
|
createdAt = Value(createdAt),
|
||||||
|
updatedAt = Value(updatedAt);
|
||||||
|
static Insertable<PlanRow> custom({
|
||||||
|
Expression<String>? id,
|
||||||
|
Expression<String>? mode,
|
||||||
|
Expression<String>? name,
|
||||||
|
Expression<String>? items,
|
||||||
|
Expression<int>? transitionSeconds,
|
||||||
|
Expression<DateTime>? createdAt,
|
||||||
|
Expression<DateTime>? updatedAt,
|
||||||
|
Expression<int>? rowid,
|
||||||
|
}) {
|
||||||
|
return RawValuesInsertable({
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
if (mode != null) 'mode': mode,
|
||||||
|
if (name != null) 'name': name,
|
||||||
|
if (items != null) 'items': items,
|
||||||
|
if (transitionSeconds != null) 'transition_seconds': transitionSeconds,
|
||||||
|
if (createdAt != null) 'created_at': createdAt,
|
||||||
|
if (updatedAt != null) 'updated_at': updatedAt,
|
||||||
|
if (rowid != null) 'rowid': rowid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PlansCompanion copyWith({
|
||||||
|
Value<String>? id,
|
||||||
|
Value<String>? mode,
|
||||||
|
Value<String>? name,
|
||||||
|
Value<List<PlanItem>>? items,
|
||||||
|
Value<int>? transitionSeconds,
|
||||||
|
Value<DateTime>? createdAt,
|
||||||
|
Value<DateTime>? updatedAt,
|
||||||
|
Value<int>? rowid,
|
||||||
|
}) {
|
||||||
|
return PlansCompanion(
|
||||||
|
id: id ?? this.id,
|
||||||
|
mode: mode ?? this.mode,
|
||||||
|
name: name ?? this.name,
|
||||||
|
items: items ?? this.items,
|
||||||
|
transitionSeconds: transitionSeconds ?? this.transitionSeconds,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
rowid: rowid ?? this.rowid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, Expression>{};
|
||||||
|
if (id.present) {
|
||||||
|
map['id'] = Variable<String>(id.value);
|
||||||
|
}
|
||||||
|
if (mode.present) {
|
||||||
|
map['mode'] = Variable<String>(mode.value);
|
||||||
|
}
|
||||||
|
if (name.present) {
|
||||||
|
map['name'] = Variable<String>(name.value);
|
||||||
|
}
|
||||||
|
if (items.present) {
|
||||||
|
map['items'] = Variable<String>($PlansTable.$converteritems.toSql(items.value));
|
||||||
|
}
|
||||||
|
if (transitionSeconds.present) {
|
||||||
|
map['transition_seconds'] = Variable<int>(transitionSeconds.value);
|
||||||
|
}
|
||||||
|
if (createdAt.present) {
|
||||||
|
map['created_at'] = Variable<DateTime>(createdAt.value);
|
||||||
|
}
|
||||||
|
if (updatedAt.present) {
|
||||||
|
map['updated_at'] = Variable<DateTime>(updatedAt.value);
|
||||||
|
}
|
||||||
|
if (rowid.present) {
|
||||||
|
map['rowid'] = Variable<int>(rowid.value);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('PlansCompanion(')
|
||||||
|
..write('id: $id, ')
|
||||||
|
..write('mode: $mode, ')
|
||||||
|
..write('name: $name, ')
|
||||||
|
..write('items: $items, ')
|
||||||
|
..write('transitionSeconds: $transitionSeconds, ')
|
||||||
|
..write('createdAt: $createdAt, ')
|
||||||
|
..write('updatedAt: $updatedAt, ')
|
||||||
|
..write('rowid: $rowid')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _$AppDatabase extends GeneratedDatabase {
|
||||||
|
_$AppDatabase(QueryExecutor e) : super(e);
|
||||||
|
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
||||||
|
late final $PlansTable plans = $PlansTable(this);
|
||||||
|
@override
|
||||||
|
Iterable<TableInfo<Table, Object?>> get allTables => allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||||
|
@override
|
||||||
|
List<DatabaseSchemaEntity> get allSchemaEntities => [plans];
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef $$PlansTableCreateCompanionBuilder =
|
||||||
|
PlansCompanion Function({
|
||||||
|
required String id,
|
||||||
|
required String mode,
|
||||||
|
required String name,
|
||||||
|
required List<PlanItem> items,
|
||||||
|
Value<int> transitionSeconds,
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
Value<int> rowid,
|
||||||
|
});
|
||||||
|
typedef $$PlansTableUpdateCompanionBuilder =
|
||||||
|
PlansCompanion Function({
|
||||||
|
Value<String> id,
|
||||||
|
Value<String> mode,
|
||||||
|
Value<String> name,
|
||||||
|
Value<List<PlanItem>> items,
|
||||||
|
Value<int> transitionSeconds,
|
||||||
|
Value<DateTime> createdAt,
|
||||||
|
Value<DateTime> updatedAt,
|
||||||
|
Value<int> rowid,
|
||||||
|
});
|
||||||
|
|
||||||
|
class $$PlansTableFilterComposer extends Composer<_$AppDatabase, $PlansTable> {
|
||||||
|
$$PlansTableFilterComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
ColumnFilters<String> get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column));
|
||||||
|
|
||||||
|
ColumnFilters<String> get mode => $composableBuilder(column: $table.mode, builder: (column) => ColumnFilters(column));
|
||||||
|
|
||||||
|
ColumnFilters<String> get name => $composableBuilder(column: $table.name, builder: (column) => ColumnFilters(column));
|
||||||
|
|
||||||
|
ColumnWithTypeConverterFilters<List<PlanItem>, List<PlanItem>, String> get items =>
|
||||||
|
$composableBuilder(column: $table.items, builder: (column) => ColumnWithTypeConverterFilters(column));
|
||||||
|
|
||||||
|
ColumnFilters<int> get transitionSeconds =>
|
||||||
|
$composableBuilder(column: $table.transitionSeconds, builder: (column) => ColumnFilters(column));
|
||||||
|
|
||||||
|
ColumnFilters<DateTime> get createdAt =>
|
||||||
|
$composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column));
|
||||||
|
|
||||||
|
ColumnFilters<DateTime> get updatedAt =>
|
||||||
|
$composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column));
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$PlansTableOrderingComposer extends Composer<_$AppDatabase, $PlansTable> {
|
||||||
|
$$PlansTableOrderingComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
ColumnOrderings<String> get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column));
|
||||||
|
|
||||||
|
ColumnOrderings<String> get mode =>
|
||||||
|
$composableBuilder(column: $table.mode, builder: (column) => ColumnOrderings(column));
|
||||||
|
|
||||||
|
ColumnOrderings<String> get name =>
|
||||||
|
$composableBuilder(column: $table.name, builder: (column) => ColumnOrderings(column));
|
||||||
|
|
||||||
|
ColumnOrderings<String> get items =>
|
||||||
|
$composableBuilder(column: $table.items, builder: (column) => ColumnOrderings(column));
|
||||||
|
|
||||||
|
ColumnOrderings<int> get transitionSeconds =>
|
||||||
|
$composableBuilder(column: $table.transitionSeconds, builder: (column) => ColumnOrderings(column));
|
||||||
|
|
||||||
|
ColumnOrderings<DateTime> get createdAt =>
|
||||||
|
$composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column));
|
||||||
|
|
||||||
|
ColumnOrderings<DateTime> get updatedAt =>
|
||||||
|
$composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column));
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$PlansTableAnnotationComposer extends Composer<_$AppDatabase, $PlansTable> {
|
||||||
|
$$PlansTableAnnotationComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
GeneratedColumn<String> get id => $composableBuilder(column: $table.id, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get mode => $composableBuilder(column: $table.mode, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get name => $composableBuilder(column: $table.name, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumnWithTypeConverter<List<PlanItem>, String> get items =>
|
||||||
|
$composableBuilder(column: $table.items, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<int> get transitionSeconds =>
|
||||||
|
$composableBuilder(column: $table.transitionSeconds, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<DateTime> get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<DateTime> get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$PlansTableTableManager
|
||||||
|
extends
|
||||||
|
RootTableManager<
|
||||||
|
_$AppDatabase,
|
||||||
|
$PlansTable,
|
||||||
|
PlanRow,
|
||||||
|
$$PlansTableFilterComposer,
|
||||||
|
$$PlansTableOrderingComposer,
|
||||||
|
$$PlansTableAnnotationComposer,
|
||||||
|
$$PlansTableCreateCompanionBuilder,
|
||||||
|
$$PlansTableUpdateCompanionBuilder,
|
||||||
|
(PlanRow, BaseReferences<_$AppDatabase, $PlansTable, PlanRow>),
|
||||||
|
PlanRow,
|
||||||
|
PrefetchHooks Function()
|
||||||
|
> {
|
||||||
|
$$PlansTableTableManager(_$AppDatabase db, $PlansTable table)
|
||||||
|
: super(
|
||||||
|
TableManagerState(
|
||||||
|
db: db,
|
||||||
|
table: table,
|
||||||
|
createFilteringComposer: () => $$PlansTableFilterComposer($db: db, $table: table),
|
||||||
|
createOrderingComposer: () => $$PlansTableOrderingComposer($db: db, $table: table),
|
||||||
|
createComputedFieldComposer: () => $$PlansTableAnnotationComposer($db: db, $table: table),
|
||||||
|
updateCompanionCallback:
|
||||||
|
({
|
||||||
|
Value<String> id = const Value.absent(),
|
||||||
|
Value<String> mode = const Value.absent(),
|
||||||
|
Value<String> name = const Value.absent(),
|
||||||
|
Value<List<PlanItem>> items = const Value.absent(),
|
||||||
|
Value<int> transitionSeconds = const Value.absent(),
|
||||||
|
Value<DateTime> createdAt = const Value.absent(),
|
||||||
|
Value<DateTime> updatedAt = const Value.absent(),
|
||||||
|
Value<int> rowid = const Value.absent(),
|
||||||
|
}) => PlansCompanion(
|
||||||
|
id: id,
|
||||||
|
mode: mode,
|
||||||
|
name: name,
|
||||||
|
items: items,
|
||||||
|
transitionSeconds: transitionSeconds,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
rowid: rowid,
|
||||||
|
),
|
||||||
|
createCompanionCallback:
|
||||||
|
({
|
||||||
|
required String id,
|
||||||
|
required String mode,
|
||||||
|
required String name,
|
||||||
|
required List<PlanItem> items,
|
||||||
|
Value<int> transitionSeconds = const Value.absent(),
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
Value<int> rowid = const Value.absent(),
|
||||||
|
}) => PlansCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
mode: mode,
|
||||||
|
name: name,
|
||||||
|
items: items,
|
||||||
|
transitionSeconds: transitionSeconds,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
rowid: rowid,
|
||||||
|
),
|
||||||
|
withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(),
|
||||||
|
prefetchHooksCallback: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef $$PlansTableProcessedTableManager =
|
||||||
|
ProcessedTableManager<
|
||||||
|
_$AppDatabase,
|
||||||
|
$PlansTable,
|
||||||
|
PlanRow,
|
||||||
|
$$PlansTableFilterComposer,
|
||||||
|
$$PlansTableOrderingComposer,
|
||||||
|
$$PlansTableAnnotationComposer,
|
||||||
|
$$PlansTableCreateCompanionBuilder,
|
||||||
|
$$PlansTableUpdateCompanionBuilder,
|
||||||
|
(PlanRow, BaseReferences<_$AppDatabase, $PlansTable, PlanRow>),
|
||||||
|
PlanRow,
|
||||||
|
PrefetchHooks Function()
|
||||||
|
>;
|
||||||
|
|
||||||
|
class $AppDatabaseManager {
|
||||||
|
final _$AppDatabase _db;
|
||||||
|
$AppDatabaseManager(this._db);
|
||||||
|
$$PlansTableTableManager get plans => $$PlansTableTableManager(_db, _db.plans);
|
||||||
|
}
|
||||||
@@ -107,7 +107,45 @@ class AppTheme {
|
|||||||
snackBarTheme: SnackBarThemeData(
|
snackBarTheme: SnackBarThemeData(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
insetPadding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@ import 'zen_style.dart';
|
|||||||
|
|
||||||
/// A tab's big title and an optional line under it.
|
/// A tab's big title and an optional line under it.
|
||||||
class PageHeader extends StatelessWidget {
|
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 title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
|
|
||||||
|
/// A button beside the title, such as "New plan".
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = ZenStyle.of(context);
|
final s = ZenStyle.of(context);
|
||||||
@@ -18,7 +21,12 @@ class PageHeader extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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) ...[
|
if (subtitle != null) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(subtitle!, style: text.bodyLarge?.copyWith(color: s.inkMuted)),
|
Text(subtitle!, style: text.bodyLarge?.copyWith(color: s.inkMuted)),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../../design/zen_style.dart';
|
import '../../design/zen_style.dart';
|
||||||
import '../../localization/app_localizations.dart';
|
import '../../localization/app_localizations.dart';
|
||||||
|
import '../../plans/views/add_to_plan_sheet.dart';
|
||||||
import '../exercise.dart';
|
import '../exercise.dart';
|
||||||
import '../exercise_labels.dart';
|
import '../exercise_labels.dart';
|
||||||
import '../exercise_library.dart';
|
import '../exercise_library.dart';
|
||||||
@@ -24,8 +25,21 @@ class ExerciseDetailView extends StatelessWidget {
|
|||||||
final text = Theme.of(context).textTheme;
|
final text = Theme.of(context).textTheme;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
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(
|
body: ListView(
|
||||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 32 + MediaQuery.paddingOf(context).bottom),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||||
children: [
|
children: [
|
||||||
FlipAnimation(exercise: exercise),
|
FlipAnimation(exercise: exercise),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|||||||
@@ -77,5 +77,165 @@
|
|||||||
"detailType": "Type",
|
"detailType": "Type",
|
||||||
"equipmentNone": "None listed",
|
"equipmentNone": "None listed",
|
||||||
"flipStart": "Start position",
|
"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?"
|
||||||
}
|
}
|
||||||
@@ -297,6 +297,318 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'End position'**
|
/// **'End position'**
|
||||||
String get flipEnd;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -158,4 +158,231 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get flipEnd => 'End position';
|
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?';
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../modes/mode.dart';
|
||||||
|
|
||||||
|
/// A user-created, ordered list of Steps and Supersets belonging to one Mode.
|
||||||
|
/// Every edit returns a new Plan.
|
||||||
|
@immutable
|
||||||
|
class Plan {
|
||||||
|
const Plan({
|
||||||
|
required this.id,
|
||||||
|
required this.mode,
|
||||||
|
required this.name,
|
||||||
|
required this.items,
|
||||||
|
this.transitionSeconds = defaultTransitionSeconds,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const defaultTransitionSeconds = 5;
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final Mode mode;
|
||||||
|
final String name;
|
||||||
|
final List<PlanItem> items;
|
||||||
|
|
||||||
|
/// Stretching only: the length of each Transition between holds.
|
||||||
|
final int transitionSeconds;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
/// Every Exercise in the Plan, counting each Superset member.
|
||||||
|
int get exerciseCount => items.fold(0, (n, item) => n + (item is Superset ? item.members.length : 1));
|
||||||
|
|
||||||
|
/// Holds plus the Transitions between them, for Stretching Plans; Workout
|
||||||
|
/// Plans run at the user's pace and have no estimate.
|
||||||
|
Duration? get estimatedDuration {
|
||||||
|
if (mode != Mode.stretching) return null;
|
||||||
|
var holds = 0;
|
||||||
|
var seconds = 0;
|
||||||
|
for (final step in items.whereType<StretchingStep>()) {
|
||||||
|
final count = step.rounds * (step.bothSides ? 2 : 1);
|
||||||
|
holds += count;
|
||||||
|
seconds += count * step.holdSeconds;
|
||||||
|
}
|
||||||
|
return Duration(seconds: seconds + (holds > 1 ? (holds - 1) * transitionSeconds : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
Plan copyWith({String? name, List<PlanItem>? items, int? transitionSeconds, DateTime? updatedAt}) => Plan(
|
||||||
|
id: id,
|
||||||
|
mode: mode,
|
||||||
|
name: name ?? this.name,
|
||||||
|
items: items ?? this.items,
|
||||||
|
transitionSeconds: transitionSeconds ?? this.transitionSeconds,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Appends a Step with the Mode's defaults: 3 × 10 reps, or a 30 s hold on one side.
|
||||||
|
Plan withExerciseAdded(String exerciseId) => copyWith(
|
||||||
|
items: [
|
||||||
|
...items,
|
||||||
|
switch (mode) {
|
||||||
|
Mode.workout => WorkoutStep(exerciseId: exerciseId, sets: 3, target: const WorkoutTarget.reps(10)),
|
||||||
|
Mode.stretching => StretchingStep(exerciseId: exerciseId, holdSeconds: 30, bothSides: false, rounds: 1),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Plan withItemReplaced(int index, PlanItem item) => copyWith(items: [...items]..[index] = item);
|
||||||
|
|
||||||
|
Plan withItemRemoved(int index) => copyWith(items: [...items]..removeAt(index));
|
||||||
|
|
||||||
|
/// Moves an item the way a reorderable list reports it: [newIndex] counts
|
||||||
|
/// the moved item still in place.
|
||||||
|
Plan withItemMoved(int oldIndex, int newIndex) {
|
||||||
|
final list = [...items];
|
||||||
|
final item = list.removeAt(oldIndex);
|
||||||
|
list.insert(newIndex > oldIndex ? newIndex - 1 : newIndex, item);
|
||||||
|
return copyWith(items: list);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canSupersetWithNext(int index) =>
|
||||||
|
mode == Mode.workout &&
|
||||||
|
index + 1 < items.length &&
|
||||||
|
_members(items[index]) != null &&
|
||||||
|
_members(items[index + 1]) != null;
|
||||||
|
|
||||||
|
/// Groups the item with the one after it. The result keeps the first item's
|
||||||
|
/// set count (and rest choice, if it was already a Superset).
|
||||||
|
Plan withSupersetWithNext(int index) {
|
||||||
|
assert(canSupersetWithNext(index));
|
||||||
|
final first = items[index];
|
||||||
|
final merged = Superset(
|
||||||
|
sets: switch (first) {
|
||||||
|
WorkoutStep(:final sets) || Superset(:final sets) => sets,
|
||||||
|
StretchingStep() => throw StateError('Stretching Steps cannot form a Superset'),
|
||||||
|
},
|
||||||
|
restBetween: first is Superset && first.restBetween,
|
||||||
|
members: [..._members(first)!, ..._members(items[index + 1])!],
|
||||||
|
);
|
||||||
|
return copyWith(items: [...items]..replaceRange(index, index + 2, [merged]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes one member out; it lands right after its Superset as a Step with the
|
||||||
|
/// Superset's set count. A Superset left with one member becomes a Step too.
|
||||||
|
Plan withMemberRemovedFromSuperset(int index, int memberIndex) {
|
||||||
|
final superset = items[index] as Superset;
|
||||||
|
final taken = superset.members[memberIndex].toStep(superset.sets);
|
||||||
|
final rest = [...superset.members]..removeAt(memberIndex);
|
||||||
|
final remaining = rest.length == 1 ? rest.single.toStep(superset.sets) : superset.copyWith(members: rest);
|
||||||
|
return copyWith(items: [...items]..replaceRange(index, index + 1, [remaining, taken]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns a Superset back into one Step per member, each with its set count.
|
||||||
|
Plan withUngrouped(int index) {
|
||||||
|
final superset = items[index] as Superset;
|
||||||
|
return copyWith(
|
||||||
|
items: [...items]..replaceRange(index, index + 1, [for (final m in superset.members) m.toStep(superset.sets)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<SupersetMember>? _members(PlanItem item) => switch (item) {
|
||||||
|
WorkoutStep() => [item.toMember()],
|
||||||
|
Superset(:final members) => members,
|
||||||
|
StretchingStep() => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of a Plan's list: a Step, or a Superset of Workout Steps.
|
||||||
|
sealed class PlanItem {
|
||||||
|
const PlanItem();
|
||||||
|
|
||||||
|
Map<String, Object?> toJson();
|
||||||
|
|
||||||
|
factory PlanItem.fromJson(Map<String, Object?> json) => switch (json['type']) {
|
||||||
|
'workout' => WorkoutStep.fromJson(json),
|
||||||
|
'stretching' => StretchingStep.fromJson(json),
|
||||||
|
'superset' => Superset.fromJson(json),
|
||||||
|
final type => throw FormatException('Unknown Plan item type $type'),
|
||||||
|
};
|
||||||
|
|
||||||
|
static String listToJson(List<PlanItem> items) => jsonEncode([for (final i in items) i.toJson()]);
|
||||||
|
|
||||||
|
static List<PlanItem> listFromJson(String json) => [
|
||||||
|
for (final i in jsonDecode(json) as List) PlanItem.fromJson((i as Map).cast<String, Object?>()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one Workout set asks for: a number of reps or a time.
|
||||||
|
@immutable
|
||||||
|
class WorkoutTarget {
|
||||||
|
const WorkoutTarget.reps(int this.reps) : seconds = null;
|
||||||
|
const WorkoutTarget.seconds(int this.seconds) : reps = null;
|
||||||
|
|
||||||
|
final int? reps;
|
||||||
|
final int? seconds;
|
||||||
|
|
||||||
|
bool get isTimed => seconds != null;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {'reps': reps, 'seconds': seconds};
|
||||||
|
|
||||||
|
factory WorkoutTarget.fromJson(Map<String, Object?> json) => json['seconds'] != null
|
||||||
|
? WorkoutTarget.seconds(json['seconds']! as int)
|
||||||
|
: WorkoutTarget.reps(json['reps']! as int);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => other is WorkoutTarget && other.reps == reps && other.seconds == seconds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(reps, seconds);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => isTimed ? '${seconds}s' : '$reps reps';
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class WorkoutStep extends PlanItem {
|
||||||
|
const WorkoutStep({required this.exerciseId, required this.sets, required this.target, this.weightKg});
|
||||||
|
|
||||||
|
final String exerciseId;
|
||||||
|
final int sets;
|
||||||
|
final WorkoutTarget target;
|
||||||
|
|
||||||
|
/// Optional target weight in kg.
|
||||||
|
final double? weightKg;
|
||||||
|
|
||||||
|
WorkoutStep copyWith({int? sets, WorkoutTarget? target, double? Function()? weightKg}) => WorkoutStep(
|
||||||
|
exerciseId: exerciseId,
|
||||||
|
sets: sets ?? this.sets,
|
||||||
|
target: target ?? this.target,
|
||||||
|
weightKg: weightKg == null ? this.weightKg : weightKg(),
|
||||||
|
);
|
||||||
|
|
||||||
|
SupersetMember toMember() => SupersetMember(exerciseId: exerciseId, target: target, weightKg: weightKg);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'type': 'workout',
|
||||||
|
'exerciseId': exerciseId,
|
||||||
|
'sets': sets,
|
||||||
|
...target.toJson(),
|
||||||
|
'weightKg': weightKg,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory WorkoutStep.fromJson(Map<String, Object?> json) => WorkoutStep(
|
||||||
|
exerciseId: json['exerciseId']! as String,
|
||||||
|
sets: json['sets']! as int,
|
||||||
|
target: WorkoutTarget.fromJson(json),
|
||||||
|
weightKg: (json['weightKg'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is WorkoutStep &&
|
||||||
|
other.exerciseId == exerciseId &&
|
||||||
|
other.sets == sets &&
|
||||||
|
other.target == target &&
|
||||||
|
other.weightKg == weightKg;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(exerciseId, sets, target, weightKg);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'WorkoutStep($exerciseId, $sets × $target, $weightKg kg)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class StretchingStep extends PlanItem {
|
||||||
|
const StretchingStep({
|
||||||
|
required this.exerciseId,
|
||||||
|
required this.holdSeconds,
|
||||||
|
required this.bothSides,
|
||||||
|
required this.rounds,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String exerciseId;
|
||||||
|
final int holdSeconds;
|
||||||
|
|
||||||
|
/// Held on the left, then the right side, each for [holdSeconds].
|
||||||
|
final bool bothSides;
|
||||||
|
final int rounds;
|
||||||
|
|
||||||
|
StretchingStep copyWith({int? holdSeconds, bool? bothSides, int? rounds}) => StretchingStep(
|
||||||
|
exerciseId: exerciseId,
|
||||||
|
holdSeconds: holdSeconds ?? this.holdSeconds,
|
||||||
|
bothSides: bothSides ?? this.bothSides,
|
||||||
|
rounds: rounds ?? this.rounds,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'type': 'stretching',
|
||||||
|
'exerciseId': exerciseId,
|
||||||
|
'holdSeconds': holdSeconds,
|
||||||
|
'bothSides': bothSides,
|
||||||
|
'rounds': rounds,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory StretchingStep.fromJson(Map<String, Object?> json) => StretchingStep(
|
||||||
|
exerciseId: json['exerciseId']! as String,
|
||||||
|
holdSeconds: json['holdSeconds']! as int,
|
||||||
|
bothSides: json['bothSides']! as bool,
|
||||||
|
rounds: json['rounds']! as int,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is StretchingStep &&
|
||||||
|
other.exerciseId == exerciseId &&
|
||||||
|
other.holdSeconds == holdSeconds &&
|
||||||
|
other.bothSides == bothSides &&
|
||||||
|
other.rounds == rounds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(exerciseId, holdSeconds, bothSides, rounds);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'StretchingStep($exerciseId, ${holdSeconds}s, both: $bothSides, × $rounds)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two or more Workout Steps performed alternately, one set of each in turn.
|
||||||
|
@immutable
|
||||||
|
class Superset extends PlanItem {
|
||||||
|
const Superset({required this.sets, required this.restBetween, required this.members});
|
||||||
|
|
||||||
|
final int sets;
|
||||||
|
|
||||||
|
/// Whether the Rest Screen also appears between members within a round, not
|
||||||
|
/// only after each round.
|
||||||
|
final bool restBetween;
|
||||||
|
final List<SupersetMember> members;
|
||||||
|
|
||||||
|
Superset copyWith({int? sets, bool? restBetween, List<SupersetMember>? members}) =>
|
||||||
|
Superset(sets: sets ?? this.sets, restBetween: restBetween ?? this.restBetween, members: members ?? this.members);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'type': 'superset',
|
||||||
|
'sets': sets,
|
||||||
|
'restBetween': restBetween,
|
||||||
|
'members': [for (final m in members) m.toJson()],
|
||||||
|
};
|
||||||
|
|
||||||
|
factory Superset.fromJson(Map<String, Object?> json) => Superset(
|
||||||
|
sets: json['sets']! as int,
|
||||||
|
restBetween: json['restBetween']! as bool,
|
||||||
|
members: [for (final m in json['members']! as List) SupersetMember.fromJson((m as Map).cast<String, Object?>())],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is Superset && other.sets == sets && other.restBetween == restBetween && listEquals(other.members, members);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(sets, restBetween, Object.hashAll(members));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'Superset($sets sets, rest: $restBetween, $members)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One Exercise inside a Superset: its own target and weight, the Superset's set count.
|
||||||
|
@immutable
|
||||||
|
class SupersetMember {
|
||||||
|
const SupersetMember({required this.exerciseId, required this.target, this.weightKg});
|
||||||
|
|
||||||
|
final String exerciseId;
|
||||||
|
final WorkoutTarget target;
|
||||||
|
final double? weightKg;
|
||||||
|
|
||||||
|
SupersetMember copyWith({WorkoutTarget? target, double? Function()? weightKg}) => SupersetMember(
|
||||||
|
exerciseId: exerciseId,
|
||||||
|
target: target ?? this.target,
|
||||||
|
weightKg: weightKg == null ? this.weightKg : weightKg(),
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkoutStep toStep(int sets) => WorkoutStep(exerciseId: exerciseId, sets: sets, target: target, weightKg: weightKg);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {'exerciseId': exerciseId, ...target.toJson(), 'weightKg': weightKg};
|
||||||
|
|
||||||
|
factory SupersetMember.fromJson(Map<String, Object?> json) => SupersetMember(
|
||||||
|
exerciseId: json['exerciseId']! as String,
|
||||||
|
target: WorkoutTarget.fromJson(json),
|
||||||
|
weightKg: (json['weightKg'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is SupersetMember && other.exerciseId == exerciseId && other.target == target && other.weightKg == weightKg;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(exerciseId, target, weightKg);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SupersetMember($exerciseId, $target, $weightKg kg)';
|
||||||
|
}
|
||||||
@@ -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(' · ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../database/database.dart';
|
||||||
|
import '../modes/mode.dart';
|
||||||
|
import 'models/plan.dart';
|
||||||
|
|
||||||
|
/// The user's Plans, per Mode.
|
||||||
|
class PlanRepository {
|
||||||
|
PlanRepository(this._db, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now;
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
final DateTime Function() _clock;
|
||||||
|
|
||||||
|
/// A Mode's Plans, the newest first.
|
||||||
|
Stream<List<Plan>> watchPlans(Mode mode) {
|
||||||
|
final query = _db.select(_db.plans)
|
||||||
|
..where((p) => p.mode.equals(mode.name))
|
||||||
|
..orderBy([(p) => OrderingTerm.desc(p.createdAt)]);
|
||||||
|
return query.watch().map((rows) => rows.map(_toPlan).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<Plan?> watchPlan(String id) => (_db.select(
|
||||||
|
_db.plans,
|
||||||
|
)..where((p) => p.id.equals(id))).watchSingleOrNull().map((r) => r == null ? null : _toPlan(r));
|
||||||
|
|
||||||
|
Future<Plan?> get(String id) async {
|
||||||
|
final row = await (_db.select(_db.plans)..where((p) => p.id.equals(id))).getSingleOrNull();
|
||||||
|
return row == null ? null : _toPlan(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Plan> create(Mode mode, String name) async {
|
||||||
|
final now = _clock();
|
||||||
|
final plan = Plan(id: const Uuid().v4(), mode: mode, name: name, items: const [], createdAt: now, updatedAt: now);
|
||||||
|
await _db.into(_db.plans).insert(_toCompanion(plan));
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save(Plan plan) =>
|
||||||
|
_db.into(_db.plans).insertOnConflictUpdate(_toCompanion(plan.copyWith(updatedAt: _clock())));
|
||||||
|
|
||||||
|
Future<Plan> duplicate(String id, String name) async {
|
||||||
|
final original = (await get(id))!;
|
||||||
|
final now = _clock();
|
||||||
|
final copy = Plan(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
mode: original.mode,
|
||||||
|
name: name,
|
||||||
|
items: original.items,
|
||||||
|
transitionSeconds: original.transitionSeconds,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
await _db.into(_db.plans).insert(_toCompanion(copy));
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(String id) => (_db.delete(_db.plans)..where((p) => p.id.equals(id))).go();
|
||||||
|
|
||||||
|
static Plan _toPlan(PlanRow row) => Plan(
|
||||||
|
id: row.id,
|
||||||
|
mode: Mode.fromName(row.mode),
|
||||||
|
name: row.name,
|
||||||
|
items: row.items,
|
||||||
|
transitionSeconds: row.transitionSeconds,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
static PlansCompanion _toCompanion(Plan plan) => PlansCompanion.insert(
|
||||||
|
id: plan.id,
|
||||||
|
mode: plan.mode.name,
|
||||||
|
name: plan.name,
|
||||||
|
items: plan.items,
|
||||||
|
transitionSeconds: Value(plan.transitionSeconds),
|
||||||
|
createdAt: plan.createdAt,
|
||||||
|
updatedAt: plan.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../design/zen_style.dart';
|
||||||
|
import '../../exercises/exercise.dart';
|
||||||
|
import '../../localization/app_localizations.dart';
|
||||||
|
import '../plan_labels.dart';
|
||||||
|
import '../plan_repository.dart';
|
||||||
|
import 'plan_dialogs.dart';
|
||||||
|
|
||||||
|
/// Lets the user pick one of the Exercise's Mode's Plans, or start a new one,
|
||||||
|
/// and adds the Exercise to its end.
|
||||||
|
Future<void> showAddToPlanSheet(BuildContext context, Exercise exercise) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final plans = context.read<PlanRepository>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final chosen = await showModalBottomSheet<_Choice>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => _AddToPlanSheet(exercise: exercise),
|
||||||
|
);
|
||||||
|
if (chosen == null || !context.mounted) return;
|
||||||
|
var planId = chosen.planId;
|
||||||
|
if (planId == null) {
|
||||||
|
final name = await promptPlanName(context, mode: exercise.mode, title: l10n.newPlanTitle);
|
||||||
|
if (name == null) return;
|
||||||
|
planId = (await plans.create(exercise.mode, name)).id;
|
||||||
|
}
|
||||||
|
final plan = (await plans.get(planId))!;
|
||||||
|
await plans.save(plan.withExerciseAdded(exercise.id));
|
||||||
|
messenger
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(content: Text(l10n.addedToPlan(plan.name))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Plan's id, or null for "New plan".
|
||||||
|
class _Choice {
|
||||||
|
const _Choice(this.planId);
|
||||||
|
|
||||||
|
final String? planId;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddToPlanSheet extends StatelessWidget {
|
||||||
|
const _AddToPlanSheet({required this.exercise});
|
||||||
|
|
||||||
|
final Exercise exercise;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final s = ZenStyle.of(context);
|
||||||
|
final text = Theme.of(context).textTheme;
|
||||||
|
return SafeArea(
|
||||||
|
child: StreamBuilder(
|
||||||
|
stream: context.read<PlanRepository>().watchPlans(exercise.mode),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final plans = snapshot.data ?? const [];
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.7),
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
child: Text(l10n.addToPlanTitle, style: text.titleLarge),
|
||||||
|
),
|
||||||
|
for (final plan in plans)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
title: Text(plan.name, style: text.titleSmall),
|
||||||
|
subtitle: Text(planSummary(l10n, plan), style: TextStyle(color: s.inkMuted)),
|
||||||
|
onTap: () => Navigator.of(context).pop(_Choice(plan.id)),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
leading: Icon(Icons.add_rounded, color: s.accent),
|
||||||
|
title: Text(l10n.newPlan, style: text.titleSmall?.copyWith(color: s.accent)),
|
||||||
|
onTap: () => Navigator.of(context).pop(const _Choice(null)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../exercises/exercise.dart';
|
||||||
|
import '../../exercises/exercise_library.dart';
|
||||||
|
import '../../exercises/views/exercise_browser.dart';
|
||||||
|
import '../../localization/app_localizations.dart';
|
||||||
|
import '../plan_repository.dart';
|
||||||
|
|
||||||
|
/// The Exercise Library of a Plan's Mode; each tap adds that Exercise to the
|
||||||
|
/// end of the Plan, so several can be added in one go.
|
||||||
|
class ExercisePickerView extends StatelessWidget {
|
||||||
|
const ExercisePickerView({super.key, required this.planId});
|
||||||
|
|
||||||
|
static const routeName = '/plan/add';
|
||||||
|
|
||||||
|
final String planId;
|
||||||
|
|
||||||
|
Future<void> _add(BuildContext context, Exercise exercise) async {
|
||||||
|
final plans = context.read<PlanRepository>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final plan = await plans.get(planId);
|
||||||
|
if (plan == null) return;
|
||||||
|
await plans.save(plan.withExerciseAdded(exercise.id));
|
||||||
|
messenger
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(content: Text(l10n.added(exercise.name)), duration: const Duration(milliseconds: 1500)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return StreamBuilder(
|
||||||
|
stream: context.read<PlanRepository>().watchPlan(planId),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final plan = snapshot.data;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(l10n.done))],
|
||||||
|
),
|
||||||
|
body: plan == null
|
||||||
|
? null
|
||||||
|
: ExerciseBrowser(
|
||||||
|
mode: plan.mode,
|
||||||
|
exercises: context.read<ExerciseLibrary>().forMode(plan.mode),
|
||||||
|
title: l10n.addExercises,
|
||||||
|
bottomPadding: 24 + MediaQuery.paddingOf(context).bottom,
|
||||||
|
onTap: (e) => _add(context, e),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../localization/app_localizations.dart';
|
||||||
|
import '../../modes/mode.dart';
|
||||||
|
|
||||||
|
/// Asks for a Plan name; null when cancelled.
|
||||||
|
Future<String?> promptPlanName(BuildContext context, {required Mode mode, String? initial, required String title}) =>
|
||||||
|
showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _PlanNameDialog(mode: mode, initial: initial, title: title),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _PlanNameDialog extends StatefulWidget {
|
||||||
|
const _PlanNameDialog({required this.mode, required this.initial, required this.title});
|
||||||
|
|
||||||
|
final Mode mode;
|
||||||
|
final String? initial;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PlanNameDialog> createState() => _PlanNameDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlanNameDialogState extends State<_PlanNameDialog> {
|
||||||
|
late final _name = TextEditingController(text: widget.initial);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_name.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final name = _name.text.trim();
|
||||||
|
if (name.isNotEmpty) Navigator.of(context).pop(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(widget.title),
|
||||||
|
content: TextField(
|
||||||
|
controller: _name,
|
||||||
|
autofocus: true,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
decoration: InputDecoration(hintText: l10n.planNameHint(widget.mode.name)),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(l10n.cancel)),
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: _name,
|
||||||
|
builder: (context, _) => FilledButton(
|
||||||
|
onPressed: _name.text.trim().isEmpty ? null : _submit,
|
||||||
|
child: Text(widget.initial == null ? l10n.create : l10n.save),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> confirmDeletePlan(BuildContext context, String name) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(l10n.deletePlanTitle(name)),
|
||||||
|
content: Text(l10n.deletePlanBody),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(l10n.cancel)),
|
||||||
|
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(l10n.delete)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return confirmed ?? false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../design/number_stepper.dart';
|
||||||
|
import '../../design/page_header.dart';
|
||||||
|
import '../../design/zen_style.dart';
|
||||||
|
import '../../exercises/exercise_library.dart';
|
||||||
|
import '../../exercises/views/library_view.dart';
|
||||||
|
import '../../localization/app_localizations.dart';
|
||||||
|
import '../../modes/mode.dart';
|
||||||
|
import '../models/plan.dart';
|
||||||
|
import '../plan_labels.dart';
|
||||||
|
import '../plan_repository.dart';
|
||||||
|
import 'exercise_picker_view.dart';
|
||||||
|
import 'plans_view.dart';
|
||||||
|
import 'step_editor_sheet.dart';
|
||||||
|
|
||||||
|
/// Builds one Plan: its Steps and Supersets in order, each editable, movable
|
||||||
|
/// by its handle and removable. Every change is saved at once.
|
||||||
|
class PlanEditorView extends StatelessWidget {
|
||||||
|
const PlanEditorView({super.key, required this.planId});
|
||||||
|
|
||||||
|
static const routeName = '/plan';
|
||||||
|
|
||||||
|
final String planId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final plans = context.read<PlanRepository>();
|
||||||
|
return StreamBuilder(
|
||||||
|
stream: plans.watchPlan(planId),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final plan = snapshot.data;
|
||||||
|
if (plan == null) return const Scaffold();
|
||||||
|
return _Editor(plan: plan, save: plans.save);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Editor extends StatelessWidget {
|
||||||
|
const _Editor({required this.plan, required this.save});
|
||||||
|
|
||||||
|
final Plan plan;
|
||||||
|
final Future<void> Function(Plan) save;
|
||||||
|
|
||||||
|
void _addExercises(BuildContext context) =>
|
||||||
|
Navigator.of(context).pushNamed(ExercisePickerView.routeName, arguments: plan.id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||||
|
final addButton = FilledButton.icon(
|
||||||
|
onPressed: () => _addExercises(context),
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: Text(l10n.addExercises),
|
||||||
|
);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
actions: [PlanMenu(plan: plan, onDeleted: () => Navigator.of(context).pop())],
|
||||||
|
),
|
||||||
|
body: plan.items.isEmpty
|
||||||
|
? ListView(
|
||||||
|
children: [
|
||||||
|
PageHeader(title: plan.name),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
EmptyState(
|
||||||
|
icon: Icons.add_task_rounded,
|
||||||
|
title: l10n.planEmptyTitle,
|
||||||
|
body: l10n.planEmptyBody(plan.mode.name),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
|
||||||
|
child: Row(children: [addButton]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: ReorderableListView.builder(
|
||||||
|
buildDefaultDragHandles: false,
|
||||||
|
padding: EdgeInsets.only(bottom: 96 + bottomInset),
|
||||||
|
header: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
PageHeader(title: plan.name, subtitle: planSummary(l10n, plan)),
|
||||||
|
if (plan.mode == Mode.stretching)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 16, 12),
|
||||||
|
child: NumberStepper(
|
||||||
|
label: l10n.transitionLabel,
|
||||||
|
value: l10n.secondsShort(plan.transitionSeconds),
|
||||||
|
onDecrement: plan.transitionSeconds > 0
|
||||||
|
? () => save(plan.copyWith(transitionSeconds: plan.transitionSeconds - 1))
|
||||||
|
: null,
|
||||||
|
onIncrement: plan.transitionSeconds < 30
|
||||||
|
? () => save(plan.copyWith(transitionSeconds: plan.transitionSeconds + 1))
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
itemCount: plan.items.length,
|
||||||
|
onReorder: (from, to) => save(plan.withItemMoved(from, to)),
|
||||||
|
proxyDecorator: (child, _, _) => Material(color: Colors.transparent, elevation: 6, child: child),
|
||||||
|
itemBuilder: (context, i) => Padding(
|
||||||
|
key: ObjectKey(plan.items[i]),
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||||
|
child: switch (plan.items[i]) {
|
||||||
|
final Superset superset => _SupersetCard(plan: plan, index: i, superset: superset, save: save),
|
||||||
|
final item => _StepTile(plan: plan, index: i, item: item, save: save),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: plan.items.isEmpty
|
||||||
|
? null
|
||||||
|
: FloatingActionButton.extended(
|
||||||
|
onPressed: () => _addExercises(context),
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: Text(l10n.addExercises),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Workout or Stretching Step on its own.
|
||||||
|
class _StepTile extends StatelessWidget {
|
||||||
|
const _StepTile({required this.plan, required this.index, required this.item, required this.save});
|
||||||
|
|
||||||
|
final Plan plan;
|
||||||
|
final int index;
|
||||||
|
final PlanItem item;
|
||||||
|
final Future<void> Function(Plan) save;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final (exerciseId, summary) = switch (item) {
|
||||||
|
final WorkoutStep step => (step.exerciseId, workoutStepSummary(l10n, step)),
|
||||||
|
final StretchingStep step => (step.exerciseId, stretchingStepSummary(l10n, step)),
|
||||||
|
Superset() => throw StateError('Supersets have their own card'),
|
||||||
|
};
|
||||||
|
final name = _exerciseName(context, exerciseId);
|
||||||
|
return Card(
|
||||||
|
child: _ExerciseRow(
|
||||||
|
exerciseId: exerciseId,
|
||||||
|
name: name,
|
||||||
|
summary: summary,
|
||||||
|
dragIndex: index,
|
||||||
|
onTap: () => showStepEditor<PlanItem>(
|
||||||
|
context,
|
||||||
|
title: name,
|
||||||
|
value: item,
|
||||||
|
onChanged: (edited) => save(plan.withItemReplaced(index, edited)),
|
||||||
|
),
|
||||||
|
menu: [
|
||||||
|
if (plan.canSupersetWithNext(index))
|
||||||
|
_MenuAction(l10n.supersetWithNext, () => save(plan.withSupersetWithNext(index))),
|
||||||
|
_MenuAction(l10n.remove, () => save(plan.withItemRemoved(index))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Superset: a header with its set count and rest choice, its members
|
||||||
|
/// below, all held together by an accent bar on the left.
|
||||||
|
class _SupersetCard extends StatelessWidget {
|
||||||
|
const _SupersetCard({required this.plan, required this.index, required this.superset, required this.save});
|
||||||
|
|
||||||
|
final Plan plan;
|
||||||
|
final int index;
|
||||||
|
final Superset superset;
|
||||||
|
final Future<void> Function(Plan) save;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final s = ZenStyle.of(context);
|
||||||
|
final text = Theme.of(context).textTheme;
|
||||||
|
return Card(
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(width: 4, decoration: BoxDecoration(gradient: s.accentGradient)),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: () => showStepEditor<Superset>(
|
||||||
|
context,
|
||||||
|
title: l10n.supersetTitle,
|
||||||
|
value: superset,
|
||||||
|
onChanged: (edited) => save(plan.withItemReplaced(index, edited)),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 10, 0, 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.link_rounded, size: 20, color: s.accent),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${l10n.supersetTitle} · ${l10n.setsCount(superset.sets)}',
|
||||||
|
style: text.titleSmall?.copyWith(color: s.accent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Menu([
|
||||||
|
if (plan.canSupersetWithNext(index))
|
||||||
|
_MenuAction(l10n.supersetWithNext, () => save(plan.withSupersetWithNext(index))),
|
||||||
|
_MenuAction(l10n.ungroup, () => save(plan.withUngrouped(index))),
|
||||||
|
_MenuAction(l10n.remove, () => save(plan.withItemRemoved(index))),
|
||||||
|
]),
|
||||||
|
_DragHandle(index: index),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final (m, member) in superset.members.indexed)
|
||||||
|
_ExerciseRow(
|
||||||
|
exerciseId: member.exerciseId,
|
||||||
|
name: _exerciseName(context, member.exerciseId),
|
||||||
|
summary: memberSummary(l10n, member),
|
||||||
|
onTap: () => showStepEditor<SupersetMember>(
|
||||||
|
context,
|
||||||
|
title: _exerciseName(context, member.exerciseId),
|
||||||
|
value: member,
|
||||||
|
onChanged: (edited) => save(
|
||||||
|
plan.withItemReplaced(index, superset.copyWith(members: [...superset.members]..[m] = edited)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
menu: [
|
||||||
|
_MenuAction(l10n.takeOutOfSuperset, () => save(plan.withMemberRemovedFromSuperset(index, m))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _exerciseName(BuildContext context, String exerciseId) =>
|
||||||
|
context.read<ExerciseLibrary>().byId(exerciseId)?.name ?? exerciseId;
|
||||||
|
|
||||||
|
class _ExerciseRow extends StatelessWidget {
|
||||||
|
const _ExerciseRow({
|
||||||
|
required this.exerciseId,
|
||||||
|
required this.name,
|
||||||
|
required this.summary,
|
||||||
|
required this.onTap,
|
||||||
|
required this.menu,
|
||||||
|
this.dragIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String exerciseId;
|
||||||
|
final String name;
|
||||||
|
final String summary;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final List<_MenuAction> menu;
|
||||||
|
|
||||||
|
/// Set for items of the Plan's own list, which can be dragged.
|
||||||
|
final int? dragIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = ZenStyle.of(context);
|
||||||
|
final text = Theme.of(context).textTheme;
|
||||||
|
final exercise = context.read<ExerciseLibrary>().byId(exerciseId);
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 8, 0, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (exercise != null) ExerciseThumbnail(exercise: exercise, size: 52),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(name, style: text.titleSmall, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(summary, style: text.bodySmall?.copyWith(color: s.inkMuted)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Menu(menu),
|
||||||
|
if (dragIndex != null) _DragHandle(index: dragIndex!) else const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DragHandle extends StatelessWidget {
|
||||||
|
const _DragHandle({required this.index});
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = ZenStyle.of(context);
|
||||||
|
return ReorderableDragStartListener(
|
||||||
|
index: index,
|
||||||
|
child: Tooltip(
|
||||||
|
message: AppLocalizations.of(context).dragToReorder,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
child: Icon(Icons.drag_indicator_rounded, color: s.inkMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MenuAction {
|
||||||
|
const _MenuAction(this.label, this.onSelected);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Menu extends StatelessWidget {
|
||||||
|
const _Menu(this.actions);
|
||||||
|
|
||||||
|
final List<_MenuAction> actions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => PopupMenuButton<int>(
|
||||||
|
tooltip: AppLocalizations.of(context).moreActions,
|
||||||
|
icon: Icon(Icons.more_vert_rounded, color: ZenStyle.of(context).inkMuted),
|
||||||
|
onSelected: (i) => actions[i].onSelected(),
|
||||||
|
itemBuilder: (context) => [for (final (i, a) in actions.indexed) PopupMenuItem(value: i, child: Text(a.label))],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,22 +2,149 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../design/page_header.dart';
|
import '../../design/page_header.dart';
|
||||||
|
import '../../design/zen_nav_bar.dart';
|
||||||
|
import '../../design/zen_style.dart';
|
||||||
import '../../localization/app_localizations.dart';
|
import '../../localization/app_localizations.dart';
|
||||||
|
import '../../modes/mode.dart';
|
||||||
import '../../settings/settings_controller.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 {
|
class PlansView extends StatelessWidget {
|
||||||
const PlansView({super.key});
|
const PlansView({super.key});
|
||||||
|
|
||||||
|
/// Asks for a name, creates the Plan and opens it in the editor.
|
||||||
|
static Future<void> createPlan(BuildContext context, Mode mode) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final plans = context.read<PlanRepository>();
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
final name = await promptPlanName(context, mode: mode, title: l10n.newPlanTitle);
|
||||||
|
if (name == null) return;
|
||||||
|
final plan = await plans.create(mode, name);
|
||||||
|
await navigator.pushNamed(PlanEditorView.routeName, arguments: plan.id);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final mode = context.watch<SettingsController>().mode;
|
final mode = context.watch<SettingsController>().mode;
|
||||||
return ListView(
|
final bottom = ZenNavBar.height + 32 + MediaQuery.paddingOf(context).bottom;
|
||||||
children: [
|
return StreamBuilder(
|
||||||
PageHeader(title: l10n.plansTitle),
|
stream: context.read<PlanRepository>().watchPlans(mode),
|
||||||
const SizedBox(height: 24),
|
builder: (context, snapshot) {
|
||||||
EmptyState(icon: Icons.playlist_add_rounded, title: l10n.plansEmptyTitle, body: l10n.plansEmptyBody(mode.name)),
|
final plans = snapshot.data;
|
||||||
|
if (plans == null) return const SizedBox.shrink();
|
||||||
|
final newPlan = FilledButton.icon(
|
||||||
|
onPressed: () => createPlan(context, mode),
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: Text(l10n.newPlan),
|
||||||
|
);
|
||||||
|
return ListView(
|
||||||
|
padding: EdgeInsets.only(bottom: bottom),
|
||||||
|
children: [
|
||||||
|
PageHeader(title: l10n.plansTitle, action: plans.isEmpty ? null : newPlan),
|
||||||
|
if (plans.isEmpty) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
EmptyState(
|
||||||
|
icon: Icons.playlist_add_rounded,
|
||||||
|
title: l10n.plansEmptyTitle,
|
||||||
|
body: l10n.plansEmptyBody(mode.name),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
|
||||||
|
child: Row(children: [newPlan]),
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
for (final plan in plans)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
child: PlanCard(plan: plan),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlanCard extends StatelessWidget {
|
||||||
|
const PlanCard({super.key, required this.plan});
|
||||||
|
|
||||||
|
final Plan plan;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final s = ZenStyle.of(context);
|
||||||
|
final text = Theme.of(context).textTheme;
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => Navigator.of(context).pushNamed(PlanEditorView.routeName, arguments: plan.id),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 4, 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(plan.name, style: text.titleLarge, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(planSummary(l10n, plan), style: text.bodyMedium?.copyWith(color: s.inkMuted)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PlanMenu(plan: plan),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename, duplicate and delete, on a Plan card and in the editor.
|
||||||
|
class PlanMenu extends StatelessWidget {
|
||||||
|
const PlanMenu({super.key, required this.plan, this.onDeleted});
|
||||||
|
|
||||||
|
final Plan plan;
|
||||||
|
|
||||||
|
/// Called after the Plan is gone, e.g. to leave its editor.
|
||||||
|
final VoidCallback? onDeleted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final plans = context.read<PlanRepository>();
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
tooltip: l10n.moreActions,
|
||||||
|
onSelected: (action) async {
|
||||||
|
switch (action) {
|
||||||
|
case 'rename':
|
||||||
|
final name = await promptPlanName(
|
||||||
|
context,
|
||||||
|
mode: plan.mode,
|
||||||
|
initial: plan.name,
|
||||||
|
title: l10n.renamePlanTitle,
|
||||||
|
);
|
||||||
|
if (name != null) await plans.save(plan.copyWith(name: name));
|
||||||
|
case 'duplicate':
|
||||||
|
await plans.duplicate(plan.id, l10n.duplicateName(plan.name));
|
||||||
|
case 'delete':
|
||||||
|
if (await confirmDeletePlan(context, plan.name)) {
|
||||||
|
await plans.delete(plan.id);
|
||||||
|
onDeleted?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(value: 'rename', child: Text(l10n.rename)),
|
||||||
|
PopupMenuItem(value: 'duplicate', child: Text(l10n.duplicate)),
|
||||||
|
PopupMenuItem(value: 'delete', child: Text(l10n.delete)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../design/number_stepper.dart';
|
||||||
|
import '../../design/zen_style.dart';
|
||||||
|
import '../../localization/app_localizations.dart';
|
||||||
|
import '../models/plan.dart';
|
||||||
|
import '../plan_labels.dart';
|
||||||
|
|
||||||
|
/// Edits a Step, a Superset or a Superset member in a bottom sheet. Every
|
||||||
|
/// change is handed to [onChanged] right away; there is no separate save.
|
||||||
|
Future<void> showStepEditor<T>(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required T value,
|
||||||
|
required ValueChanged<T> onChanged,
|
||||||
|
}) => showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => _StepEditorSheet<T>(title: title, initial: value, onChanged: onChanged),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _StepEditorSheet<T> extends StatefulWidget {
|
||||||
|
const _StepEditorSheet({required this.title, required this.initial, required this.onChanged});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final T initial;
|
||||||
|
final ValueChanged<T> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_StepEditorSheet<T>> createState() => _StepEditorSheetState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StepEditorSheetState<T> extends State<_StepEditorSheet<T>> {
|
||||||
|
late T _value = widget.initial;
|
||||||
|
|
||||||
|
void _set(Object value) {
|
||||||
|
setState(() => _value = value as T);
|
||||||
|
widget.onChanged(_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final text = Theme.of(context).textTheme;
|
||||||
|
final value = _value;
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.title, style: text.titleLarge),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...switch (value) {
|
||||||
|
WorkoutStep() => [
|
||||||
|
_setsStepper(l10n, value.sets, (n) => _set(value.copyWith(sets: n))),
|
||||||
|
_TargetEditor(
|
||||||
|
target: value.target,
|
||||||
|
onChanged: (t) => _set(value.copyWith(target: t)),
|
||||||
|
),
|
||||||
|
_WeightEditor(
|
||||||
|
weightKg: value.weightKg,
|
||||||
|
onChanged: (kg) => _set(value.copyWith(weightKg: () => kg)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
SupersetMember() => [
|
||||||
|
_TargetEditor(
|
||||||
|
target: value.target,
|
||||||
|
onChanged: (t) => _set(value.copyWith(target: t)),
|
||||||
|
),
|
||||||
|
_WeightEditor(
|
||||||
|
weightKg: value.weightKg,
|
||||||
|
onChanged: (kg) => _set(value.copyWith(weightKg: () => kg)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Superset() => [
|
||||||
|
_setsStepper(l10n, value.sets, (n) => _set(value.copyWith(sets: n))),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(l10n.restBetweenLabel, style: text.titleSmall),
|
||||||
|
subtitle: Text(l10n.restBetweenHint),
|
||||||
|
value: value.restBetween,
|
||||||
|
onChanged: (on) => _set(value.copyWith(restBetween: on)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
StretchingStep() => [
|
||||||
|
NumberStepper(
|
||||||
|
label: l10n.stepHold,
|
||||||
|
value: l10n.secondsShort(value.holdSeconds),
|
||||||
|
onDecrement: value.holdSeconds > 5
|
||||||
|
? () => _set(value.copyWith(holdSeconds: value.holdSeconds - 5))
|
||||||
|
: null,
|
||||||
|
onIncrement: value.holdSeconds < 300
|
||||||
|
? () => _set(value.copyWith(holdSeconds: value.holdSeconds + 5))
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(l10n.stepBothSides, style: text.titleSmall),
|
||||||
|
subtitle: Text(l10n.stepBothSidesHint),
|
||||||
|
value: value.bothSides,
|
||||||
|
onChanged: (on) => _set(value.copyWith(bothSides: on)),
|
||||||
|
),
|
||||||
|
NumberStepper(
|
||||||
|
label: l10n.stepRounds,
|
||||||
|
value: '${value.rounds}',
|
||||||
|
onDecrement: value.rounds > 1 ? () => _set(value.copyWith(rounds: value.rounds - 1)) : null,
|
||||||
|
onIncrement: value.rounds < 10 ? () => _set(value.copyWith(rounds: value.rounds + 1)) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
_ => const <Widget>[],
|
||||||
|
},
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: FilledButton(onPressed: () => Navigator.of(context).pop(), child: Text(l10n.done)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _setsStepper(AppLocalizations l10n, int sets, ValueChanged<int> onChanged) => NumberStepper(
|
||||||
|
label: l10n.stepSets,
|
||||||
|
value: '$sets',
|
||||||
|
onDecrement: sets > 1 ? () => onChanged(sets - 1) : null,
|
||||||
|
onIncrement: sets < 20 ? () => onChanged(sets + 1) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reps or time per set, switching between the two with sensible starting values.
|
||||||
|
class _TargetEditor extends StatelessWidget {
|
||||||
|
const _TargetEditor({required this.target, required this.onChanged});
|
||||||
|
|
||||||
|
final WorkoutTarget target;
|
||||||
|
final ValueChanged<WorkoutTarget> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
showSelectedIcon: false,
|
||||||
|
segments: [
|
||||||
|
ButtonSegment(value: false, label: Text(l10n.stepReps)),
|
||||||
|
ButtonSegment(value: true, label: Text(l10n.stepTime)),
|
||||||
|
],
|
||||||
|
selected: {target.isTimed},
|
||||||
|
onSelectionChanged: (timed) =>
|
||||||
|
onChanged(timed.single ? const WorkoutTarget.seconds(30) : const WorkoutTarget.reps(10)),
|
||||||
|
),
|
||||||
|
if (target.isTimed)
|
||||||
|
NumberStepper(
|
||||||
|
label: l10n.stepTime,
|
||||||
|
value: l10n.secondsShort(target.seconds!),
|
||||||
|
onDecrement: target.seconds! > 5 ? () => onChanged(WorkoutTarget.seconds(target.seconds! - 5)) : null,
|
||||||
|
onIncrement: target.seconds! < 600 ? () => onChanged(WorkoutTarget.seconds(target.seconds! + 5)) : null,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
NumberStepper(
|
||||||
|
label: l10n.stepReps,
|
||||||
|
value: '${target.reps}',
|
||||||
|
onDecrement: target.reps! > 1 ? () => onChanged(WorkoutTarget.reps(target.reps! - 1)) : null,
|
||||||
|
onIncrement: target.reps! < 100 ? () => onChanged(WorkoutTarget.reps(target.reps! + 1)) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An optional target weight in 2.5 kg steps.
|
||||||
|
class _WeightEditor extends StatelessWidget {
|
||||||
|
const _WeightEditor({required this.weightKg, required this.onChanged});
|
||||||
|
|
||||||
|
static const step = 2.5;
|
||||||
|
|
||||||
|
final double? weightKg;
|
||||||
|
final ValueChanged<double?> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final s = ZenStyle.of(context);
|
||||||
|
final kg = weightKg;
|
||||||
|
if (kg == null) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => onChanged(20),
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: Text(l10n.addWeight),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
NumberStepper(
|
||||||
|
label: l10n.stepWeight,
|
||||||
|
value: l10n.weightKg(kgNumber(kg)),
|
||||||
|
onDecrement: kg >= step ? () => onChanged(kg - step) : null,
|
||||||
|
onIncrement: kg < 500 ? () => onChanged(kg + step) : null,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => onChanged(null),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: s.inkMuted, padding: EdgeInsets.zero),
|
||||||
|
child: Text(l10n.removeWeight),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+384
@@ -1,6 +1,30 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
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:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -17,6 +41,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
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:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -25,6 +97,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -41,6 +137,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -65,6 +209,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -93,6 +245,38 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -101,6 +285,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.20.2"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -133,6 +333,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.1"
|
version: "5.1.1"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -157,6 +365,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.16.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -165,6 +381,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -173,6 +397,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -213,6 +461,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
pool:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pool
|
||||||
|
sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.3"
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -221,6 +477,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
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:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -277,11 +557,35 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -290,6 +594,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
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:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -306,6 +634,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
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:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -330,6 +666,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -346,6 +698,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.0"
|
version: "15.0.0"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -354,6 +714,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -362,6 +738,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.4"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.1 <4.0.0"
|
dart: ">=3.8.1 <4.0.0"
|
||||||
flutter: ">=3.29.0"
|
flutter: ">=3.29.0"
|
||||||
@@ -7,6 +7,8 @@ environment:
|
|||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
drift: ^2.31.0
|
||||||
|
drift_flutter: ^0.2.8
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
@@ -14,11 +16,15 @@ dependencies:
|
|||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
provider: ^6.1.5+1
|
provider: ^6.1.5+1
|
||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
|
uuid: ^4.6.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^5.0.0
|
flutter_lints: ^5.0.0
|
||||||
|
drift_dev: ^2.31.0
|
||||||
|
build_runner: ^2.15.1
|
||||||
|
sqlite3: ^2.9.4
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:zenxercise/src/database/database.dart';
|
||||||
|
import 'package:zenxercise/src/modes/mode.dart';
|
||||||
|
import 'package:zenxercise/src/plans/models/plan.dart';
|
||||||
|
import 'package:zenxercise/src/plans/plan_repository.dart';
|
||||||
|
|
||||||
|
import '../support/test_database.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late PlanRepository plans;
|
||||||
|
var now = DateTime(2026, 10, 1, 8);
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
db = testDatabase();
|
||||||
|
now = DateTime(2026, 10, 1, 8);
|
||||||
|
plans = PlanRepository(db, clock: () => now);
|
||||||
|
});
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
test('a created Plan is empty, belongs to its Mode and is listed newest first', () async {
|
||||||
|
final morning = await plans.create(Mode.stretching, 'Morning');
|
||||||
|
now = now.add(const Duration(hours: 1));
|
||||||
|
final evening = await plans.create(Mode.stretching, 'Evening');
|
||||||
|
await plans.create(Mode.workout, 'Legs');
|
||||||
|
|
||||||
|
final listed = await plans.watchPlans(Mode.stretching).first;
|
||||||
|
|
||||||
|
expect(listed.map((p) => p.name), ['Evening', 'Morning']);
|
||||||
|
expect(morning.items, isEmpty);
|
||||||
|
expect(evening.transitionSeconds, Plan.defaultTransitionSeconds);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saving keeps the items and stamps the change time', () async {
|
||||||
|
final plan = await plans.create(Mode.workout, 'Push');
|
||||||
|
now = DateTime(2026, 10, 2);
|
||||||
|
await plans.save(plan.withExerciseAdded('Bench').withExerciseAdded('Dips').withSupersetWithNext(0));
|
||||||
|
|
||||||
|
final stored = (await plans.get(plan.id))!;
|
||||||
|
|
||||||
|
expect(stored.items.single, isA<Superset>());
|
||||||
|
expect(stored.updatedAt, DateTime(2026, 10, 2));
|
||||||
|
expect(stored.createdAt, plan.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a duplicate copies the items under a new id and name', () async {
|
||||||
|
final plan = await plans.create(Mode.workout, 'Push');
|
||||||
|
await plans.save(plan.withExerciseAdded('Bench'));
|
||||||
|
|
||||||
|
final copy = await plans.duplicate(plan.id, 'Push (copy)');
|
||||||
|
|
||||||
|
expect(copy.id, isNot(plan.id));
|
||||||
|
expect(copy.items, (await plans.get(plan.id))!.items);
|
||||||
|
expect((await plans.watchPlans(Mode.workout).first), hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deleted Plan is gone', () async {
|
||||||
|
final plan = await plans.create(Mode.workout, 'Push');
|
||||||
|
|
||||||
|
await plans.delete(plan.id);
|
||||||
|
|
||||||
|
expect(await plans.get(plan.id), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:zenxercise/src/modes/mode.dart';
|
||||||
|
import 'package:zenxercise/src/plans/models/plan.dart';
|
||||||
|
|
||||||
|
Plan _plan(Mode mode, List<PlanItem> items) => Plan(
|
||||||
|
id: 'p1',
|
||||||
|
mode: mode,
|
||||||
|
name: 'Push day',
|
||||||
|
items: items,
|
||||||
|
createdAt: DateTime(2026, 10, 1),
|
||||||
|
updatedAt: DateTime(2026, 10, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkoutStep _w(String id, {int sets = 3, WorkoutTarget target = const WorkoutTarget.reps(10), double? kg}) =>
|
||||||
|
WorkoutStep(exerciseId: id, sets: sets, target: target, weightKg: kg);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('adding Exercises', () {
|
||||||
|
test('a Workout Plan adds a Workout Step of 3 × 10 reps without weight', () {
|
||||||
|
final plan = _plan(Mode.workout, const []).withExerciseAdded('Bench_Press');
|
||||||
|
|
||||||
|
expect(plan.items.single, _w('Bench_Press'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Stretching Plan adds a single 30 s hold on one side', () {
|
||||||
|
final plan = _plan(Mode.stretching, const []).withExerciseAdded('Calf_Stretch');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
plan.items.single,
|
||||||
|
const StretchingStep(exerciseId: 'Calf_Stretch', holdSeconds: 30, bothSides: false, rounds: 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving follows the list convention of an index past the removed item', () {
|
||||||
|
final plan = _plan(Mode.workout, [_w('A'), _w('B'), _w('C')]);
|
||||||
|
|
||||||
|
expect(plan.withItemMoved(0, 3).items, [_w('B'), _w('C'), _w('A')]);
|
||||||
|
expect(plan.withItemMoved(2, 0).items, [_w('C'), _w('A'), _w('B')]);
|
||||||
|
expect(plan.withItemRemoved(1).items, [_w('A'), _w('C')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Supersets', () {
|
||||||
|
test('a Step grouped with the next keeps each target and takes the first set count', () {
|
||||||
|
final plan = _plan(Mode.workout, [
|
||||||
|
_w('A', sets: 4, kg: 20),
|
||||||
|
_w('B', sets: 2, target: const WorkoutTarget.seconds(45)),
|
||||||
|
_w('C'),
|
||||||
|
]).withSupersetWithNext(0);
|
||||||
|
|
||||||
|
expect(plan.items, [
|
||||||
|
const Superset(
|
||||||
|
sets: 4,
|
||||||
|
restBetween: false,
|
||||||
|
members: [
|
||||||
|
SupersetMember(exerciseId: 'A', target: WorkoutTarget.reps(10), weightKg: 20),
|
||||||
|
SupersetMember(exerciseId: 'B', target: WorkoutTarget.seconds(45)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_w('C'),
|
||||||
|
]);
|
||||||
|
expect(plan.exerciseCount, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grouping again with the next item grows the Superset, also when the next is a Superset', () {
|
||||||
|
final grown = _plan(Mode.workout, [_w('A'), _w('B'), _w('C')]).withSupersetWithNext(0).withSupersetWithNext(0);
|
||||||
|
expect((grown.items.single as Superset).members.map((m) => m.exerciseId), ['A', 'B', 'C']);
|
||||||
|
|
||||||
|
final merged = _plan(Mode.workout, [_w('A'), _w('B'), _w('C')]).withSupersetWithNext(1).withSupersetWithNext(0);
|
||||||
|
expect((merged.items.single as Superset).members.map((m) => m.exerciseId), ['A', 'B', 'C']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only an item with a next item can be grouped', () {
|
||||||
|
final plan = _plan(Mode.workout, [_w('A'), _w('B')]);
|
||||||
|
|
||||||
|
expect(plan.canSupersetWithNext(0), isTrue);
|
||||||
|
expect(plan.canSupersetWithNext(1), isFalse);
|
||||||
|
expect(_plan(Mode.stretching, const []).withExerciseAdded('X').canSupersetWithNext(0), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a member taken out lands right after its Superset with the Superset set count', () {
|
||||||
|
final plan = _plan(Mode.workout, [
|
||||||
|
_w('A'),
|
||||||
|
_w('B'),
|
||||||
|
_w('C'),
|
||||||
|
_w('D'),
|
||||||
|
]).withSupersetWithNext(0).withSupersetWithNext(0).withMemberRemovedFromSuperset(0, 0);
|
||||||
|
|
||||||
|
expect((plan.items[0] as Superset).members.map((m) => m.exerciseId), ['B', 'C']);
|
||||||
|
expect(plan.items[1], _w('A'));
|
||||||
|
expect(plan.items[2], _w('D'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Superset left with one member turns back into a Step', () {
|
||||||
|
final plan = _plan(Mode.workout, [
|
||||||
|
_w('A', sets: 5),
|
||||||
|
_w('B'),
|
||||||
|
]).withSupersetWithNext(0).withMemberRemovedFromSuperset(0, 1);
|
||||||
|
|
||||||
|
expect(plan.items, [_w('A', sets: 5), _w('B', sets: 5)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ungrouping gives every member its own Step with the Superset set count', () {
|
||||||
|
final plan = _plan(Mode.workout, [_w('A', sets: 4), _w('B')]).withSupersetWithNext(0).withUngrouped(0);
|
||||||
|
|
||||||
|
expect(plan.items, [_w('A', sets: 4), _w('B', sets: 4)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Stretching Plan estimates its length from holds, sides, rounds and Transitions', () {
|
||||||
|
final plan = _plan(Mode.stretching, const [
|
||||||
|
StretchingStep(exerciseId: 'A', holdSeconds: 30, bothSides: true, rounds: 2),
|
||||||
|
StretchingStep(exerciseId: 'B', holdSeconds: 60, bothSides: false, rounds: 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Holds: 4 × 30 s + 60 s = 180 s; 5 holds have 4 Transitions of 5 s between them.
|
||||||
|
expect(plan.estimatedDuration, const Duration(seconds: 200));
|
||||||
|
expect(_plan(Mode.workout, [_w('A')]).estimatedDuration, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('items survive a JSON round trip', () {
|
||||||
|
final items = [
|
||||||
|
_w('A', kg: 12.5),
|
||||||
|
const StretchingStep(exerciseId: 'B', holdSeconds: 45, bothSides: true, rounds: 3),
|
||||||
|
const Superset(
|
||||||
|
sets: 3,
|
||||||
|
restBetween: true,
|
||||||
|
members: [
|
||||||
|
SupersetMember(exerciseId: 'C', target: WorkoutTarget.seconds(30)),
|
||||||
|
SupersetMember(exerciseId: 'D', target: WorkoutTarget.reps(8), weightKg: 40),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(PlanItem.listFromJson(PlanItem.listToJson(items)), items);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ import 'package:zenxercise/src/app.dart';
|
|||||||
import 'package:zenxercise/src/exercises/exercise.dart';
|
import 'package:zenxercise/src/exercises/exercise.dart';
|
||||||
import 'package:zenxercise/src/exercises/exercise_library.dart';
|
import 'package:zenxercise/src/exercises/exercise_library.dart';
|
||||||
import 'package:zenxercise/src/modes/mode.dart';
|
import 'package:zenxercise/src/modes/mode.dart';
|
||||||
|
import 'package:zenxercise/src/plans/plan_repository.dart';
|
||||||
import 'package:zenxercise/src/settings/settings_controller.dart';
|
import 'package:zenxercise/src/settings/settings_controller.dart';
|
||||||
|
|
||||||
|
import 'test_database.dart';
|
||||||
|
|
||||||
Exercise exercise(
|
Exercise exercise(
|
||||||
String id,
|
String id,
|
||||||
Mode mode, {
|
Mode mode, {
|
||||||
@@ -58,15 +61,19 @@ Future<SettingsController> pumpApp(
|
|||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
ExerciseLibrary? library,
|
ExerciseLibrary? library,
|
||||||
Map<String, Object> prefs = const {},
|
Map<String, Object> prefs = const {},
|
||||||
|
PlanRepository? plans,
|
||||||
}) async {
|
}) async {
|
||||||
SharedPreferences.setMockInitialValues(prefs);
|
SharedPreferences.setMockInitialValues(prefs);
|
||||||
final settings = SettingsController(SettingsService());
|
final settings = SettingsController(SettingsService());
|
||||||
await settings.loadSettings();
|
await settings.loadSettings();
|
||||||
|
final db = testDatabase();
|
||||||
|
addTearDown(db.close);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider.value(value: settings),
|
ChangeNotifierProvider.value(value: settings),
|
||||||
Provider.value(value: library ?? sampleLibrary),
|
Provider.value(value: library ?? sampleLibrary),
|
||||||
|
Provider.value(value: plans ?? PlanRepository(db)),
|
||||||
],
|
],
|
||||||
child: const ZenXerciseApp(),
|
child: const ZenXerciseApp(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:zenxercise/src/modes/mode.dart';
|
||||||
|
import 'package:zenxercise/src/plans/models/plan.dart';
|
||||||
|
import 'package:zenxercise/src/plans/plan_repository.dart';
|
||||||
|
|
||||||
|
import '../support/test_app.dart';
|
||||||
|
import '../support/test_database.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PlanRepository plans;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
final db = testDatabase();
|
||||||
|
addTearDown(db.close);
|
||||||
|
plans = PlanRepository(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A Workout Plan with two Squat Steps, open in the editor.
|
||||||
|
Future<Plan> openWorkoutPlan(WidgetTester tester) async {
|
||||||
|
final plan = await plans.create(Mode.workout, 'Legs');
|
||||||
|
await plans.save(plan.withExerciseAdded('Barbell_Squat').withExerciseAdded('Barbell_Squat'));
|
||||||
|
usePhoneScreen(tester);
|
||||||
|
await pumpApp(tester, plans: plans, prefs: {'mode': 'workout'});
|
||||||
|
await tester.tap(find.text('Legs'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('a new Plan is named, filled from the picker and listed with its count', (tester) async {
|
||||||
|
usePhoneScreen(tester);
|
||||||
|
await pumpApp(tester, plans: plans);
|
||||||
|
|
||||||
|
await tester.tap(find.text('New plan'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField), 'Morning');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Create'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Morning'), findsOneWidget);
|
||||||
|
expect(find.text('No exercises yet'), findsWidgets);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add exercises'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('90/90 Hamstring'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Calf Stretch'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Done'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('90/90 Hamstring'), findsOneWidget);
|
||||||
|
expect(find.text('Calf Stretch'), findsOneWidget);
|
||||||
|
// Two 30 s holds and one 5 s Transition.
|
||||||
|
expect(find.text('2 exercises · about 1 min'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pageBack();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Morning'), findsOneWidget);
|
||||||
|
expect(find.text('2 exercises · about 1 min'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('editing a Step saves at once and shows in its summary', (tester) async {
|
||||||
|
final plan = await openWorkoutPlan(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Barbell Squat').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byTooltip('Sets +'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Add a target weight'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Done'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('4 × 10 reps · 20 kg'), findsOneWidget);
|
||||||
|
expect((await plans.get(plan.id))!.items.first, isA<WorkoutStep>().having((s) => s.sets, 'sets', 4));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('two Steps become a Superset and split again', (tester) async {
|
||||||
|
await openWorkoutPlan(tester);
|
||||||
|
|
||||||
|
// The first Step's menu; the Plan's own menu in the app bar comes last.
|
||||||
|
await tester.tap(find.byTooltip('More actions').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Superset with next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Superset · 3 sets'), findsOneWidget);
|
||||||
|
expect(find.text('10 reps'), findsNWidgets(2));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('More actions').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Ungroup'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Superset · 3 sets'), findsNothing);
|
||||||
|
expect(find.text('3 × 10 reps'), findsNWidgets(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('deleting the Plan from its editor returns to the list', (tester) async {
|
||||||
|
await openWorkoutPlan(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('More actions').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Legs'), findsNothing);
|
||||||
|
expect(find.text('No plans yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an Exercise is added to a Plan from its detail page', (tester) async {
|
||||||
|
final plan = await plans.create(Mode.stretching, 'Evening');
|
||||||
|
usePhoneScreen(tester);
|
||||||
|
await pumpApp(tester, plans: plans);
|
||||||
|
await tester.tap(find.byTooltip('Library'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Calf Stretch'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add to plan'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Evening'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Added to Evening'), findsOneWidget);
|
||||||
|
expect((await plans.get(plan.id))!.items.single, isA<StretchingStep>());
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user