Merge branch 'master' into issue852/hide-diet-plan

This commit is contained in:
dhituval
2025-12-08 14:00:39 -05:00
committed by GitHub
785 changed files with 12542 additions and 5918 deletions

View File

@@ -0,0 +1,36 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:wger/helpers/material.dart';
class WidescreenWrapper extends StatelessWidget {
final Widget child;
const WidescreenWrapper({required this.child, super.key});
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: MATERIAL_MD_BREAKPOINT),
child: child,
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,7 @@ class $IngredientsTable extends Ingredients with TableInfo<$IngredientsTable, In
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$IngredientsTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
@@ -28,7 +26,9 @@ class $IngredientsTable extends Ingredients with TableInfo<$IngredientsTable, In
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _lastFetchedMeta = const VerificationMeta('lastFetched');
static const VerificationMeta _lastFetchedMeta = const VerificationMeta(
'lastFetched',
);
@override
late final GeneratedColumn<DateTime> lastFetched = GeneratedColumn<DateTime>(
'last_fetched',
@@ -37,17 +37,13 @@ class $IngredientsTable extends Ingredients with TableInfo<$IngredientsTable, In
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [id, data, lastFetched];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'ingredients';
@override
VerificationContext validateIntegrity(
Insertable<IngredientTable> instance, {
@@ -61,14 +57,20 @@ class $IngredientsTable extends Ingredients with TableInfo<$IngredientsTable, In
context.missing(_idMeta);
}
if (data.containsKey('data')) {
context.handle(_dataMeta, this.data.isAcceptableOrUnknown(data['data']!, _dataMeta));
context.handle(
_dataMeta,
this.data.isAcceptableOrUnknown(data['data']!, _dataMeta),
);
} else if (isInserting) {
context.missing(_dataMeta);
}
if (data.containsKey('last_fetched')) {
context.handle(
_lastFetchedMeta,
lastFetched.isAcceptableOrUnknown(data['last_fetched']!, _lastFetchedMeta),
lastFetched.isAcceptableOrUnknown(
data['last_fetched']!,
_lastFetchedMeta,
),
);
} else if (isInserting) {
context.missing(_lastFetchedMeta);
@@ -78,13 +80,18 @@ class $IngredientsTable extends Ingredients with TableInfo<$IngredientsTable, In
@override
Set<GeneratedColumn> get $primaryKey => const {};
@override
IngredientTable map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return IngredientTable(
id: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}id'])!,
data: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}data'])!,
id: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}id'],
)!,
data: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}data'],
)!,
lastFetched: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}last_fetched'],
@@ -104,9 +111,11 @@ class IngredientTable extends DataClass implements Insertable<IngredientTable> {
/// The date when the ingredient was last fetched from the server
final DateTime lastFetched;
const IngredientTable({required this.id, required this.data, required this.lastFetched});
const IngredientTable({
required this.id,
required this.data,
required this.lastFetched,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
@@ -117,10 +126,17 @@ class IngredientTable extends DataClass implements Insertable<IngredientTable> {
}
IngredientsCompanion toCompanion(bool nullToAbsent) {
return IngredientsCompanion(id: Value(id), data: Value(data), lastFetched: Value(lastFetched));
return IngredientsCompanion(
id: Value(id),
data: Value(data),
lastFetched: Value(lastFetched),
);
}
factory IngredientTable.fromJson(Map<String, dynamic> json, {ValueSerializer? serializer}) {
factory IngredientTable.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return IngredientTable(
id: serializer.fromJson<int>(json['id']),
@@ -128,7 +144,6 @@ class IngredientTable extends DataClass implements Insertable<IngredientTable> {
lastFetched: serializer.fromJson<DateTime>(json['lastFetched']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
@@ -144,7 +159,6 @@ class IngredientTable extends DataClass implements Insertable<IngredientTable> {
data: data ?? this.data,
lastFetched: lastFetched ?? this.lastFetched,
);
IngredientTable copyWithCompanion(IngredientsCompanion data) {
return IngredientTable(
id: data.id.present ? data.id.value : this.id,
@@ -165,7 +179,6 @@ class IngredientTable extends DataClass implements Insertable<IngredientTable> {
@override
int get hashCode => Object.hash(id, data, lastFetched);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -180,14 +193,12 @@ class IngredientsCompanion extends UpdateCompanion<IngredientTable> {
final Value<String> data;
final Value<DateTime> lastFetched;
final Value<int> rowid;
const IngredientsCompanion({
this.id = const Value.absent(),
this.data = const Value.absent(),
this.lastFetched = const Value.absent(),
this.rowid = const Value.absent(),
});
IngredientsCompanion.insert({
required int id,
required String data,
@@ -196,7 +207,6 @@ class IngredientsCompanion extends UpdateCompanion<IngredientTable> {
}) : id = Value(id),
data = Value(data),
lastFetched = Value(lastFetched);
static Insertable<IngredientTable> custom({
Expression<int>? id,
Expression<String>? data,
@@ -257,14 +267,11 @@ class IngredientsCompanion extends UpdateCompanion<IngredientTable> {
abstract class _$IngredientDatabase extends GeneratedDatabase {
_$IngredientDatabase(QueryExecutor e) : super(e);
$IngredientDatabaseManager get managers => $IngredientDatabaseManager(this);
late final $IngredientsTable ingredients = $IngredientsTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [ingredients];
}
@@ -292,15 +299,20 @@ class $$IngredientsTableFilterComposer extends Composer<_$IngredientDatabase, $I
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get data => $composableBuilder(
column: $table.data,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get data =>
$composableBuilder(column: $table.data, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get lastFetched =>
$composableBuilder(column: $table.lastFetched, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get lastFetched => $composableBuilder(
column: $table.lastFetched,
builder: (column) => ColumnFilters(column),
);
}
class $$IngredientsTableOrderingComposer extends Composer<_$IngredientDatabase, $IngredientsTable> {
@@ -311,15 +323,20 @@ class $$IngredientsTableOrderingComposer extends Composer<_$IngredientDatabase,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get data => $composableBuilder(
column: $table.data,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get data =>
$composableBuilder(column: $table.data, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get lastFetched =>
$composableBuilder(column: $table.lastFetched, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get lastFetched => $composableBuilder(
column: $table.lastFetched,
builder: (column) => ColumnOrderings(column),
);
}
class $$IngredientsTableAnnotationComposer
@@ -331,14 +348,15 @@ class $$IngredientsTableAnnotationComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id => $composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get data =>
$composableBuilder(column: $table.data, builder: (column) => column);
GeneratedColumn<DateTime> get lastFetched =>
$composableBuilder(column: $table.lastFetched, builder: (column) => column);
GeneratedColumn<DateTime> get lastFetched => $composableBuilder(
column: $table.lastFetched,
builder: (column) => column,
);
}
class $$IngredientsTableTableManager
@@ -359,8 +377,10 @@ class $$IngredientsTableTableManager
IngredientTable,
PrefetchHooks Function()
> {
$$IngredientsTableTableManager(_$IngredientDatabase db, $IngredientsTable table)
: super(
$$IngredientsTableTableManager(
_$IngredientDatabase db,
$IngredientsTable table,
) : super(
TableManagerState(
db: db,
table: table,
@@ -374,8 +394,12 @@ class $$IngredientsTableTableManager
Value<String> data = const Value.absent(),
Value<DateTime> lastFetched = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) =>
IngredientsCompanion(id: id, data: data, lastFetched: lastFetched, rowid: rowid),
}) => IngredientsCompanion(
id: id,
data: data,
lastFetched: lastFetched,
rowid: rowid,
),
createCompanionCallback:
({
required int id,
@@ -412,9 +436,7 @@ typedef $$IngredientsTableProcessedTableManager =
class $IngredientDatabaseManager {
final _$IngredientDatabase _db;
$IngredientDatabaseManager(this._db);
$$IngredientsTableTableManager get ingredients =>
$$IngredientsTableTableManager(_db, _db.ingredients);
}

View File

@@ -16,11 +16,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/// Returns a timezone aware DateTime object from a date and time string.
DateTime getDateTimeFromDateAndTime(String date, String time) {
return DateTime.parse('$date $time');
}
/// Returns a list of [DateTime] objects from [first] to [last], inclusive.
List<DateTime> daysInRange(DateTime first, DateTime last) {
final dayCount = last.difference(first).inDays + 1;

View File

@@ -9,126 +9,127 @@ import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
String getTranslation(String value, BuildContext context) {
final logger = Logger('getTranslation');
String getServerStringTranslation(String value, BuildContext context) {
final logger = Logger('getServerStringTranslation');
final i18n = AppLocalizations.of(context);
switch (value) {
case 'Abs':
return AppLocalizations.of(context).abs;
return i18n.abs;
case 'Arms':
return AppLocalizations.of(context).arms;
return i18n.arms;
case 'Back':
return AppLocalizations.of(context).back;
return i18n.back;
case 'Barbell':
return AppLocalizations.of(context).barbell;
return i18n.barbell;
case 'Bench':
return AppLocalizations.of(context).bench;
return i18n.bench;
case 'Biceps':
return AppLocalizations.of(context).biceps;
return i18n.biceps;
case 'Body Weight':
return AppLocalizations.of(context).body_weight;
return i18n.body_weight;
case 'Calves':
return AppLocalizations.of(context).calves;
return i18n.calves;
case 'Cardio':
return AppLocalizations.of(context).cardio;
return i18n.cardio;
case 'Chest':
return AppLocalizations.of(context).chest;
return i18n.chest;
case 'Dumbbell':
return AppLocalizations.of(context).dumbbell;
return i18n.dumbbell;
case 'Glutes':
return AppLocalizations.of(context).glutes;
return i18n.glutes;
case 'Gym mat':
return AppLocalizations.of(context).gym_mat;
return i18n.gym_mat;
case 'Hamstrings':
return AppLocalizations.of(context).hamstrings;
return i18n.hamstrings;
case 'Incline bench':
return AppLocalizations.of(context).incline_bench;
return i18n.incline_bench;
case 'Kettlebell':
return AppLocalizations.of(context).kettlebell;
return i18n.kettlebell;
case 'Kilometers':
return AppLocalizations.of(context).kilometers;
return i18n.kilometers;
case 'Kilometers Per Hour':
return AppLocalizations.of(context).kilometers_per_hour;
return i18n.kilometers_per_hour;
case 'Lats':
return AppLocalizations.of(context).lats;
return i18n.lats;
case 'Legs':
return AppLocalizations.of(context).legs;
return i18n.legs;
case 'Lower back':
return AppLocalizations.of(context).lower_back;
return i18n.lower_back;
case 'Max Reps':
return AppLocalizations.of(context).max_reps;
return i18n.max_reps;
case 'Miles':
return AppLocalizations.of(context).miles;
return i18n.miles;
case 'Miles Per Hour':
return AppLocalizations.of(context).miles_per_hour;
return i18n.miles_per_hour;
case 'Minutes':
return AppLocalizations.of(context).minutes;
return i18n.minutes;
case 'Plates':
return AppLocalizations.of(context).plates;
return i18n.plates;
case 'Pull-up bar':
return AppLocalizations.of(context).pull_up_bar;
return i18n.pull_up_bar;
case 'Quads':
return AppLocalizations.of(context).quads;
return i18n.quads;
case 'Repetitions':
return AppLocalizations.of(context).repetitions;
return i18n.repetitions;
case 'Resistance band':
return AppLocalizations.of(context).resistance_band;
return i18n.resistance_band;
case 'SZ-Bar':
return AppLocalizations.of(context).sz_bar;
return i18n.sz_bar;
case 'Seconds':
return AppLocalizations.of(context).seconds;
return i18n.seconds;
case 'Shoulders':
return AppLocalizations.of(context).shoulders;
return i18n.shoulders;
case 'Swiss Ball':
return AppLocalizations.of(context).swiss_ball;
return i18n.swiss_ball;
case 'Triceps':
return AppLocalizations.of(context).triceps;
return i18n.triceps;
case 'Until Failure':
return AppLocalizations.of(context).until_failure;
return i18n.until_failure;
case 'kg':
return AppLocalizations.of(context).kg;
return i18n.kg;
case 'lb':
return AppLocalizations.of(context).lb;
return i18n.lb;
case 'none (bodyweight exercise)':
return AppLocalizations.of(context).none__bodyweight_exercise_;
return i18n.none__bodyweight_exercise_;
default:
logger.warning('Could not translate the server string $value');

View File

@@ -23,6 +23,10 @@ num stringToNum(String? e) {
return e == null ? 0 : num.parse(e);
}
num? stringToNumNull(String? e) {
return e == null ? null : num.parse(e);
}
num stringOrIntToNum(dynamic e) {
if (e is int) {
return e.toDouble(); // Convert int to double (a type of num)
@@ -30,10 +34,6 @@ num stringOrIntToNum(dynamic e) {
return num.tryParse(e) ?? 0;
}
num? stringToNumNull(String? e) {
return e == null ? null : num.parse(e);
}
String? numToString(num? e) {
if (e == null) {
return null;
@@ -62,6 +62,14 @@ String dateToUtcIso8601(DateTime dateTime) {
return dateTime.toUtc().toIso8601String();
}
/// Converts an ISO8601 datetime string in UTC to a local DateTime object.
///
/// Needs to be used in conjunction with [dateToUtcIso8601] in the models to
/// correctly handle timezones.
DateTime utcIso8601ToLocalDate(String dateTime) {
return DateTime.parse(dateTime).toLocal();
}
/*
* Converts a time to a date object.
* Needed e.g. when the wger api only sends a time but no date information.

23
lib/helpers/material.dart Normal file
View File

@@ -0,0 +1,23 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// From https://m3.material.io/foundations/layout/applying-layout/window-size-classes
const MATERIAL_XS_BREAKPOINT = 600.0;
const MATERIAL_MD_BREAKPOINT = 840.0;
const MATERIAL_LG_BREAKPOINT = 1200.0;
const MATERIAL_XL_BREAKPOINT = 1600.0;

View File

@@ -18,50 +18,6 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/models/workouts/repetition_unit.dart';
import 'package:wger/models/workouts/weight_unit.dart';
/// Returns the text representation for a single setting, used in the gym mode
String repText(
num? repetitions,
RepetitionUnit? repetitionUnitObj,
num? weight,
WeightUnit? weightUnitObj,
num? rir,
) {
// TODO(x): how to (easily?) translate strings like the units or 'RiR'
final List<String> out = [];
if (repetitions != null) {
out.add(formatNum(repetitions).toString());
// The default repetition unit is 'reps', which we don't show unless there
// is no weight defined so that we don't just output something like "8" but
// rather "8 repetitions". If there is weight we want to output "8 x 50kg",
// since the repetitions are implied. If other units are used, we always
// print them
if (repetitionUnitObj != null && repetitionUnitObj.id != REP_UNIT_REPETITIONS_ID ||
weight == 0 ||
weight == null) {
out.add(repetitionUnitObj!.name);
}
}
if (weight != null && weight != 0) {
out.add('×');
out.add(formatNum(weight).toString());
out.add(weightUnitObj!.name);
}
if (rir != null && rir != '') {
out.add('\n');
out.add('($rir RiR)');
}
return out.join(' ');
}
void launchURL(String url, BuildContext context) async {
final scaffoldMessenger = ScaffoldMessenger.of(context);

45
lib/helpers/uuid.dart Normal file
View File

@@ -0,0 +1,45 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:math';
import 'dart:typed_data';
String uuidV4() {
final rnd = Random.secure();
final bytes = Uint8List(16);
for (var i = 0; i < 16; i++) {
bytes[i] = rnd.nextInt(256);
}
// Set version to 4 -> xxxx0100
bytes[6] = (bytes[6] & 0x0F) | 0x40;
// Set variant to RFC4122 -> 10xxxxxx
bytes[8] = (bytes[8] & 0x3F) | 0x80;
return _bytesToUuid(bytes);
}
String _bytesToUuid(Uint8List bytes) {
final sb = StringBuffer();
for (var i = 0; i < bytes.length; i++) {
sb.write(bytes[i].toRadixString(16).padLeft(2, '0'));
if (i == 3 || i == 5 || i == 7 || i == 9) sb.write('-');
}
return sb.toString();
}

View File

@@ -1041,5 +1041,91 @@
"startDate": "Anfangsdatum",
"@startDate": {},
"applicationLogs": "Anwendungsprotokolle",
"@applicationLogs": {}
"@applicationLogs": {},
"dayTypeEnom": "Jede volle Minute",
"@dayTypeEnom": {},
"dayTypeAmrap": "So viele Runden wie möglich",
"@dayTypeAmrap": {},
"dayTypeHiit": "Hochintensives Intervalltraining",
"@dayTypeHiit": {},
"dayTypeEdt": "Training mit zunehmender Dichte",
"@dayTypeEdt": {},
"dayTypeRft": "Runden auf Zeit",
"@dayTypeRft": {},
"dayTypeAfap": "So schnell wie möglich",
"@dayTypeAfap": {},
"slotEntryTypeNormal": "Normal",
"@slotEntryTypeNormal": {},
"slotEntryTypeDropset": "Abnehmender Satz",
"@slotEntryTypeDropset": {},
"slotEntryTypeMyo": "Muskel",
"@slotEntryTypeMyo": {},
"slotEntryTypePartial": "teilweise",
"@slotEntryTypePartial": {},
"slotEntryTypeForced": "erzwungen",
"@slotEntryTypeForced": {},
"slotEntryTypeTut": "Zeit unter Spannung",
"@slotEntryTypeTut": {},
"slotEntryTypeIso": "isometrisches Halten",
"@slotEntryTypeIso": {},
"slotEntryTypeJump": "Sprung",
"@slotEntryTypeJump": {},
"openEnded": "Offenes Ende",
"@openEnded": {
"description": "When a nutrition plan has no pre-defined end date"
},
"overview": "Überblick",
"@overview": {},
"identicalExercisePleaseDiscard": "Wenn Sie eine Übung entdecken, die mit der von Ihnen hinzugefügten identisch ist, verwerfen Sie bitte Ihren Entwurf und bearbeiten Sie stattdessen diese Übung.",
"@identicalExercisePleaseDiscard": {},
"checkInformationBeforeSubmitting": "Bitte überprüfe, ob die eingegebenen Informationen korrekt sind, bevor du die Übung abschickst",
"@checkInformationBeforeSubmitting": {},
"imageDetailsTitle": "Bilddetails",
"@imageDetailsTitle": {
"description": "Title for image details form"
},
"imageDetailsLicenseTitle": "Bildtitel",
"@imageDetailsLicenseTitle": {
"description": "Label for image title field"
},
"imageDetailsLicenseTitleHint": "Bildtitel eingeben",
"@imageDetailsLicenseTitleHint": {
"description": "Hint text for image title field"
},
"imageDetailsSourceLink": "Link zur Website der Quelle",
"@imageDetailsSourceLink": {
"description": "Label for source link field"
},
"author": "Autoren",
"@author": {},
"authorHint": "Name des Autors eingeben",
"@authorHint": {
"description": "Hint text for author field"
},
"imageDetailsAuthorLink": "Link zur Website oder zum Profil des Autors",
"@imageDetailsAuthorLink": {
"description": "Label for author link field"
},
"imageDetailsDerivativeSource": "Link zur Originalquelle, wenn es sich um ein abgeleitetes Werk handelt",
"@imageDetailsDerivativeSource": {
"description": "Label for derivative source field"
},
"imageDetailsDerivativeHelp": "Ein abgeleitetes Werk basiert auf einem früheren Werk, enthält jedoch genügend neues, schöpferisches Material, um eigenständig urheberrechtlich geschützt zu sein.",
"@imageDetailsDerivativeHelp": {
"description": "Helper text explaining derivative works"
},
"imageDetailsImageType": "Bildtyp",
"@imageDetailsImageType": {
"description": "Label for image type selector"
},
"imageDetailsLicenseNotice": "Mit der Übermittlung dieses Bildes erklären Sie sich damit einverstanden, es unter der CC-BY-SA-4 zu veröffentlichen. Das Bild muss entweder Ihr eigenes Werk sein oder der Urheber muss es unter einer damit kompatiblen Lizenz veröffentlicht haben.",
"@imageDetailsLicenseNotice": {},
"imageDetailsLicenseNoticeLinkToLicense": "Siehe Lizenztext.",
"@imageDetailsLicenseNoticeLinkToLicense": {},
"enterTextInLanguage": "Bitte geben Sie den Text in der richtigen Sprache ein!",
"@enterTextInLanguage": {},
"endWorkout": "Training beenden",
"@endWorkout": {},
"dayTypeCustom": "personalisierte",
"@dayTypeCustom": {}
}

View File

@@ -232,6 +232,9 @@
"@comment": {
"description": "Comment, additional information"
},
"impressionGood": "Good",
"impressionNeutral": "Neutral",
"impressionBad": "Bad",
"impression": "Impression",
"@impression": {
"description": "General impression (e.g. for a workout session) such as good, bad, etc."
@@ -263,6 +266,33 @@
"@gymMode": {
"description": "Label when starting the gym mode"
},
"gymModeShowExercises": "Show exercise overview pages",
"gymModeShowTimer": "Show timer between sets",
"gymModeTimerType": "Timer type",
"gymModeTimerTypeHelText": "If a set has pause time, a countdown is always used.",
"countdown": "Countdown",
"stopwatch": "Stopwatch",
"gymModeDefaultCountdownTime": "Default countdown time, in seconds",
"gymModeNotifyOnCountdownFinish": "Notify on countdown end",
"duration": "Duration",
"durationHoursMinutes": "{hours}h {minutes}m",
"@durationHoursMinutes": {
"description": "A duration, in hours and minutes",
"type": "text",
"placeholders": {
"hours": {
"type": "int"
},
"minutes": {
"type": "int"
}
}
},
"volume": "Volume",
"@volume": {
"description": "The volume of a workout or set, i.e. weight x reps"
},
"workoutCompleted": "Workout completed",
"plateCalculator": "Plates",
"@plateCalculator": {
"description": "Label used for the plate calculator in the gym mode"
@@ -643,6 +673,19 @@
}
}
},
"formMinMaxValues": "Please enter a value between {min} and {max}",
"@formMinMaxValues": {
"description": "Error message when the user needs to enter a value between min and max",
"type": "text",
"placeholders": {
"min": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"enterMinCharacters": "Please enter at least {min} characters",
"@enterMinCharacters": {
"description": "Error message when the user hasn't entered the minimum amount characters in a form",
@@ -845,6 +888,7 @@
"fitInWeek": "Fit in week",
"fitInWeekHelp": "If enabled, the days will repeat in a weekly cycle, otherwise the days will follow sequentially without regards to the start of a new week.",
"addSuperset": "Add superset",
"superset": "Superset",
"setHasProgression": "Set has progression",
"setHasProgressionWarning": "Please note that at the moment it is not possible to edit all settings for a set on the mobile application or configure automatic progression. For now, please use the web application.",
"setHasNoExercises": "This set has no exercises yet!",
@@ -1063,7 +1107,10 @@
"@indicatorAvg": {
"description": "added for localization of Class Indicator's field text"
},
"endWorkout": "End Workout",
"endWorkout": "End workout",
"@endWorkout": {
"description": "Use the imperative, label on button to finish the current workout in gym mode"
},
"themeMode": "Theme mode",
"darkMode": "Always dark mode",
"lightMode": "Always light mode",

48
lib/l10n/app_fil.arb Normal file
View File

@@ -0,0 +1,48 @@
{
"userProfile": "Profile mo",
"@userProfile": {},
"login": "Pumasok",
"@login": {
"description": "Text for login button"
},
"logout": "Mag-sign out",
"@logout": {
"description": "Text for logout button"
},
"register": "Mag-rehistro",
"@register": {
"description": "Text for registration button"
},
"useDefaultServer": "Gamitin ang default na server",
"@useDefaultServer": {
"description": "Toggle button allowing users to switch between the default and a custom wger server"
},
"useCustomServer": "Gumamit ng custom na server",
"@useCustomServer": {
"description": "Toggle button allowing users to switch between the default and a custom wger server"
},
"invalidUrl": "Mangyaring magpasok ng wastong URL",
"@invalidUrl": {
"description": "Error message when the user enters an invalid URL, e.g. in the login form"
},
"usernameValidChars": "Ang username ay maaari lamang maglaman ng mga titik, digit, at mga character na @, +, ., -, at _",
"@usernameValidChars": {
"description": "Error message when the user tries to register a username with forbidden characters"
},
"passwordsDontMatch": "Ang password ay hindi tugma",
"@passwordsDontMatch": {
"description": "Error message when the user enters two different passwords during registration"
},
"passwordTooShort": "Masyadong maikli ang password",
"@passwordTooShort": {
"description": "Error message when the user a password that is too short"
},
"selectAvailablePlates": "Pumili ng magagamit na mga plato",
"@selectAvailablePlates": {},
"barWeight": "Timbang ng bar",
"@barWeight": {},
"useColors": "Gumamit ng mga kulay",
"@useColors": {},
"password": "Password",
"@password": {}
}

View File

@@ -1,94 +1,198 @@
{
"passwordTooShort": "Het wachtwoord is te kort",
"@passwordTooShort": {
"description": "Error message when the user a password that is too short"
},
"register": "Registreren",
"@register": {
"description": "Text for registration button"
},
"logout": "Uitloggen",
"@logout": {
"description": "Text for logout button"
},
"usernameValidChars": "Een gebruikersnaam mag alleen letters, nummers en de tekens @, +, ., -, en _ bevatten",
"@usernameValidChars": {
"description": "Error message when the user tries to register a username with forbidden characters"
},
"passwordsDontMatch": "De wachtwoorden komen niet overeen",
"@passwordsDontMatch": {
"description": "Error message when the user enters two different passwords during registration"
},
"login": "Inloggen",
"@login": {
"description": "Text for login button"
},
"invalidEmail": "Vul een geldig e-mailadres in",
"@invalidEmail": {
"description": "Error message when the user enters an invalid email"
},
"confirmPassword": "Bevestig wachtwoord",
"@confirmPassword": {},
"userProfile": "Jouw profiel",
"@userProfile": {},
"useDefaultServer": "Gebruik standaard server",
"@useDefaultServer": {
"description": "Toggle button allowing users to switch between the default and a custom wger server"
},
"useCustomServer": "Gebruik aangepaste server",
"@useCustomServer": {
"description": "Toggle button allowing users to switch between the default and a custom wger server"
},
"password": "Wachtwoord",
"@password": {},
"invalidUrl": "Vul een geldige URL in",
"@invalidUrl": {
"description": "Error message when the user enters an invalid URL, e.g. in the login form"
},
"email": "E-mailadres",
"@email": {},
"username": "Gebruikersnaam",
"@username": {},
"invalidUsername": "Vul een geldige gebruikersnaam in",
"@invalidUsername": {
"description": "Error message when the user enters an invalid username"
},
"customServerUrl": "URL van de wger instantie",
"@customServerUrl": {
"description": "Label in the form where the users can enter their own wger instance"
},
"successfullySaved": "Opgeslagen",
"@successfullySaved": {
"description": "Message when an item was successfully saved"
},
"exerciseList": "Oefeningen lijst",
"@exerciseList": {},
"exercise": "Oefening",
"@exercise": {
"description": "An exercise for a workout"
},
"exercises": "Oefeningen",
"@exercises": {
"description": "Multiple exercises for a workout"
},
"exerciseName": "Oefening naam",
"@exerciseName": {
"description": "Label for the name of a workout exercise"
},
"success": "Succes",
"@success": {
"description": "Message when an action completed successfully, usually used as a heading"
},
"category": "Categorie",
"@category": {
"description": "Category for an exercise, ingredient, etc."
},
"reps": "Herhalingen",
"@reps": {
"description": "Shorthand for repetitions, used when space constraints are tighter"
},
"muscles": "Spieren",
"@muscles": {
"description": "(main) muscles trained by an exercise"
}
}
"passwordTooShort": "Het wachtwoord is te kort",
"@passwordTooShort": {
"description": "Error message when the user a password that is too short"
},
"register": "Registreren",
"@register": {
"description": "Text for registration button"
},
"logout": "Uitloggen",
"@logout": {
"description": "Text for logout button"
},
"usernameValidChars": "Een gebruikersnaam mag alleen letters, nummers en de tekens @, +, ., -, en _ bevatten",
"@usernameValidChars": {
"description": "Error message when the user tries to register a username with forbidden characters"
},
"passwordsDontMatch": "De wachtwoorden komen niet overeen",
"@passwordsDontMatch": {
"description": "Error message when the user enters two different passwords during registration"
},
"login": "Inloggen",
"@login": {
"description": "Text for login button"
},
"invalidEmail": "Vul een geldig e-mailadres in",
"@invalidEmail": {
"description": "Error message when the user enters an invalid email"
},
"confirmPassword": "Bevestig wachtwoord",
"@confirmPassword": {},
"userProfile": "Jouw profiel",
"@userProfile": {},
"useDefaultServer": "Gebruik standaard server",
"@useDefaultServer": {
"description": "Toggle button allowing users to switch between the default and a custom wger server"
},
"useCustomServer": "Gebruik aangepaste server",
"@useCustomServer": {
"description": "Toggle button allowing users to switch between the default and a custom wger server"
},
"password": "Wachtwoord",
"@password": {},
"invalidUrl": "Vul een geldige URL in",
"@invalidUrl": {
"description": "Error message when the user enters an invalid URL, e.g. in the login form"
},
"email": "E-mailadres",
"@email": {},
"username": "Gebruikersnaam",
"@username": {},
"invalidUsername": "Vul een geldige gebruikersnaam in",
"@invalidUsername": {
"description": "Error message when the user enters an invalid username"
},
"customServerUrl": "URL van de wger instantie",
"@customServerUrl": {
"description": "Label in the form where the users can enter their own wger instance"
},
"successfullySaved": "Opgeslagen",
"@successfullySaved": {
"description": "Message when an item was successfully saved"
},
"exerciseList": "Oefeningen lijst",
"@exerciseList": {},
"exercise": "Oefening",
"@exercise": {
"description": "An exercise for a workout"
},
"exercises": "Oefeningen",
"@exercises": {
"description": "Multiple exercises for a workout"
},
"exerciseName": "Oefening naam",
"@exerciseName": {
"description": "Label for the name of a workout exercise"
},
"success": "Succes",
"@success": {
"description": "Message when an action completed successfully, usually used as a heading"
},
"category": "Categorie",
"@category": {
"description": "Category for an exercise, ingredient, etc."
},
"reps": "Herhalingen",
"@reps": {
"description": "Shorthand for repetitions, used when space constraints are tighter"
},
"muscles": "Spieren",
"@muscles": {
"description": "(main) muscles trained by an exercise"
},
"selectAvailablePlates": "Selecteer beschikbare platen",
"@selectAvailablePlates": {},
"barWeight": "Gewicht stang",
"@barWeight": {},
"useColors": "Gebruik kleuren",
"@useColors": {},
"useApiToken": "Gebruik API Token",
"@useApiToken": {},
"useUsernameAndPassword": "Gebruik gebruikersnaam en wachtwoord",
"@useUsernameAndPassword": {},
"comment": "Commentaar",
"@comment": {
"description": "Comment, additional information"
},
"apiToken": "API Token",
"@apiToken": {},
"invalidApiToken": "Voer alstublieft een correcte API key in",
"@invalidApiToken": {
"description": "Error message when the user enters an invalid API key"
},
"apiTokenValidChars": "Een API key mag enkel letters a-f, nummers 0-9 en exact 40 karakters lang zijn",
"@apiTokenValidChars": {
"description": "Error message when the user tries to input a API key with forbidden characters"
},
"customServerHint": "Voer het adres van uw eigen server in, anders wordt de standaard optie gebruikt",
"@customServerHint": {
"description": "Hint text for the form where the users can enter their own wger instance"
},
"reset": "Reset",
"@reset": {
"description": "Button text allowing the user to reset the entered values to the default"
},
"registerInstead": "Nog geen account? Registreer nu",
"@registerInstead": {},
"loginInstead": "Heeft u al een account? Login",
"@loginInstead": {},
"labelBottomNavWorkout": "Workout",
"@labelBottomNavWorkout": {
"description": "Label used in bottom navigation, use a short word"
},
"labelBottomNavNutrition": "Voeding",
"@labelBottomNavNutrition": {
"description": "Label used in bottom navigation, use a short word"
},
"labelWorkoutLogs": "Trainings logboek",
"@labelWorkoutLogs": {
"description": "(Workout) logs"
},
"labelWorkoutPlan": "Workout schema",
"@labelWorkoutPlan": {
"description": "Title for screen workout plan"
},
"labelDashboard": "Dashboard",
"@labelDashboard": {
"description": "Title for screen dashboard"
},
"successfullyDeleted": "Verwijderd",
"@successfullyDeleted": {
"description": "Message when an item was successfully deleted"
},
"searchExercise": "Zoek een oefening om hem toe te voegen",
"@searchExercise": {
"description": "Label on set form. Selected exercises are added to the set"
},
"noIngredientsDefined": "Nog geen ingrediënten gedefinieerd",
"@noIngredientsDefined": {},
"noMatchingExerciseFound": "Geen overeenkomende oefeningen gevonden",
"@noMatchingExerciseFound": {
"description": "Message returned if no exercises match the searched string"
},
"searchNamesInEnglish": "Zoek ook voor namen in het Engels",
"@searchNamesInEnglish": {},
"equipment": "Uitrusting",
"@equipment": {
"description": "Equipment needed to perform an exercise"
},
"musclesSecondary": "Secundaire spieren",
"@musclesSecondary": {
"description": "secondary muscles trained by an exercise"
},
"startDate": "Start datum",
"@startDate": {
"description": "The start date of a nutritional plan or routine"
},
"dayTypeCustom": "Aangepast",
"@dayTypeCustom": {},
"dayTypeEnom": "Elke minuut op de minuut",
"@dayTypeEnom": {},
"dayTypeAmrap": "Zoveel mogelijk rondes mogelijk",
"@dayTypeAmrap": {},
"dayTypeHiit": "Hoge intensiteit interval training",
"@dayTypeHiit": {},
"dayTypeTabata": "Tabata",
"@dayTypeTabata": {},
"dayTypeEdt": "Escaleerende dichtheids training",
"@dayTypeEdt": {},
"dayTypeRft": "Rondes voor tijd",
"@dayTypeRft": {},
"dayTypeAfap": "Zo snel mogelijk",
"@dayTypeAfap": {},
"slotEntryTypeNormal": "Normaal",
"@slotEntryTypeNormal": {},
"slotEntryTypeDropset": "Dropset",
"@slotEntryTypeDropset": {}
}

View File

@@ -1074,5 +1074,11 @@
"description": "The End date of a nutritional plan"
},
"startDate": "Data inicial",
"@startDate": {}
"@startDate": {},
"dayTypeCustom": "Personalizado",
"@dayTypeCustom": {},
"dayTypeHiit": "Treino de alta intensidade",
"@dayTypeHiit": {},
"dayTypeTabata": "Tabata",
"@dayTypeTabata": {}
}

View File

@@ -1074,5 +1074,23 @@
"openEnded": "Sem fim definido",
"@openEnded": {
"description": "When a nutrition plan has no pre-defined end date"
}
},
"dayTypeCustom": "Customizado",
"@dayTypeCustom": {},
"dayTypeEnom": "A cada minuto",
"@dayTypeEnom": {},
"dayTypeAmrap": "O máximo de rondas possível",
"@dayTypeAmrap": {},
"dayTypeHiit": "Treino intervalado de alta intensidade",
"@dayTypeHiit": {},
"dayTypeTabata": "Tabata",
"@dayTypeTabata": {},
"dayTypeEdt": "Treino de densidade crescente",
"@dayTypeEdt": {},
"dayTypeRft": "Rondas por tempo",
"@dayTypeRft": {},
"dayTypeAfap": "O mais rápido possível",
"@dayTypeAfap": {},
"slotEntryTypeNormal": "Normal",
"@slotEntryTypeNormal": {}
}

View File

@@ -29,7 +29,7 @@ class WeightEntry {
@JsonKey(required: true, fromJson: stringToNum, toJson: numToString)
late num weight = 0;
@JsonKey(required: true)
@JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
late DateTime date;
WeightEntry({this.id, weight, DateTime? date}) {

View File

@@ -11,12 +11,12 @@ WeightEntry _$WeightEntryFromJson(Map<String, dynamic> json) {
return WeightEntry(
id: (json['id'] as num?)?.toInt(),
weight: stringToNum(json['weight'] as String?),
date: json['date'] == null ? null : DateTime.parse(json['date'] as String),
date: utcIso8601ToLocalDate(json['date'] as String),
);
}
Map<String, dynamic> _$WeightEntryToJson(WeightEntry instance) => <String, dynamic>{
'id': instance.id,
'weight': numToString(instance.weight),
'date': instance.date.toIso8601String(),
'date': dateToUtcIso8601(instance.date),
};

View File

@@ -8,7 +8,10 @@ part of 'category.dart';
ExerciseCategory _$ExerciseCategoryFromJson(Map<String, dynamic> json) {
$checkKeys(json, requiredKeys: const ['id', 'name']);
return ExerciseCategory(id: (json['id'] as num).toInt(), name: json['name'] as String);
return ExerciseCategory(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
);
}
Map<String, dynamic> _$ExerciseCategoryToJson(ExerciseCategory instance) => <String, dynamic>{

View File

@@ -38,7 +38,9 @@ Exercise _$ExerciseFromJson(Map<String, dynamic> json) {
.toList(),
category: json['categories'] == null
? null
: ExerciseCategory.fromJson(json['categories'] as Map<String, dynamic>),
: ExerciseCategory.fromJson(
json['categories'] as Map<String, dynamic>,
),
)
..categoryId = (json['category'] as num).toInt()
..musclesIds = (json['muscles'] as List<dynamic>).map((e) => (e as num).toInt()).toList()

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,9 @@ _ExerciseBaseData _$ExerciseBaseDataFromJson(Map<String, dynamic> json) => _Exer
created: DateTime.parse(json['created'] as String),
lastUpdate: DateTime.parse(json['last_update'] as String),
lastUpdateGlobal: DateTime.parse(json['last_update_global'] as String),
category: ExerciseCategory.fromJson(json['category'] as Map<String, dynamic>),
category: ExerciseCategory.fromJson(
json['category'] as Map<String, dynamic>,
),
muscles: (json['muscles'] as List<dynamic>)
.map((e) => Muscle.fromJson(e as Map<String, dynamic>))
.toList(),
@@ -56,34 +58,39 @@ Map<String, dynamic> _$ExerciseBaseDataToJson(_ExerciseBaseData instance) => <St
'total_authors_history': instance.authorsGlobal,
};
_ExerciseSearchDetails _$ExerciseSearchDetailsFromJson(Map<String, dynamic> json) =>
_ExerciseSearchDetails(
translationId: (json['id'] as num).toInt(),
exerciseId: (json['base_id'] as num).toInt(),
name: json['name'] as String,
category: json['category'] as String,
image: json['image'] as String?,
imageThumbnail: json['image_thumbnail'] as String?,
);
_ExerciseSearchDetails _$ExerciseSearchDetailsFromJson(
Map<String, dynamic> json,
) => _ExerciseSearchDetails(
translationId: (json['id'] as num).toInt(),
exerciseId: (json['base_id'] as num).toInt(),
name: json['name'] as String,
category: json['category'] as String,
image: json['image'] as String?,
imageThumbnail: json['image_thumbnail'] as String?,
);
Map<String, dynamic> _$ExerciseSearchDetailsToJson(_ExerciseSearchDetails instance) =>
<String, dynamic>{
'id': instance.translationId,
'base_id': instance.exerciseId,
'name': instance.name,
'category': instance.category,
'image': instance.image,
'image_thumbnail': instance.imageThumbnail,
};
Map<String, dynamic> _$ExerciseSearchDetailsToJson(
_ExerciseSearchDetails instance,
) => <String, dynamic>{
'id': instance.translationId,
'base_id': instance.exerciseId,
'name': instance.name,
'category': instance.category,
'image': instance.image,
'image_thumbnail': instance.imageThumbnail,
};
_ExerciseSearchEntry _$ExerciseSearchEntryFromJson(Map<String, dynamic> json) =>
_ExerciseSearchEntry(
value: json['value'] as String,
data: ExerciseSearchDetails.fromJson(json['data'] as Map<String, dynamic>),
data: ExerciseSearchDetails.fromJson(
json['data'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$ExerciseSearchEntryToJson(_ExerciseSearchEntry instance) =>
<String, dynamic>{'value': instance.value, 'data': instance.data};
Map<String, dynamic> _$ExerciseSearchEntryToJson(
_ExerciseSearchEntry instance,
) => <String, dynamic>{'value': instance.value, 'data': instance.data};
_ExerciseApiSearch _$ExerciseApiSearchFromJson(Map<String, dynamic> json) => _ExerciseApiSearch(
suggestions: (json['suggestions'] as List<dynamic>)

File diff suppressed because it is too large Load Diff

View File

@@ -6,17 +6,21 @@ part of 'exercise_submission.dart';
// JsonSerializableGenerator
// **************************************************************************
_ExerciseAliasSubmissionApi _$ExerciseAliasSubmissionApiFromJson(Map<String, dynamic> json) =>
_ExerciseAliasSubmissionApi(alias: json['alias'] as String);
_ExerciseAliasSubmissionApi _$ExerciseAliasSubmissionApiFromJson(
Map<String, dynamic> json,
) => _ExerciseAliasSubmissionApi(alias: json['alias'] as String);
Map<String, dynamic> _$ExerciseAliasSubmissionApiToJson(_ExerciseAliasSubmissionApi instance) =>
<String, dynamic>{'alias': instance.alias};
Map<String, dynamic> _$ExerciseAliasSubmissionApiToJson(
_ExerciseAliasSubmissionApi instance,
) => <String, dynamic>{'alias': instance.alias};
_ExerciseCommentSubmissionApi _$ExerciseCommentSubmissionApiFromJson(Map<String, dynamic> json) =>
_ExerciseCommentSubmissionApi(alias: json['alias'] as String);
_ExerciseCommentSubmissionApi _$ExerciseCommentSubmissionApiFromJson(
Map<String, dynamic> json,
) => _ExerciseCommentSubmissionApi(alias: json['alias'] as String);
Map<String, dynamic> _$ExerciseCommentSubmissionApiToJson(_ExerciseCommentSubmissionApi instance) =>
<String, dynamic>{'alias': instance.alias};
Map<String, dynamic> _$ExerciseCommentSubmissionApiToJson(
_ExerciseCommentSubmissionApi instance,
) => <String, dynamic>{'alias': instance.alias};
_ExerciseTranslationSubmissionApi _$ExerciseTranslationSubmissionApiFromJson(
Map<String, dynamic> json,
@@ -27,12 +31,18 @@ _ExerciseTranslationSubmissionApi _$ExerciseTranslationSubmissionApiFromJson(
author: json['license_author'] as String,
aliases:
(json['aliases'] as List<dynamic>?)
?.map((e) => ExerciseAliasSubmissionApi.fromJson(e as Map<String, dynamic>))
?.map(
(e) => ExerciseAliasSubmissionApi.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
comments:
(json['comments'] as List<dynamic>?)
?.map((e) => ExerciseCommentSubmissionApi.fromJson(e as Map<String, dynamic>))
?.map(
(e) => ExerciseCommentSubmissionApi.fromJson(
e as Map<String, dynamic>,
),
)
.toList() ??
const [],
);
@@ -48,30 +58,36 @@ Map<String, dynamic> _$ExerciseTranslationSubmissionApiToJson(
'comments': instance.comments,
};
_ExerciseSubmissionApi _$ExerciseSubmissionApiFromJson(Map<String, dynamic> json) =>
_ExerciseSubmissionApi(
category: (json['category'] as num).toInt(),
muscles: (json['muscles'] as List<dynamic>).map((e) => (e as num).toInt()).toList(),
musclesSecondary: (json['muscles_secondary'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
equipment: (json['equipment'] as List<dynamic>).map((e) => (e as num).toInt()).toList(),
author: json['license_author'] as String,
variation: (json['variation'] as num?)?.toInt(),
variationConnectTo: (json['variations_connect_to'] as num?)?.toInt(),
translations: (json['translations'] as List<dynamic>)
.map((e) => ExerciseTranslationSubmissionApi.fromJson(e as Map<String, dynamic>))
.toList(),
);
_ExerciseSubmissionApi _$ExerciseSubmissionApiFromJson(
Map<String, dynamic> json,
) => _ExerciseSubmissionApi(
category: (json['category'] as num).toInt(),
muscles: (json['muscles'] as List<dynamic>).map((e) => (e as num).toInt()).toList(),
musclesSecondary: (json['muscles_secondary'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
equipment: (json['equipment'] as List<dynamic>).map((e) => (e as num).toInt()).toList(),
author: json['license_author'] as String,
variation: (json['variation'] as num?)?.toInt(),
variationConnectTo: (json['variations_connect_to'] as num?)?.toInt(),
translations: (json['translations'] as List<dynamic>)
.map(
(e) => ExerciseTranslationSubmissionApi.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
Map<String, dynamic> _$ExerciseSubmissionApiToJson(_ExerciseSubmissionApi instance) =>
<String, dynamic>{
'category': instance.category,
'muscles': instance.muscles,
'muscles_secondary': instance.musclesSecondary,
'equipment': instance.equipment,
'license_author': instance.author,
'variation': instance.variation,
'variations_connect_to': instance.variationConnectTo,
'translations': instance.translations,
};
Map<String, dynamic> _$ExerciseSubmissionApiToJson(
_ExerciseSubmissionApi instance,
) => <String, dynamic>{
'category': instance.category,
'muscles': instance.muscles,
'muscles_secondary': instance.musclesSecondary,
'equipment': instance.equipment,
'license_author': instance.author,
'variation': instance.variation,
'variations_connect_to': instance.variationConnectTo,
'translations': instance.translations,
};

View File

@@ -53,7 +53,7 @@ class Muscle extends Equatable {
List<Object?> get props => [id, name, isFront];
String nameTranslated(BuildContext context) {
return name + (nameEn.isNotEmpty ? ' (${getTranslation(nameEn, context)})' : '');
return name + (nameEn.isNotEmpty ? ' (${getServerStringTranslation(nameEn, context)})' : '');
}
@override

View File

@@ -9,7 +9,15 @@ part of 'translation.dart';
Translation _$TranslationFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'uuid', 'language', 'created', 'exercise', 'name', 'description'],
requiredKeys: const [
'id',
'uuid',
'language',
'created',
'exercise',
'name',
'description',
],
);
return Translation(
id: (json['id'] as num?)?.toInt(),

View File

@@ -26,7 +26,7 @@ class Image {
@JsonKey(required: true)
int? id;
@JsonKey(required: true, toJson: dateToYYYYMMDD)
@JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
late DateTime date;
@JsonKey(required: true, name: 'image')

View File

@@ -10,7 +10,7 @@ Image _$ImageFromJson(Map<String, dynamic> json) {
$checkKeys(json, requiredKeys: const ['id', 'date', 'image']);
return Image(
id: (json['id'] as num?)?.toInt(),
date: DateTime.parse(json['date'] as String),
date: utcIso8601ToLocalDate(json['date'] as String),
url: json['image'] as String?,
description: json['description'] as String? ?? '',
);
@@ -18,7 +18,7 @@ Image _$ImageFromJson(Map<String, dynamic> json) {
Map<String, dynamic> _$ImageToJson(Image instance) => <String, dynamic>{
'id': instance.id,
'date': dateToYYYYMMDD(instance.date),
'date': dateToUtcIso8601(instance.date),
'image': instance.url,
'description': instance.description,
};

View File

@@ -20,7 +20,9 @@ MeasurementCategory _$MeasurementCategoryFromJson(Map<String, dynamic> json) {
);
}
Map<String, dynamic> _$MeasurementCategoryToJson(MeasurementCategory instance) => <String, dynamic>{
Map<String, dynamic> _$MeasurementCategoryToJson(
MeasurementCategory instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'unit': instance.unit,

View File

@@ -50,7 +50,9 @@ Ingredient _$IngredientFromJson(Map<String, dynamic> json) {
: IngredientImage.fromJson(json['image'] as Map<String, dynamic>),
thumbnails: json['thumbnails'] == null
? null
: IngredientImageThumbnails.fromJson(json['thumbnails'] as Map<String, dynamic>),
: IngredientImageThumbnails.fromJson(
json['thumbnails'] as Map<String, dynamic>,
),
);
}

View File

@@ -6,7 +6,9 @@ part of 'ingredient_image_thumbnails.dart';
// JsonSerializableGenerator
// **************************************************************************
IngredientImageThumbnails _$IngredientImageThumbnailsFromJson(Map<String, dynamic> json) {
IngredientImageThumbnails _$IngredientImageThumbnailsFromJson(
Map<String, dynamic> json,
) {
$checkKeys(
json,
requiredKeys: const [
@@ -28,12 +30,13 @@ IngredientImageThumbnails _$IngredientImageThumbnailsFromJson(Map<String, dynami
);
}
Map<String, dynamic> _$IngredientImageThumbnailsToJson(IngredientImageThumbnails instance) =>
<String, dynamic>{
'small': instance.small,
'small_cropped': instance.smallCropped,
'medium': instance.medium,
'medium_cropped': instance.mediumCropped,
'large': instance.large,
'large_cropped': instance.largeCropped,
};
Map<String, dynamic> _$IngredientImageThumbnailsToJson(
IngredientImageThumbnails instance,
) => <String, dynamic>{
'small': instance.small,
'small_cropped': instance.smallCropped,
'medium': instance.medium,
'medium_cropped': instance.mediumCropped,
'large': instance.large,
'large_cropped': instance.largeCropped,
};

View File

@@ -7,21 +7,27 @@ part of 'ingredient_weight_unit.dart';
// **************************************************************************
IngredientWeightUnit _$IngredientWeightUnitFromJson(Map<String, dynamic> json) {
$checkKeys(json, requiredKeys: const ['id', 'weight_unit', 'ingredient', 'grams', 'amount']);
$checkKeys(
json,
requiredKeys: const ['id', 'weight_unit', 'ingredient', 'grams', 'amount'],
);
return IngredientWeightUnit(
id: (json['id'] as num).toInt(),
weightUnit: WeightUnit.fromJson(json['weight_unit'] as Map<String, dynamic>),
weightUnit: WeightUnit.fromJson(
json['weight_unit'] as Map<String, dynamic>,
),
ingredient: Ingredient.fromJson(json['ingredient'] as Map<String, dynamic>),
grams: (json['grams'] as num).toInt(),
amount: (json['amount'] as num).toDouble(),
);
}
Map<String, dynamic> _$IngredientWeightUnitToJson(IngredientWeightUnit instance) =>
<String, dynamic>{
'id': instance.id,
'weight_unit': instance.weightUnit,
'ingredient': instance.ingredient,
'grams': instance.grams,
'amount': instance.amount,
};
Map<String, dynamic> _$IngredientWeightUnitToJson(
IngredientWeightUnit instance,
) => <String, dynamic>{
'id': instance.id,
'weight_unit': instance.weightUnit,
'ingredient': instance.ingredient,
'grams': instance.grams,
'amount': instance.amount,
};

View File

@@ -36,7 +36,7 @@ class Log {
@JsonKey(required: true, name: 'plan')
int planId;
@JsonKey(required: true, toJson: dateToUtcIso8601)
@JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
late DateTime datetime;
String? comment;

View File

@@ -25,7 +25,7 @@ Log _$LogFromJson(Map<String, dynamic> json) {
weightUnitId: (json['weight_unit'] as num?)?.toInt(),
amount: stringToNum(json['amount'] as String?),
planId: (json['plan'] as num).toInt(),
datetime: DateTime.parse(json['datetime'] as String),
datetime: utcIso8601ToLocalDate(json['datetime'] as String),
comment: json['comment'] as String?,
);
}

View File

@@ -41,7 +41,12 @@ class NutritionalPlan {
@JsonKey(required: true)
late String description;
@JsonKey(required: true, name: 'creation_date', toJson: dateToUtcIso8601)
@JsonKey(
required: true,
name: 'creation_date',
fromJson: utcIso8601ToLocalDate,
toJson: dateToUtcIso8601,
)
late DateTime creationDate;
@JsonKey(required: true, name: 'start', toJson: dateToYYYYMMDD)

View File

@@ -26,9 +26,7 @@ NutritionalPlan _$NutritionalPlanFromJson(Map<String, dynamic> json) {
return NutritionalPlan(
id: (json['id'] as num?)?.toInt(),
description: json['description'] as String,
creationDate: json['creation_date'] == null
? null
: DateTime.parse(json['creation_date'] as String),
creationDate: utcIso8601ToLocalDate(json['creation_date'] as String),
startDate: DateTime.parse(json['start'] as String),
endDate: json['end'] == null ? null : DateTime.parse(json['end'] as String),
onlyLogging: json['only_logging'] as bool? ?? false,

View File

@@ -16,8 +16,10 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/i18n.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/helpers/misc.dart';
import 'package:wger/models/exercises/exercise.dart';
@@ -27,6 +29,13 @@ import 'package:wger/models/workouts/weight_unit.dart';
part 'log.g.dart';
enum LogTargetStatus {
lessThanTarget,
atTarget,
moreThanTarget,
notSet,
}
@JsonSerializable()
class Log {
@JsonKey(required: true)
@@ -50,16 +59,16 @@ class Log {
@JsonKey(required: true, name: 'slot_entry')
int? slotEntryId;
@JsonKey(required: false, fromJson: stringToNum)
@JsonKey(required: false, fromJson: stringToNumNull)
num? rir;
@JsonKey(required: false, fromJson: stringToNum, name: 'rir_target')
@JsonKey(required: false, fromJson: stringToNumNull, name: 'rir_target')
num? rirTarget;
@JsonKey(required: true, fromJson: stringToNum, name: 'repetitions')
@JsonKey(required: true, fromJson: stringToNumNull, name: 'repetitions')
num? repetitions;
@JsonKey(required: true, fromJson: stringToNum, name: 'repetitions_target')
@JsonKey(required: true, fromJson: stringToNumNull, name: 'repetitions_target')
num? repetitionsTarget;
@JsonKey(required: true, name: 'repetitions_unit')
@@ -68,10 +77,10 @@ class Log {
@JsonKey(includeFromJson: false, includeToJson: false)
late RepetitionUnit? repetitionsUnitObj;
@JsonKey(required: true, fromJson: stringToNum, toJson: numToString)
@JsonKey(required: true, fromJson: stringToNumNull, toJson: numToString)
late num? weight;
@JsonKey(required: true, fromJson: stringToNum, toJson: numToString, name: 'weight_target')
@JsonKey(required: true, fromJson: stringToNumNull, toJson: numToString, name: 'weight_target')
num? weightTarget;
@JsonKey(required: true, name: 'weight_unit')
@@ -80,7 +89,7 @@ class Log {
@JsonKey(includeFromJson: false, includeToJson: false)
late WeightUnit? weightUnitObj;
@JsonKey(required: true, toJson: dateToUtcIso8601)
@JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
late DateTime date;
Log({
@@ -105,16 +114,25 @@ class Log {
Log.fromSetConfigData(SetConfigData data) {
date = DateTime.now();
sessionId = null;
slotEntryId = data.slotEntryId;
exerciseBase = data.exercise;
weight = data.weight;
weightTarget = data.weight;
weightUnit = data.weightUnit;
if (data.weight != null) {
weight = data.weight;
weightTarget = data.weight;
}
if (data.weightUnit != null) {
weightUnit = data.weightUnit;
}
repetitions = data.repetitions;
repetitionsTarget = data.repetitions;
repetitionUnit = data.repetitionsUnit;
if (data.repetitions != null) {
repetitions = data.repetitions;
repetitionsTarget = data.repetitions;
}
if (data.repetitionsUnit != null) {
repetitionUnit = data.repetitionsUnit;
}
rir = data.rir;
rirTarget = data.rir;
@@ -140,15 +158,80 @@ class Log {
repetitionsUnitId = repetitionUnit?.id;
}
/// Returns the text representation for a single setting, used in the gym mode
String get singleLogRepTextNoNl {
return repText(
repetitions,
repetitionsUnitObj,
weight,
weightUnitObj,
rir,
).replaceAll('\n', '');
/// Returns the text representation for a single setting, removes new lines
String repTextNoNl(BuildContext context) {
return repText(context).replaceAll('\n', '');
}
/// Returns the text representation for a single setting
String repText(BuildContext context) {
final List<String> out = [];
if (repetitions != null) {
out.add(formatNum(repetitions!).toString());
// The default repetition unit is 'reps', which we don't show unless there
// is no weight defined so that we don't just output something like "8" but
// rather "8 repetitions". If there is weight we want to output "8 x 50kg",
// since the repetitions are implied. If other units are used, we always
// print them
if (repetitionsUnitObj != null && repetitionsUnitObj!.id != REP_UNIT_REPETITIONS_ID ||
weight == 0 ||
weight == null) {
out.add(getServerStringTranslation(repetitionsUnitObj!.name, context));
}
}
if (weight != null && weight != 0) {
out.add('×');
out.add(formatNum(weight!).toString());
out.add(weightUnitObj!.name);
}
if (rir != null) {
out.add('\n($rir RiR)');
}
return out.join(' ');
}
/// Calculates the volume for this log entry
num volume({bool metric = true}) {
final unitId = metric ? WEIGHT_UNIT_KG : WEIGHT_UNIT_LB;
if (weight != null &&
weightUnitId == unitId &&
repetitions != null &&
repetitionsUnitId == REP_UNIT_REPETITIONS_ID) {
return weight! * repetitions!;
}
return 0;
}
LogTargetStatus get targetStatus {
if (weightTarget == null && repetitionsTarget == null && rirTarget == null) {
return LogTargetStatus.notSet;
}
final weightOk = weightTarget == null || (weight != null && weight! >= weightTarget!);
final repsOk =
repetitionsTarget == null || (repetitions != null && repetitions! >= repetitionsTarget!);
final rirOk = rirTarget == null || (rir != null && rir! <= rirTarget!);
if (weightOk && repsOk && rirOk) {
return LogTargetStatus.atTarget;
}
final weightMore = weightTarget != null && weight != null && weight! > weightTarget!;
final repsMore =
repetitionsTarget != null && repetitions != null && repetitions! > repetitionsTarget!;
final rirLess = rirTarget != null && rir != null && rir! < rirTarget!;
if (weightMore || repsMore || rirLess) {
return LogTargetStatus.moreThanTarget;
}
return LogTargetStatus.lessThanTarget;
}
/// Override the equals operator

View File

@@ -31,15 +31,15 @@ Log _$LogFromJson(Map<String, dynamic> json) {
iteration: (json['iteration'] as num?)?.toInt(),
slotEntryId: (json['slot_entry'] as num?)?.toInt(),
routineId: (json['routine'] as num).toInt(),
repetitions: stringToNum(json['repetitions'] as String?),
repetitionsTarget: stringToNum(json['repetitions_target'] as String?),
repetitions: stringToNumNull(json['repetitions'] as String?),
repetitionsTarget: stringToNumNull(json['repetitions_target'] as String?),
repetitionsUnitId: (json['repetitions_unit'] as num?)?.toInt() ?? REP_UNIT_REPETITIONS_ID,
rir: stringToNum(json['rir'] as String?),
rirTarget: stringToNum(json['rir_target'] as String?),
weight: stringToNum(json['weight'] as String?),
weightTarget: stringToNum(json['weight_target'] as String?),
rir: stringToNumNull(json['rir'] as String?),
rirTarget: stringToNumNull(json['rir_target'] as String?),
weight: stringToNumNull(json['weight'] as String?),
weightTarget: stringToNumNull(json['weight_target'] as String?),
weightUnitId: (json['weight_unit'] as num?)?.toInt() ?? WEIGHT_UNIT_KG,
date: DateTime.parse(json['date'] as String),
date: utcIso8601ToLocalDate(json['date'] as String),
)..sessionId = (json['session'] as num?)?.toInt();
}

View File

@@ -19,6 +19,7 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:wger/helpers/date.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/day.dart';
import 'package:wger/models/workouts/day_data.dart';
import 'package:wger/models/workouts/log.dart';
@@ -42,7 +43,7 @@ class Routine {
@JsonKey(required: true, includeToJson: false)
int? id;
@JsonKey(required: true, toJson: dateToUtcIso8601)
@JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
late DateTime created;
@JsonKey(required: true, name: 'name')
@@ -176,4 +177,37 @@ class Routine {
return groupedLogs;
}
void replaceExercise(int oldExerciseId, Exercise newExercise) {
for (final session in sessions) {
for (final log in session.logs) {
if (log.exerciseId == oldExerciseId) {
log.exerciseId = newExercise.id!;
log.exercise = newExercise;
}
}
}
for (final day in dayData) {
for (final slot in day.slots) {
for (final config in slot.setConfigs) {
if (config.exerciseId == oldExerciseId) {
config.exerciseId = newExercise.id!;
config.exercise = newExercise;
}
}
}
}
for (final day in dayDataGym) {
for (final slot in day.slots) {
for (final config in slot.setConfigs) {
if (config.exerciseId == oldExerciseId) {
config.exerciseId = newExercise.id!;
config.exercise = newExercise;
}
}
}
}
}
}

View File

@@ -9,11 +9,19 @@ part of 'routine.dart';
Routine _$RoutineFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'created', 'name', 'description', 'fit_in_week', 'start', 'end'],
requiredKeys: const [
'id',
'created',
'name',
'description',
'fit_in_week',
'start',
'end',
],
);
return Routine(
id: (json['id'] as num?)?.toInt(),
created: json['created'] == null ? null : DateTime.parse(json['created'] as String),
created: utcIso8601ToLocalDate(json['created'] as String),
name: json['name'] as String,
start: json['start'] == null ? null : DateTime.parse(json['start'] as String),
end: json['end'] == null ? null : DateTime.parse(json['end'] as String),

View File

@@ -18,7 +18,9 @@
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:logging/logging.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/log.dart';
part 'session.g.dart';
@@ -27,6 +29,8 @@ const IMPRESSION_MAP = {1: 'bad', 2: 'neutral', 3: 'good'};
@JsonSerializable()
class WorkoutSession {
final _logger = Logger('WorkoutSession');
@JsonKey(required: true)
int? id;
@@ -68,12 +72,63 @@ class WorkoutSession {
this.date = date ?? DateTime.now();
}
Duration? get duration {
if (timeStart == null || timeEnd == null) {
return null;
}
final now = DateTime.now();
final startDate = DateTime(now.year, now.month, now.day, timeStart!.hour, timeStart!.minute);
final endDate = DateTime(now.year, now.month, now.day, timeEnd!.hour, timeEnd!.minute);
return endDate.difference(startDate);
}
String durationTxt(BuildContext context) {
final duration = this.duration;
if (duration == null) {
return '-/-';
}
return AppLocalizations.of(
context,
).durationHoursMinutes(duration.inHours, duration.inMinutes.remainder(60));
}
String durationTxtWithStartEnd(BuildContext context) {
final duration = this.duration;
if (duration == null) {
return '-/-';
}
final startTime = MaterialLocalizations.of(context).formatTimeOfDay(timeStart!);
final endTime = MaterialLocalizations.of(context).formatTimeOfDay(timeEnd!);
return '${durationTxt(context)} ($startTime - $endTime)';
}
/// Get total volume of the session for metric and imperial units
/// (i.e. sets that have "repetitions" as units and weight in kg or lbs).
/// Other combinations such as "seconds" are ignored.
Map<String, Object> get volume {
final volumeMetric = logs.fold<double>(0, (sum, log) => sum + log.volume(metric: true));
final volumeImperial = logs.fold<double>(0, (sum, log) => sum + log.volume(metric: false));
return {'metric': volumeMetric, 'imperial': volumeImperial};
}
// Boilerplate
factory WorkoutSession.fromJson(Map<String, dynamic> json) => _$WorkoutSessionFromJson(json);
Map<String, dynamic> toJson() => _$WorkoutSessionToJson(this);
String get impressionAsString {
return IMPRESSION_MAP[impression]!;
String impressionAsString(BuildContext context) {
if (impression == 1) {
return AppLocalizations.of(context).impressionBad;
} else if (impression == 2) {
return AppLocalizations.of(context).impressionNeutral;
} else if (impression == 3) {
return AppLocalizations.of(context).impressionGood;
}
_logger.warning('Unknown impression value: $impression');
return AppLocalizations.of(context).impressionGood;
}
}

View File

@@ -9,7 +9,15 @@ part of 'session.dart';
WorkoutSession _$WorkoutSessionFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'routine', 'day', 'date', 'impression', 'time_start', 'time_end'],
requiredKeys: const [
'id',
'routine',
'day',
'date',
'impression',
'time_start',
'time_end',
],
);
return WorkoutSession(
id: (json['id'] as num?)?.toInt(),

View File

@@ -44,6 +44,16 @@ class WorkoutSessionApi {
return exerciseSet.toList();
}
/// Get total volume of the session for metric and imperial units
/// (i.e. sets that have "repetitions" as units and weight in kg or lbs).
/// Other combinations such as "seconds" are ignored.
Map<String, num> get volume {
final volumeMetric = logs.fold<double>(0, (sum, log) => sum + log.volume(metric: true));
final volumeImperial = logs.fold<double>(0, (sum, log) => sum + log.volume(metric: false));
return {'metric': volumeMetric, 'imperial': volumeImperial};
}
// Boilerplate
factory WorkoutSessionApi.fromJson(Map<String, dynamic> json) =>
_$WorkoutSessionApiFromJson(json);

View File

@@ -46,55 +46,55 @@ class SetConfigData {
String get textReprWithType => '$textRepr${type.typeLabel}';
@JsonKey(required: true, name: 'sets')
late num? nrOfSets;
num? nrOfSets;
@JsonKey(required: true, name: 'max_sets')
late num? maxNrOfSets;
num? maxNrOfSets;
@JsonKey(required: true, fromJson: stringToNumNull)
late num? weight;
num? weight;
@JsonKey(required: true, name: 'max_weight', fromJson: stringToNumNull)
late num? maxWeight;
num? maxWeight;
@JsonKey(required: true, name: 'weight_unit')
late int? weightUnitId;
int? weightUnitId;
@JsonKey(includeToJson: false, includeFromJson: false)
late WeightUnit? weightUnit;
WeightUnit? weightUnit;
@JsonKey(required: true, name: 'weight_rounding', fromJson: stringToNumNull)
late num? weightRounding;
num? weightRounding;
@JsonKey(required: true, name: 'repetitions', fromJson: stringToNumNull)
late num? repetitions;
num? repetitions;
@JsonKey(required: true, name: 'max_repetitions', fromJson: stringToNumNull)
late num? maxRepetitions;
num? maxRepetitions;
@JsonKey(required: true, name: 'repetitions_unit')
late int? repetitionsUnitId;
int? repetitionsUnitId;
@JsonKey(includeToJson: false, includeFromJson: false)
late RepetitionUnit? repetitionsUnit;
RepetitionUnit? repetitionsUnit;
@JsonKey(required: true, name: 'repetitions_rounding', fromJson: stringToNumNull)
late num? repetitionsRounding;
num? repetitionsRounding;
@JsonKey(required: true, fromJson: stringToNumNull)
late num? rir;
num? rir;
@JsonKey(required: true, name: 'max_rir', fromJson: stringToNumNull)
late num? maxRir;
num? maxRir;
@JsonKey(required: true, fromJson: stringToNumNull)
late num? rpe;
num? rpe;
@JsonKey(required: true, name: 'rest', fromJson: stringToNumNull)
late num? restTime;
num? restTime;
@JsonKey(required: true, name: 'max_rest', fromJson: stringToNumNull)
late num? maxRestTime;
num? maxRestTime;
@JsonKey(required: true)
late String comment;
@@ -103,20 +103,20 @@ class SetConfigData {
required this.exerciseId,
required this.slotEntryId,
this.type = SlotEntryType.normal,
required this.nrOfSets,
this.nrOfSets,
this.maxNrOfSets,
required this.weight,
this.weight,
this.maxWeight,
this.weightUnitId = WEIGHT_UNIT_KG,
this.weightRounding,
required this.repetitions,
this.repetitions,
this.maxRepetitions,
this.repetitionsUnitId = REP_UNIT_REPETITIONS_ID,
this.repetitionsRounding,
required this.rir,
this.rir,
this.maxRir,
required this.rpe,
required this.restTime,
this.rpe,
this.restTime,
this.maxRestTime,
this.comment = '',
this.textRepr = '',
@@ -135,6 +135,58 @@ class SetConfigData {
}
}
SetConfigData copyWith({
int? exerciseId,
int? slotEntryId,
SlotEntryType? type,
String? textRepr,
num? nrOfSets,
num? maxNrOfSets,
num? weight,
num? maxWeight,
int? weightUnitId,
num? weightRounding,
num? repetitions,
num? maxRepetitions,
int? repetitionsUnitId,
num? repetitionsRounding,
num? rir,
num? maxRir,
num? rpe,
num? restTime,
num? maxRestTime,
String? comment,
Exercise? exercise,
WeightUnit? weightUnit,
RepetitionUnit? repetitionsUnit,
}) {
return SetConfigData(
exerciseId: exerciseId ?? this.exerciseId,
slotEntryId: slotEntryId ?? this.slotEntryId,
type: type ?? this.type,
textRepr: textRepr ?? this.textRepr,
nrOfSets: nrOfSets ?? this.nrOfSets,
maxNrOfSets: maxNrOfSets ?? this.maxNrOfSets,
weight: weight ?? this.weight,
maxWeight: maxWeight ?? this.maxWeight,
weightUnitId: weightUnitId ?? this.weightUnitId,
weightRounding: weightRounding ?? this.weightRounding,
repetitions: repetitions ?? this.repetitions,
maxRepetitions: maxRepetitions ?? this.maxRepetitions,
repetitionsUnitId: repetitionsUnitId ?? this.repetitionsUnitId,
repetitionsRounding: repetitionsRounding ?? this.repetitionsRounding,
rir: rir ?? this.rir,
maxRir: maxRir ?? this.maxRir,
rpe: rpe ?? this.rpe,
restTime: restTime ?? this.restTime,
maxRestTime: maxRestTime ?? this.maxRestTime,
comment: comment ?? this.comment,
exercise: exercise ?? this.exercise,
weightUnit: weightUnit ?? this.weightUnit,
repetitionsUnit: repetitionsUnit ?? this.repetitionsUnit,
);
}
// Boilerplate
factory SetConfigData.fromJson(Map<String, dynamic> json) => _$SetConfigDataFromJson(json);

View File

@@ -8,7 +8,10 @@ part of 'weight_unit.dart';
WeightUnit _$WeightUnitFromJson(Map<String, dynamic> json) {
$checkKeys(json, requiredKeys: const ['id', 'name']);
return WeightUnit(id: (json['id'] as num).toInt(), name: json['name'] as String);
return WeightUnit(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
);
}
Map<String, dynamic> _$WeightUnitToJson(WeightUnit instance) => <String, dynamic>{

View File

@@ -1,101 +1,704 @@
import 'package:clock/clock.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:wger/helpers/shared_preferences.dart';
import 'package:wger/helpers/uuid.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/day_data.dart';
import 'package:wger/models/workouts/routine.dart';
import 'package:wger/models/workouts/set_config_data.dart';
part 'gym_state.g.dart';
const DEFAULT_DURATION = Duration(hours: 5);
final StateNotifierProvider<GymStateNotifier, GymState> gymStateProvider =
StateNotifierProvider<GymStateNotifier, GymState>((ref) {
return GymStateNotifier();
});
const PREFS_SHOW_EXERCISES = 'showExercisePrefs';
const PREFS_SHOW_TIMER = 'showTimerPrefs';
const PREFS_ALERT_COUNTDOWN = 'alertCountdownPrefs';
const PREFS_USE_COUNTDOWN_BETWEEN_SETS = 'useCountdownBetweenSetsPrefs';
const PREFS_COUNTDOWN_DURATION = 'countdownDurationSecondsPrefs';
class GymState {
final Map<Exercise, int> exercisePages;
final bool showExercisePages;
final int currentPage;
final int? dayId;
late TimeOfDay startTime;
late DateTime validUntil;
/// In seconds
const DEFAULT_COUNTDOWN_DURATION = 180;
const MIN_COUNTDOWN_DURATION = 10;
const MAX_COUNTDOWN_DURATION = 1800;
GymState({
this.exercisePages = const {},
this.showExercisePages = true,
this.currentPage = 0,
this.dayId,
DateTime? validUntil,
TimeOfDay? startTime,
enum PageType {
start,
set,
session,
workoutSummary,
}
enum SlotPageType {
exerciseOverview,
log,
timer,
}
class PageEntry {
final String uuid;
final PageType type;
/// Absolute page index
final int pageIndex;
final List<SlotPageEntry> slotPages;
PageEntry({
required this.type,
required this.pageIndex,
this.slotPages = const [],
String? uuid,
}) : uuid = uuid ?? uuidV4(),
assert(
slotPages.isEmpty || type == PageType.set,
'SlotEntries can only be set for set pages',
);
PageEntry copyWith({
String? uuid,
PageType? type,
int? pageIndex,
List<SlotPageEntry>? slotPages,
}) {
this.validUntil = validUntil ?? DateTime.now().add(DEFAULT_DURATION);
this.startTime = startTime ?? TimeOfDay.fromDateTime(clock.now());
return PageEntry(
uuid: uuid ?? this.uuid,
type: type ?? this.type,
pageIndex: pageIndex ?? this.pageIndex,
slotPages: slotPages ?? this.slotPages,
);
}
GymState copyWith({
Map<Exercise, int>? exercisePages,
bool? showExercisePages,
int? currentPage,
List<Exercise> get exercises {
final exerciseSet = <Exercise>{};
for (final entry in slotPages) {
exerciseSet.add(entry.setConfigData!.exercise);
}
return exerciseSet.toList();
}
// Whether all sub-pages (e.g. log pages) are marked as done.
bool get allLogsDone =>
slotPages.where((entry) => entry.type == SlotPageType.log).every((entry) => entry.logDone);
@override
String toString() => 'PageEntry(type: $type, pageIndex: $pageIndex)';
}
class SlotPageEntry {
final String uuid;
final SlotPageType type;
/// index within a set for overview (e.g. "1 of 5 sets")
final int setIndex;
/// Absolute page index
final int pageIndex;
/// Whether the log page has been marked as done
final bool logDone;
/// The associated SetConfigData
final SetConfigData? setConfigData;
SlotPageEntry({
required this.type,
required this.pageIndex,
required this.setIndex,
this.setConfigData,
this.logDone = false,
String? uuid,
}) : uuid = uuid ?? uuidV4();
SlotPageEntry copyWith({
String? uuid,
SlotPageType? type,
int? exerciseId,
int? setIndex,
int? pageIndex,
SetConfigData? setConfigData,
bool? logDone,
}) {
return SlotPageEntry(
uuid: uuid ?? this.uuid,
type: type ?? this.type,
setIndex: setIndex ?? this.setIndex,
pageIndex: pageIndex ?? this.pageIndex,
setConfigData: setConfigData ?? this.setConfigData,
logDone: logDone ?? this.logDone,
);
}
@override
String toString() =>
'SlotPageEntry('
'uuid: $uuid, '
'type: $type, '
'setIndex: $setIndex, '
'pageIndex: $pageIndex, '
'logDone: $logDone'
')';
}
class GymModeState {
final _logger = Logger('GymModeState');
// Navigation data
final bool isInitialized;
final List<PageEntry> pages;
final int currentPage;
final TimeOfDay startTime;
final DateTime validUntil;
// User settings
final bool showExercisePages;
final bool showTimerPages;
final bool alertOnCountdownEnd;
final bool useCountdownBetweenSets;
final Duration countdownDuration;
// Routine data
late final int dayId;
late final int iteration;
late final Routine routine;
GymModeState({
this.isInitialized = false,
this.pages = const [],
this.currentPage = 0,
this.showExercisePages = true,
this.showTimerPages = true,
this.alertOnCountdownEnd = true,
this.useCountdownBetweenSets = false,
this.countdownDuration = const Duration(seconds: DEFAULT_COUNTDOWN_DURATION),
int? dayId,
int? iteration,
Routine? routine,
DateTime? validUntil,
TimeOfDay? startTime,
}) : validUntil = validUntil ?? clock.now().add(DEFAULT_DURATION),
startTime = startTime ?? TimeOfDay.fromDateTime(clock.now()) {
if (dayId != null) {
this.dayId = dayId;
}
if (iteration != null) {
this.iteration = iteration;
}
if (routine != null) {
this.routine = routine;
}
}
GymModeState copyWith({
// Navigation data
bool? isInitialized,
List<PageEntry>? pages,
int? currentPage,
// Routine data
int? dayId,
int? iteration,
DateTime? validUntil,
TimeOfDay? startTime,
Routine? routine,
// User settings
bool? showExercisePages,
bool? showTimerPages,
bool? alertOnCountdownEnd,
bool? useCountdownBetweenSets,
int? countdownDuration,
}) {
return GymState(
exercisePages: exercisePages ?? this.exercisePages,
showExercisePages: showExercisePages ?? this.showExercisePages,
return GymModeState(
isInitialized: isInitialized ?? this.isInitialized,
pages: pages ?? this.pages,
currentPage: currentPage ?? this.currentPage,
dayId: dayId ?? this.dayId,
validUntil: validUntil ?? this.validUntil.add(DEFAULT_DURATION),
iteration: iteration ?? this.iteration,
validUntil: validUntil ?? this.validUntil,
startTime: startTime ?? this.startTime,
routine: routine ?? this.routine,
showExercisePages: showExercisePages ?? this.showExercisePages,
showTimerPages: showTimerPages ?? this.showTimerPages,
alertOnCountdownEnd: alertOnCountdownEnd ?? this.alertOnCountdownEnd,
useCountdownBetweenSets: useCountdownBetweenSets ?? this.useCountdownBetweenSets,
countdownDuration: Duration(
seconds: countdownDuration ?? this.countdownDuration.inSeconds,
),
);
}
int get totalPages {
// Main pages (start, session, etc.)
var count = pages.where((p) => p.type != PageType.set).length;
// Add all other sub pages (sets, timer, etc.)
count += pages.fold(0, (prev, e) => prev + e.slotPages.length);
return count;
}
DayData get dayDataGym =>
routine.dayDataGym.where((e) => e.iteration == iteration && e.day?.id == dayId).first;
DayData get dayDataDisplay => routine.dayData.firstWhere(
(e) => e.iteration == iteration && e.day?.id == dayId,
);
PageEntry? getPageByIndex([int? pageIndex]) {
final index = pageIndex ?? currentPage;
for (final page in pages) {
for (final slotPage in page.slotPages) {
if (slotPage.pageIndex == index) {
return page;
}
}
}
return null;
}
SlotPageEntry? getSlotEntryPageByIndex([int? pageIndex]) {
final index = pageIndex ?? currentPage;
for (final slotPage in pages.expand((p) => p.slotPages)) {
if (slotPage.pageIndex == index) {
return slotPage;
}
}
return null;
}
SlotPageEntry? getSlotPageByUUID(String uuid) {
for (final slotPage in pages.expand((p) => p.slotPages)) {
if (slotPage.uuid == uuid) {
return slotPage;
}
}
return null;
}
double get ratioCompleted {
if (totalPages == 0) {
return 0.0;
}
// Note: add 1 to currentPage to make it 1-based
return (currentPage + 1) / totalPages;
}
@override
String toString() {
return 'GymState('
'currentPage: $currentPage, '
'showExercisePages: $showExercisePages, '
'exercisePages: ${exercisePages.length} exercises, '
'dayId: $dayId, '
'validUntil: $validUntil '
'startTime: $startTime, '
'showExercisePages: $showExercisePages, '
'showTimerPages: $showTimerPages, '
')';
}
}
class GymStateNotifier extends StateNotifier<GymState> {
@Riverpod(keepAlive: true)
class GymStateNotifier extends _$GymStateNotifier {
final _logger = Logger('GymStateNotifier');
GymStateNotifier() : super(GymState());
@override
GymModeState build() {
_logger.finer('Initializing GymStateNotifier');
return GymModeState();
}
Future<void> loadPrefs() async {
final prefs = PreferenceHelper.asyncPref;
final showExercise = await prefs.getBool(PREFS_SHOW_EXERCISES);
if (showExercise != null && showExercise != state.showExercisePages) {
state = state.copyWith(showExercisePages: showExercise);
}
final showTimer = await prefs.getBool(PREFS_SHOW_TIMER);
if (showTimer != null && showTimer != state.showTimerPages) {
state = state.copyWith(showTimerPages: showTimer);
}
final alertOnCountdownEnd = await prefs.getBool(PREFS_ALERT_COUNTDOWN);
if (alertOnCountdownEnd != null && alertOnCountdownEnd != state.alertOnCountdownEnd) {
state = state.copyWith(alertOnCountdownEnd: alertOnCountdownEnd);
}
final useCountdownBetweenSets = await prefs.getBool(PREFS_USE_COUNTDOWN_BETWEEN_SETS);
if (useCountdownBetweenSets != null &&
useCountdownBetweenSets != state.useCountdownBetweenSets) {
state = state.copyWith(useCountdownBetweenSets: useCountdownBetweenSets);
}
final defaultCountdownDurationSeconds = await prefs.getInt(PREFS_COUNTDOWN_DURATION);
if (defaultCountdownDurationSeconds != null &&
defaultCountdownDurationSeconds != state.countdownDuration.inSeconds) {
state = state.copyWith(
countdownDuration: defaultCountdownDurationSeconds,
);
}
_logger.finer(
'Loaded saved preferences: '
'showExercise=$showExercise '
'showTimer=$showTimer '
'alertOnCountdownEnd=$alertOnCountdownEnd '
'useCountdownBetweenSets=$useCountdownBetweenSets '
'defaultCountdownDurationSeconds=$defaultCountdownDurationSeconds',
);
}
Future<void> _savePrefs() async {
final prefs = PreferenceHelper.asyncPref;
await prefs.setBool(PREFS_SHOW_EXERCISES, state.showExercisePages);
await prefs.setBool(PREFS_SHOW_TIMER, state.showTimerPages);
await prefs.setBool(PREFS_ALERT_COUNTDOWN, state.alertOnCountdownEnd);
await prefs.setBool(PREFS_USE_COUNTDOWN_BETWEEN_SETS, state.useCountdownBetweenSets);
await prefs.setInt(
PREFS_COUNTDOWN_DURATION,
state.countdownDuration.inSeconds,
);
_logger.finer(
'Saved preferences: '
'showExercise=${state.showExercisePages} '
'showTimer=${state.showTimerPages} '
'alertOnCountdownEnd=${state.alertOnCountdownEnd} '
'useCountdownBetweenSets=${state.useCountdownBetweenSets} '
'defaultCountdownDuration=${state.countdownDuration.inSeconds}',
);
}
/// Calculates the page entries
void calculatePages() {
var pageIndex = 0;
final List<PageEntry> pages = [
// Start page
PageEntry(type: PageType.start, pageIndex: pageIndex),
];
pageIndex++;
for (final slotData in state.dayDataGym.slots) {
final slotPageIndex = pageIndex;
final slotEntries = <SlotPageEntry>[];
int setIndex = 0;
// exercise overview page
if (state.showExercisePages) {
// Add one overview page per exercise in the slot (e.g. for supersets)
for (final exerciseId in slotData.exerciseIds) {
final setConfig = slotData.setConfigs.firstWhereOrNull((c) => c.exerciseId == exerciseId);
if (setConfig == null) {
_logger.warning('Exercise with ID $exerciseId not found in slotData!!');
continue;
}
slotEntries.add(
SlotPageEntry(
type: SlotPageType.exerciseOverview,
setIndex: setIndex,
pageIndex: pageIndex,
setConfigData: setConfig,
),
);
pageIndex++;
}
}
for (final config in slotData.setConfigs) {
// Log page
slotEntries.add(
SlotPageEntry(
type: SlotPageType.log,
setIndex: setIndex,
pageIndex: pageIndex,
setConfigData: config,
),
);
pageIndex++;
setIndex++;
// Timer page
if (state.showTimerPages) {
slotEntries.add(
SlotPageEntry(
type: SlotPageType.timer,
setIndex: setIndex,
pageIndex: pageIndex,
setConfigData: config,
),
);
pageIndex++;
}
}
pages.add(
PageEntry(
type: PageType.set,
pageIndex: slotPageIndex,
slotPages: slotEntries,
),
);
}
// Session and summary page
pages.add(PageEntry(type: PageType.session, pageIndex: pageIndex));
pages.add(PageEntry(type: PageType.workoutSummary, pageIndex: pageIndex + 1));
state = state.copyWith(pages: pages);
// _logger.finer(readPageStructure());
_logger.finer('Initialized ${state.pages.length} pages');
}
// Recalculates the indices of all pages
void recalculateIndices() {
var pageIndex = 0;
final updatedPages = <PageEntry>[];
for (final page in state.pages) {
final slotPageIndex = pageIndex;
var setIndex = 0;
final updatedSlotPages = <SlotPageEntry>[];
for (final slotPage in page.slotPages) {
updatedSlotPages.add(
slotPage.copyWith(
pageIndex: pageIndex,
setIndex: setIndex,
),
);
setIndex++;
pageIndex++;
}
if (page.type != PageType.set) {
pageIndex++;
}
updatedPages.add(
page.copyWith(
pageIndex: slotPageIndex,
slotPages: updatedSlotPages,
),
);
}
state = state.copyWith(pages: updatedPages);
// _logger.fine(readPageStructure());
_logger.fine('Recalculated page indices');
}
/// Reads the current page structure for debugging purposes
String readPageStructure() {
final List<String> out = [];
out.add('GymModeState structure:');
for (final page in state.pages) {
out.add('Page ${page.pageIndex}: ${page.type}');
for (final slotPage in page.slotPages) {
out.add(
' SlotPage ${slotPage.pageIndex.toString().padLeft(2, ' ')} (set index ${slotPage.setIndex}): ${slotPage.type}',
);
}
}
return out.join('\n');
}
int initData(Routine routine, int dayId, int iteration) {
final validUntil = state.validUntil;
final currentPage = state.currentPage;
final shouldReset =
(!state.isInitialized || state.isInitialized && dayId != state.dayId) ||
validUntil.isBefore(DateTime.now());
if (shouldReset) {
_logger.fine('Day ID mismatch or expired validUntil date. Resetting to page 0.');
}
final initialPage = shouldReset ? 0 : currentPage;
// set dayId and initial page
state = state.copyWith(
isInitialized: true,
dayId: dayId,
routine: routine,
iteration: iteration,
currentPage: initialPage,
);
// Calculate the pages.
// Note that this is only done if we need to reset, otherwise we keep the
// existing state like the exercises that have already been done
if (shouldReset) {
calculatePages();
}
_logger.fine('Initialized GymModeState, initialPage=$initialPage');
return initialPage;
}
void setCurrentPage(int page) {
// _logger.fine('Setting page from ${state.currentPage} to $page');
state = state.copyWith(currentPage: page);
}
void toggleExercisePages() {
state = state.copyWith(showExercisePages: !state.showExercisePages);
void setShowExercisePages(bool value) {
state = state.copyWith(showExercisePages: value);
calculatePages();
_savePrefs();
}
void setDayId(int dayId) {
// _logger.fine('Setting day id from ${state.dayId} to $dayId');
state = state.copyWith(dayId: dayId);
void setShowTimerPages(bool value) {
state = state.copyWith(showTimerPages: value);
calculatePages();
_savePrefs();
}
void setExercisePages(Map<Exercise, int> exercisePages) {
// _logger.fine('Setting exercise pages - ${exercisePages.length} exercises');
state = state.copyWith(exercisePages: exercisePages);
// _logger.fine(
// 'Exercise pages set - ${exercisePages.entries.map((e) => '${e.key.id}: ${e.value}').join(', ')}');
void setAlertOnCountdownEnd(bool value) {
state = state.copyWith(alertOnCountdownEnd: value);
_savePrefs();
}
void setUseCountdownBetweenSets(bool value) {
state = state.copyWith(useCountdownBetweenSets: value);
_savePrefs();
}
void setCountdownDuration(int duration) {
state = state.copyWith(countdownDuration: duration);
_savePrefs();
}
void markSlotPageAsDone(String uuid, {required bool isDone}) {
final slotPage = state.getSlotPageByUUID(uuid);
if (slotPage == null) {
_logger.warning('No slot page found for UUID $uuid');
return;
}
final updatedSlotPage = slotPage.copyWith(logDone: isDone);
final updatedPages = state.pages.map((page) {
if (page.type != PageType.set) {
return page;
}
final updatedSlotPages = page.slotPages.map((sp) {
if (sp.uuid == uuid) {
return updatedSlotPage;
}
return sp;
}).toList();
return page.copyWith(slotPages: updatedSlotPages);
}).toList();
state = state.copyWith(pages: updatedPages);
_logger.fine('Set logDone=$isDone for slot page UUID $uuid');
}
void replaceExercises(
String pageEntryUUID, {
required int originalExerciseId,
required Exercise newExercise,
}) {
final updatedPages = state.pages.map((page) {
if (page.type != PageType.set) {
return page;
}
if (page.uuid != pageEntryUUID) {
return page;
}
final updatedSlotPages = page.slotPages.map((slotPage) {
if (slotPage.setConfigData != null &&
slotPage.setConfigData!.exercise.id == originalExerciseId) {
final updatedSetConfigData = slotPage.setConfigData!.copyWith(
exerciseId: newExercise.id,
exercise: newExercise,
);
return slotPage.copyWith(setConfigData: updatedSetConfigData);
}
return slotPage;
}).toList();
return page.copyWith(slotPages: updatedSlotPages);
}).toList();
// TODO: this should not be done in-place!
state.routine.replaceExercise(originalExerciseId, newExercise);
state = state.copyWith(
pages: updatedPages,
);
_logger.fine('Replaced exercise $originalExerciseId with ${newExercise.id}');
}
void addExerciseAfterPage(
String pageEntryUUID, {
required Exercise newExercise,
}) {
final List<PageEntry> pages = [];
for (final page in state.pages) {
pages.add(page);
if (page.uuid == pageEntryUUID) {
final setConfigData = page.slotPages.first.setConfigData!;
final List<SlotPageEntry> newSlotPages = [];
for (var i = 1; i <= 4; i++) {
newSlotPages.add(
SlotPageEntry(
type: SlotPageType.log,
pageIndex: 1,
setIndex: 0,
setConfigData: SetConfigData(
textRepr: '-/-',
exerciseId: newExercise.id!,
exercise: newExercise,
slotEntryId: setConfigData.slotEntryId,
),
),
);
}
final newPage = PageEntry(type: PageType.set, pageIndex: 1, slotPages: newSlotPages);
pages.add(newPage);
}
}
state = state.copyWith(
pages: pages,
);
recalculateIndices();
}
void clear() {
_logger.fine('Clearing state');
state = state.copyWith(
exercisePages: {},
isInitialized: false,
pages: [],
currentPage: 0,
dayId: null,
validUntil: DateTime.now().add(DEFAULT_DURATION),
startTime: TimeOfDay.now(),
validUntil: clock.now().add(DEFAULT_DURATION),
startTime: null,
);
}
}

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'gym_state.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(GymStateNotifier)
const gymStateProvider = GymStateNotifierProvider._();
final class GymStateNotifierProvider extends $NotifierProvider<GymStateNotifier, GymModeState> {
const GymStateNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'gymStateProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$gymStateNotifierHash();
@$internal
@override
GymStateNotifier create() => GymStateNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GymModeState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GymModeState>(value),
);
}
}
String _$gymStateNotifierHash() => r'449bd80d3b534f68af4f0dbb8556c7f093f3b918';
abstract class _$GymStateNotifier extends $Notifier<GymModeState> {
GymModeState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<GymModeState, GymModeState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<GymModeState, GymModeState>,
GymModeState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -302,6 +302,37 @@ class NutritionPlansProvider with ChangeNotifier {
await database.deleteEverything();
}
/// Saves an ingredient to the cache
Future<void> cacheIngredient(Ingredient ingredient, {IngredientDatabase? database}) async {
database ??= this.database;
if (!ingredients.any((e) => e.id == ingredient.id)) {
ingredients.add(ingredient);
}
final ingredientDb = await (database.select(
database.ingredients,
)..where((e) => e.id.equals(ingredient.id))).getSingleOrNull();
if (ingredientDb == null) {
final data = ingredient.toJson();
try {
await database
.into(database.ingredients)
.insert(
IngredientsCompanion.insert(
id: ingredient.id,
data: jsonEncode(data),
lastFetched: DateTime.now(),
),
);
_logger.finer("Saved ingredient '${ingredient.name}' to db cache");
} catch (e) {
_logger.finer("Error caching ingredient '${ingredient.name}': $e");
}
}
}
/// Fetch and return an ingredient
///
/// If the ingredient is not known locally, it is fetched from the server
@@ -329,22 +360,14 @@ class NutritionPlansProvider with ChangeNotifier {
(database.delete(database.ingredients)..where((i) => i.id.equals(ingredientId))).go();
}
} else {
_logger.info("Fetching ingredient ID $ingredientId from server");
final data = await baseProvider.fetch(
baseProvider.makeUrl(_ingredientInfoPath, id: ingredientId),
);
ingredient = Ingredient.fromJson(data);
ingredients.add(ingredient);
database
.into(database.ingredients)
.insert(
IngredientsCompanion.insert(
id: ingredientId,
data: jsonEncode(data),
lastFetched: DateTime.now(),
),
);
_logger.finer("Saved ingredient '${ingredient.name}' to db cache");
// Cache the ingredient
await cacheIngredient(ingredient, database: database);
}
}
@@ -376,6 +399,7 @@ class NutritionPlansProvider with ChangeNotifier {
}
// Send the request
_logger.info("Fetching ingredients from server");
final response = await baseProvider.fetch(
baseProvider.makeUrl(
_ingredientInfoPath,
@@ -406,6 +430,7 @@ class NutritionPlansProvider with ChangeNotifier {
if (data['count'] == 0) {
return null;
}
// TODO we should probably add it to ingredient cache.
return Ingredient.fromJson(data['results'][0]);
}

View File

@@ -16,10 +16,9 @@ const DEFAULT_BAR_WEIGHT_LB = 45;
const PREFS_KEY_PLATES = 'selectedPlates';
final plateCalculatorProvider =
StateNotifierProvider<PlateCalculatorNotifier, PlateCalculatorState>((ref) {
return PlateCalculatorNotifier();
});
final plateCalculatorProvider = NotifierProvider<PlateCalculatorNotifier, PlateCalculatorState>(
PlateCalculatorNotifier.new,
);
class PlateCalculatorState {
final _logger = Logger('PlateWeightsState');
@@ -135,14 +134,19 @@ class PlateCalculatorState {
}
}
class PlateCalculatorNotifier extends StateNotifier<PlateCalculatorState> {
class PlateCalculatorNotifier extends Notifier<PlateCalculatorState> {
final _logger = Logger('PlateCalculatorNotifier');
late SharedPreferencesAsync prefs;
PlateCalculatorNotifier({SharedPreferencesAsync? prefs}) : super(PlateCalculatorState()) {
PlateCalculatorNotifier({SharedPreferencesAsync? prefs}) : super() {
this.prefs = prefs ?? PreferenceHelper.asyncPref;
}
@override
PlateCalculatorState build() {
_readDataFromSharedPrefs();
return PlateCalculatorState();
}
Future<void> saveToSharedPrefs() async {

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/errors.dart';
@@ -151,55 +152,57 @@ class _AddExerciseStepperState extends State<AddExerciseStepper> {
Widget build(BuildContext context) {
return Scaffold(
appBar: EmptyAppBar(AppLocalizations.of(context).contributeExercise),
body: Stepper(
controlsBuilder: _controlsBuilder,
steps: [
Step(
title: Text(AppLocalizations.of(context).baseData),
content: Step1Basics(formkey: _keys[0]),
),
Step(
title: Text(AppLocalizations.of(context).variations),
content: Step2Variations(formkey: _keys[1]),
),
Step(
title: Text(AppLocalizations.of(context).description),
content: Step3Description(formkey: _keys[2]),
),
Step(
title: Text(AppLocalizations.of(context).translation),
content: Step4Translation(formkey: _keys[3]),
),
Step(
title: Text(AppLocalizations.of(context).images),
content: Step5Images(formkey: _keys[4]),
),
Step(title: Text(AppLocalizations.of(context).overview), content: Step6Overview()),
],
currentStep: _currentStep,
onStepContinue: () {
if (_keys[_currentStep].currentState?.validate() ?? false) {
_keys[_currentStep].currentState?.save();
body: WidescreenWrapper(
child: Stepper(
controlsBuilder: _controlsBuilder,
steps: [
Step(
title: Text(AppLocalizations.of(context).baseData),
content: Step1Basics(formkey: _keys[0]),
),
Step(
title: Text(AppLocalizations.of(context).variations),
content: Step2Variations(formkey: _keys[1]),
),
Step(
title: Text(AppLocalizations.of(context).description),
content: Step3Description(formkey: _keys[2]),
),
Step(
title: Text(AppLocalizations.of(context).translation),
content: Step4Translation(formkey: _keys[3]),
),
Step(
title: Text(AppLocalizations.of(context).images),
content: Step5Images(formkey: _keys[4]),
),
Step(title: Text(AppLocalizations.of(context).overview), content: Step6Overview()),
],
currentStep: _currentStep,
onStepContinue: () {
if (_keys[_currentStep].currentState?.validate() ?? false) {
_keys[_currentStep].currentState?.save();
if (_currentStep != lastStepIndex) {
setState(() {
_currentStep += 1;
});
if (_currentStep != lastStepIndex) {
setState(() {
_currentStep += 1;
});
}
}
}
},
onStepCancel: () => setState(() {
if (_currentStep != 0) {
_currentStep -= 1;
}
}),
/*
onStepTapped: (int index) {
setState(() {
_currentStep = index;
});
},
*/
},
onStepCancel: () => setState(() {
if (_currentStep != 0) {
_currentStep -= 1;
}
}),
/*
onStepTapped: (int index) {
setState(() {
_currentStep = index;
});
},
*/
),
),
);
}

View File

@@ -242,7 +242,7 @@ class _AuthCardState extends State<AuthCard> {
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
final deviceSize = MediaQuery.of(context).size;
final deviceSize = MediaQuery.sizeOf(context);
return Card(
shape: RoundedRectangleBorder(

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/widgets/routines/plate_calculator.dart';
@@ -13,7 +14,7 @@ class ConfigurePlatesScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: Text(i18n.selectAvailablePlates)),
body: const ConfigureAvailablePlates(),
body: const WidescreenWrapper(child: ConfigureAvailablePlates()),
);
}
}

View File

@@ -28,7 +28,7 @@ import 'package:wger/widgets/dashboard/widgets/weight.dart';
import 'package:wger/providers/user.dart';
class DashboardScreen extends StatelessWidget {
const DashboardScreen();
const DashboardScreen({super.key});
static const routeName = '/dashboard';

View File

@@ -17,6 +17,7 @@
*/
import 'package:flutter/material.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/widgets/exercises/exercises.dart';
@@ -33,9 +34,11 @@ class ExerciseDetailScreen extends StatelessWidget {
appBar: AppBar(
title: Text(exercise.getTranslation(Localizations.localeOf(context).languageCode).name),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: ExerciseDetail(exercise),
body: WidescreenWrapper(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: ExerciseDetail(exercise),
),
),
);
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/providers/exercises.dart';
@@ -23,21 +24,23 @@ class _ExercisesScreenState extends State<ExercisesScreen> {
return Scaffold(
appBar: EmptyAppBar(AppLocalizations.of(context).exercises),
body: Column(
children: [
const FilterRow(),
Expanded(
child: exercisesList.isEmpty
? const Center(
child: SizedBox(
height: 30,
width: 30,
child: CircularProgressIndicator(),
),
)
: _ExercisesList(exerciseList: exercisesList),
),
],
body: WidescreenWrapper(
child: Column(
children: [
const FilterRow(),
Expanded(
child: exercisesList.isEmpty
? const Center(
child: SizedBox(
height: 30,
width: 30,
child: CircularProgressIndicator(),
),
)
: _ExercisesList(exerciseList: exercisesList),
),
],
),
),
);
}

View File

@@ -18,6 +18,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
/// Arguments passed to the form screen
class FormScreenArguments {
@@ -54,18 +55,20 @@ class FormScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: Text(args.title)),
body: args.hasListView
? Scrollable(
viewportBuilder: (BuildContext context, ViewportOffset position) => Padding(
padding: args.padding,
child: args.widget,
body: WidescreenWrapper(
child: args.hasListView
? Scrollable(
viewportBuilder: (BuildContext context, ViewportOffset position) => Padding(
padding: args.padding,
child: args.widget,
),
)
: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.end,
children: [Padding(padding: args.padding, child: args.widget)],
),
)
: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.end,
children: [Padding(padding: args.padding, child: args.widget)],
),
),
);
}
}

View File

@@ -18,6 +18,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/helpers/platform.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/gallery.dart';
@@ -52,8 +53,10 @@ class GalleryScreen extends StatelessWidget {
);
},
),
body: Consumer<GalleryProvider>(
builder: (context, workoutProvider, child) => const Gallery(),
body: WidescreenWrapper(
child: Consumer<GalleryProvider>(
builder: (context, workoutProvider, child) => const Gallery(),
),
),
);
}

View File

@@ -17,7 +17,9 @@
*/
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart' hide Consumer;
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/providers/routines.dart';
import 'package:wger/widgets/routines/gym_mode/gym_mode.dart';
@@ -29,30 +31,23 @@ class GymModeArguments {
const GymModeArguments(this.routineId, this.dayId, this.iteration);
}
class GymModeScreen extends StatelessWidget {
class GymModeScreen extends ConsumerWidget {
const GymModeScreen();
static const routeName = '/gym-mode';
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final args = ModalRoute.of(context)!.settings.arguments as GymModeArguments;
final routinesProvider = context.read<RoutinesProvider>();
final routine = routinesProvider.findById(args.routineId);
final dayDataDisplay = routine.dayData.firstWhere(
(e) => e.iteration == args.iteration && e.day?.id == args.dayId,
);
final dayDataGym = routine.dayDataGym
.where((e) => e.iteration == args.iteration && e.day?.id == args.dayId)
.first;
return Scaffold(
// backgroundColor: Theme.of(context).cardColor,
// primary: false,
//primary: false,
body: SafeArea(
child: Consumer<RoutinesProvider>(
builder: (context, value, child) => GymMode(dayDataGym, dayDataDisplay, args.iteration),
child: WidescreenWrapper(
child: Consumer<RoutinesProvider>(
builder: (context, value, child) => GymMode(args),
),
),
),
);

View File

@@ -21,6 +21,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:rive/rive.dart';
import 'package:wger/helpers/material.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/auth.dart';
import 'package:wger/providers/body_weight.dart';
@@ -51,6 +52,7 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProvid
late Future<void> _initialData;
bool _errorHandled = false;
int _selectedIndex = 0;
bool _isWideScreen = false;
@override
void initState() {
@@ -59,6 +61,14 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProvid
_initialData = _loadEntries();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final size = MediaQuery.sizeOf(context);
_isWideScreen = size.width > MATERIAL_XS_BREAKPOINT;
}
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
@@ -141,6 +151,57 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProvid
@override
Widget build(BuildContext context) {
final destinations = [
NavigationDestination(
icon: const Icon(Icons.home),
label: AppLocalizations.of(context).labelDashboard,
),
NavigationDestination(
icon: const Icon(Icons.fitness_center),
label: AppLocalizations.of(context).labelBottomNavWorkout,
),
NavigationDestination(
icon: const Icon(Icons.restaurant),
label: AppLocalizations.of(context).labelBottomNavNutrition,
),
NavigationDestination(
icon: const FaIcon(FontAwesomeIcons.weightScale, size: 20),
label: AppLocalizations.of(context).weight,
),
NavigationDestination(
icon: const Icon(Icons.photo_library),
label: AppLocalizations.of(context).gallery,
),
];
/// Navigation bar for narrow screens
Widget getNavigationBar() {
return NavigationBar(
destinations: destinations,
onDestinationSelected: _onItemTapped,
selectedIndex: _selectedIndex,
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
);
}
/// Navigation rail for wide screens
Widget getNavigationRail() {
return NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: _onItemTapped,
labelType: NavigationRailLabelType.all,
scrollable: true,
destinations: destinations
.map(
(d) => NavigationRailDestination(
icon: d.icon,
label: Text(d.label),
),
)
.toList(),
);
}
return FutureBuilder<void>(
future: _initialData,
builder: (context, snapshot) {
@@ -173,34 +234,13 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProvid
}
return Scaffold(
body: _screenList.elementAt(_selectedIndex),
bottomNavigationBar: NavigationBar(
destinations: [
NavigationDestination(
icon: const Icon(Icons.home),
label: AppLocalizations.of(context).labelDashboard,
),
NavigationDestination(
icon: const Icon(Icons.fitness_center),
label: AppLocalizations.of(context).labelBottomNavWorkout,
),
NavigationDestination(
icon: const Icon(Icons.restaurant),
label: AppLocalizations.of(context).labelBottomNavNutrition,
),
NavigationDestination(
icon: const FaIcon(FontAwesomeIcons.weightScale, size: 20),
label: AppLocalizations.of(context).weight,
),
NavigationDestination(
icon: const Icon(Icons.photo_library),
label: AppLocalizations.of(context).gallery,
),
body: Row(
children: [
if (_isWideScreen) getNavigationRail(),
Expanded(child: _screenList.elementAt(_selectedIndex)),
],
onDestinationSelected: _onItemTapped,
selectedIndex: _selectedIndex,
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
),
bottomNavigationBar: _isWideScreen ? null : getNavigationBar(),
);
},
);

View File

@@ -17,8 +17,8 @@
*/
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/date.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/nutrition/meal.dart';
@@ -44,14 +44,13 @@ class LogMealScreen extends StatefulWidget {
class _LogMealScreenState extends State<LogMealScreen> {
double portionPct = 100;
final _dateController = TextEditingController();
final _dateController = TextEditingController(text: '');
final _timeController = TextEditingController();
@override
void initState() {
super.initState();
_dateController.text = dateToYYYYMMDD(DateTime.now())!;
_timeController.text = timeToString(TimeOfDay.now())!;
}
@@ -64,6 +63,9 @@ class _LogMealScreenState extends State<LogMealScreen> {
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode);
final i18n = AppLocalizations.of(context);
final args = ModalRoute.of(context)!.settings.arguments as LogMealArguments;
final meal = args.meal.copyWith(
mealItems: args.meal.mealItems
@@ -71,7 +73,9 @@ class _LogMealScreenState extends State<LogMealScreen> {
.toList(),
);
final i18n = AppLocalizations.of(context);
if (_dateController.text.isEmpty) {
_dateController.text = dateFormat.format(DateTime.now());
}
return Scaffold(
appBar: AppBar(title: Text(i18n.logMeal)),
@@ -123,12 +127,12 @@ class _LogMealScreenState extends State<LogMealScreen> {
final pickedDate = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(DateTime.now().year - 10),
firstDate: DateTime.now().subtract(const Duration(days: 3000)),
lastDate: DateTime.now(),
);
if (pickedDate != null) {
_dateController.text = dateToYYYYMMDD(pickedDate)!;
_dateController.text = dateFormat.format(pickedDate);
}
},
onSaved: (newValue) {
@@ -170,15 +174,13 @@ class _LogMealScreenState extends State<LogMealScreen> {
TextButton(
child: Text(i18n.save),
onPressed: () async {
final loggedTime = getDateTimeFromDateAndTime(
_dateController.text,
_timeController.text,
final loggedDate = dateFormat.parse(
'${_dateController.text} ${_timeController.text}',
);
await Provider.of<NutritionPlansProvider>(
context,
listen: false,
).logMealToDiary(meal, loggedTime);
).logMealToDiary(meal, loggedDate);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -18,6 +18,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/measurement.dart';
import 'package:wger/screens/form_screen.dart';
@@ -46,8 +47,10 @@ class MeasurementCategoriesScreen extends StatelessWidget {
);
},
),
body: Consumer<MeasurementProvider>(
builder: (context, provider, child) => const CategoriesList(),
body: WidescreenWrapper(
child: Consumer<MeasurementProvider>(
builder: (context, provider, child) => const CategoriesList(),
),
),
);
}

View File

@@ -18,6 +18,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/measurement.dart';
import 'package:wger/screens/form_screen.dart';
@@ -134,9 +135,11 @@ class MeasurementEntriesScreen extends StatelessWidget {
);
},
),
body: SingleChildScrollView(
child: Consumer<MeasurementProvider>(
builder: (context, provider, child) => EntriesList(category),
body: WidescreenWrapper(
child: SingleChildScrollView(
child: Consumer<MeasurementProvider>(
builder: (context, provider, child) => EntriesList(category),
),
),
),
);

View File

@@ -19,6 +19,7 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/models/nutrition/nutritional_plan.dart';
import 'package:wger/providers/nutrition.dart';
import 'package:wger/widgets/nutrition/nutritional_diary_detail.dart';
@@ -46,11 +47,13 @@ class NutritionalDiaryScreen extends StatelessWidget {
appBar: AppBar(
title: Text(DateFormat.yMd(Localizations.localeOf(context).languageCode).format(args.date)),
),
body: Consumer<NutritionPlansProvider>(
builder: (context, nutritionProvider, child) => SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: NutritionalDiaryDetailWidget(args.plan, args.date),
body: WidescreenWrapper(
child: Consumer<NutritionPlansProvider>(
builder: (context, nutritionProvider, child) => SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: NutritionalDiaryDetailWidget(args.plan, args.date),
),
),
),
),

View File

@@ -18,6 +18,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/nutrition.dart';
import 'package:wger/screens/form_screen.dart';
@@ -48,8 +49,10 @@ class NutritionalPlansScreen extends StatelessWidget {
);
},
),
body: Consumer<NutritionPlansProvider>(
builder: (context, nutritionProvider, child) => NutritionalPlansList(nutritionProvider),
body: WidescreenWrapper(
child: Consumer<NutritionPlansProvider>(
builder: (context, nutritionProvider, child) => NutritionalPlansList(nutritionProvider),
),
),
);
}

View File

@@ -18,6 +18,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/providers/routines.dart';
import 'package:wger/widgets/core/app_bar.dart';
import 'package:wger/widgets/routines/routine_edit.dart';
@@ -34,7 +35,7 @@ class RoutineEditScreen extends StatelessWidget {
return Scaffold(
appBar: EmptyAppBar(routine.name),
body: RoutineEdit(routine),
body: WidescreenWrapper(child: RoutineEdit(routine)),
);
}
}

View File

@@ -18,6 +18,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/routine.dart';
import 'package:wger/providers/routines.dart';
@@ -49,8 +50,10 @@ class RoutineListScreen extends StatelessWidget {
},
child: const Icon(Icons.add, color: Colors.white),
),
body: Consumer<RoutinesProvider>(
builder: (context, workoutProvider, child) => RoutinesList(workoutProvider),
body: WidescreenWrapper(
child: Consumer<RoutinesProvider>(
builder: (context, workoutProvider, child) => RoutinesList(workoutProvider),
),
),
);
}

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
* Copyright (C) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@@ -18,9 +18,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/providers/routines.dart';
import 'package:wger/widgets/core/app_bar.dart';
import 'package:wger/widgets/routines/workout_logs.dart';
import 'package:wger/widgets/routines/logs/log_overview_routine.dart';
class WorkoutLogsScreen extends StatelessWidget {
const WorkoutLogsScreen();
@@ -34,7 +35,7 @@ class WorkoutLogsScreen extends StatelessWidget {
return Scaffold(
appBar: EmptyAppBar(routine.name),
body: WorkoutLogs(routine),
body: WidescreenWrapper(child: WorkoutLogs(routine)),
);
}
}

View File

@@ -18,6 +18,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/providers/routines.dart';
import 'package:wger/widgets/routines/app_bar.dart';
import 'package:wger/widgets/routines/routine_detail.dart';
@@ -36,9 +37,11 @@ class RoutineScreen extends StatelessWidget {
return Scaffold(
appBar: RoutineDetailAppBar(routine),
body: SingleChildScrollView(
child: Consumer<RoutinesProvider>(
builder: (context, value, child) => RoutineDetail(routine),
body: WidescreenWrapper(
child: SingleChildScrollView(
child: Consumer<RoutinesProvider>(
builder: (context, value, child) => RoutineDetail(routine),
),
),
),
);

View File

@@ -18,6 +18,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/body_weight.dart';
import 'package:wger/screens/form_screen.dart';
@@ -47,9 +48,11 @@ class WeightScreen extends StatelessWidget {
);
},
),
body: SingleChildScrollView(
child: Consumer<BodyWeightProvider>(
builder: (context, provider, child) => WeightOverview(provider),
body: WidescreenWrapper(
child: SingleChildScrollView(
child: Consumer<BodyWeightProvider>(
builder: (context, provider, child) => WeightOverview(provider),
),
),
),
);

View File

@@ -71,7 +71,7 @@ class Step1Basics extends StatelessWidget {
return AppLocalizations.of(context).selectEntry;
}
},
displayName: (ExerciseCategory c) => getTranslation(c.name, context),
displayName: (ExerciseCategory c) => getServerStringTranslation(c.name, context),
),
AddExerciseMultiselectButton<Equipment>(
key: const Key('equipment-multiselect'),
@@ -84,7 +84,7 @@ class Step1Basics extends StatelessWidget {
onSaved: (dynamic entries) {
addExerciseProvider.equipment = entries.cast<Equipment>();
},
displayName: (Equipment e) => getTranslation(e.name, context),
displayName: (Equipment e) => getServerStringTranslation(e.name, context),
),
AddExerciseMultiselectButton<Muscle>(
key: const Key('primary-muscles-multiselect'),
@@ -98,7 +98,10 @@ class Step1Basics extends StatelessWidget {
addExerciseProvider.primaryMuscles = muscles.cast<Muscle>();
},
displayName: (Muscle e) =>
e.name + (e.nameEn.isNotEmpty ? '\n(${getTranslation(e.nameEn, context)})' : ''),
e.name +
(e.nameEn.isNotEmpty
? '\n(${getServerStringTranslation(e.nameEn, context)})'
: ''),
),
AddExerciseMultiselectButton<Muscle>(
key: const Key('secondary-muscles-multiselect'),
@@ -112,7 +115,10 @@ class Step1Basics extends StatelessWidget {
addExerciseProvider.secondaryMuscles = muscles.cast<Muscle>();
},
displayName: (Muscle e) =>
e.name + (e.nameEn.isNotEmpty ? '\n(${getTranslation(e.nameEn, context)})' : ''),
e.name +
(e.nameEn.isNotEmpty
? '\n(${getServerStringTranslation(e.nameEn, context)})'
: ''),
),
MuscleRowWidget(
muscles: provider.primaryMuscles,

View File

@@ -19,6 +19,7 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/misc.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
@@ -53,164 +54,166 @@ class AboutPage extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: Text(i18n.aboutPageTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo.png',
width: 60,
semanticLabel: 'wger logo',
),
const SizedBox(width: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'wger',
style: TextStyle(
fontSize: 23,
fontWeight: FontWeight.w600,
body: WidescreenWrapper(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo.png',
width: 60,
semanticLabel: 'wger logo',
),
const SizedBox(width: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'wger',
style: TextStyle(
fontSize: 23,
fontWeight: FontWeight.w600,
),
),
),
Text(
'App: ${authProvider.applicationVersion?.version ?? 'N/A'}\n'
'Server: ${authProvider.serverVersion ?? 'N/A'}',
style: Theme.of(context).textTheme.bodySmall,
),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'\u{a9} ${today.year} wger contributors',
Text(
'App: ${authProvider.applicationVersion?.version ?? 'N/A'}\n'
'Server: ${authProvider.serverVersion ?? 'N/A'}',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
),
_buildSectionSpacer(),
_buildSectionHeader(context, i18n.aboutWhySupportTitle),
Text(i18n.aboutDescription),
_buildSectionSpacer(),
_buildSectionHeader(context, i18n.aboutContributeTitle),
Text(i18n.aboutContributeText),
ListTile(
leading: const Icon(Icons.bug_report),
title: Text(i18n.aboutBugsListTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(GITHUB_ISSUES_URL, context),
),
ListTile(
leading: const Icon(Icons.translate),
title: Text(i18n.aboutTranslationListTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(WEBLATE_URL, context),
),
ListTile(
leading: const Icon(Icons.code),
title: Text(i18n.aboutSourceListTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(GITHUB_PROJECT_URL, context),
),
ListTile(
leading: const FaIcon(FontAwesomeIcons.dumbbell, size: 18),
title: Text(i18n.contributeExercise),
contentPadding: EdgeInsets.zero,
onTap: () => Navigator.of(context).pushNamed(AddExerciseScreen.routeName),
),
_buildSectionSpacer(),
_buildSectionHeader(context, i18n.aboutDonateTitle),
Text(i18n.aboutDonateText),
const SizedBox(height: 15),
// Using Wrap for buttons to handle different screen sizes potentially
Center(
child: Wrap(
spacing: 10.0,
runSpacing: 10.0,
alignment: WrapAlignment.center,
children: [
ElevatedButton.icon(
icon: const FaIcon(FontAwesomeIcons.mugHot, size: 18),
label: const Text('Buy me a coffee'),
onPressed: () => launchURL(BUY_ME_A_COFFEE_URL, context),
),
ElevatedButton.icon(
icon: const FaIcon(FontAwesomeIcons.solidHeart, size: 18),
label: const Text('Liberapay'),
onPressed: () => launchURL(LIBERAPAY_URL, context),
),
ElevatedButton.icon(
icon: const FaIcon(FontAwesomeIcons.github, size: 18),
label: const Text('GitHub Sponsors'),
onPressed: () => launchURL(GITHUB_SPONSORS_URL, context),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'\u{a9} ${today.year} wger contributors',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
),
),
_buildSectionSpacer(),
_buildSectionHeader(context, i18n.aboutJoinCommunityTitle),
ListTile(
leading: const FaIcon(FontAwesomeIcons.discord),
trailing: const Icon(Icons.arrow_outward),
title: Text(i18n.aboutDiscordTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(DISCORD_URL, context),
),
ListTile(
leading: const FaIcon(FontAwesomeIcons.mastodon),
trailing: const Icon(Icons.arrow_outward),
title: Text(i18n.aboutMastodonTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(MASTODON_URL, context),
),
_buildSectionSpacer(),
_buildSectionHeader(context, i18n.aboutWhySupportTitle),
Text(i18n.aboutDescription),
_buildSectionSpacer(),
_buildSectionHeader(context, i18n.others),
_buildSectionSpacer(),
_buildSectionHeader(context, i18n.aboutContributeTitle),
Text(i18n.aboutContributeText),
ListTile(
leading: const Icon(Icons.bug_report),
title: Text(i18n.aboutBugsListTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(GITHUB_ISSUES_URL, context),
),
ListTile(
leading: const Icon(Icons.translate),
title: Text(i18n.aboutTranslationListTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(WEBLATE_URL, context),
),
ListTile(
leading: const Icon(Icons.code),
title: Text(i18n.aboutSourceListTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(GITHUB_PROJECT_URL, context),
),
ListTile(
leading: const FaIcon(FontAwesomeIcons.dumbbell, size: 18),
title: Text(i18n.contributeExercise),
contentPadding: EdgeInsets.zero,
onTap: () => Navigator.of(context).pushNamed(AddExerciseScreen.routeName),
),
ListTile(
leading: const Icon(Icons.article),
trailing: const Icon(Icons.chevron_right),
title: Text(i18n.applicationLogs),
contentPadding: EdgeInsets.zero,
onTap: () {
Navigator.of(context).pushNamed(LogOverviewPage.routeName);
},
),
ListTile(
leading: const Icon(Icons.article),
trailing: const Icon(Icons.chevron_right),
title: const Text('View Licenses'),
contentPadding: EdgeInsets.zero,
onTap: () {
showLicensePage(
context: context,
applicationName: 'wger',
applicationVersion:
'App: ${authProvider.applicationVersion?.version ?? 'N/A'} '
'Server: ${authProvider.serverVersion ?? 'N/A'}',
applicationLegalese: '\u{a9} ${today.year} wger contributors',
applicationIcon: Padding(
padding: const EdgeInsets.only(top: 10),
child: Image.asset(
'assets/images/logo.png',
width: 60,
semanticLabel: 'wger logo',
_buildSectionSpacer(),
_buildSectionHeader(context, i18n.aboutDonateTitle),
Text(i18n.aboutDonateText),
const SizedBox(height: 15),
// Using Wrap for buttons to handle different screen sizes potentially
Center(
child: Wrap(
spacing: 10.0,
runSpacing: 10.0,
alignment: WrapAlignment.center,
children: [
ElevatedButton.icon(
icon: const FaIcon(FontAwesomeIcons.mugHot, size: 18),
label: const Text('Buy me a coffee'),
onPressed: () => launchURL(BUY_ME_A_COFFEE_URL, context),
),
),
);
},
),
],
ElevatedButton.icon(
icon: const FaIcon(FontAwesomeIcons.solidHeart, size: 18),
label: const Text('Liberapay'),
onPressed: () => launchURL(LIBERAPAY_URL, context),
),
ElevatedButton.icon(
icon: const FaIcon(FontAwesomeIcons.github, size: 18),
label: const Text('GitHub Sponsors'),
onPressed: () => launchURL(GITHUB_SPONSORS_URL, context),
),
],
),
),
_buildSectionSpacer(),
_buildSectionHeader(context, i18n.aboutJoinCommunityTitle),
ListTile(
leading: const FaIcon(FontAwesomeIcons.discord),
trailing: const Icon(Icons.arrow_outward),
title: Text(i18n.aboutDiscordTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(DISCORD_URL, context),
),
ListTile(
leading: const FaIcon(FontAwesomeIcons.mastodon),
trailing: const Icon(Icons.arrow_outward),
title: Text(i18n.aboutMastodonTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(MASTODON_URL, context),
),
_buildSectionSpacer(),
_buildSectionHeader(context, i18n.others),
ListTile(
leading: const Icon(Icons.article),
trailing: const Icon(Icons.chevron_right),
title: Text(i18n.applicationLogs),
contentPadding: EdgeInsets.zero,
onTap: () {
Navigator.of(context).pushNamed(LogOverviewPage.routeName);
},
),
ListTile(
leading: const Icon(Icons.article),
trailing: const Icon(Icons.chevron_right),
title: const Text('View Licenses'),
contentPadding: EdgeInsets.zero,
onTap: () {
showLicensePage(
context: context,
applicationName: 'wger',
applicationVersion:
'App: ${authProvider.applicationVersion?.version ?? 'N/A'} '
'Server: ${authProvider.serverVersion ?? 'N/A'}',
applicationLegalese: '\u{a9} ${today.year} wger contributors',
applicationIcon: Padding(
padding: const EdgeInsets.only(top: 10),
child: Image.asset(
'assets/images/logo.png',
width: 60,
semanticLabel: 'wger logo',
),
),
);
},
),
],
),
),
),
);

View File

@@ -1,20 +1,73 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
class ImageFormatNotSupported extends StatelessWidget {
Widget handleImageError(
BuildContext context,
Object error,
StackTrace? stackTrace,
String imageUrl,
) {
final imageFormat = imageUrl.split('.').last.toUpperCase();
final logger = Logger('handleImageError');
logger.warning('Failed to load image $imageUrl: $error, $stackTrace');
// NOTE: for the moment the other error messages are not localized
String message = '';
switch (error.runtimeType) {
case NetworkImageLoadException:
message = 'Network error';
case HttpException:
message = 'Http error';
case FormatException:
//TODO: not sure if this is the right exception for unsupported image formats?
message = AppLocalizations.of(context).imageFormatNotSupported(imageFormat);
default:
message = 'Other exception';
}
return AspectRatio(
aspectRatio: 1,
child: ImageError(
message,
errorMessage: error.toString(),
),
);
}
class ImageError extends StatelessWidget {
final String title;
final String? errorMessage;
const ImageFormatNotSupported(this.title, {super.key});
const ImageError(this.title, {this.errorMessage, super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(5),
color: theme.colorScheme.errorContainer,
child: Row(
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [const Icon(Icons.broken_image), Text(title)],
children: [
if (errorMessage != null)
Tooltip(message: errorMessage, child: const Icon(Icons.broken_image))
else
const Icon(Icons.broken_image),
Text(
title,
overflow: TextOverflow.ellipsis,
maxLines: 2,
textAlign: TextAlign.center,
),
],
),
);
}

View File

@@ -18,6 +18,7 @@
//import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/screens/configure_plates_screen.dart';
import 'package:wger/widgets/core/settings/exercise_cache.dart';

View File

@@ -145,7 +145,10 @@ class _DashboardCalendarWidgetState extends State<DashboardCalendarWidget>
// Add events to lists
_events[date]?.add(
Event(EventType.session, '${i18n.impression}: ${session.impressionAsString} $time'),
Event(
EventType.session,
'${i18n.impression}: ${session.impressionAsString(context)} $time',
),
);
}
});

View File

@@ -85,46 +85,62 @@ class _DashboardNutritionWidgetState extends State<DashboardNutritionWidget> {
PlanForm(),
),
if (_hasContent)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
child: Text(AppLocalizations.of(context).goToDetailPage),
onPressed: () {
Navigator.of(context).pushNamed(
NutritionalPlanScreen.routeName,
arguments: _plan,
);
},
),
Expanded(child: Container()),
IconButton(
icon: const SvgIcon(
icon: SvgIconData('assets/icons/ingredient-diary.svg'),
LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
child: Text(AppLocalizations.of(context).goToDetailPage),
onPressed: () {
Navigator.of(context).pushNamed(
NutritionalPlanScreen.routeName,
arguments: _plan,
);
},
),
Row(
children: [
IconButton(
icon: const SvgIcon(
icon: SvgIconData('assets/icons/ingredient-diary.svg'),
),
tooltip: AppLocalizations.of(context).logIngredient,
onPressed: () {
Navigator.pushNamed(
context,
FormScreen.routeName,
arguments: FormScreenArguments(
AppLocalizations.of(context).logIngredient,
getIngredientLogForm(_plan!),
hasListView: true,
),
);
},
),
IconButton(
icon: const SvgIcon(
icon: SvgIconData('assets/icons/meal-diary.svg'),
),
tooltip: AppLocalizations.of(context).logMeal,
onPressed: () {
Navigator.of(
context,
).pushNamed(LogMealsScreen.routeName, arguments: _plan);
},
),
],
),
],
),
),
tooltip: AppLocalizations.of(context).logIngredient,
onPressed: () {
Navigator.pushNamed(
context,
FormScreen.routeName,
arguments: FormScreenArguments(
AppLocalizations.of(context).logIngredient,
getIngredientLogForm(_plan!),
hasListView: true,
),
);
},
),
IconButton(
icon: const SvgIcon(
icon: SvgIconData('assets/icons/meal-diary.svg'),
),
tooltip: AppLocalizations.of(context).logMeal,
onPressed: () {
Navigator.of(context).pushNamed(LogMealsScreen.routeName, arguments: _plan);
},
),
],
);
},
),
],
),

View File

@@ -75,36 +75,47 @@ class DashboardWeightWidget extends StatelessWidget {
entries7dAvg.last,
weightUnit(profile.isMetric, context),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
child: Text(
AppLocalizations.of(context).goToDetailPage,
),
onPressed: () {
Navigator.of(context).pushNamed(WeightScreen.routeName);
},
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
Navigator.pushNamed(
context,
FormScreen.routeName,
arguments: FormScreenArguments(
AppLocalizations.of(context).newEntry,
WeightForm(
weightProvider.getNewestEntry()?.copyWith(
id: null,
date: DateTime.now(),
LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
child: Text(
AppLocalizations.of(context).goToDetailPage,
overflow: TextOverflow.ellipsis,
),
onPressed: () {
Navigator.of(context).pushNamed(WeightScreen.routeName);
},
),
),
);
},
),
],
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
Navigator.pushNamed(
context,
FormScreen.routeName,
arguments: FormScreenArguments(
AppLocalizations.of(context).newEntry,
WeightForm(
weightProvider.getNewestEntry()?.copyWith(
id: null,
date: DateTime.now(),
),
),
),
);
},
),
],
),
),
);
},
),
],
)

View File

@@ -229,7 +229,7 @@ class ExerciseDetail extends StatelessWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Chip(
label: Text(getTranslation(_exercise.category!.name, context)),
label: Text(getServerStringTranslation(_exercise.category!.name, context)),
padding: EdgeInsets.zero,
backgroundColor: theme.splashColor,
),
@@ -241,7 +241,7 @@ class ExerciseDetail extends StatelessWidget {
(e) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Chip(
label: Text(getTranslation(e.name, context)),
label: Text(getServerStringTranslation(e.name, context)),
padding: EdgeInsets.zero,
backgroundColor: theme.splashColor,
),

View File

@@ -67,7 +67,7 @@ class _ExerciseFilterModalBodyState extends State<ExerciseFilterModalBody> {
body: Column(
children: filterCategory.items.entries.map((currentEntry) {
return SwitchListTile(
title: Text(getTranslation(currentEntry.key.name, context)),
title: Text(getServerStringTranslation(currentEntry.key.name, context)),
value: currentEntry.value,
onChanged: (_) {
setState(() {

View File

@@ -37,14 +37,12 @@ class ExerciseImageWidget extends StatelessWidget {
? Image.network(
image!.url,
semanticLabel: 'Exercise image',
errorBuilder: (context, error, stackTrace) {
_logger.warning('Failed to load image ${image!.url}: $error, $stackTrace');
final imageFormat = image!.url.split('.').last.toUpperCase();
return ImageFormatNotSupported(
i18n.imageFormatNotSupported(imageFormat),
);
},
errorBuilder: (context, error, stackTrace) => handleImageError(
context,
error,
stackTrace,
image!.url,
),
)
: const Image(
image: AssetImage('assets/images/placeholder.png'),

View File

@@ -29,8 +29,6 @@ class ExerciseListTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
//final size = MediaQuery.of(context).size;
//final theme = Theme.of(context);
const double IMG_SIZE = 60;
return ListTile(
@@ -54,7 +52,7 @@ class ExerciseListTile extends StatelessWidget {
maxLines: 2,
),
subtitle: Text(
'${getTranslation(exercise.category!.name, context)} / ${exercise.equipment.map((e) => getTranslation(e.name, context)).toList().join(', ')}',
'${getServerStringTranslation(exercise.category!.name, context)} / ${exercise.equipment.map((e) => getServerStringTranslation(e.name, context)).toList().join(', ')}',
),
onTap: () {
Navigator.pushNamed(context, ExerciseDetailScreen.routeName, arguments: exercise);

View File

@@ -20,9 +20,9 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/gallery/image.dart' as gallery;
import 'package:wger/providers/gallery.dart';
@@ -43,7 +43,7 @@ class _ImageFormState extends State<ImageForm> {
XFile? _file;
final dateController = TextEditingController();
final dateController = TextEditingController(text: '');
final TextEditingController descriptionController = TextEditingController();
@override
@@ -57,7 +57,6 @@ class _ImageFormState extends State<ImageForm> {
void initState() {
super.initState();
dateController.text = dateToYYYYMMDD(widget._image.date)!;
descriptionController.text = widget._image.description;
}
@@ -97,6 +96,12 @@ class _ImageFormState extends State<ImageForm> {
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode);
if (dateController.text.isEmpty) {
dateController.text = dateFormat.format(widget._image.date);
}
return Form(
key: _form,
child: Column(
@@ -156,14 +161,15 @@ class _ImageFormState extends State<ImageForm> {
final pickedDate = await showDatePicker(
context: context,
initialDate: widget._image.date,
firstDate: DateTime(DateTime.now().year - 10),
firstDate: DateTime.now().subtract(const Duration(days: 3000)),
lastDate: DateTime.now(),
);
dateController.text = dateToYYYYMMDD(pickedDate)!;
if (pickedDate != null) {
dateController.text = dateFormat.format(pickedDate);
}
},
onSaved: (newValue) {
widget._image.date = DateTime.parse(newValue!);
widget._image.date = dateFormat.parse(newValue!);
},
validator: (value) {
if (widget._image.id == null && _file == null) {

View File

@@ -36,8 +36,6 @@ class Gallery extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = Provider.of<GalleryProvider>(context);
final i18n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(5),
@@ -66,23 +64,12 @@ class Gallery extends StatelessWidget {
image: NetworkImage(currentImage.url!),
fit: BoxFit.cover,
imageSemanticLabel: currentImage.description,
imageErrorBuilder: (context, error, stackTrace) {
final imageFormat = currentImage.url!.split('.').last.toUpperCase();
return AspectRatio(
aspectRatio: 1,
child: Container(
color: theme.colorScheme.errorContainer,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [
const Icon(Icons.broken_image),
Text(i18n.imageFormatNotSupported(imageFormat)),
],
),
),
);
},
imageErrorBuilder: (context, error, stackTrace) => handleImageError(
context,
error,
stackTrace,
currentImage.url!,
),
),
);
},
@@ -102,7 +89,6 @@ class ImageDetail extends StatelessWidget {
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
return Container(
key: Key('image-${image.id!}-detail'),
padding: const EdgeInsets.all(10),
@@ -116,13 +102,12 @@ class ImageDetail extends StatelessWidget {
child: Image.network(
image.url!,
semanticLabel: image.description,
errorBuilder: (context, error, stackTrace) {
final imageFormat = image.url!.split('.').last.toUpperCase();
return ImageFormatNotSupported(
i18n.imageFormatNotSupported(imageFormat),
);
},
errorBuilder: (context, error, stackTrace) => handleImageError(
context,
error,
stackTrace,
image.url!,
),
),
),
Padding(

View File

@@ -49,33 +49,43 @@ class CategoriesCard extends StatelessWidget {
currentCategory.unit,
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
child: Text(AppLocalizations.of(context).goToDetailPage),
onPressed: () {
Navigator.pushNamed(
context,
MeasurementEntriesScreen.routeName,
arguments: currentCategory.id,
);
},
),
IconButton(
onPressed: () async {
await Navigator.pushNamed(
context,
FormScreen.routeName,
arguments: FormScreenArguments(
AppLocalizations.of(context).newEntry,
MeasurementEntryForm(currentCategory.id!),
),
);
},
icon: const Icon(Icons.add),
),
],
LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
child: Text(AppLocalizations.of(context).goToDetailPage),
onPressed: () {
Navigator.pushNamed(
context,
MeasurementEntriesScreen.routeName,
arguments: currentCategory.id,
);
},
),
IconButton(
onPressed: () async {
await Navigator.pushNamed(
context,
FormScreen.routeName,
arguments: FormScreenArguments(
AppLocalizations.of(context).newEntry,
MeasurementEntryForm(currentCategory.id!),
),
);
},
icon: const Icon(Icons.add),
),
],
),
),
);
},
),
],
),

View File

@@ -20,7 +20,6 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/measurements/measurement_category.dart';
import 'package:wger/models/measurements/measurement_entry.dart';
@@ -136,7 +135,7 @@ class MeasurementEntryForm extends StatelessWidget {
final _form = GlobalKey<FormState>();
final int _categoryId;
final _valueController = TextEditingController();
final _dateController = TextEditingController();
final _dateController = TextEditingController(text: '');
final _notesController = TextEditingController();
late final Map<String, dynamic> _entryData;
@@ -158,18 +157,23 @@ class MeasurementEntryForm extends StatelessWidget {
_entryData['notes'] = entry.notes;
}
_dateController.text = dateToYYYYMMDD(_entryData['date'])!;
_valueController.text = '';
_notesController.text = _entryData['notes']!;
}
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode);
final measurementProvider = Provider.of<MeasurementProvider>(context, listen: false);
final measurementCategory = measurementProvider.categories.firstWhere(
(category) => category.id == _categoryId,
);
if (_dateController.text.isEmpty) {
_dateController.text = dateFormat.format(_entryData['date']);
}
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
// If the value is not empty, format it
@@ -213,10 +217,10 @@ class MeasurementEntryForm extends StatelessWidget {
},
);
_dateController.text = pickedDate == null ? '' : dateToYYYYMMDD(pickedDate)!;
_dateController.text = pickedDate == null ? '' : dateFormat.format(pickedDate);
},
onSaved: (newValue) {
_entryData['date'] = DateTime.parse(newValue!);
_entryData['date'] = dateFormat.parse(newValue!);
},
validator: (value) {
if (value!.isEmpty) {

View File

@@ -20,7 +20,6 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/date.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/nutrition/ingredient.dart';
@@ -182,18 +181,10 @@ class IngredientFormState extends State<IngredientForm> {
final _ingredientIdController = TextEditingController();
final _amountController = TextEditingController();
final _dateController = TextEditingController(); // optional
final _timeController = TextEditingController(); // optional
final _timeController = TextEditingController(text: ''); // optional
final _mealItem = MealItem.empty();
var _searchQuery = ''; // copy from typeahead. for filtering suggestions
@override
void initState() {
super.initState();
final now = DateTime.now();
_dateController.text = dateToYYYYMMDD(now)!;
_timeController.text = timeToString(TimeOfDay.fromDateTime(now))!;
}
@override
void dispose() {
_ingredientController.dispose();
@@ -236,6 +227,17 @@ class IngredientFormState extends State<IngredientForm> {
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode);
final timeFormat = DateFormat.Hm(Localizations.localeOf(context).languageCode);
if (_dateController.text.isEmpty) {
_dateController.text = dateFormat.format(DateTime.now());
}
if (_timeController.text.isEmpty) {
_timeController.text = timeFormat.format(DateTime.now());
}
final String unit = AppLocalizations.of(context).g;
final queryLower = _searchQuery.toLowerCase();
final suggestions = widget.recent
@@ -311,7 +313,7 @@ class IngredientFormState extends State<IngredientForm> {
);
if (pickedDate != null) {
_dateController.text = dateToYYYYMMDD(pickedDate)!;
_dateController.text = dateFormat.format(pickedDate);
}
},
onSaved: (newValue) {
@@ -402,9 +404,8 @@ class IngredientFormState extends State<IngredientForm> {
_form.currentState!.save();
_mealItem.ingredientId = int.parse(_ingredientIdController.text);
final loggedDate = getDateTimeFromDateAndTime(
_dateController.text,
_timeController.text,
final loggedDate = dateFormat.parse(
'${_dateController.text} ${_timeController.text}',
);
widget.onSave(context, _mealItem, loggedDate);

View File

@@ -184,7 +184,10 @@ class _IngredientTypeaheadState extends State<IngredientTypeahead> {
opacity: CurvedAnimation(parent: animation, curve: Curves.fastOutSlowIn),
child: child,
),
onSelected: (suggestion) {
onSelected: (suggestion) async {
// Cache selected ingredient
final provider = Provider.of<NutritionPlansProvider>(context, listen: false);
await provider.cacheIngredient(suggestion);
widget.selectIngredient(suggestion.id, suggestion.name, null);
},
),

View File

@@ -17,27 +17,21 @@
*/
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/slot_entry.dart';
/// Input widget for Reps In Reserve
class RiRInputWidget extends StatefulWidget {
final _logger = Logger('RiRInputWidget');
final num? _initialValue;
final ValueChanged<String> onChanged;
late String dropdownValue;
late double _currentSetSliderValue;
static const SLIDER_START = -0.5;
RiRInputWidget(this._initialValue, {required this.onChanged}) {
dropdownValue = _initialValue != null ? _initialValue.toString() : SlotEntry.DEFAULT_RIR;
// Read string RiR into a double
if (_initialValue != null) {
_currentSetSliderValue = _initialValue.toDouble();
} else {
_currentSetSliderValue = SLIDER_START;
}
_logger.finer('Initializing with initial value: $_initialValue');
}
@override
@@ -45,6 +39,28 @@ class RiRInputWidget extends StatefulWidget {
}
class _RiRInputWidgetState extends State<RiRInputWidget> {
late double _currentSetSliderValue;
@override
void initState() {
super.initState();
_currentSetSliderValue = widget._initialValue?.toDouble() ?? RiRInputWidget.SLIDER_START;
widget._logger.finer('initState - starting slider value: ${widget._initialValue}');
}
@override
void didUpdateWidget(covariant RiRInputWidget oldWidget) {
super.didUpdateWidget(oldWidget);
final newValue = widget._initialValue?.toDouble() ?? RiRInputWidget.SLIDER_START;
if (widget._initialValue != oldWidget._initialValue) {
widget._logger.finer('didUpdateWidget - new initial value: ${widget._initialValue}');
setState(() {
_currentSetSliderValue = newValue;
});
}
}
/// Returns the string used in the slider
String getSliderLabel(double value) {
if (value < 0) {
@@ -77,15 +93,15 @@ class _RiRInputWidgetState extends State<RiRInputWidget> {
Text(AppLocalizations.of(context).rir),
Expanded(
child: Slider(
value: widget._currentSetSliderValue,
value: _currentSetSliderValue,
min: RiRInputWidget.SLIDER_START,
max: (SlotEntry.POSSIBLE_RIR_VALUES.length - 2) / 2,
divisions: SlotEntry.POSSIBLE_RIR_VALUES.length - 1,
label: getSliderLabel(widget._currentSetSliderValue),
label: getSliderLabel(_currentSetSliderValue),
onChanged: (double value) {
widget.onChanged(mapDoubleToAllowedRir(value));
setState(() {
widget._currentSetSliderValue = value;
_currentSetSliderValue = value;
});
},
),

View File

@@ -142,6 +142,15 @@ class _SessionFormState extends State<SessionForm> {
decoration: InputDecoration(
labelText: AppLocalizations.of(context).timeStart,
errorMaxLines: 2,
suffix: IconButton(
onPressed: () => {
setState(() {
timeStartController.text = '';
widget._session.timeStart = null;
}),
},
icon: const Icon(Icons.clear),
),
),
controller: timeStartController,
onFieldSubmitted: (_) {},
@@ -187,6 +196,15 @@ class _SessionFormState extends State<SessionForm> {
key: const ValueKey('time-end'),
decoration: InputDecoration(
labelText: AppLocalizations.of(context).timeEnd,
suffix: IconButton(
onPressed: () => {
setState(() {
timeEndController.text = '';
widget._session.timeEnd = null;
}),
},
icon: const Icon(Icons.clear),
),
),
controller: timeEndController,
onFieldSubmitted: (_) {},

View File

@@ -16,44 +16,45 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/widgets/exercises/exercises.dart';
import 'package:wger/widgets/routines/gym_mode/navigation.dart';
class ExerciseOverview extends StatelessWidget {
class ExerciseOverview extends ConsumerWidget {
final _logger = Logger('ExerciseOverview');
final PageController _controller;
final Exercise _exercise;
final double _ratioCompleted;
final Map<Exercise, int> _exercisePages;
final int _totalPages;
const ExerciseOverview(
this._controller,
this._exercise,
this._ratioCompleted,
this._exercisePages,
this._totalPages,
);
ExerciseOverview(this._controller);
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final page = ref.watch(gymStateProvider).getSlotEntryPageByIndex();
if (page == null) {
_logger.info(
'getPageByIndex returned null, showing empty container.',
);
return Container();
}
final exercise = page.setConfigData!.exercise;
return Column(
children: [
NavigationHeader(
_exercise.getTranslation(Localizations.localeOf(context).languageCode).name,
exercise.getTranslation(Localizations.localeOf(context).languageCode).name,
_controller,
totalPages: _totalPages,
exercisePages: _exercisePages,
),
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: ExerciseDetail(_exercise),
child: ExerciseDetail(exercise),
),
),
),
NavigationFooter(_controller, _ratioCompleted),
NavigationFooter(_controller),
],
);
}

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
* Copyright (C) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@@ -15,178 +15,107 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart' as provider;
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/day_data.dart';
import 'package:wger/providers/exercises.dart';
import 'package:provider/provider.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/providers/routines.dart';
import 'package:wger/widgets/routines/gym_mode/exercise_overview.dart';
import 'package:wger/widgets/routines/gym_mode/log_page.dart';
import 'package:wger/widgets/routines/gym_mode/session_page.dart';
import 'package:wger/widgets/routines/gym_mode/start_page.dart';
import 'package:wger/widgets/routines/gym_mode/timer.dart';
import 'package:wger/screens/gym_mode.dart';
import 'package:wger/widgets/core/progress_indicator.dart';
import 'exercise_overview.dart';
import 'log_page.dart';
import 'session_page.dart';
import 'start_page.dart';
import 'summary.dart';
import 'timer.dart';
class GymMode extends ConsumerStatefulWidget {
final DayData _dayDataGym;
final DayData _dayDataDisplay;
final int _iteration;
final GymModeArguments _args;
final _logger = Logger('GymMode');
GymMode(this._dayDataGym, this._dayDataDisplay, this._iteration);
GymMode(this._args);
@override
ConsumerState<GymMode> createState() => _GymModeState();
}
class _GymModeState extends ConsumerState<GymMode> {
var _totalElements = 1;
var _totalPages = 1;
late Future<int> _initData;
bool _initialPageJumped = false;
/// Map with the first (navigation) page for each exercise
final Map<Exercise, int> _exercisePages = {};
late final PageController _controller;
@override
void initState() {
super.initState();
_controller = PageController(initialPage: 0);
_initData = _loadGymState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_initData = _loadGymState();
_controller = PageController(initialPage: 0);
_calculatePages();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(gymStateProvider.notifier).setExercisePages(_exercisePages);
});
}
Future<int> _loadGymState() async {
// Re-fetch the current routine data to ensure we have the latest session
// data since it is possible that the user created or deleted it from the
// web interface.
await context.read<RoutinesProvider>().fetchAndSetRoutineFull(
widget._dayDataGym.day!.routineId,
widget._logger.fine('Loading gym state');
final routine = await context.read<RoutinesProvider>().fetchAndSetRoutineFull(
widget._args.routineId,
);
widget._logger.fine('Refreshed routine data');
final validUntil = ref.read(gymStateProvider).validUntil;
final currentPage = ref.read(gymStateProvider).currentPage;
final savedDayId = ref.read(gymStateProvider).dayId;
final newDayId = widget._dayDataGym.day!.id!;
final shouldReset = newDayId != savedDayId || validUntil.isBefore(DateTime.now());
if (shouldReset) {
widget._logger.fine('Day ID mismatch or expired validUntil date. Resetting to page 0.');
}
final initialPage = shouldReset ? 0 : currentPage;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(gymStateProvider.notifier)
..setDayId(newDayId)
..setCurrentPage(initialPage);
});
final gymViewModel = ref.read(gymStateProvider.notifier);
final initialPage = gymViewModel.initData(
routine,
widget._args.dayId,
widget._args.iteration,
);
await gymViewModel.loadPrefs();
gymViewModel.calculatePages();
return initialPage;
}
void _calculatePages() {
for (final slot in widget._dayDataGym.slots) {
_totalElements += slot.setConfigs.length;
// add 1 for each exercise
_totalPages += 1;
for (final config in slot.setConfigs) {
// add nrOfSets * 2, 1 for log page and 1 for timer
_totalPages += (config.nrOfSets! * 2).toInt();
}
}
_exercisePages.clear();
var currentPage = 1;
for (final slot in widget._dayDataGym.slots) {
var firstPage = true;
for (final config in slot.setConfigs) {
final exercise = context.read<ExercisesProvider>().findExerciseById(config.exerciseId);
if (firstPage) {
_exercisePages[exercise] = currentPage;
currentPage++;
}
currentPage += 2;
firstPage = false;
}
}
}
List<Widget> getContent() {
final state = ref.watch(gymStateProvider);
final exerciseProvider = context.read<ExercisesProvider>();
final routinesProvider = context.read<RoutinesProvider>();
var currentElement = 1;
List<Widget> _getContent(GymModeState state) {
final gymState = ref.watch(gymStateProvider);
final List<Widget> out = [];
for (final slotData in widget._dayDataGym.slots) {
var firstPage = true;
for (final config in slotData.setConfigs) {
final ratioCompleted = currentElement / _totalElements;
final exercise = exerciseProvider.findExerciseById(config.exerciseId);
currentElement++;
// Workout overview
out.add(StartPage(_controller));
if (firstPage && state.showExercisePages) {
out.add(
ExerciseOverview(
_controller,
exercise,
ratioCompleted,
state.exercisePages,
_totalPages,
),
);
// Sets
for (final page in state.pages) {
for (final slotPage in page.slotPages) {
if (slotPage.type == SlotPageType.exerciseOverview) {
out.add(ExerciseOverview(_controller));
}
out.add(
LogPage(
_controller,
config,
slotData,
exercise,
routinesProvider.findById(widget._dayDataGym.day!.routineId),
ratioCompleted,
state.exercisePages,
_totalPages,
widget._iteration,
),
);
// If there is a rest time, add a countdown timer
if (config.restTime != null) {
out.add(
TimerCountdownWidget(
_controller,
config.restTime!.toInt(),
ratioCompleted,
state.exercisePages,
_totalPages,
),
);
} else {
out.add(TimerWidget(_controller, ratioCompleted, state.exercisePages, _totalPages));
if (slotPage.type == SlotPageType.log) {
out.add(LogPage(_controller));
}
firstPage = false;
// Timer. Use rest time from config data if available, otherwise use user settings
final rest = slotPage.setConfigData?.restTime;
if (slotPage.type == SlotPageType.timer) {
out.add(
(rest != null || gymState.useCountdownBetweenSets)
? TimerCountdownWidget(
_controller,
(rest ?? gymState.countdownDuration.inSeconds).toInt(),
)
: TimerWidget(_controller),
);
}
}
}
// End
out.add(SessionPage(_controller));
out.add(WorkoutSummary(_controller));
return out;
}
@@ -196,43 +125,40 @@ class _GymModeState extends ConsumerState<GymMode> {
future: _initData,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
return const BoxedProgressIndicator();
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
return Center(child: Text('Error: ${snapshot.error}: ${snapshot.stackTrace}'));
} else if (snapshot.connectionState == ConnectionState.done) {
final initialPage = snapshot.data!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialPageJumped && _controller.hasClients) {
_controller.jumpToPage(initialPage);
setState(() => _initialPageJumped = true);
}
});
final state = ref.watch(gymStateProvider);
final children = [
..._getContent(state),
];
return PageView(
controller: _controller,
onPageChanged: (page) {
ref.read(gymStateProvider.notifier).setCurrentPage(page);
// Check if the last page is reached
if (page == children.length - 1) {
widget._logger.finer('Last page reached, clearing gym state');
ref.read(gymStateProvider.notifier).clear();
}
},
children: children,
);
}
final initialPage = snapshot.data!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialPageJumped && _controller.hasClients) {
_controller.jumpToPage(initialPage);
setState(() => _initialPageJumped = true);
}
});
final List<Widget> children = [
StartPage(_controller, widget._dayDataDisplay, _exercisePages),
...getContent(),
SessionPage(
context.read<RoutinesProvider>().findById(widget._dayDataGym.day!.routineId),
_controller,
ref.read(gymStateProvider).startTime,
_exercisePages,
dayId: widget._dayDataGym.day!.id!,
),
];
return PageView(
controller: _controller,
onPageChanged: (page) {
ref.read(gymStateProvider.notifier).setCurrentPage(page);
// Check if the last page is reached
if (page == children.length - 1) {
ref.read(gymStateProvider.notifier).clear();
}
},
children: children,
);
return const Center(child: Text('Unexpected state'));
},
);
}

View File

@@ -23,12 +23,10 @@ import 'package:provider/provider.dart' as provider;
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/log.dart';
import 'package:wger/models/workouts/routine.dart';
import 'package:wger/models/workouts/set_config_data.dart';
import 'package:wger/models/workouts/slot_data.dart';
import 'package:wger/models/workouts/slot_entry.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/providers/plate_weights.dart';
import 'package:wger/providers/routines.dart';
import 'package:wger/screens/configure_plates_screen.dart';
@@ -41,29 +39,11 @@ import 'package:wger/widgets/routines/gym_mode/navigation.dart';
import 'package:wger/widgets/routines/plate_calculator.dart';
class LogPage extends ConsumerStatefulWidget {
final PageController _controller;
final SetConfigData _configData;
final SlotData _slotData;
final Exercise _exercise;
final Routine _routine;
final double _ratioCompleted;
final Map<Exercise, int> _exercisePages;
final Log _log;
final int _totalPages;
final _logger = Logger('LogPage');
LogPage(
this._controller,
this._configData,
this._slotData,
this._exercise,
this._routine,
this._ratioCompleted,
this._exercisePages,
this._totalPages,
int? iteration,
) : _log = Log.fromSetConfigData(_configData)
..routineId = _routine.id!
..iteration = iteration;
final PageController _controller;
LogPage(this._controller);
@override
_LogPageState createState() => _LogPageState();
@@ -89,14 +69,40 @@ class _LogPageState extends ConsumerState<LogPage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final state = ref.watch(gymStateProvider);
final page = state.getPageByIndex();
if (page == null) {
widget._logger.info(
'getPageByIndex for ${state.currentPage} returned null, showing empty container.',
);
return Container();
}
final slotEntryPage = state.getSlotEntryPageByIndex();
if (slotEntryPage == null) {
widget._logger.info(
'getSlotPageByIndex for ${state.currentPage} returned null, showing empty container',
);
return Container();
}
final setConfigData = slotEntryPage.setConfigData!;
final log = Log.fromSetConfigData(setConfigData)
..routineId = state.routine.id!
..iteration = state.iteration;
// Mark done sets
final decorationStyle = slotEntryPage.logDone
? TextDecoration.lineThrough
: TextDecoration.none;
return Column(
children: [
NavigationHeader(
widget._exercise.getTranslation(Localizations.localeOf(context).languageCode).name,
log.exercise.getTranslation(Localizations.localeOf(context).languageCode).name,
widget._controller,
totalPages: widget._totalPages,
exercisePages: widget._exercisePages,
),
Container(
@@ -105,34 +111,47 @@ class _LogPageState extends ConsumerState<LogPage> {
child: Center(
child: Column(
children: [
Column(
children: [
Text(
setConfigData.textRepr,
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
decoration: decorationStyle,
),
),
if (setConfigData.type != SlotEntryType.normal)
Text(
setConfigData.type.name.toUpperCase(),
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
decoration: decorationStyle,
),
),
],
),
Text(
widget._configData.textRepr,
style: theme.textTheme.headlineMedium?.copyWith(
'${slotEntryPage.setIndex + 1} / ${page.slotPages.where((e) => e.type == SlotPageType.log).length}',
style: theme.textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
if (widget._configData.type != SlotEntryType.normal)
Text(
widget._configData.type.name.toUpperCase(),
style: theme.textTheme.headlineMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
],
),
),
),
if (widget._log.exercise.showPlateCalculator) const LogsPlatesWidget(),
if (widget._slotData.comment.isNotEmpty)
Text(widget._slotData.comment, textAlign: TextAlign.center),
if (log.exercise.showPlateCalculator) const LogsPlatesWidget(),
if (slotEntryPage.setConfigData!.comment.isNotEmpty)
Text(slotEntryPage.setConfigData!.comment, textAlign: TextAlign.center),
const SizedBox(height: 10),
Expanded(
child: (widget._routine.filterLogsByExercise(widget._exercise.id!).isNotEmpty)
child: (state.routine.filterLogsByExercise(log.exercise.id!).isNotEmpty)
? LogsPastLogsWidget(
log: widget._log,
pastLogs: widget._routine.filterLogsByExercise(widget._exercise.id!),
log: log,
pastLogs: state.routine.filterLogsByExercise(log.exercise.id!),
onCopy: (pastLog) {
_logFormKey.currentState?.copyFromPastLog(pastLog);
},
@@ -153,14 +172,14 @@ class _LogPageState extends ConsumerState<LogPage> {
child: LogFormWidget(
key: _logFormKey,
controller: widget._controller,
configData: widget._configData,
log: widget._log,
configData: setConfigData,
log: log,
focusNode: focusNode,
),
),
),
),
NavigationFooter(widget._controller, widget._ratioCompleted),
NavigationFooter(widget._controller),
],
);
}
@@ -294,8 +313,10 @@ class LogsRepsWidget extends StatelessWidget {
IconButton(
icon: const Icon(Icons.add, color: Colors.black),
onPressed: () {
final value = controller.text.isNotEmpty ? controller.text : '0';
try {
final newValue = numberFormat.parse(controller.text) + repsValueChange;
final newValue = numberFormat.parse(value) + repsValueChange;
setStateCallback(() {
log.repetitions = newValue;
controller.text = numberFormat.format(newValue);
@@ -393,8 +414,10 @@ class LogsWeightWidget extends ConsumerWidget {
IconButton(
icon: const Icon(Icons.add, color: Colors.black),
onPressed: () {
final value = controller.text.isNotEmpty ? controller.text : '0';
try {
final newValue = numberFormat.parse(controller.text) + weightValueChange;
final newValue = numberFormat.parse(value) + weightValueChange;
setStateCallback(() {
log.weight = newValue;
controller.text = numberFormat.format(newValue);
@@ -441,7 +464,8 @@ class LogsPastLogsWidget extends StatelessWidget {
),
...pastLogs.map((pastLog) {
return ListTile(
title: Text(pastLog.singleLogRepTextNoNl),
key: ValueKey('past-log-${pastLog.id}'),
title: Text(pastLog.repTextNoNl(context)),
subtitle: Text(
DateFormat.yMd(Localizations.localeOf(context).languageCode).format(pastLog.date),
),
@@ -472,12 +496,14 @@ class LogsPastLogsWidget extends StatelessWidget {
}
class LogFormWidget extends ConsumerStatefulWidget {
final _logger = Logger('LogFormWidget');
final PageController controller;
final SetConfigData configData;
final Log log;
final FocusNode focusNode;
const LogFormWidget({
LogFormWidget({
super.key,
required this.controller,
required this.configData,
@@ -493,6 +519,7 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
final _form = GlobalKey<FormState>();
var _detailed = false;
bool _isSaving = false;
late Log _log;
late final TextEditingController _repetitionsController;
late final TextEditingController _weightController;
@@ -501,6 +528,7 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
void initState() {
super.initState();
_log = widget.log;
_repetitionsController = TextEditingController();
_weightController = TextEditingController();
@@ -533,7 +561,13 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
_repetitionsController.text = pastLog.repetitions != null
? numberFormat.format(pastLog.repetitions)
: '';
widget._logger.finer('Setting log repetitions to ${_repetitionsController.text}');
_weightController.text = pastLog.weight != null ? numberFormat.format(pastLog.weight) : '';
widget._logger.finer('Setting log weight to ${_weightController.text}');
_log.rir = pastLog.rir;
widget._logger.finer('Setting log rir to ${_log.rir}');
});
}
@@ -559,7 +593,7 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
controller: _repetitionsController,
configData: widget.configData,
focusNode: widget.focusNode,
log: widget.log,
log: _log,
setStateCallback: (fn) {
setState(fn);
},
@@ -571,7 +605,7 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
controller: _weightController,
configData: widget.configData,
focusNode: widget.focusNode,
log: widget.log,
log: _log,
setStateCallback: (fn) {
setState(fn);
},
@@ -588,7 +622,7 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
controller: _repetitionsController,
configData: widget.configData,
focusNode: widget.focusNode,
log: widget.log,
log: _log,
setStateCallback: (fn) {
setState(fn);
},
@@ -597,7 +631,7 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
const SizedBox(width: 8),
Flexible(
child: RepetitionUnitInputWidget(
widget.log.repetitionsUnitId,
_log.repetitionsUnitId,
onChanged: (v) => {},
),
),
@@ -613,7 +647,7 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
controller: _weightController,
configData: widget.configData,
focusNode: widget.focusNode,
log: widget.log,
log: _log,
setStateCallback: (fn) {
setState(fn);
},
@@ -621,19 +655,19 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
),
const SizedBox(width: 8),
Flexible(
child: WeightUnitInputWidget(widget.log.weightUnitId, onChanged: (v) => {}),
child: WeightUnitInputWidget(_log.weightUnitId, onChanged: (v) => {}),
),
const SizedBox(width: 8),
],
),
if (_detailed)
RiRInputWidget(
widget.log.rir,
_log.rir,
onChanged: (value) {
if (value == '') {
widget.log.rir = null;
_log.rir = null;
} else {
widget.log.rir = num.parse(value);
_log.rir = num.parse(value);
}
},
),
@@ -659,10 +693,15 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
_form.currentState!.save();
try {
final gymState = ref.read(gymStateProvider);
final gymProvider = ref.read(gymStateProvider.notifier);
await provider.Provider.of<RoutinesProvider>(
context,
listen: false,
).addLog(widget.log);
).addLog(_log);
final page = gymState.getSlotEntryPageByIndex()!;
gymProvider.markSlotPageAsDone(page.uuid, isDone: true);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -17,130 +17,18 @@
*/
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/theme/theme.dart';
class NavigationFooter extends StatelessWidget {
final PageController _controller;
final double _ratioCompleted;
final bool showPrevious;
final bool showNext;
const NavigationFooter(
this._controller,
this._ratioCompleted, {
this.showPrevious = true,
this.showNext = true,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
if (showPrevious)
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
_controller.previousPage(
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
},
)
else
const SizedBox(width: 48),
Expanded(
child: LinearProgressIndicator(
minHeight: 3,
value: _ratioCompleted,
valueColor: const AlwaysStoppedAnimation<Color>(wgerPrimaryColor),
),
),
if (showNext)
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
_controller.nextPage(
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
},
)
else
const SizedBox(width: 48),
],
);
}
}
import 'package:wger/widgets/routines/gym_mode/workout_menu.dart';
class NavigationHeader extends StatelessWidget {
final PageController _controller;
final String _title;
final Map<Exercise, int> exercisePages;
final int ?totalPages;
final bool showEndWorkoutButton;
const NavigationHeader(
this._title,
this._controller, {
this.totalPages,
required this.exercisePages
});
Widget getDialog(BuildContext context) {
final TextButton? endWorkoutButton = totalPages != null
? TextButton(
child: Text(AppLocalizations.of(context).endWorkout),
onPressed: () {
_controller.animateToPage(
totalPages!,
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
Navigator.of(context).pop();
},
)
: null;
return AlertDialog(
title: Text(
AppLocalizations.of(context).jumpTo,
textAlign: TextAlign.center,
),
contentPadding: EdgeInsets.zero,
content: SingleChildScrollView(
child: Column(
children: [
...exercisePages.keys.map((e) {
return ListTile(
title: Text(e.getTranslation(Localizations.localeOf(context).languageCode).name),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_controller.animateToPage(
exercisePages[e]!,
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
Navigator.of(context).pop();
},
);
}),
],
),
),
actions: [
?endWorkoutButton,
TextButton(
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
}
const NavigationHeader(this._title, this._controller, {this.showEndWorkoutButton = true});
@override
Widget build(BuildContext context) {
@@ -163,11 +51,11 @@ class NavigationHeader extends StatelessWidget {
),
),
IconButton(
icon: const Icon(Icons.toc),
icon: const Icon(Icons.menu),
onPressed: () {
showDialog(
context: context,
builder: (ctx) => getDialog(context),
builder: (ctx) => WorkoutMenuDialog(_controller),
);
},
),
@@ -175,3 +63,65 @@ class NavigationHeader extends StatelessWidget {
);
}
}
class NavigationFooter extends ConsumerWidget {
final PageController _controller;
final bool showPrevious;
final bool showNext;
const NavigationFooter(
this._controller, {
this.showPrevious = true,
this.showNext = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final gymState = ref.watch(gymStateProvider);
return Row(
children: [
if (showPrevious)
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
_controller.previousPage(
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
},
)
else
const SizedBox(width: 48),
Expanded(
child: GestureDetector(
onTap: () => showDialog(
context: context,
builder: (ctx) => WorkoutMenuDialog(_controller, initialIndex: 1),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 15),
child: LinearProgressIndicator(
minHeight: 3,
value: gymState.ratioCompleted,
valueColor: const AlwaysStoppedAnimation<Color>(wgerPrimaryColor),
),
),
),
),
if (showNext)
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
_controller.nextPage(
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
},
)
else
const SizedBox(width: 48),
],
);
}
}

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
* Copyright (C) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@@ -17,60 +17,57 @@
*/
import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/date.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/routine.dart';
import 'package:wger/models/workouts/session.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/widgets/routines/forms/session.dart';
import 'package:wger/widgets/routines/gym_mode/navigation.dart';
class SessionPage extends StatelessWidget {
final Routine _routine;
final WorkoutSession _session;
class SessionPage extends ConsumerWidget {
final PageController _controller;
final Map<Exercise, int> _exercisePages;
SessionPage(
this._routine,
this._controller,
TimeOfDay start,
this._exercisePages, {
int? dayId,
}) : _session = _routine.sessions
.map((sessionApi) => sessionApi.session)
.firstWhere(
(session) => session.date.isSameDayAs(clock.now()),
orElse: () => WorkoutSession(
dayId: dayId,
routineId: _routine.id!,
impression: DEFAULT_IMPRESSION,
date: clock.now(),
timeStart: start,
timeEnd: TimeOfDay.fromDateTime(clock.now()),
),
);
const SessionPage(this._controller);
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(gymStateProvider);
final session = state.routine.sessions
.map((sessionApi) => sessionApi.session)
.firstWhere(
(session) => session.date.isSameDayAs(clock.now()),
orElse: () => WorkoutSession(
dayId: state.dayId,
routineId: state.routine.id,
impression: DEFAULT_IMPRESSION,
date: clock.now(),
timeStart: state.startTime,
timeEnd: TimeOfDay.fromDateTime(clock.now()),
),
);
return Column(
children: [
NavigationHeader(
AppLocalizations.of(context).workoutSession,
_controller,
exercisePages: _exercisePages,
),
Expanded(child: Container()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: SessionForm(
_routine.id!,
onSaved: () => Navigator.of(context).pop(),
session: _session,
state.routine.id,
onSaved: () => _controller.nextPage(
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
),
session: session,
),
),
NavigationFooter(_controller, 1, showNext: false),
NavigationFooter(_controller),
],
);
}

View File

@@ -1,78 +1,243 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* wger Workout Manager is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/day.dart';
import 'package:wger/models/workouts/day_data.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/widgets/exercises/images.dart';
import 'package:wger/widgets/routines/gym_mode/navigation.dart';
class StartPage extends StatelessWidget {
final PageController _controller;
final DayData _dayData;
final Map<Exercise, int> _exercisePages;
class GymModeOptions extends ConsumerStatefulWidget {
const GymModeOptions({super.key});
const StartPage(this._controller, this._dayData, this._exercisePages);
@override
ConsumerState<GymModeOptions> createState() => _GymModeOptionsState();
}
class _GymModeOptionsState extends ConsumerState<GymModeOptions> {
bool _showOptions = false;
late TextEditingController _countdownController;
@override
void initState() {
super.initState();
final initial = ref.read(gymStateProvider).countdownDuration.inSeconds.toString();
_countdownController = TextEditingController(text: initial);
}
@override
void dispose() {
_countdownController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final gymState = ref.watch(gymStateProvider);
final gymNotifier = ref.watch(gymStateProvider.notifier);
final i18n = AppLocalizations.of(context);
// If the value in the provider changed, update the controller text
final currentText = gymState.countdownDuration.inSeconds.toString();
if (_countdownController.text != currentText) {
_countdownController.text = currentText;
}
return Column(
children: [
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: Card(
child: SingleChildScrollView(
child: Column(
children: [
SwitchListTile(
key: const ValueKey('gym-mode-option-show-exercises'),
title: Text(i18n.gymModeShowExercises),
value: gymState.showExercisePages,
onChanged: (value) => gymNotifier.setShowExercisePages(value),
),
SwitchListTile(
key: const ValueKey('gym-mode-option-show-timer'),
title: Text(i18n.gymModeShowTimer),
value: gymState.showTimerPages,
onChanged: (value) => gymNotifier.setShowTimerPages(value),
),
ListTile(
key: const ValueKey('gym-mode-timer-type'),
enabled: gymState.showTimerPages,
title: Text(i18n.gymModeTimerType),
trailing: DropdownButton<bool>(
key: const ValueKey('countdown-type-dropdown'),
value: gymState.useCountdownBetweenSets,
onChanged: gymState.showTimerPages
? (bool? newValue) {
if (newValue != null) {
gymNotifier.setUseCountdownBetweenSets(newValue);
}
}
: null,
items: [false, true].map<DropdownMenuItem<bool>>((bool value) {
final label = value ? i18n.countdown : i18n.stopwatch;
return DropdownMenuItem<bool>(value: value, child: Text(label));
}).toList(),
),
subtitle: Text(i18n.gymModeTimerTypeHelText),
),
ListTile(
key: const ValueKey('gym-mode-default-countdown-time'),
enabled: gymState.showTimerPages,
title: TextFormField(
controller: _countdownController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: i18n.gymModeDefaultCountdownTime,
suffix: IconButton(
onPressed: gymState.showTimerPages && gymState.useCountdownBetweenSets
? () => gymNotifier.setCountdownDuration(
DEFAULT_COUNTDOWN_DURATION,
)
: null,
icon: const Icon(Icons.refresh),
),
),
onChanged: (value) {
final intValue = int.tryParse(value);
if (intValue != null &&
intValue > 0 &&
intValue < MAX_COUNTDOWN_DURATION) {
gymNotifier.setCountdownDuration(intValue);
}
},
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (String? value) {
final intValue = int.tryParse(value!);
if (intValue == null ||
intValue < MIN_COUNTDOWN_DURATION ||
intValue > MAX_COUNTDOWN_DURATION) {
return i18n.formMinMaxValues(
MIN_COUNTDOWN_DURATION,
MAX_COUNTDOWN_DURATION,
);
}
return null;
},
enabled: gymState.showTimerPages && gymState.useCountdownBetweenSets,
),
),
SwitchListTile(
key: const ValueKey('gym-mode-notify-countdown'),
title: Text(i18n.gymModeNotifyOnCountdownFinish),
value: gymState.alertOnCountdownEnd,
onChanged: (gymState.showTimerPages && gymState.useCountdownBetweenSets)
? (value) => gymNotifier.setAlertOnCountdownEnd(value)
: null,
),
],
),
),
),
),
),
crossFadeState: _showOptions ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
ListTile(
key: const ValueKey('gym-mode-options-tile'),
title: Text(i18n.settingsTitle),
leading: const Icon(Icons.settings),
onTap: () => setState(() => _showOptions = !_showOptions),
),
],
);
}
}
class StartPage extends ConsumerWidget {
final PageController _controller;
const StartPage(this._controller);
@override
Widget build(BuildContext context, WidgetRef ref) {
final dayDataDisplay = ref.watch(gymStateProvider).dayDataDisplay;
return Column(
children: [
NavigationHeader(
AppLocalizations.of(context).todaysWorkout,
_controller,
exercisePages: _exercisePages,
showEndWorkoutButton: false,
),
Expanded(
child: ListView(
children: [
if (_dayData.day!.isSpecialType)
if (dayDataDisplay.day!.isSpecialType)
Center(
child: Padding(
padding: const EdgeInsets.all(15),
child: Text(
'${_dayData.day!.type.name.toUpperCase()}\n${_dayData.day!.type.i18Label(AppLocalizations.of(context))}',
'${dayDataDisplay.day!.type.name.toUpperCase()}\n${dayDataDisplay.day!.type.i18Label(AppLocalizations.of(context))}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
),
),
..._dayData.slots.map((slotData) {
return Column(
children: [
...slotData.setConfigs
.fold<Map<Exercise, List<String>>>({}, (acc, entry) {
acc.putIfAbsent(entry.exercise, () => []).add(entry.textReprWithType);
return acc;
})
.entries
.map((entry) {
final exercise = entry.key;
return Column(
children: [
ListTile(
leading: SizedBox(
width: 45,
child: ExerciseImageWidget(image: exercise.getMainImage),
),
title: Text(
exercise
.getTranslation(Localizations.localeOf(context).languageCode)
.name,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entry.value.map((text) => Text(text)).toList(),
),
),
],
);
}),
],
);
}),
...dayDataDisplay.slots
.expand((slot) => slot.setConfigs)
.fold<Map<Exercise, List<String>>>({}, (acc, entry) {
acc.putIfAbsent(entry.exercise, () => []).add(entry.textReprWithType);
return acc;
})
.entries
.map((entry) {
final exercise = entry.key;
return Column(
children: [
ListTile(
leading: SizedBox(
width: 45,
child: ExerciseImageWidget(image: exercise.getMainImage),
),
title: Text(
exercise
.getTranslation(Localizations.localeOf(context).languageCode)
.name,
),
subtitle: Text(entry.value.toList().join('\n')),
),
],
);
}),
],
),
),
const GymModeOptions(),
FilledButton(
child: Text(AppLocalizations.of(context).start),
onPressed: () {
@@ -82,7 +247,7 @@ class StartPage extends StatelessWidget {
);
},
),
NavigationFooter(_controller, 0, showPrevious: false),
NavigationFooter(_controller, showPrevious: false),
],
);
}

View File

@@ -0,0 +1,209 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:clock/clock.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/date.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/routine.dart';
import 'package:wger/models/workouts/session_api.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/providers/routines.dart';
import 'package:wger/widgets/core/progress_indicator.dart';
import 'package:wger/widgets/routines/gym_mode/navigation.dart';
import '../logs/exercises_expansion_card.dart';
import '../logs/muscle_groups.dart';
class WorkoutSummary extends ConsumerStatefulWidget {
final _logger = Logger('WorkoutSummary');
final PageController _controller;
WorkoutSummary(this._controller);
@override
ConsumerState<WorkoutSummary> createState() => _WorkoutSummaryState();
}
class _WorkoutSummaryState extends ConsumerState<WorkoutSummary> {
late Future<void> _initData;
late Routine _routine;
@override
void initState() {
super.initState();
_initData = _reloadRoutineData();
}
Future<void> _reloadRoutineData() async {
widget._logger.fine('Loading routine data');
final gymState = ref.read(gymStateProvider);
_routine = await context.read<RoutinesProvider>().fetchAndSetRoutineFull(
gymState.routine.id!,
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
NavigationHeader(
AppLocalizations.of(context).workoutCompleted,
widget._controller,
showEndWorkoutButton: false,
),
Expanded(
child: FutureBuilder<void>(
future: _initData,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const BoxedProgressIndicator();
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}: ${snapshot.stackTrace}'));
} else if (snapshot.connectionState == ConnectionState.done) {
return WorkoutSessionStats(
_routine.sessions.firstWhereOrNull(
(s) => s.session.date.isSameDayAs(clock.now()),
),
);
}
return const Center(child: Text('Unexpected state!'));
},
),
),
NavigationFooter(widget._controller, showNext: false),
],
);
}
}
class WorkoutSessionStats extends ConsumerWidget {
final _logger = Logger('WorkoutSessionStats');
final WorkoutSessionApi? _sessionApi;
WorkoutSessionStats(this._sessionApi, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final i18n = AppLocalizations.of(context);
if (_sessionApi == null) {
return Center(
child: Text(
'Nothing logged yet.',
style: Theme.of(context).textTheme.titleMedium,
),
);
}
final session = _sessionApi.session;
final sessionDuration = session.duration;
final totalVolume = _sessionApi.volume;
/// We assume that users will do exercises (mostly) either in metric or imperial
/// units so we just display the higher one.
String volumeUnit;
num volumeValue;
if (totalVolume['metric']! > totalVolume['imperial']!) {
volumeValue = totalVolume['metric']!;
volumeUnit = i18n.kg;
} else {
volumeValue = totalVolume['imperial']!;
volumeUnit = i18n.lb;
}
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
Row(
children: [
Expanded(
child: InfoCard(
title: i18n.duration,
value: sessionDuration != null
? i18n.durationHoursMinutes(
sessionDuration.inHours,
sessionDuration.inMinutes.remainder(60),
)
: '-/-',
),
),
const SizedBox(width: 10),
Expanded(
child: InfoCard(
title: i18n.volume,
value: '${volumeValue.toStringAsFixed(0)} $volumeUnit',
),
),
],
),
// const SizedBox(height: 16),
// InfoCard(
// title: 'Personal Records',
// value: prCount.toString(),
// color: theme.colorScheme.tertiaryContainer,
// ),
const SizedBox(height: 10),
MuscleGroupsCard(_sessionApi.logs),
const SizedBox(height: 10),
ExercisesCard(_sessionApi),
FilledButton(
onPressed: () {
ref.read(gymStateProvider.notifier).clear();
Navigator.of(context).pop();
},
child: Text(i18n.endWorkout),
),
],
);
}
}
class InfoCard extends StatelessWidget {
final String title;
final String value;
final Color? color;
const InfoCard({required this.title, required this.value, this.color, super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
color: color,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(value, style: theme.textTheme.headlineMedium),
],
),
),
);
}
}

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
* Copyright (C) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@@ -18,19 +18,18 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/theme/theme.dart';
import 'package:wger/widgets/routines/gym_mode/navigation.dart';
class TimerWidget extends StatefulWidget {
final PageController _controller;
final double _ratioCompleted;
final Map<Exercise, int> _exercisePages;
final _totalPages;
const TimerWidget(this._controller, this._ratioCompleted, this._exercisePages, this._totalPages);
const TimerWidget(this._controller);
@override
_TimerWidgetState createState() => _TimerWidgetState();
@@ -69,8 +68,6 @@ class _TimerWidgetState extends State<TimerWidget> {
NavigationHeader(
AppLocalizations.of(context).pause,
widget._controller,
totalPages: widget._totalPages,
exercisePages: widget._exercisePages,
),
Expanded(
child: Center(
@@ -80,35 +77,31 @@ class _TimerWidgetState extends State<TimerWidget> {
),
),
),
NavigationFooter(widget._controller, widget._ratioCompleted),
NavigationFooter(widget._controller),
],
);
}
}
class TimerCountdownWidget extends StatefulWidget {
class TimerCountdownWidget extends ConsumerStatefulWidget {
final PageController _controller;
final double _ratioCompleted;
final int _seconds;
final Map<Exercise, int> _exercisePages;
final int _totalPages;
const TimerCountdownWidget(
this._controller,
this._seconds,
this._ratioCompleted,
this._exercisePages,
this._totalPages,
);
@override
_TimerCountdownWidgetState createState() => _TimerCountdownWidgetState();
}
class _TimerCountdownWidgetState extends State<TimerCountdownWidget> {
class _TimerCountdownWidgetState extends ConsumerState<TimerCountdownWidget> {
late DateTime _endTime;
late Timer _uiTimer;
bool _hasNotified = false;
@override
void initState() {
super.initState();
@@ -131,24 +124,40 @@ class _TimerCountdownWidgetState extends State<TimerCountdownWidget> {
final remaining = _endTime.difference(DateTime.now());
final remainingSeconds = remaining.inSeconds <= 0 ? 0 : remaining.inSeconds;
final displayTime = DateTime(2000, 1, 1, 0, 0, 0).add(Duration(seconds: remainingSeconds));
final gymState = ref.watch(gymStateProvider);
// When countdown finishes, notify ONCE, and respect settings
if (remainingSeconds == 0 && !_hasNotified) {
if (gymState.alertOnCountdownEnd) {
HapticFeedback.mediumImpact();
// Not that this only works on desktop platforms
SystemSound.play(SystemSoundType.alert);
}
setState(() {
_hasNotified = true;
});
}
return Column(
children: [
NavigationHeader(
AppLocalizations.of(context).pause,
widget._controller,
totalPages: widget._totalPages,
exercisePages: widget._exercisePages,
),
Expanded(
child: Center(
child: Text(
DateFormat('m:ss').format(displayTime),
style: Theme.of(context).textTheme.displayLarge!.copyWith(color: wgerPrimaryColor),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
DateFormat('m:ss').format(displayTime),
style: Theme.of(context).textTheme.displayLarge!.copyWith(color: wgerPrimaryColor),
),
const SizedBox(height: 16),
],
),
),
NavigationFooter(widget._controller, widget._ratioCompleted),
NavigationFooter(widget._controller),
],
);
}

View File

@@ -0,0 +1,454 @@
// /*
// * This file is part of wger Workout Manager <https://github.com/wger-project>.
// * Copyright (C) 2020, 2025 wger Team
// *
// * wger Workout Manager is free software: you can redistribute it and/or modify
// * it under the terms of the GNU Affero General Public License as published by
// * the Free Software Foundation, either version 3 of the License, or
// * (at your option) any later version.
// *
// * wger Workout Manager is distributed in the hope that it will be useful,
// * but WITHOUT ANY WARRANTY; without even the implied warranty of
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// * GNU Affero General Public License for more details.
// *
// * You should have received a copy of the GNU Affero General Public License
// * along with this program. If not, see <http://www.gnu.org/licenses/>.
// */
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/widgets/exercises/autocompleter.dart';
class WorkoutMenu extends StatelessWidget {
final PageController _controller;
final int initialIndex;
const WorkoutMenu(this._controller, {this.initialIndex = 0, super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
initialIndex: initialIndex,
length: 2,
child: Column(
children: [
const TabBar(
tabs: [
Tab(icon: Icon(Icons.menu_open)),
Tab(icon: Icon(Icons.stacked_bar_chart)),
],
),
Flexible(
child: TabBarView(
children: [
NavigationTab(_controller),
ProgressionTab(_controller),
],
),
),
],
),
);
}
}
class NavigationTab extends ConsumerWidget {
final PageController _controller;
const NavigationTab(this._controller);
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(gymStateProvider);
return SingleChildScrollView(
child: Column(
children: [
...state.pages.where((pageEntry) => pageEntry.type == PageType.set).map((page) {
return ListTile(
leading: page.allLogsDone ? const Icon(Icons.check) : null,
title: Text(
page.exercises
.map(
(exercise) => exercise
.getTranslation(Localizations.localeOf(context).languageCode)
.name,
)
.toList()
.join('\n'),
style: TextStyle(
decoration: page.allLogsDone ? TextDecoration.lineThrough : TextDecoration.none,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_controller.animateToPage(
page.pageIndex,
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
Navigator.of(context).pop();
},
);
}),
],
),
);
}
}
class ProgressionTab extends ConsumerStatefulWidget {
final _logger = Logger('ProgressionTab');
final PageController _controller;
ProgressionTab(this._controller, {super.key});
@override
ConsumerState<ProgressionTab> createState() => _ProgressionTabState();
}
class _ProgressionTabState extends ConsumerState<ProgressionTab> {
String? showSwapWidgetToPage;
String? showAddExerciseWidgetToPage;
_ProgressionTabState();
@override
Widget build(BuildContext context) {
final state = ref.watch(gymStateProvider);
final theme = Theme.of(context);
final languageCode = Localizations.localeOf(context).languageCode;
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Column(
children: [
...state.pages.where((page) => page.type == PageType.set).map((page) {
if (page.exercises.isEmpty) {
widget._logger.warning('Page ${page.uuid} has no exercises, skipping');
return Container();
}
// For supersets, prefix the exercise with A, B, C so it can be identified
// in the set list below
final isSuperset = page.exercises.length > 1;
final pageExerciseTitle = isSuperset
? page.exercises
.asMap()
.entries
.map((entry) {
final label = String.fromCharCode(65 + entry.key);
final name = entry.value
.getTranslation(Localizations.localeOf(context).languageCode)
.name;
return '$label: $name';
})
.join('\n')
: page.exercises.first.getTranslation(languageCode).name;
return Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(pageExerciseTitle, style: Theme.of(context).textTheme.bodyLarge),
...page.slotPages.where((slotPage) => slotPage.type == SlotPageType.log).map(
(slotPage) {
String setPrefix = '';
if (isSuperset) {
final exerciseIndex = page.exercises.indexWhere(
(ex) => ex.id == slotPage.setConfigData!.exercise.id,
);
if (exerciseIndex != -1) {
setPrefix = '${String.fromCharCode(65 + exerciseIndex)}: ';
}
}
// Sets that are done are marked with a strikethrough
final decoration = slotPage.logDone
? TextDecoration.lineThrough
: TextDecoration.none;
// Sets that are done have a lighter color
final color = slotPage.logDone
? theme.colorScheme.onSurface.withValues(alpha: 0.6)
: null;
// The row for the current page is highlighted in bold
final fontWeight = state.currentPage == slotPage.pageIndex
? FontWeight.bold
: null;
IconData icon = Icons.circle_outlined;
if (slotPage.logDone) {
icon = Icons.check_circle_rounded;
} else if (state.currentPage == slotPage.pageIndex) {
icon = Icons.play_circle_fill;
}
return Row(
children: [
Icon(icon, size: 16),
const SizedBox(width: 4),
Text(
'$setPrefix${slotPage.setConfigData!.textReprWithType}',
style: theme.textTheme.bodyMedium!.copyWith(
decoration: decoration,
fontWeight: fontWeight,
color: color,
),
),
],
);
},
),
Row(
mainAxisSize: MainAxisSize.max,
//mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: page.allLogsDone
? null
: () {
if (showSwapWidgetToPage == page.uuid) {
setState(() {
showSwapWidgetToPage = null;
});
} else {
setState(() {
showSwapWidgetToPage = page.uuid;
showAddExerciseWidgetToPage = null;
});
}
},
icon: Icon(
key: ValueKey('swap-icon-${page.uuid}'),
showSwapWidgetToPage == page.uuid
? Icons.change_circle
: Icons.change_circle_outlined,
),
),
IconButton(
onPressed: page.allLogsDone
? null
: () {
if (showAddExerciseWidgetToPage == page.uuid) {
setState(() {
showAddExerciseWidgetToPage = null;
});
} else {
setState(() {
showAddExerciseWidgetToPage = page.uuid;
showSwapWidgetToPage = null;
});
}
},
icon: Icon(
key: ValueKey('add-icon-${page.uuid}'),
showAddExerciseWidgetToPage == page.uuid ? Icons.add_circle : Icons.add,
),
),
Expanded(child: Container()),
IconButton(
onPressed: () {
widget._controller.animateToPage(
page.pageIndex,
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
Navigator.of(context).pop();
},
icon: const Icon(Icons.chevron_right),
),
],
),
if (showSwapWidgetToPage == page.uuid)
ExerciseSwapWidget(
page.uuid,
onDone: () {
setState(() {
showSwapWidgetToPage = null;
});
},
),
if (showAddExerciseWidgetToPage == page.uuid)
ExerciseAddWidget(
page.uuid,
onDone: () {
setState(() {
showAddExerciseWidgetToPage = null;
});
},
),
const SizedBox(height: 8),
],
);
}),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Swapping or adding an exercise only affects the current workout, '
'no changes are saved.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
],
),
),
);
}
}
class ExerciseSwapWidget extends ConsumerWidget {
final _logger = Logger('ExerciseSwapWidget');
final String pageUUID;
final VoidCallback? onDone;
ExerciseSwapWidget(this.pageUUID, {this.onDone, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(gymStateProvider);
final gymProvider = ref.read(gymStateProvider.notifier);
final page = state.pages.firstWhere((p) => p.uuid == pageUUID);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Card(
child: Padding(
padding: const EdgeInsets.all(5),
child: Column(
children: [
...page.exercises.map((e) {
return Column(
mainAxisSize: MainAxisSize.max,
children: [
Text(
e.getTranslation(Localizations.localeOf(context).languageCode).name,
style: Theme.of(context).textTheme.bodyLarge,
),
const Icon(Icons.swap_vert),
ExerciseAutocompleter(
onExerciseSelected: (exercise) {
gymProvider.replaceExercises(
page.uuid,
originalExerciseId: e.id!,
newExercise: exercise,
);
onDone?.call();
_logger.fine('Replaced exercise ${e.id} with ${exercise.id}');
},
),
const SizedBox(height: 10),
],
);
}),
],
),
),
),
);
}
}
class ExerciseAddWidget extends ConsumerWidget {
final _logger = Logger('ExerciseAddWidget');
final String pageUUID;
final VoidCallback? onDone;
ExerciseAddWidget(this.pageUUID, {this.onDone, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(gymStateProvider);
final gymProvider = ref.read(gymStateProvider.notifier);
final page = state.pages.firstWhere((p) => p.uuid == pageUUID);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Card(
child: Padding(
padding: const EdgeInsets.all(5),
child: Column(
children: [
ExerciseAutocompleter(
onExerciseSelected: (exercise) {
gymProvider.addExerciseAfterPage(
page.uuid,
newExercise: exercise,
);
onDone?.call();
_logger.fine('Added exercise ${exercise.id} after page $pageUUID');
},
),
const Icon(Icons.arrow_downward),
const SizedBox(height: 10),
],
),
),
),
);
}
}
class WorkoutMenuDialog extends ConsumerWidget {
final PageController controller;
final bool showEndWorkoutButton;
final int initialIndex;
const WorkoutMenuDialog(
this.controller, {
super.key,
this.showEndWorkoutButton = true,
this.initialIndex = 0,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final gymState = ref.watch(gymStateProvider);
final endWorkoutButton = true
? TextButton(
child: Text(AppLocalizations.of(context).endWorkout),
onPressed: () {
controller.animateToPage(
gymState.totalPages,
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
);
Navigator.of(context).pop();
},
)
: null;
return AlertDialog(
title: Text(
AppLocalizations.of(context).jumpTo,
textAlign: TextAlign.center,
),
contentPadding: EdgeInsets.zero,
content: SizedBox(
width: double.maxFinite,
child: WorkoutMenu(controller, initialIndex: initialIndex),
),
actions: [
?endWorkoutButton,
TextButton(
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
}
}

View File

@@ -1,225 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* wger Workout Manager is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:wger/helpers/colors.dart';
import 'package:wger/helpers/date.dart';
import 'package:wger/helpers/errors.dart';
import 'package:wger/helpers/misc.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/log.dart';
import 'package:wger/models/workouts/routine.dart';
import 'package:wger/models/workouts/session.dart';
import 'package:wger/widgets/measurements/charts.dart';
import 'package:wger/widgets/routines/charts.dart';
import 'package:wger/widgets/routines/forms/session.dart';
class SessionInfo extends StatefulWidget {
final WorkoutSession _session;
const SessionInfo(this._session);
@override
State<SessionInfo> createState() => _SessionInfoState();
}
class _SessionInfoState extends State<SessionInfo> {
bool editMode = false;
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
ListTile(
title: Text(
i18n.workoutSession,
style: Theme.of(context).textTheme.headlineSmall,
),
subtitle: Text(
DateFormat.yMd(
Localizations.localeOf(context).languageCode,
).format(widget._session.date),
),
onTap: () => setState(() => editMode = !editMode),
trailing: Icon(editMode ? Icons.edit_off : Icons.edit),
contentPadding: EdgeInsets.zero,
),
if (editMode)
SessionForm(
widget._session.routineId!,
onSaved: () => setState(() => editMode = false),
session: widget._session,
)
else
Column(
children: [
_buildInfoRow(
context,
i18n.timeStart,
widget._session.timeStart != null
? MaterialLocalizations.of(
context,
).formatTimeOfDay(widget._session.timeStart!)
: '-/-',
),
_buildInfoRow(
context,
i18n.timeEnd,
widget._session.timeEnd != null
? MaterialLocalizations.of(context).formatTimeOfDay(widget._session.timeEnd!)
: '-/-',
),
_buildInfoRow(
context,
i18n.impression,
widget._session.impressionAsString,
),
_buildInfoRow(
context,
i18n.notes,
widget._session.notes.isNotEmpty ? widget._session.notes : '-/-',
),
],
),
],
),
);
}
Widget _buildInfoRow(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$label: ',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Expanded(child: Text(value)),
],
),
);
}
}
class ExerciseLogChart extends StatelessWidget {
final Map<num, List<Log>> _logs;
final DateTime _selectedDate;
const ExerciseLogChart(this._logs, this._selectedDate);
@override
Widget build(BuildContext context) {
final colors = generateChartColors(_logs.keys.length).iterator;
return Column(
mainAxisSize: MainAxisSize.max,
children: [
LogChartWidgetFl(_logs, _selectedDate),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
..._logs.keys.map((reps) {
colors.moveNext();
return Indicator(
color: colors.current,
text: formatNum(reps).toString(),
isSquare: false,
);
}),
],
),
),
const SizedBox(height: 15),
],
);
}
}
class DayLogWidget extends StatelessWidget {
final DateTime _date;
final Routine _routine;
const DayLogWidget(this._date, this._routine);
@override
Widget build(BuildContext context) {
final sessionApi = _routine.sessions.firstWhere(
(sessionApi) => sessionApi.session.date.isSameDayAs(_date),
);
final exercises = sessionApi.exercises;
return Card(
child: Column(
children: [
SessionInfo(sessionApi.session),
...exercises.map((exercise) {
final translation = exercise.getTranslation(
Localizations.localeOf(context).languageCode,
);
return Column(
children: [
Text(
translation.name,
style: Theme.of(context).textTheme.titleMedium,
),
...sessionApi.logs
.where((l) => l.exerciseId == exercise.id)
.map(
(log) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(log.singleLogRepTextNoNl),
IconButton(
icon: const Icon(Icons.delete),
key: ValueKey('delete-log-${log.id}'),
onPressed: () {
showDeleteDialog(context, translation.name, log);
},
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: ExerciseLogChart(
_routine.groupLogsByRepetition(
logs: _routine.filterLogsByExercise(exercise.id!),
filterNullReps: true,
filterNullWeights: true,
),
_date,
),
),
],
);
}),
],
),
);
}
}

View File

@@ -0,0 +1,101 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:wger/helpers/date.dart';
import 'package:wger/helpers/errors.dart';
import 'package:wger/models/workouts/routine.dart';
import 'exercise_log_chart.dart';
import 'muscle_groups.dart';
import 'session_info.dart';
class DayLogWidget extends StatelessWidget {
final DateTime _date;
final Routine _routine;
const DayLogWidget(this._date, this._routine);
@override
Widget build(BuildContext context) {
final sessionApi = _routine.sessions.firstWhere(
(sessionApi) => sessionApi.session.date.isSameDayAs(_date),
);
final exercises = sessionApi.exercises;
return Column(
spacing: 10,
children: [
Card(child: SessionInfo(sessionApi.session)),
MuscleGroupsCard(sessionApi.logs),
Column(
spacing: 10,
children: [
...exercises.map((exercise) {
final translation = exercise.getTranslation(
Localizations.localeOf(context).languageCode,
);
return Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Text(
translation.name,
style: Theme.of(context).textTheme.titleLarge,
),
...sessionApi.logs
.where((l) => l.exerciseId == exercise.id)
.map(
(log) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(log.repTextNoNl(context)),
IconButton(
icon: const Icon(Icons.delete),
key: ValueKey('delete-log-${log.id}'),
onPressed: () {
showDeleteDialog(context, translation.name, log);
},
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: ExerciseLogChart(
_routine.groupLogsByRepetition(
logs: _routine.filterLogsByExercise(exercise.id!),
filterNullReps: true,
filterNullWeights: true,
),
_date,
),
),
],
),
),
);
}),
],
),
],
);
}
}

View File

@@ -0,0 +1,61 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/widgets.dart';
import 'package:wger/helpers/colors.dart';
import 'package:wger/helpers/misc.dart';
import 'package:wger/models/workouts/log.dart';
import 'package:wger/widgets/measurements/charts.dart';
import 'package:wger/widgets/routines/charts.dart';
class ExerciseLogChart extends StatelessWidget {
final Map<num, List<Log>> _logs;
final DateTime _selectedDate;
const ExerciseLogChart(this._logs, this._selectedDate);
@override
Widget build(BuildContext context) {
final colors = generateChartColors(_logs.keys.length).iterator;
return Column(
mainAxisSize: MainAxisSize.max,
children: [
LogChartWidgetFl(_logs, _selectedDate),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
..._logs.keys.map((reps) {
colors.moveNext();
return Indicator(
color: colors.current,
text: formatNum(reps).toString(),
isSquare: false,
);
}),
],
),
),
const SizedBox(height: 15),
],
);
}
}

View File

@@ -0,0 +1,115 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020, 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:wger/helpers/i18n.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/log.dart';
import 'package:wger/models/workouts/session_api.dart';
class ExercisesCard extends StatelessWidget {
final WorkoutSessionApi session;
const ExercisesCard(this.session, {super.key});
@override
Widget build(BuildContext context) {
final exercises = session.exercises;
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).exercises,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
...exercises.map((exercise) {
final logs = session.logs.where((log) => log.exerciseId == exercise.id).toList();
return _ExerciseExpansionTile(exercise: exercise, logs: logs);
}),
],
),
),
);
}
}
class _ExerciseExpansionTile extends StatelessWidget {
const _ExerciseExpansionTile({
required this.exercise,
required this.logs,
});
final Exercise exercise;
final List<Log> logs;
@override
Widget build(BuildContext context) {
final languageCode = Localizations.localeOf(context).languageCode;
final theme = Theme.of(context);
final topSet = logs.isEmpty
? null
: logs.reduce((a, b) => (a.weight ?? 0) > (b.weight ?? 0) ? a : b);
final topSetWeight = topSet?.weight?.toStringAsFixed(0) ?? 'N/A';
final topSetWeightUnit = topSet?.weightUnitObj != null
? getServerStringTranslation(topSet!.weightUnitObj!.name, context)
: '';
return ExpansionTile(
// leading: const Icon(Icons.fitness_center),
title: Text(exercise.getTranslation(languageCode).name, style: theme.textTheme.titleMedium),
subtitle: Text('Top set: $topSetWeight $topSetWeightUnit'),
children: logs.map((log) => _SetDataRow(log: log)).toList(),
);
}
}
class _SetDataRow extends StatelessWidget {
const _SetDataRow({required this.log});
final Log log;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final i18n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 5,
children: [
Text(
log.repTextNoNl(context),
style: theme.textTheme.bodyMedium,
),
// if (log.volume() > 0)
// Text(
// '${log.volume().toStringAsFixed(0)} ${getServerStringTranslation(log.weightUnitObj!.name, context)}',
// style: theme.textTheme.bodyMedium,
// ),
],
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More