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
@@ -1,15 +1,19 @@
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'src/app.dart';
|
||||
import 'src/database/database.dart';
|
||||
import 'src/exercises/exercise_library.dart';
|
||||
import 'src/plans/plan_repository.dart';
|
||||
import 'src/settings/settings_controller.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
|
||||
|
||||
final db = AppDatabase(driftDatabase(name: 'zenxercise'));
|
||||
final library = await ExerciseLibrary.load(rootBundle);
|
||||
final settings = SettingsController(SettingsService());
|
||||
await settings.loadSettings();
|
||||
@@ -19,6 +23,7 @@ void main() async {
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: settings),
|
||||
Provider.value(value: library),
|
||||
Provider.value(value: PlanRepository(db)),
|
||||
],
|
||||
child: const ZenXerciseApp(),
|
||||
),
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'design/zen_style.dart';
|
||||
import 'exercises/views/exercise_detail_view.dart';
|
||||
import 'home/home_view.dart';
|
||||
import 'localization/app_localizations.dart';
|
||||
import 'plans/views/exercise_picker_view.dart';
|
||||
import 'plans/views/plan_editor_view.dart';
|
||||
import 'settings/settings_controller.dart';
|
||||
|
||||
class ZenXerciseApp extends StatelessWidget {
|
||||
@@ -34,6 +36,8 @@ class ZenXerciseApp extends StatelessWidget {
|
||||
settings: settings,
|
||||
builder: (context) => switch (settings.name) {
|
||||
ExerciseDetailView.routeName => ExerciseDetailView(exerciseId: settings.arguments! as String),
|
||||
PlanEditorView.routeName => PlanEditorView(planId: settings.arguments! as String),
|
||||
ExercisePickerView.routeName => ExercisePickerView(planId: settings.arguments! as String),
|
||||
_ => const HomeView(),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
insetPadding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
|
||||
shape: RoundedRectangleBorder(borderRadius: control),
|
||||
// Deep pine on the light ground; a lifted graphite with a hairline on the dark one.
|
||||
backgroundColor: workout ? s.surfaceSoft : s.ink,
|
||||
contentTextStyle: TextStyle(fontFamily: s.bodyFont, color: workout ? s.ink : s.background),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 16)),
|
||||
side: workout ? BorderSide(color: s.line) : BorderSide.none,
|
||||
),
|
||||
),
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: s.accent,
|
||||
foregroundColor: s.onAccent,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 28))),
|
||||
),
|
||||
popupMenuTheme: PopupMenuThemeData(
|
||||
color: s.surfaceSoft,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 16))),
|
||||
),
|
||||
segmentedButtonTheme: SegmentedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.resolveWith(
|
||||
(states) => states.contains(WidgetState.selected) ? s.accent : s.surfaceSoft,
|
||||
),
|
||||
foregroundColor: WidgetStateProperty.resolveWith(
|
||||
(states) => states.contains(WidgetState.selected) ? s.onAccent : s.ink,
|
||||
),
|
||||
side: WidgetStatePropertyAll(BorderSide(color: s.line)),
|
||||
shape: WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 20))),
|
||||
),
|
||||
),
|
||||
),
|
||||
switchTheme: SwitchThemeData(
|
||||
thumbColor: WidgetStateProperty.resolveWith(
|
||||
(states) => states.contains(WidgetState.selected) ? s.onAccent : s.inkMuted,
|
||||
),
|
||||
trackColor: WidgetStateProperty.resolveWith(
|
||||
(states) => states.contains(WidgetState.selected) ? s.accent : s.surfaceSoft,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
class PageHeader extends StatelessWidget {
|
||||
const PageHeader({super.key, required this.title, this.subtitle});
|
||||
const PageHeader({super.key, required this.title, this.subtitle, this.action});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
/// A button beside the title, such as "New plan".
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = ZenStyle.of(context);
|
||||
@@ -18,7 +21,12 @@ class PageHeader extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: text.headlineLarge),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(title, style: text.headlineLarge)),
|
||||
if (action != null) ...[const SizedBox(width: 12), action!],
|
||||
],
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(subtitle!, style: text.bodyLarge?.copyWith(color: s.inkMuted)),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../design/zen_style.dart';
|
||||
import '../../localization/app_localizations.dart';
|
||||
import '../../plans/views/add_to_plan_sheet.dart';
|
||||
import '../exercise.dart';
|
||||
import '../exercise_labels.dart';
|
||||
import '../exercise_library.dart';
|
||||
@@ -24,8 +25,21 @@ class ExerciseDetailView extends StatelessWidget {
|
||||
final text = Theme.of(context).textTheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => showAddToPlanSheet(context, exercise),
|
||||
icon: const Icon(Icons.playlist_add_rounded),
|
||||
label: Text(l10n.addToPlan),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 32 + MediaQuery.paddingOf(context).bottom),
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
children: [
|
||||
FlipAnimation(exercise: exercise),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
@@ -77,5 +77,165 @@
|
||||
"detailType": "Type",
|
||||
"equipmentNone": "None listed",
|
||||
"flipStart": "Start position",
|
||||
"flipEnd": "End position"
|
||||
"flipEnd": "End position",
|
||||
"newPlan": "New plan",
|
||||
"newPlanTitle": "Name your plan",
|
||||
"planNameHint": "{mode, select, stretching{e.g. Morning stretch} other{e.g. Push day}}",
|
||||
"@planNameHint": {
|
||||
"placeholders": {
|
||||
"mode": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"create": "Create",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"rename": "Rename",
|
||||
"duplicate": "Duplicate",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"edit": "Edit",
|
||||
"done": "Done",
|
||||
"moreActions": "More actions",
|
||||
"renamePlanTitle": "Rename plan",
|
||||
"duplicateName": "{name} (copy)",
|
||||
"@duplicateName": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deletePlanTitle": "Delete {name}?",
|
||||
"@deletePlanTitle": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deletePlanBody": "The plan is removed. Sessions you already finished stay in your history.",
|
||||
"planExerciseCount": "{count, plural, =0{No exercises yet} =1{1 exercise} other{{count} exercises}}",
|
||||
"@planExerciseCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"planDuration": "{minutes, plural, =0{under a minute} other{about {minutes} min}}",
|
||||
"@planDuration": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"addExercises": "Add exercises",
|
||||
"planEmptyTitle": "No exercises yet",
|
||||
"planEmptyBody": "{mode, select, stretching{Add stretches from the library to build your routine.} other{Add exercises from the library to build your workout.}}",
|
||||
"@planEmptyBody": {
|
||||
"placeholders": {
|
||||
"mode": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transitionLabel": "Pause between holds",
|
||||
"secondsShort": "{seconds} s",
|
||||
"@secondsShort": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repsCount": "{count, plural, =1{1 rep} other{{count} reps}}",
|
||||
"@repsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"setsTimes": "{sets} × {target}",
|
||||
"@setsTimes": {
|
||||
"placeholders": {
|
||||
"sets": {
|
||||
"type": "int"
|
||||
},
|
||||
"target": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"weightKg": "{kg} kg",
|
||||
"@weightKg": {
|
||||
"placeholders": {
|
||||
"kg": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"holdSummary": "{seconds} s hold",
|
||||
"@holdSummary": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bothSidesShort": "both sides",
|
||||
"roundsCount": "{count, plural, =1{1 round} other{{count} rounds}}",
|
||||
"@roundsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"setsCount": "{count, plural, =1{1 set} other{{count} sets}}",
|
||||
"@setsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stepSets": "Sets",
|
||||
"stepReps": "Reps",
|
||||
"stepTime": "Time",
|
||||
"stepWeight": "Weight",
|
||||
"addWeight": "Add a target weight",
|
||||
"removeWeight": "Remove weight",
|
||||
"stepHold": "Hold",
|
||||
"stepBothSides": "Both sides",
|
||||
"stepBothSidesHint": "Left side first, then the right",
|
||||
"stepRounds": "Rounds",
|
||||
"supersetTitle": "Superset",
|
||||
"restBetweenLabel": "Rest between exercises",
|
||||
"restBetweenHint": "When off, you go straight to the next exercise and rest after each round.",
|
||||
"supersetWithNext": "Superset with next",
|
||||
"takeOutOfSuperset": "Take out of superset",
|
||||
"ungroup": "Ungroup",
|
||||
"dragToReorder": "Drag to reorder",
|
||||
"added": "Added {name}",
|
||||
"@added": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"addedToPlan": "Added to {plan}",
|
||||
"@addedToPlan": {
|
||||
"placeholders": {
|
||||
"plan": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"addToPlan": "Add to plan",
|
||||
"addToPlanTitle": "Add to which plan?"
|
||||
}
|
||||
@@ -297,6 +297,318 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'End position'**
|
||||
String get flipEnd;
|
||||
|
||||
/// No description provided for @newPlan.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New plan'**
|
||||
String get newPlan;
|
||||
|
||||
/// No description provided for @newPlanTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name your plan'**
|
||||
String get newPlanTitle;
|
||||
|
||||
/// No description provided for @planNameHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{mode, select, stretching{e.g. Morning stretch} other{e.g. Push day}}'**
|
||||
String planNameHint(String mode);
|
||||
|
||||
/// No description provided for @create.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create'**
|
||||
String get create;
|
||||
|
||||
/// No description provided for @cancel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cancel'**
|
||||
String get cancel;
|
||||
|
||||
/// No description provided for @save.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get save;
|
||||
|
||||
/// No description provided for @rename.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rename'**
|
||||
String get rename;
|
||||
|
||||
/// No description provided for @duplicate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Duplicate'**
|
||||
String get duplicate;
|
||||
|
||||
/// No description provided for @delete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get delete;
|
||||
|
||||
/// No description provided for @remove.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove'**
|
||||
String get remove;
|
||||
|
||||
/// No description provided for @edit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit'**
|
||||
String get edit;
|
||||
|
||||
/// No description provided for @done.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done'**
|
||||
String get done;
|
||||
|
||||
/// No description provided for @moreActions.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'More actions'**
|
||||
String get moreActions;
|
||||
|
||||
/// No description provided for @renamePlanTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rename plan'**
|
||||
String get renamePlanTitle;
|
||||
|
||||
/// No description provided for @duplicateName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{name} (copy)'**
|
||||
String duplicateName(String name);
|
||||
|
||||
/// No description provided for @deletePlanTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete {name}?'**
|
||||
String deletePlanTitle(String name);
|
||||
|
||||
/// No description provided for @deletePlanBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The plan is removed. Sessions you already finished stay in your history.'**
|
||||
String get deletePlanBody;
|
||||
|
||||
/// No description provided for @planExerciseCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =0{No exercises yet} =1{1 exercise} other{{count} exercises}}'**
|
||||
String planExerciseCount(int count);
|
||||
|
||||
/// No description provided for @planDuration.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{minutes, plural, =0{under a minute} other{about {minutes} min}}'**
|
||||
String planDuration(int minutes);
|
||||
|
||||
/// No description provided for @addExercises.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add exercises'**
|
||||
String get addExercises;
|
||||
|
||||
/// No description provided for @planEmptyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No exercises yet'**
|
||||
String get planEmptyTitle;
|
||||
|
||||
/// No description provided for @planEmptyBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{mode, select, stretching{Add stretches from the library to build your routine.} other{Add exercises from the library to build your workout.}}'**
|
||||
String planEmptyBody(String mode);
|
||||
|
||||
/// No description provided for @transitionLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pause between holds'**
|
||||
String get transitionLabel;
|
||||
|
||||
/// No description provided for @secondsShort.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{seconds} s'**
|
||||
String secondsShort(int seconds);
|
||||
|
||||
/// No description provided for @repsCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 rep} other{{count} reps}}'**
|
||||
String repsCount(int count);
|
||||
|
||||
/// No description provided for @setsTimes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{sets} × {target}'**
|
||||
String setsTimes(int sets, String target);
|
||||
|
||||
/// No description provided for @weightKg.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{kg} kg'**
|
||||
String weightKg(String kg);
|
||||
|
||||
/// No description provided for @holdSummary.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{seconds} s hold'**
|
||||
String holdSummary(int seconds);
|
||||
|
||||
/// No description provided for @bothSidesShort.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'both sides'**
|
||||
String get bothSidesShort;
|
||||
|
||||
/// No description provided for @roundsCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 round} other{{count} rounds}}'**
|
||||
String roundsCount(int count);
|
||||
|
||||
/// No description provided for @setsCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 set} other{{count} sets}}'**
|
||||
String setsCount(int count);
|
||||
|
||||
/// No description provided for @stepSets.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sets'**
|
||||
String get stepSets;
|
||||
|
||||
/// No description provided for @stepReps.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reps'**
|
||||
String get stepReps;
|
||||
|
||||
/// No description provided for @stepTime.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Time'**
|
||||
String get stepTime;
|
||||
|
||||
/// No description provided for @stepWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weight'**
|
||||
String get stepWeight;
|
||||
|
||||
/// No description provided for @addWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add a target weight'**
|
||||
String get addWeight;
|
||||
|
||||
/// No description provided for @removeWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove weight'**
|
||||
String get removeWeight;
|
||||
|
||||
/// No description provided for @stepHold.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hold'**
|
||||
String get stepHold;
|
||||
|
||||
/// No description provided for @stepBothSides.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Both sides'**
|
||||
String get stepBothSides;
|
||||
|
||||
/// No description provided for @stepBothSidesHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Left side first, then the right'**
|
||||
String get stepBothSidesHint;
|
||||
|
||||
/// No description provided for @stepRounds.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rounds'**
|
||||
String get stepRounds;
|
||||
|
||||
/// No description provided for @supersetTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Superset'**
|
||||
String get supersetTitle;
|
||||
|
||||
/// No description provided for @restBetweenLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rest between exercises'**
|
||||
String get restBetweenLabel;
|
||||
|
||||
/// No description provided for @restBetweenHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'When off, you go straight to the next exercise and rest after each round.'**
|
||||
String get restBetweenHint;
|
||||
|
||||
/// No description provided for @supersetWithNext.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Superset with next'**
|
||||
String get supersetWithNext;
|
||||
|
||||
/// No description provided for @takeOutOfSuperset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Take out of superset'**
|
||||
String get takeOutOfSuperset;
|
||||
|
||||
/// No description provided for @ungroup.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ungroup'**
|
||||
String get ungroup;
|
||||
|
||||
/// No description provided for @dragToReorder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Drag to reorder'**
|
||||
String get dragToReorder;
|
||||
|
||||
/// No description provided for @added.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added {name}'**
|
||||
String added(String name);
|
||||
|
||||
/// No description provided for @addedToPlan.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added to {plan}'**
|
||||
String addedToPlan(String plan);
|
||||
|
||||
/// No description provided for @addToPlan.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to plan'**
|
||||
String get addToPlan;
|
||||
|
||||
/// No description provided for @addToPlanTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to which plan?'**
|
||||
String get addToPlanTitle;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -158,4 +158,231 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get flipEnd => 'End position';
|
||||
|
||||
@override
|
||||
String get newPlan => 'New plan';
|
||||
|
||||
@override
|
||||
String get newPlanTitle => 'Name your plan';
|
||||
|
||||
@override
|
||||
String planNameHint(String mode) {
|
||||
String _temp0 = intl.Intl.selectLogic(mode, {
|
||||
'stretching': 'e.g. Morning stretch',
|
||||
'other': 'e.g. Push day',
|
||||
});
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get create => 'Create';
|
||||
|
||||
@override
|
||||
String get cancel => 'Cancel';
|
||||
|
||||
@override
|
||||
String get save => 'Save';
|
||||
|
||||
@override
|
||||
String get rename => 'Rename';
|
||||
|
||||
@override
|
||||
String get duplicate => 'Duplicate';
|
||||
|
||||
@override
|
||||
String get delete => 'Delete';
|
||||
|
||||
@override
|
||||
String get remove => 'Remove';
|
||||
|
||||
@override
|
||||
String get edit => 'Edit';
|
||||
|
||||
@override
|
||||
String get done => 'Done';
|
||||
|
||||
@override
|
||||
String get moreActions => 'More actions';
|
||||
|
||||
@override
|
||||
String get renamePlanTitle => 'Rename plan';
|
||||
|
||||
@override
|
||||
String duplicateName(String name) {
|
||||
return '$name (copy)';
|
||||
}
|
||||
|
||||
@override
|
||||
String deletePlanTitle(String name) {
|
||||
return 'Delete $name?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get deletePlanBody =>
|
||||
'The plan is removed. Sessions you already finished stay in your history.';
|
||||
|
||||
@override
|
||||
String planExerciseCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count exercises',
|
||||
one: '1 exercise',
|
||||
zero: 'No exercises yet',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planDuration(int minutes) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
minutes,
|
||||
locale: localeName,
|
||||
other: 'about $minutes min',
|
||||
zero: 'under a minute',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get addExercises => 'Add exercises';
|
||||
|
||||
@override
|
||||
String get planEmptyTitle => 'No exercises yet';
|
||||
|
||||
@override
|
||||
String planEmptyBody(String mode) {
|
||||
String _temp0 = intl.Intl.selectLogic(mode, {
|
||||
'stretching': 'Add stretches from the library to build your routine.',
|
||||
'other': 'Add exercises from the library to build your workout.',
|
||||
});
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transitionLabel => 'Pause between holds';
|
||||
|
||||
@override
|
||||
String secondsShort(int seconds) {
|
||||
return '$seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String repsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count reps',
|
||||
one: '1 rep',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String setsTimes(int sets, String target) {
|
||||
return '$sets × $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String weightKg(String kg) {
|
||||
return '$kg kg';
|
||||
}
|
||||
|
||||
@override
|
||||
String holdSummary(int seconds) {
|
||||
return '$seconds s hold';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bothSidesShort => 'both sides';
|
||||
|
||||
@override
|
||||
String roundsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count rounds',
|
||||
one: '1 round',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String setsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count sets',
|
||||
one: '1 set',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get stepSets => 'Sets';
|
||||
|
||||
@override
|
||||
String get stepReps => 'Reps';
|
||||
|
||||
@override
|
||||
String get stepTime => 'Time';
|
||||
|
||||
@override
|
||||
String get stepWeight => 'Weight';
|
||||
|
||||
@override
|
||||
String get addWeight => 'Add a target weight';
|
||||
|
||||
@override
|
||||
String get removeWeight => 'Remove weight';
|
||||
|
||||
@override
|
||||
String get stepHold => 'Hold';
|
||||
|
||||
@override
|
||||
String get stepBothSides => 'Both sides';
|
||||
|
||||
@override
|
||||
String get stepBothSidesHint => 'Left side first, then the right';
|
||||
|
||||
@override
|
||||
String get stepRounds => 'Rounds';
|
||||
|
||||
@override
|
||||
String get supersetTitle => 'Superset';
|
||||
|
||||
@override
|
||||
String get restBetweenLabel => 'Rest between exercises';
|
||||
|
||||
@override
|
||||
String get restBetweenHint =>
|
||||
'When off, you go straight to the next exercise and rest after each round.';
|
||||
|
||||
@override
|
||||
String get supersetWithNext => 'Superset with next';
|
||||
|
||||
@override
|
||||
String get takeOutOfSuperset => 'Take out of superset';
|
||||
|
||||
@override
|
||||
String get ungroup => 'Ungroup';
|
||||
|
||||
@override
|
||||
String get dragToReorder => 'Drag to reorder';
|
||||
|
||||
@override
|
||||
String added(String name) {
|
||||
return 'Added $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String addedToPlan(String plan) {
|
||||
return 'Added to $plan';
|
||||
}
|
||||
|
||||
@override
|
||||
String get addToPlan => 'Add to plan';
|
||||
|
||||
@override
|
||||
String get addToPlanTitle => 'Add to which plan?';
|
||||
}
|
||||
@@ -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 '../../design/page_header.dart';
|
||||
import '../../design/zen_nav_bar.dart';
|
||||
import '../../design/zen_style.dart';
|
||||
import '../../localization/app_localizations.dart';
|
||||
import '../../modes/mode.dart';
|
||||
import '../../settings/settings_controller.dart';
|
||||
import '../models/plan.dart';
|
||||
import '../plan_labels.dart';
|
||||
import '../plan_repository.dart';
|
||||
import 'plan_dialogs.dart';
|
||||
import 'plan_editor_view.dart';
|
||||
|
||||
/// The current Mode's Plans. Users build every Plan themselves.
|
||||
/// The current Mode's Plans, newest first. Users build every Plan themselves.
|
||||
class PlansView extends StatelessWidget {
|
||||
const PlansView({super.key});
|
||||
|
||||
/// Asks for a name, creates the Plan and opens it in the editor.
|
||||
static Future<void> createPlan(BuildContext context, Mode mode) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final plans = context.read<PlanRepository>();
|
||||
final navigator = Navigator.of(context);
|
||||
final name = await promptPlanName(context, mode: mode, title: l10n.newPlanTitle);
|
||||
if (name == null) return;
|
||||
final plan = await plans.create(mode, name);
|
||||
await navigator.pushNamed(PlanEditorView.routeName, arguments: plan.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final mode = context.watch<SettingsController>().mode;
|
||||
return ListView(
|
||||
children: [
|
||||
PageHeader(title: l10n.plansTitle),
|
||||
const SizedBox(height: 24),
|
||||
EmptyState(icon: Icons.playlist_add_rounded, title: l10n.plansEmptyTitle, body: l10n.plansEmptyBody(mode.name)),
|
||||
final bottom = ZenNavBar.height + 32 + MediaQuery.paddingOf(context).bottom;
|
||||
return StreamBuilder(
|
||||
stream: context.read<PlanRepository>().watchPlans(mode),
|
||||
builder: (context, snapshot) {
|
||||
final plans = snapshot.data;
|
||||
if (plans == null) return const SizedBox.shrink();
|
||||
final newPlan = FilledButton.icon(
|
||||
onPressed: () => createPlan(context, mode),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(l10n.newPlan),
|
||||
);
|
||||
return ListView(
|
||||
padding: EdgeInsets.only(bottom: bottom),
|
||||
children: [
|
||||
PageHeader(title: l10n.plansTitle, action: plans.isEmpty ? null : newPlan),
|
||||
if (plans.isEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
EmptyState(
|
||||
icon: Icons.playlist_add_rounded,
|
||||
title: l10n.plansEmptyTitle,
|
||||
body: l10n.plansEmptyBody(mode.name),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
|
||||
child: Row(children: [newPlan]),
|
||||
),
|
||||
] else
|
||||
for (final plan in plans)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: PlanCard(plan: plan),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlanCard extends StatelessWidget {
|
||||
const PlanCard({super.key, required this.plan});
|
||||
|
||||
final Plan plan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final s = ZenStyle.of(context);
|
||||
final text = Theme.of(context).textTheme;
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.of(context).pushNamed(PlanEditorView.routeName, arguments: plan.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 4, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(plan.name, style: text.titleLarge, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 4),
|
||||
Text(planSummary(l10n, plan), style: text.bodyMedium?.copyWith(color: s.inkMuted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
PlanMenu(plan: plan),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename, duplicate and delete, on a Plan card and in the editor.
|
||||
class PlanMenu extends StatelessWidget {
|
||||
const PlanMenu({super.key, required this.plan, this.onDeleted});
|
||||
|
||||
final Plan plan;
|
||||
|
||||
/// Called after the Plan is gone, e.g. to leave its editor.
|
||||
final VoidCallback? onDeleted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final plans = context.read<PlanRepository>();
|
||||
return PopupMenuButton<String>(
|
||||
tooltip: l10n.moreActions,
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case 'rename':
|
||||
final name = await promptPlanName(
|
||||
context,
|
||||
mode: plan.mode,
|
||||
initial: plan.name,
|
||||
title: l10n.renamePlanTitle,
|
||||
);
|
||||
if (name != null) await plans.save(plan.copyWith(name: name));
|
||||
case 'duplicate':
|
||||
await plans.duplicate(plan.id, l10n.duplicateName(plan.name));
|
||||
case 'delete':
|
||||
if (await confirmDeletePlan(context, plan.name)) {
|
||||
await plans.delete(plan.id);
|
||||
onDeleted?.call();
|
||||
}
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(value: 'rename', child: Text(l10n.rename)),
|
||||
PopupMenuItem(value: 'duplicate', child: Text(l10n.duplicate)),
|
||||
PopupMenuItem(value: 'delete', child: Text(l10n.delete)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user