Merge branch 'master' into feature/day-type

# Conflicts:
#	lib/models/workouts/day.g.dart
#	lib/models/workouts/day_data.g.dart
#	lib/models/workouts/set_config_data.g.dart
#	lib/models/workouts/slot_data.g.dart
#	lib/models/workouts/slot_entry.g.dart
#	lib/models/workouts/weight_unit.g.dart
#	test/auth/auth_screen_test.mocks.dart
#	test/core/settings_test.mocks.dart
#	test/exercises/contribute_exercise_test.mocks.dart
#	test/gallery/gallery_form_test.mocks.dart
#	test/gallery/gallery_screen_test.mocks.dart
#	test/measurements/measurement_categories_screen_test.mocks.dart
#	test/measurements/measurement_provider_test.mocks.dart
#	test/nutrition/nutritional_meal_form_test.mocks.dart
#	test/nutrition/nutritional_plan_form_test.mocks.dart
#	test/nutrition/nutritional_plan_screen_test.mocks.dart
#	test/nutrition/nutritional_plans_screen_test.mocks.dart
#	test/other/base_provider_test.mocks.dart
#	test/routine/day_form_test.mocks.dart
#	test/routine/gym_mode_screen_test.mocks.dart
#	test/routine/gym_mode_session_screen_test.mocks.dart
#	test/routine/repetition_unit_form_widget_test.mocks.dart
#	test/routine/routine_edit_screen_test.mocks.dart
#	test/routine/routine_edit_test.mocks.dart
#	test/routine/routine_form_test.mocks.dart
#	test/routine/routine_logs_screen_test.mocks.dart
#	test/routine/routine_screen_test.mocks.dart
#	test/routine/routines_provider_test.mocks.dart
#	test/routine/routines_screen_test.mocks.dart
#	test/routine/slot_entry_form_test.mocks.dart
#	test/routine/weight_unit_form_widget_test.mocks.dart
#	test/user/provider_test.mocks.dart
#	test/weight/weight_provider_test.mocks.dart
#	test/weight/weight_screen_test.mocks.dart
This commit is contained in:
Roland Geider
2025-10-09 17:39:39 +02:00
157 changed files with 7831 additions and 3427 deletions

View File

@@ -22,7 +22,7 @@ jobs:
uses: ./.github/actions/flutter-common
- name: Setup uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
- name: Validate version
run: |

3
.gitignore vendored
View File

@@ -30,6 +30,8 @@
.pub-cache/
.pub/
/build/
/**/build/
/android/app/.cxx
**/failures/*.png
@@ -62,3 +64,4 @@ ios/fastlane/Preview.html
ios/fastlane/output
ios/Runner.ipa
ios/Runner.app.dSYM.zip

View File

@@ -21,6 +21,7 @@ analyzer:
formatter:
page_width: 100
trailing_commas: preserve
linter:
rules:

View File

@@ -25,23 +25,27 @@ import '../test_data/routines.dart';
Widget createDashboardScreen({String locale = 'en'}) {
final mockWorkoutProvider = MockRoutinesProvider();
when(mockWorkoutProvider.items).thenReturn([getTestRoutine(exercises: getScreenshotExercises())]);
when(mockWorkoutProvider.currentRoutine)
.thenReturn(getTestRoutine(exercises: getScreenshotExercises()));
when(
mockWorkoutProvider.currentRoutine,
).thenReturn(getTestRoutine(exercises: getScreenshotExercises()));
when(mockWorkoutProvider.fetchSessionData()).thenAnswer((a) => Future.value([
WorkoutSession(
routineId: 1,
date: DateTime.now().add(const Duration(days: -1)),
timeStart: const TimeOfDay(hour: 17, minute: 34),
timeEnd: const TimeOfDay(hour: 19, minute: 3),
impression: 3,
),
]));
when(mockWorkoutProvider.fetchSessionData()).thenAnswer(
(a) => Future.value([
WorkoutSession(
routineId: 1,
date: DateTime.now().add(const Duration(days: -1)),
timeStart: const TimeOfDay(hour: 17, minute: 34),
timeEnd: const TimeOfDay(hour: 19, minute: 3),
impression: 3,
),
]),
);
final mockNutritionProvider = weight.MockNutritionPlansProvider();
when(mockNutritionProvider.currentPlan)
.thenAnswer((realInvocation) => getNutritionalPlanScreenshot());
when(
mockNutritionProvider.currentPlan,
).thenAnswer((realInvocation) => getNutritionalPlanScreenshot());
when(mockNutritionProvider.items).thenReturn([getNutritionalPlanScreenshot()]);
final mockWeightProvider = weight.MockBodyWeightProvider();

View File

@@ -17,7 +17,7 @@ enum DeviceType {
sevenInchScreenshots,
tenInchScreenshots,
tvScreenshots,
wearScreenshots
wearScreenshots,
}
final destination = DeviceType.phoneScreenshots.name;
@@ -42,7 +42,6 @@ Future<void> takeScreenshot(
const languages = [
// Note: it seems if too many languages are processed at once, some processes
// disappear and no images are written. Doing this in smaller steps works fine
'ar',
'ca',
'cs-CZ',

29
lib/core/validators.dart Normal file
View File

@@ -0,0 +1,29 @@
import 'package:wger/l10n/generated/app_localizations.dart';
String? validateUrl(String? value, AppLocalizations i18n, {bool required = true}) {
// Required?
if (required && (value == null || value.trim().isEmpty)) {
return i18n.enterValue;
}
if (!required && (value == null || value.trim().isEmpty)) {
return null;
}
value = value!.trim();
if (!value.startsWith('http://') && !value.startsWith('https://')) {
return i18n.invalidUrl;
}
// Try to parse as URI
try {
final uri = Uri.parse(value);
if (!uri.hasScheme || !uri.hasAuthority) {
return i18n.invalidUrl;
}
} catch (e) {
return i18n.invalidUrl;
}
return null;
}

View File

@@ -85,20 +85,20 @@ class ExerciseDatabase extends _$ExerciseDatabase {
/// will fetch everything as needed from the server
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
// no-op, but needs to be defined
return;
},
beforeOpen: (openingDetails) async {
if (openingDetails.hadUpgrade) {
final m = createMigrator();
for (final table in allTables) {
await m.deleteTable(table.actualTableName);
await m.createTable(table);
}
}
},
);
onUpgrade: (m, from, to) async {
// no-op, but needs to be defined
return;
},
beforeOpen: (openingDetails) async {
if (openingDetails.hadUpgrade) {
final m = createMigrator();
for (final table in allTables) {
await m.deleteTable(table.actualTableName);
await m.createTable(table);
}
}
},
);
Future<void> deleteEverything() {
return transaction(() async {

File diff suppressed because it is too large Load Diff

View File

@@ -38,20 +38,20 @@ class IngredientDatabase extends _$IngredientDatabase {
/// will fetch everything as needed from the server
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
// no-op, but needs to be defined
return;
},
beforeOpen: (openingDetails) async {
if (openingDetails.hadUpgrade) {
final m = createMigrator();
for (final table in allTables) {
await m.deleteTable(table.actualTableName);
await m.createTable(table);
}
}
},
);
onUpgrade: (m, from, to) async {
// no-op, but needs to be defined
return;
},
beforeOpen: (openingDetails) async {
if (openingDetails.hadUpgrade) {
final m = createMigrator();
for (final table in allTables) {
await m.deleteTable(table.actualTableName);
await m.createTable(table);
}
}
},
);
Future<void> deleteEverything() {
return transaction(() async {

View File

@@ -7,30 +7,52 @@ 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>('id', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true);
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
static const VerificationMeta _dataMeta = const VerificationMeta('data');
@override
late final GeneratedColumn<String> data = GeneratedColumn<String>('data', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
late final GeneratedColumn<String> data = GeneratedColumn<String>(
'data',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _lastFetchedMeta = const VerificationMeta('lastFetched');
@override
late final GeneratedColumn<DateTime> lastFetched = GeneratedColumn<DateTime>(
'last_fetched', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
'last_fetched',
aliasedName,
false,
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,
{bool isInserting = false}) {
VerificationContext validateIntegrity(
Insertable<IngredientTable> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
@@ -44,8 +66,10 @@ class $IngredientsTable extends Ingredients with TableInfo<$IngredientsTable, In
context.missing(_dataMeta);
}
if (data.containsKey('last_fetched')) {
context.handle(_lastFetchedMeta,
lastFetched.isAcceptableOrUnknown(data['last_fetched']!, _lastFetchedMeta));
context.handle(
_lastFetchedMeta,
lastFetched.isAcceptableOrUnknown(data['last_fetched']!, _lastFetchedMeta),
);
} else if (isInserting) {
context.missing(_lastFetchedMeta);
}
@@ -54,14 +78,17 @@ 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'])!,
lastFetched: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}last_fetched'])!,
lastFetched: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}last_fetched'],
)!,
);
}
@@ -77,7 +104,9 @@ 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});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
@@ -88,11 +117,7 @@ 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}) {
@@ -103,6 +128,7 @@ class IngredientTable extends DataClass implements Insertable<IngredientTable> {
lastFetched: serializer.fromJson<DateTime>(json['lastFetched']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
@@ -114,10 +140,11 @@ class IngredientTable extends DataClass implements Insertable<IngredientTable> {
}
IngredientTable copyWith({int? id, String? data, DateTime? lastFetched}) => IngredientTable(
id: id ?? this.id,
data: data ?? this.data,
lastFetched: lastFetched ?? this.lastFetched,
);
id: id ?? this.id,
data: data ?? this.data,
lastFetched: lastFetched ?? this.lastFetched,
);
IngredientTable copyWithCompanion(IngredientsCompanion data) {
return IngredientTable(
id: data.id.present ? data.id.value : this.id,
@@ -138,6 +165,7 @@ class IngredientTable extends DataClass implements Insertable<IngredientTable> {
@override
int get hashCode => Object.hash(id, data, lastFetched);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -152,20 +180,23 @@ 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,
required DateTime lastFetched,
this.rowid = const Value.absent(),
}) : id = Value(id),
data = Value(data),
lastFetched = Value(lastFetched);
}) : id = Value(id),
data = Value(data),
lastFetched = Value(lastFetched);
static Insertable<IngredientTable> custom({
Expression<int>? id,
Expression<String>? data,
@@ -180,8 +211,12 @@ class IngredientsCompanion extends UpdateCompanion<IngredientTable> {
});
}
IngredientsCompanion copyWith(
{Value<int>? id, Value<String>? data, Value<DateTime>? lastFetched, Value<int>? rowid}) {
IngredientsCompanion copyWith({
Value<int>? id,
Value<String>? data,
Value<DateTime>? lastFetched,
Value<int>? rowid,
}) {
return IngredientsCompanion(
id: id ?? this.id,
data: data ?? this.data,
@@ -222,27 +257,32 @@ 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];
}
typedef $$IngredientsTableCreateCompanionBuilder = IngredientsCompanion Function({
required int id,
required String data,
required DateTime lastFetched,
Value<int> rowid,
});
typedef $$IngredientsTableUpdateCompanionBuilder = IngredientsCompanion Function({
Value<int> id,
Value<String> data,
Value<DateTime> lastFetched,
Value<int> rowid,
});
typedef $$IngredientsTableCreateCompanionBuilder =
IngredientsCompanion Function({
required int id,
required String data,
required DateTime lastFetched,
Value<int> rowid,
});
typedef $$IngredientsTableUpdateCompanionBuilder =
IngredientsCompanion Function({
Value<int> id,
Value<String> data,
Value<DateTime> lastFetched,
Value<int> rowid,
});
class $$IngredientsTableFilterComposer extends Composer<_$IngredientDatabase, $IngredientsTable> {
$$IngredientsTableFilterComposer({
@@ -252,6 +292,7 @@ class $$IngredientsTableFilterComposer extends Composer<_$IngredientDatabase, $I
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column));
@@ -270,6 +311,7 @@ class $$IngredientsTableOrderingComposer extends Composer<_$IngredientDatabase,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column));
@@ -289,6 +331,7 @@ class $$IngredientsTableAnnotationComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id => $composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get data =>
@@ -298,72 +341,80 @@ class $$IngredientsTableAnnotationComposer
$composableBuilder(column: $table.lastFetched, builder: (column) => column);
}
class $$IngredientsTableTableManager extends RootTableManager<
_$IngredientDatabase,
$IngredientsTable,
IngredientTable,
$$IngredientsTableFilterComposer,
$$IngredientsTableOrderingComposer,
$$IngredientsTableAnnotationComposer,
$$IngredientsTableCreateCompanionBuilder,
$$IngredientsTableUpdateCompanionBuilder,
(IngredientTable, BaseReferences<_$IngredientDatabase, $IngredientsTable, IngredientTable>),
IngredientTable,
PrefetchHooks Function()> {
class $$IngredientsTableTableManager
extends
RootTableManager<
_$IngredientDatabase,
$IngredientsTable,
IngredientTable,
$$IngredientsTableFilterComposer,
$$IngredientsTableOrderingComposer,
$$IngredientsTableAnnotationComposer,
$$IngredientsTableCreateCompanionBuilder,
$$IngredientsTableUpdateCompanionBuilder,
(
IngredientTable,
BaseReferences<_$IngredientDatabase, $IngredientsTable, IngredientTable>,
),
IngredientTable,
PrefetchHooks Function()
> {
$$IngredientsTableTableManager(_$IngredientDatabase db, $IngredientsTable table)
: super(TableManagerState(
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () => $$IngredientsTableFilterComposer($db: db, $table: table),
createOrderingComposer: () => $$IngredientsTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$IngredientsTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
Value<int> id = const Value.absent(),
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,
),
createCompanionCallback: ({
required int id,
required String data,
required DateTime lastFetched,
Value<int> rowid = const Value.absent(),
}) =>
IngredientsCompanion.insert(
id: id,
data: data,
lastFetched: lastFetched,
rowid: rowid,
),
updateCompanionCallback:
({
Value<int> id = const Value.absent(),
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),
createCompanionCallback:
({
required int id,
required String data,
required DateTime lastFetched,
Value<int> rowid = const Value.absent(),
}) => IngredientsCompanion.insert(
id: id,
data: data,
lastFetched: lastFetched,
rowid: rowid,
),
withReferenceMapper: (p0) =>
p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(),
prefetchHooksCallback: null,
));
),
);
}
typedef $$IngredientsTableProcessedTableManager = ProcessedTableManager<
_$IngredientDatabase,
$IngredientsTable,
IngredientTable,
$$IngredientsTableFilterComposer,
$$IngredientsTableOrderingComposer,
$$IngredientsTableAnnotationComposer,
$$IngredientsTableCreateCompanionBuilder,
$$IngredientsTableUpdateCompanionBuilder,
(IngredientTable, BaseReferences<_$IngredientDatabase, $IngredientsTable, IngredientTable>),
IngredientTable,
PrefetchHooks Function()>;
typedef $$IngredientsTableProcessedTableManager =
ProcessedTableManager<
_$IngredientDatabase,
$IngredientsTable,
IngredientTable,
$$IngredientsTableFilterComposer,
$$IngredientsTableOrderingComposer,
$$IngredientsTableAnnotationComposer,
$$IngredientsTableCreateCompanionBuilder,
$$IngredientsTableUpdateCompanionBuilder,
(IngredientTable, BaseReferences<_$IngredientDatabase, $IngredientsTable, IngredientTable>),
IngredientTable,
PrefetchHooks Function()
>;
class $IngredientDatabaseManager {
final _$IngredientDatabase _db;
$IngredientDatabaseManager(this._db);
$$IngredientsTableTableManager get ingredients =>
$$IngredientsTableTableManager(_db, _db.ingredients);
}

View File

@@ -143,3 +143,6 @@ const String API_RESULTS_PAGE_SIZE = '100';
/// Marker used for identifying interpolated values in a list, e.g. for measurements
/// the milliseconds in the entry date are set to this value
const INTERPOLATION_MARKER = 123;
/// Creative Commons license IDs
const CC_BY_SA_4_ID = 2;

View File

@@ -101,7 +101,8 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
if (error is TimeoutException) {
issueTitle = 'Network Timeout';
issueErrorMessage = 'The connection to the server timed out. Please check your '
issueErrorMessage =
'The connection to the server timed out. Please check your '
'internet connection and try again.';
} else if (error is FlutterErrorDetails) {
issueTitle = 'Application Error';
@@ -116,7 +117,7 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
}
final String fullStackTrace = stackTrace?.toString() ?? 'No stack trace available.';
final applicationLogs = InMemoryLogStore().formattedLogs;
final applicationLogs = InMemoryLogStore().getFormattedLogs();
showDialog(
context: dialogContext,
@@ -127,15 +128,9 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
spacing: 8,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
color: Theme.of(context).colorScheme.error,
),
Icon(icon, color: Theme.of(context).colorScheme.error),
Expanded(
child: Text(
errorTitle,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
child: Text(errorTitle, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
],
),
@@ -150,10 +145,7 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
tilePadding: EdgeInsets.zero,
title: Text(i18n.errorViewDetails),
children: [
Text(
issueErrorMessage,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(issueErrorMessage, style: const TextStyle(fontWeight: FontWeight.bold)),
Container(
alignment: Alignment.topLeft,
padding: const EdgeInsets.symmetric(vertical: 8.0),
@@ -166,15 +158,13 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
),
),
CopyToClipboardButton(
text: 'Error Title: $issueTitle\n'
text:
'Error Title: $issueTitle\n'
'Error Message: $issueErrorMessage\n\n'
'Stack Trace:\n$fullStackTrace',
),
const SizedBox(height: 8),
Text(
i18n.applicationLogs,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(i18n.applicationLogs, style: const TextStyle(fontWeight: FontWeight.bold)),
Container(
alignment: Alignment.topLeft,
padding: const EdgeInsets.symmetric(vertical: 8.0),
@@ -182,10 +172,12 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
child: SingleChildScrollView(
child: Column(
children: [
...applicationLogs.map((entry) => Text(
entry,
style: TextStyle(fontSize: 12.0, color: Colors.grey[700]),
))
...applicationLogs.map(
(entry) => Text(
entry,
style: TextStyle(fontSize: 12.0, color: Colors.grey[700]),
),
),
],
),
),
@@ -215,7 +207,8 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
'App logs (last ${applicationLogs.length} entries):\n'
'```\n$logText\n```',
);
final githubIssueUrl = '$GITHUB_ISSUES_BUG_URL'
final githubIssueUrl =
'$GITHUB_ISSUES_BUG_URL'
'&title=$issueTitle'
'&description=$description';
final Uri reportUri = Uri.parse(githubIssueUrl);
@@ -226,9 +219,9 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
if (kDebugMode) {
logger.warning('Error launching URL: $e');
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error opening issue tracker: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error opening issue tracker: $e')));
}
},
),
@@ -248,10 +241,7 @@ class CopyToClipboardButton extends StatelessWidget {
final logger = Logger('CopyToClipboardButton');
final String text;
CopyToClipboardButton({
required this.text,
super.key,
});
CopyToClipboardButton({required this.text, super.key});
@override
Widget build(BuildContext context) {
@@ -265,21 +255,23 @@ class CopyToClipboardButton extends StatelessWidget {
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
Clipboard.setData(ClipboardData(text: text)).then((_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Details copied to clipboard!')),
);
}
}).catchError((copyError) {
logger.warning('Error copying to clipboard: $copyError');
Clipboard.setData(ClipboardData(text: text))
.then((_) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Details copied to clipboard!')));
}
})
.catchError((copyError) {
logger.warning('Error copying to clipboard: $copyError');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not copy details.')),
);
}
});
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Could not copy details.')));
}
});
},
);
}
@@ -290,9 +282,7 @@ void showDeleteDialog(BuildContext context, String confirmDeleteName, Log log) a
context: context,
builder: (BuildContext contextDialog) {
return AlertDialog(
content: Text(
AppLocalizations.of(context).confirmDelete(confirmDeleteName),
),
content: Text(AppLocalizations.of(context).confirmDelete(confirmDeleteName)),
actions: [
TextButton(
key: const ValueKey('cancel-button'),
@@ -387,7 +377,10 @@ List<Widget> formatApiErrors(List<ApiError> errors, {Color? color}) {
for (final error in errors) {
errorList.add(
Text(error.key, style: TextStyle(fontWeight: FontWeight.bold, color: textColor)),
Text(
error.key,
style: TextStyle(fontWeight: FontWeight.bold, color: textColor),
),
);
print(error.errorMessages);
@@ -408,7 +401,10 @@ List<Widget> formatTextErrors(List<String> errors, {String? title, Color? color}
if (title != null) {
errorList.add(
Text(title, style: TextStyle(fontWeight: FontWeight.bold, color: textColor)),
Text(
title,
style: TextStyle(fontWeight: FontWeight.bold, color: textColor),
),
);
}
@@ -458,11 +454,7 @@ class GeneralErrorsWidget extends StatelessWidget {
child: Column(
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error),
...formatTextErrors(
widgets,
title: title,
color: Theme.of(context).colorScheme.error,
),
...formatTextErrors(widgets, title: title, color: Theme.of(context).colorScheme.error),
],
),
),

View File

@@ -14,10 +14,17 @@ String? validateName(String? name, BuildContext context) {
}
if (name.length < MIN_CHARS_NAME || name.length > MAX_CHARS_NAME) {
return AppLocalizations.of(context).enterCharacters(
MIN_CHARS_NAME.toString(),
MAX_CHARS_NAME.toString(),
);
return AppLocalizations.of(
context,
).enterCharacters(MIN_CHARS_NAME.toString(), MAX_CHARS_NAME.toString());
}
return null;
}
String? validateAuthorName(String? name, BuildContext context) {
if (name!.isEmpty) {
return AppLocalizations.of(context).enterValue;
}
return null;

View File

@@ -41,10 +41,16 @@ class InMemoryLogStore {
List<LogRecord> get logs => List.unmodifiable(_logs);
List<String> get formattedLogs => _logs
.map((log) =>
'${log.time.toIso8601String()} ${log.level.name} [${log.loggerName}] ${log.message}')
.toList();
List<String> getFormattedLogs({Level? minLevel}) {
final level = minLevel ?? Logger.root.level;
return _logs
.where((log) => log.level >= level)
.map(
(log) =>
'${log.time.toIso8601String()} ${log.level.name} [${log.loggerName}] ${log.message}',
)
.toList();
}
void clear() => _logs.clear();
}

View File

@@ -25,11 +25,11 @@ extension MeasurementChartEntryListExtensions on List<MeasurementChartEntry> {
return where((e) => e.date.isAfter(start) && (end == null || e.date.isBefore(end))).toList();
}
// assures values on the start (and optionally end) dates exist, by interpolating if needed
// this is used for when you are looking at a specific time frame (e.g. for a nutrition plan)
// while gaps in the middle of a chart can be "visually interpolated", it's good to have a clearer
// explicit interpolation for the start and end dates (if needed)
// this also helps with computing delta's across the entire window
// assures values on the start (and optionally end) dates exist, by interpolating if needed
// this is used for when you are looking at a specific time frame (e.g. for a nutrition plan)
// while gaps in the middle of a chart can be "visually interpolated", it's good to have a clearer
// explicit interpolation for the start and end dates (if needed)
// this also helps with computing delta's across the entire window
List<MeasurementChartEntry> whereDateWithInterpolation(DateTime start, DateTime? end) {
// Make sure our list is sorted by date
sort((a, b) => a.date.compareTo(b.date));
@@ -90,7 +90,10 @@ extension MeasurementChartEntryListExtensions on List<MeasurementChartEntry> {
// caller needs to make sure that before.date < date < after.date
MeasurementChartEntry interpolateBetween(
MeasurementChartEntry before, MeasurementChartEntry after, DateTime date) {
MeasurementChartEntry before,
MeasurementChartEntry after,
DateTime date,
) {
final totalDuration = after.date.difference(before.date).inMilliseconds;
final startDuration = date.difference(before.date).inMilliseconds;

View File

@@ -744,6 +744,52 @@
"identicalExercisePleaseDiscard": "If you notice an exercise that is identical to the one you're adding, please discard your draft and edit that exercise instead.",
"checkInformationBeforeSubmitting": "Please check that the information you entered is correct before submitting the exercise",
"add_exercise_image_license": "Images must be compatible with the CC BY SA license. If in doubt, upload only photos you've taken yourself.",
"imageDetailsTitle": "Image details",
"@imageDetailsTitle": {
"description": "Title for image details form"
},
"imageDetailsLicenseTitle": "Title",
"@imageDetailsLicenseTitle": {
"description": "Label for image title field"
},
"imageDetailsLicenseTitleHint": "Enter image title",
"@imageDetailsLicenseTitleHint": {
"description": "Hint text for image title field"
},
"imageDetailsSourceLink": "Link to the source website",
"@imageDetailsSourceLink": {
"description": "Label for source link field"
},
"author": "Author(s)",
"@Author": {
"description": "Label for author field"
},
"authorHint": "Enter author name",
"@authorHint": {
"description": "Hint text for author field"
},
"imageDetailsAuthorLink": "Link to author website or profile",
"@imageDetailsAuthorLink": {
"description": "Label for author link field"
},
"imageDetailsDerivativeSource": "Link to the original source, if this is a derivative work",
"@imageDetailsDerivativeSource": {
"description": "Label for derivative source field"
},
"imageDetailsDerivativeHelp": "A derivative work is based on a previous work but contains sufficient new, creative content to entitle it to its own copyright.",
"@imageDetailsDerivativeHelp": {
"description": "Helper text explaining derivative works"
},
"imageDetailsImageType": "Image Type",
"@imageDetailsImageType": {
"description": "Label for image type selector"
},
"imageDetailsLicenseNotice": "By submitting this image, you agree to release it under the CC-BY-SA-4. The image must be either your own work or the author must have released it under a license compatible with it.",
"imageDetailsLicenseNoticeLinkToLicense": "See license text.",
"add": "add",
"@add": {
"description": "Add button text"
},
"variations": "Variations",
"@variations": {
"description": "Variations of one exercise (e.g. benchpress and benchpress narrow)"

View File

@@ -146,8 +146,8 @@ class MainApp extends StatelessWidget {
future: auth.tryAutoLogin(),
builder: (ctx, authResultSnapshot) =>
authResultSnapshot.connectionState == ConnectionState.waiting
? const SplashScreen()
: const AuthScreen(),
? const SplashScreen()
: const AuthScreen(),
);
}
}

View File

@@ -41,10 +41,10 @@ class WeightEntry {
}
WeightEntry copyWith({int? id, int? weight, DateTime? date}) => WeightEntry(
id: id,
weight: weight ?? this.weight,
date: date ?? this.date,
);
id: id,
weight: weight ?? this.weight,
date: date ?? this.date,
);
// Boilerplate
factory WeightEntry.fromJson(Map<String, dynamic> json) => _$WeightEntryFromJson(json);

View File

@@ -7,10 +7,7 @@ part of 'weight_entry.dart';
// **************************************************************************
WeightEntry _$WeightEntryFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'weight', 'date'],
);
$checkKeys(json, requiredKeys: const ['id', 'weight', 'date']);
return WeightEntry(
id: (json['id'] as num?)?.toInt(),
weight: stringToNum(json['weight'] as String?),
@@ -19,7 +16,7 @@ WeightEntry _$WeightEntryFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$WeightEntryToJson(WeightEntry instance) => <String, dynamic>{
'id': instance.id,
'weight': numToString(instance.weight),
'date': instance.date.toIso8601String(),
};
'id': instance.id,
'weight': numToString(instance.weight),
'date': instance.date.toIso8601String(),
};

View File

@@ -7,10 +7,7 @@ part of 'alias.dart';
// **************************************************************************
Alias _$AliasFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'alias'],
);
$checkKeys(json, requiredKeys: const ['id', 'alias']);
return Alias(
id: (json['id'] as num?)?.toInt(),
translationId: (json['translation'] as num?)?.toInt(),
@@ -19,7 +16,7 @@ Alias _$AliasFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$AliasToJson(Alias instance) => <String, dynamic>{
'id': instance.id,
'translation': instance.translationId,
'alias': instance.alias,
};
'id': instance.id,
'translation': instance.translationId,
'alias': instance.alias,
};

View File

@@ -7,17 +7,11 @@ 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,
);
$checkKeys(json, requiredKeys: const ['id', 'name']);
return ExerciseCategory(id: (json['id'] as num).toInt(), name: json['name'] as String);
}
Map<String, dynamic> _$ExerciseCategoryToJson(ExerciseCategory instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
};
'id': instance.id,
'name': instance.name,
};

View File

@@ -7,10 +7,7 @@ part of 'comment.dart';
// **************************************************************************
Comment _$CommentFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'comment'],
);
$checkKeys(json, requiredKeys: const ['id', 'comment']);
return Comment(
id: (json['id'] as num).toInt(),
translationId: (json['translation'] as num).toInt(),
@@ -19,7 +16,7 @@ Comment _$CommentFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$CommentToJson(Comment instance) => <String, dynamic>{
'id': instance.id,
'translation': instance.translationId,
'comment': instance.comment,
};
'id': instance.id,
'translation': instance.translationId,
'comment': instance.comment,
};

View File

@@ -7,10 +7,7 @@ part of 'equipment.dart';
// **************************************************************************
Equipment _$EquipmentFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'name'],
);
$checkKeys(json, requiredKeys: const ['id', 'name']);
return Equipment(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
@@ -18,6 +15,6 @@ Equipment _$EquipmentFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$EquipmentToJson(Equipment instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
};
'id': instance.id,
'name': instance.name,
};

View File

@@ -145,10 +145,10 @@ class Exercise extends Equatable {
}
Exercise.fromApiDataString(String baseData, List<Language> languages)
: this.fromApiData(ExerciseApiData.fromString(baseData), languages);
: this.fromApiData(ExerciseApiData.fromString(baseData), languages);
Exercise.fromApiDataJson(Map<String, dynamic> baseData, List<Language> languages)
: this.fromApiData(ExerciseApiData.fromJson(baseData), languages);
: this.fromApiData(ExerciseApiData.fromJson(baseData), languages);
Exercise.fromApiData(ExerciseApiData exerciseData, List<Language> languages) {
id = exerciseData.id;
@@ -228,13 +228,13 @@ class Exercise extends Equatable {
@override
List<Object?> get props => [
id,
uuid,
created,
lastUpdate,
category,
equipment,
muscles,
musclesSecondary,
];
id,
uuid,
created,
lastUpdate,
category,
equipment,
muscles,
musclesSecondary,
];
}

View File

@@ -19,43 +19,46 @@ Exercise _$ExerciseFromJson(Map<String, dynamic> json) {
'category',
'muscles',
'muscles_secondary',
'equipment'
'equipment',
],
);
return Exercise(
id: (json['id'] as num?)?.toInt(),
uuid: json['uuid'] as String?,
created: json['created'] == null ? null : DateTime.parse(json['created'] as String),
lastUpdate: json['last_update'] == null ? null : DateTime.parse(json['last_update'] as String),
lastUpdateGlobal: json['last_update_global'] == null
? null
: DateTime.parse(json['last_update_global'] as String),
variationId: (json['variations'] as num?)?.toInt(),
translations: (json['translations'] as List<dynamic>?)
?.map((e) => Translation.fromJson(e as Map<String, dynamic>))
.toList(),
category: json['categories'] == null
? null
: ExerciseCategory.fromJson(json['categories'] as Map<String, dynamic>),
)
id: (json['id'] as num?)?.toInt(),
uuid: json['uuid'] as String?,
created: json['created'] == null ? null : DateTime.parse(json['created'] as String),
lastUpdate: json['last_update'] == null
? null
: DateTime.parse(json['last_update'] as String),
lastUpdateGlobal: json['last_update_global'] == null
? null
: DateTime.parse(json['last_update_global'] as String),
variationId: (json['variations'] as num?)?.toInt(),
translations: (json['translations'] as List<dynamic>?)
?.map((e) => Translation.fromJson(e as Map<String, dynamic>))
.toList(),
category: json['categories'] == null
? null
: 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()
..musclesSecondaryIds =
(json['muscles_secondary'] as List<dynamic>).map((e) => (e as num).toInt()).toList()
..musclesSecondaryIds = (json['muscles_secondary'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList()
..equipmentIds = (json['equipment'] as List<dynamic>).map((e) => (e as num).toInt()).toList();
}
Map<String, dynamic> _$ExerciseToJson(Exercise instance) => <String, dynamic>{
'id': instance.id,
'uuid': instance.uuid,
'variations': instance.variationId,
'created': instance.created?.toIso8601String(),
'last_update': instance.lastUpdate?.toIso8601String(),
'last_update_global': instance.lastUpdateGlobal?.toIso8601String(),
'category': instance.categoryId,
'categories': instance.category?.toJson(),
'muscles': instance.musclesIds,
'muscles_secondary': instance.musclesSecondaryIds,
'musclesSecondary': instance.musclesSecondary.map((e) => e.toJson()).toList(),
'equipment': instance.equipmentIds,
};
'id': instance.id,
'uuid': instance.uuid,
'variations': instance.variationId,
'created': instance.created?.toIso8601String(),
'last_update': instance.lastUpdate?.toIso8601String(),
'last_update_global': instance.lastUpdateGlobal?.toIso8601String(),
'category': instance.categoryId,
'categories': instance.category?.toJson(),
'muscles': instance.musclesIds,
'muscles_secondary': instance.musclesSecondaryIds,
'musclesSecondary': instance.musclesSecondary.map((e) => e.toJson()).toList(),
'equipment': instance.equipmentIds,
};

File diff suppressed because it is too large Load Diff

View File

@@ -7,54 +7,54 @@ part of 'exercise_api.dart';
// **************************************************************************
_ExerciseBaseData _$ExerciseBaseDataFromJson(Map<String, dynamic> json) => _ExerciseBaseData(
id: (json['id'] as num).toInt(),
uuid: json['uuid'] as String,
variationId: (json['variations'] as num?)?.toInt() ?? null,
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>),
muscles: (json['muscles'] as List<dynamic>)
.map((e) => Muscle.fromJson(e as Map<String, dynamic>))
.toList(),
musclesSecondary: (json['muscles_secondary'] as List<dynamic>)
.map((e) => Muscle.fromJson(e as Map<String, dynamic>))
.toList(),
equipment: (json['equipment'] as List<dynamic>)
.map((e) => Equipment.fromJson(e as Map<String, dynamic>))
.toList(),
translations: (json['translations'] as List<dynamic>?)
?.map((e) => Translation.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
images: (json['images'] as List<dynamic>)
.map((e) => ExerciseImage.fromJson(e as Map<String, dynamic>))
.toList(),
videos: (json['videos'] as List<dynamic>)
.map((e) => Video.fromJson(e as Map<String, dynamic>))
.toList(),
authors: (json['author_history'] as List<dynamic>).map((e) => e as String).toList(),
authorsGlobal:
(json['total_authors_history'] as List<dynamic>).map((e) => e as String).toList(),
);
id: (json['id'] as num).toInt(),
uuid: json['uuid'] as String,
variationId: (json['variations'] as num?)?.toInt() ?? null,
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>),
muscles: (json['muscles'] as List<dynamic>)
.map((e) => Muscle.fromJson(e as Map<String, dynamic>))
.toList(),
musclesSecondary: (json['muscles_secondary'] as List<dynamic>)
.map((e) => Muscle.fromJson(e as Map<String, dynamic>))
.toList(),
equipment: (json['equipment'] as List<dynamic>)
.map((e) => Equipment.fromJson(e as Map<String, dynamic>))
.toList(),
translations:
(json['translations'] as List<dynamic>?)
?.map((e) => Translation.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
images: (json['images'] as List<dynamic>)
.map((e) => ExerciseImage.fromJson(e as Map<String, dynamic>))
.toList(),
videos: (json['videos'] as List<dynamic>)
.map((e) => Video.fromJson(e as Map<String, dynamic>))
.toList(),
authors: (json['author_history'] as List<dynamic>).map((e) => e as String).toList(),
authorsGlobal: (json['total_authors_history'] as List<dynamic>).map((e) => e as String).toList(),
);
Map<String, dynamic> _$ExerciseBaseDataToJson(_ExerciseBaseData instance) => <String, dynamic>{
'id': instance.id,
'uuid': instance.uuid,
'variations': instance.variationId,
'created': instance.created.toIso8601String(),
'last_update': instance.lastUpdate.toIso8601String(),
'last_update_global': instance.lastUpdateGlobal.toIso8601String(),
'category': instance.category,
'muscles': instance.muscles,
'muscles_secondary': instance.musclesSecondary,
'equipment': instance.equipment,
'translations': instance.translations,
'images': instance.images,
'videos': instance.videos,
'author_history': instance.authors,
'total_authors_history': instance.authorsGlobal,
};
'id': instance.id,
'uuid': instance.uuid,
'variations': instance.variationId,
'created': instance.created.toIso8601String(),
'last_update': instance.lastUpdate.toIso8601String(),
'last_update_global': instance.lastUpdateGlobal.toIso8601String(),
'category': instance.category,
'muscles': instance.muscles,
'muscles_secondary': instance.musclesSecondary,
'equipment': instance.equipment,
'translations': instance.translations,
'images': instance.images,
'videos': instance.videos,
'author_history': instance.authors,
'total_authors_history': instance.authorsGlobal,
};
_ExerciseSearchDetails _$ExerciseSearchDetailsFromJson(Map<String, dynamic> json) =>
_ExerciseSearchDetails(
@@ -83,17 +83,14 @@ _ExerciseSearchEntry _$ExerciseSearchEntryFromJson(Map<String, dynamic> json) =>
);
Map<String, dynamic> _$ExerciseSearchEntryToJson(_ExerciseSearchEntry instance) =>
<String, dynamic>{
'value': instance.value,
'data': instance.data,
};
<String, dynamic>{'value': instance.value, 'data': instance.data};
_ExerciseApiSearch _$ExerciseApiSearchFromJson(Map<String, dynamic> json) => _ExerciseApiSearch(
suggestions: (json['suggestions'] as List<dynamic>)
.map((e) => ExerciseSearchEntry.fromJson(e as Map<String, dynamic>))
.toList(),
);
suggestions: (json['suggestions'] as List<dynamic>)
.map((e) => ExerciseSearchEntry.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$ExerciseApiSearchToJson(_ExerciseApiSearch instance) => <String, dynamic>{
'suggestions': instance.suggestions,
};
'suggestions': instance.suggestions,
};

View File

@@ -660,6 +660,8 @@ abstract mixin class $ExerciseTranslationSubmissionApiCopyWith<$Res> {
});
}
/// @nodoc
class _$ExerciseTranslationSubmissionApiCopyWithImpl<$Res>
@@ -941,6 +943,8 @@ abstract mixin class _$ExerciseTranslationSubmissionApiCopyWith<$Res>
});
}
/// @nodoc
class __$ExerciseTranslationSubmissionApiCopyWithImpl<$Res>
@@ -1066,6 +1070,8 @@ abstract mixin class $ExerciseSubmissionApiCopyWith<$Res> {
});
}
/// @nodoc
class _$ExerciseSubmissionApiCopyWithImpl<$Res>
@@ -1385,6 +1391,8 @@ abstract mixin class _$ExerciseSubmissionApiCopyWith<$Res>
});
}
/// @nodoc
class __$ExerciseSubmissionApiCopyWithImpl<$Res>

View File

@@ -0,0 +1,67 @@
/*
* 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.
*
* 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:io';
import 'package:flutter/material.dart';
enum ImageType {
photo(id: 1, label: 'Photo', icon: Icons.photo_camera),
threeD(id: 2, label: '3D', icon: Icons.view_in_ar),
line(id: 3, label: 'Line', icon: Icons.show_chart),
lowPoly(id: 4, label: 'Low-Poly', icon: Icons.filter_vintage),
other(id: 5, label: 'Other', icon: Icons.more_horiz);
const ImageType({required this.id, required this.label, required this.icon});
final int id;
final String label;
final IconData icon;
}
class ExerciseSubmissionImage {
final File imageFile;
String? title;
String? author;
String? authorUrl;
String? sourceUrl;
String? derivativeSourceUrl;
ImageType type = ImageType.photo;
ExerciseSubmissionImage({
this.title,
this.author,
this.authorUrl,
this.sourceUrl,
this.derivativeSourceUrl,
this.type = ImageType.photo,
required this.imageFile,
});
Map<String, String> toJson() {
return {
'license_title': title ?? '',
'license_author': author ?? '',
'license_author_url': authorUrl ?? '',
'license_object_url': sourceUrl ?? '',
'license_derivative_source_url': derivativeSourceUrl ?? '',
'style': type.id.toString(),
};
}
}

View File

@@ -7,10 +7,7 @@ part of 'image.dart';
// **************************************************************************
ExerciseImage _$ExerciseImageFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'uuid', 'exercise', 'image'],
);
$checkKeys(json, requiredKeys: const ['id', 'uuid', 'exercise', 'image']);
return ExerciseImage(
id: (json['id'] as num).toInt(),
uuid: json['uuid'] as String,
@@ -21,9 +18,9 @@ ExerciseImage _$ExerciseImageFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$ExerciseImageToJson(ExerciseImage instance) => <String, dynamic>{
'id': instance.id,
'uuid': instance.uuid,
'exercise': instance.exerciseId,
'image': instance.url,
'is_main': instance.isMain,
};
'id': instance.id,
'uuid': instance.uuid,
'exercise': instance.exerciseId,
'image': instance.url,
'is_main': instance.isMain,
};

View File

@@ -7,10 +7,7 @@ part of 'language.dart';
// **************************************************************************
Language _$LanguageFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'short_name', 'full_name'],
);
$checkKeys(json, requiredKeys: const ['id', 'short_name', 'full_name']);
return Language(
id: (json['id'] as num).toInt(),
shortName: json['short_name'] as String,
@@ -19,7 +16,7 @@ Language _$LanguageFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$LanguageToJson(Language instance) => <String, dynamic>{
'id': instance.id,
'short_name': instance.shortName,
'full_name': instance.fullName,
};
'id': instance.id,
'short_name': instance.shortName,
'full_name': instance.fullName,
};

View File

@@ -7,10 +7,7 @@ part of 'muscle.dart';
// **************************************************************************
Muscle _$MuscleFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'name', 'name_en', 'is_front'],
);
$checkKeys(json, requiredKeys: const ['id', 'name', 'name_en', 'is_front']);
return Muscle(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
@@ -20,8 +17,8 @@ Muscle _$MuscleFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$MuscleToJson(Muscle instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'name_en': instance.nameEn,
'is_front': instance.isFront,
};
'id': instance.id,
'name': instance.name,
'name_en': instance.nameEn,
'is_front': instance.isFront,
};

View File

@@ -92,12 +92,12 @@ class Translation extends Equatable {
@override
List<Object?> get props => [
id,
exerciseId,
uuid,
languageId,
created,
name,
description,
];
id,
exerciseId,
uuid,
languageId,
created,
name,
description,
];
}

View File

@@ -12,13 +12,13 @@ Translation _$TranslationFromJson(Map<String, dynamic> json) {
requiredKeys: const ['id', 'uuid', 'language', 'created', 'exercise', 'name', 'description'],
);
return Translation(
id: (json['id'] as num?)?.toInt(),
uuid: json['uuid'] as String?,
created: json['created'] == null ? null : DateTime.parse(json['created'] as String),
name: json['name'] as String,
description: json['description'] as String,
exerciseId: (json['exercise'] as num?)?.toInt(),
)
id: (json['id'] as num?)?.toInt(),
uuid: json['uuid'] as String?,
created: json['created'] == null ? null : DateTime.parse(json['created'] as String),
name: json['name'] as String,
description: json['description'] as String,
exerciseId: (json['exercise'] as num?)?.toInt(),
)
..languageId = (json['language'] as num).toInt()
..notes = (json['notes'] as List<dynamic>)
.map((e) => Comment.fromJson(e as Map<String, dynamic>))
@@ -29,11 +29,11 @@ Translation _$TranslationFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$TranslationToJson(Translation instance) => <String, dynamic>{
'id': instance.id,
'uuid': instance.uuid,
'language': instance.languageId,
'created': instance.created?.toIso8601String(),
'exercise': instance.exerciseId,
'name': instance.name,
'description': instance.description,
};
'id': instance.id,
'uuid': instance.uuid,
'language': instance.languageId,
'created': instance.created?.toIso8601String(),
'exercise': instance.exerciseId,
'name': instance.name,
'description': instance.description,
};

View File

@@ -7,15 +7,10 @@ part of 'variation.dart';
// **************************************************************************
Variation _$VariationFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id'],
);
return Variation(
id: (json['id'] as num).toInt(),
);
$checkKeys(json, requiredKeys: const ['id']);
return Variation(id: (json['id'] as num).toInt());
}
Map<String, dynamic> _$VariationToJson(Variation instance) => <String, dynamic>{
'id': instance.id,
};
'id': instance.id,
};

View File

@@ -21,7 +21,7 @@ Video _$VideoFromJson(Map<String, dynamic> json) {
'codec',
'codec_long',
'license',
'license_author'
'license_author',
],
);
return Video(
@@ -41,16 +41,16 @@ Video _$VideoFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$VideoToJson(Video instance) => <String, dynamic>{
'id': instance.id,
'uuid': instance.uuid,
'video': instance.url,
'exercise': instance.exerciseId,
'size': instance.size,
'duration': numToString(instance.duration),
'width': instance.width,
'height': instance.height,
'codec': instance.codec,
'codec_long': instance.codecLong,
'license': instance.license,
'license_author': instance.licenseAuthor,
};
'id': instance.id,
'uuid': instance.uuid,
'video': instance.url,
'exercise': instance.exerciseId,
'size': instance.size,
'duration': numToString(instance.duration),
'width': instance.width,
'height': instance.height,
'codec': instance.codec,
'codec_long': instance.codecLong,
'license': instance.license,
'license_author': instance.licenseAuthor,
};

View File

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

View File

@@ -7,15 +7,13 @@ part of 'measurement_category.dart';
// **************************************************************************
MeasurementCategory _$MeasurementCategoryFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'name', 'unit'],
);
$checkKeys(json, requiredKeys: const ['id', 'name', 'unit']);
return MeasurementCategory(
id: (json['id'] as num?)?.toInt(),
name: json['name'] as String,
unit: json['unit'] as String,
entries: (json['entries'] as List<dynamic>?)
entries:
(json['entries'] as List<dynamic>?)
?.map((e) => MeasurementEntry.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
@@ -23,8 +21,8 @@ MeasurementCategory _$MeasurementCategoryFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$MeasurementCategoryToJson(MeasurementCategory instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'unit': instance.unit,
'entries': MeasurementCategory._nullValue(instance.entries),
};
'id': instance.id,
'name': instance.name,
'unit': instance.unit,
'entries': MeasurementCategory._nullValue(instance.entries),
};

View File

@@ -21,9 +21,9 @@ MeasurementEntry _$MeasurementEntryFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$MeasurementEntryToJson(MeasurementEntry instance) => <String, dynamic>{
'id': instance.id,
'category': instance.category,
'date': dateToYYYYMMDD(instance.date),
'value': instance.value,
'notes': instance.notes,
};
'id': instance.id,
'category': instance.category,
'date': dateToYYYYMMDD(instance.date),
'value': instance.value,
'notes': instance.notes,
};

View File

@@ -25,7 +25,7 @@ Ingredient _$IngredientFromJson(Map<String, dynamic> json) {
'fat',
'fat_saturated',
'fiber',
'sodium'
'sodium',
],
);
return Ingredient(
@@ -55,22 +55,22 @@ Ingredient _$IngredientFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$IngredientToJson(Ingredient instance) => <String, dynamic>{
'id': instance.id,
'remote_id': instance.remoteId,
'source_name': instance.sourceName,
'source_url': instance.sourceUrl,
'license_object_url': instance.licenseObjectURl,
'code': instance.code,
'name': instance.name,
'created': instance.created.toIso8601String(),
'energy': instance.energy,
'carbohydrates': numToString(instance.carbohydrates),
'carbohydrates_sugar': numToString(instance.carbohydratesSugar),
'protein': numToString(instance.protein),
'fat': numToString(instance.fat),
'fat_saturated': numToString(instance.fatSaturated),
'fiber': numToString(instance.fiber),
'sodium': numToString(instance.sodium),
'image': instance.image,
'thumbnails': instance.thumbnails,
};
'id': instance.id,
'remote_id': instance.remoteId,
'source_name': instance.sourceName,
'source_url': instance.sourceUrl,
'license_object_url': instance.licenseObjectURl,
'code': instance.code,
'name': instance.name,
'created': instance.created.toIso8601String(),
'energy': instance.energy,
'carbohydrates': numToString(instance.carbohydrates),
'carbohydrates_sugar': numToString(instance.carbohydratesSugar),
'protein': numToString(instance.protein),
'fat': numToString(instance.fat),
'fat_saturated': numToString(instance.fatSaturated),
'fiber': numToString(instance.fiber),
'sodium': numToString(instance.sodium),
'image': instance.image,
'thumbnails': instance.thumbnails,
};

View File

@@ -20,7 +20,7 @@ IngredientImage _$IngredientImageFromJson(Map<String, dynamic> json) {
'license_author_url',
'license_title',
'license_object_url',
'license_derivative_source_url'
'license_derivative_source_url',
],
);
return IngredientImage(
@@ -39,15 +39,15 @@ IngredientImage _$IngredientImageFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$IngredientImageToJson(IngredientImage instance) => <String, dynamic>{
'id': instance.id,
'uuid': instance.uuid,
'ingredient_id': instance.ingredientId,
'image': instance.url,
'size': instance.size,
'license': instance.licenseId,
'license_author': instance.author,
'license_author_url': instance.authorUrl,
'license_title': instance.title,
'license_object_url': instance.objectUrl,
'license_derivative_source_url': instance.derivativeSourceUrl,
};
'id': instance.id,
'uuid': instance.uuid,
'ingredient_id': instance.ingredientId,
'image': instance.url,
'size': instance.size,
'license': instance.licenseId,
'license_author': instance.author,
'license_author_url': instance.authorUrl,
'license_title': instance.title,
'license_object_url': instance.objectUrl,
'license_derivative_source_url': instance.derivativeSourceUrl,
};

View File

@@ -15,7 +15,7 @@ IngredientImageThumbnails _$IngredientImageThumbnailsFromJson(Map<String, dynami
'medium',
'medium_cropped',
'large',
'large_cropped'
'large_cropped',
],
);
return IngredientImageThumbnails(

View File

@@ -7,10 +7,7 @@ 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>),

View File

@@ -84,8 +84,9 @@ class Log {
NutritionalValues get nutritionalValues {
// This is already done on the server. It might be better to read it from there.
final weight =
weightUnitObj == null ? amount : amount * weightUnitObj!.amount * weightUnitObj!.grams;
final weight = weightUnitObj == null
? amount
: amount * weightUnitObj!.amount * weightUnitObj!.grams;
return ingredient.nutritionalValues / (100 / weight);
}

View File

@@ -9,7 +9,14 @@ part of 'log.dart';
Log _$LogFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'plan', 'datetime', 'ingredient', 'weight_unit', 'amount'],
requiredKeys: const [
'id',
'plan',
'datetime',
'ingredient',
'weight_unit',
'amount',
],
);
return Log(
id: (json['id'] as num?)?.toInt(),
@@ -24,12 +31,12 @@ Log _$LogFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$LogToJson(Log instance) => <String, dynamic>{
'id': instance.id,
'meal': instance.mealId,
'plan': instance.planId,
'datetime': dateToUtcIso8601(instance.datetime),
'comment': instance.comment,
'ingredient': instance.ingredientId,
'weight_unit': instance.weightUnitId,
'amount': instance.amount,
};
'id': instance.id,
'meal': instance.mealId,
'plan': instance.planId,
'datetime': dateToUtcIso8601(instance.datetime),
'comment': instance.comment,
'ingredient': instance.ingredientId,
'weight_unit': instance.weightUnitId,
'amount': instance.amount,
};

View File

@@ -7,14 +7,14 @@ part of 'meal.dart';
// **************************************************************************
Meal _$MealFromJson(Map<String, dynamic> json) => Meal(
id: (json['id'] as num?)?.toInt(),
time: stringToTimeNull(json['time'] as String?),
name: json['name'] as String?,
)..planId = (json['plan'] as num).toInt();
id: (json['id'] as num?)?.toInt(),
time: stringToTimeNull(json['time'] as String?),
name: json['name'] as String?,
)..planId = (json['plan'] as num).toInt();
Map<String, dynamic> _$MealToJson(Meal instance) => <String, dynamic>{
'id': instance.id,
'plan': instance.planId,
'time': timeToString(instance.time),
'name': instance.name,
};
'id': instance.id,
'plan': instance.planId,
'time': timeToString(instance.time),
'name': instance.name,
};

View File

@@ -7,10 +7,7 @@ part of 'meal_item.dart';
// **************************************************************************
MealItem _$MealItemFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'amount'],
);
$checkKeys(json, requiredKeys: const ['id', 'amount']);
return MealItem(
id: (json['id'] as num?)?.toInt(),
mealId: (json['meal'] as num?)?.toInt(),
@@ -21,9 +18,9 @@ MealItem _$MealItemFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$MealItemToJson(MealItem instance) => <String, dynamic>{
'id': instance.id,
'meal': instance.mealId,
'ingredient': instance.ingredientId,
'weight_unit': instance.weightUnitId,
'amount': numToString(instance.amount),
};
'id': instance.id,
'meal': instance.mealId,
'ingredient': instance.ingredientId,
'weight_unit': instance.weightUnitId,
'amount': numToString(instance.amount),
};

View File

@@ -57,7 +57,8 @@ class NutritionalGoals {
(energy! - protein! * ENERGY_PROTEIN - fat! * ENERGY_FAT) / ENERGY_CARBOHYDRATES;
assert(carbohydrates! > 0);
} else if (fat == null && protein != null && carbohydrates != null) {
fat = (energy! - protein! * ENERGY_PROTEIN - carbohydrates! * ENERGY_CARBOHYDRATES) /
fat =
(energy! - protein! * ENERGY_PROTEIN - carbohydrates! * ENERGY_CARBOHYDRATES) /
ENERGY_FAT;
assert(fat! > 0);
}
@@ -141,15 +142,15 @@ class NutritionalGoals {
@override
//ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hash(
energy,
protein,
carbohydrates,
carbohydratesSugar,
fat,
fatSaturated,
fiber,
sodium,
);
energy,
protein,
carbohydrates,
carbohydratesSugar,
fat,
fatSaturated,
fiber,
sodium,
);
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes

View File

@@ -20,14 +20,15 @@ NutritionalPlan _$NutritionalPlanFromJson(Map<String, dynamic> json) {
'goal_protein',
'goal_carbohydrates',
'goal_fat',
'goal_fiber'
'goal_fiber',
],
);
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: json['creation_date'] == null
? null
: DateTime.parse(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,
@@ -40,15 +41,15 @@ NutritionalPlan _$NutritionalPlanFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$NutritionalPlanToJson(NutritionalPlan instance) => <String, dynamic>{
'id': instance.id,
'description': instance.description,
'creation_date': dateToUtcIso8601(instance.creationDate),
'start': dateToYYYYMMDD(instance.startDate),
'end': dateToYYYYMMDD(instance.endDate),
'only_logging': instance.onlyLogging,
'goal_energy': instance.goalEnergy,
'goal_protein': instance.goalProtein,
'goal_carbohydrates': instance.goalCarbohydrates,
'goal_fat': instance.goalFat,
'goal_fiber': instance.goalFiber,
};
'id': instance.id,
'description': instance.description,
'creation_date': dateToUtcIso8601(instance.creationDate),
'start': dateToYYYYMMDD(instance.startDate),
'end': dateToYYYYMMDD(instance.endDate),
'only_logging': instance.onlyLogging,
'goal_energy': instance.goalEnergy,
'goal_protein': instance.goalProtein,
'goal_carbohydrates': instance.goalCarbohydrates,
'goal_fat': instance.goalFat,
'goal_fiber': instance.goalFiber,
};

View File

@@ -132,13 +132,13 @@ class NutritionalValues {
@override
//ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hash(
energy,
protein,
carbohydrates,
carbohydratesSugar,
fat,
fatSaturated,
fiber,
sodium,
);
energy,
protein,
carbohydrates,
carbohydratesSugar,
fat,
fatSaturated,
fiber,
sodium,
);
}

View File

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

View File

@@ -9,7 +9,13 @@ part of 'profile.dart';
Profile _$ProfileFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['username', 'email_verified', 'is_trustworthy', 'weight_unit', 'email'],
requiredKeys: const [
'username',
'email_verified',
'is_trustworthy',
'weight_unit',
'email',
],
);
return Profile(
username: json['username'] as String,
@@ -21,9 +27,9 @@ Profile _$ProfileFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$ProfileToJson(Profile instance) => <String, dynamic>{
'username': instance.username,
'email_verified': instance.emailVerified,
'is_trustworthy': instance.isTrustworthy,
'weight_unit': instance.weightUnitStr,
'email': instance.email,
};
'username': instance.username,
'email_verified': instance.emailVerified,
'is_trustworthy': instance.isTrustworthy,
'weight_unit': instance.weightUnitStr,
'email': instance.email,
};

View File

@@ -17,7 +17,7 @@ BaseConfig _$BaseConfigFromJson(Map<String, dynamic> json) {
'operation',
'step',
'repeat',
'requirements'
'requirements',
],
);
return BaseConfig(
@@ -33,11 +33,11 @@ BaseConfig _$BaseConfigFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$BaseConfigToJson(BaseConfig instance) => <String, dynamic>{
'slot_entry': instance.slotEntryId,
'iteration': instance.iteration,
'value': instance.value,
'operation': instance.operation,
'step': instance.step,
'repeat': instance.repeat,
'requirements': instance.requirements,
};
'slot_entry': instance.slotEntryId,
'iteration': instance.iteration,
'value': instance.value,
'operation': instance.operation,
'step': instance.step,
'repeat': instance.repeat,
'requirements': instance.requirements,
};

View File

@@ -142,8 +142,13 @@ class Log {
/// 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', '');
return repText(
repetitions,
repetitionsUnitObj,
weight,
weightUnitObj,
rir,
).replaceAll('\n', '');
}
/// Override the equals operator

View File

@@ -22,7 +22,7 @@ Log _$LogFromJson(Map<String, dynamic> json) {
'weight',
'weight_target',
'weight_unit',
'date'
'date',
],
);
return Log(
@@ -44,19 +44,19 @@ Log _$LogFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$LogToJson(Log instance) => <String, dynamic>{
'id': instance.id,
'exercise': instance.exerciseId,
'routine': instance.routineId,
'session': instance.sessionId,
'iteration': instance.iteration,
'slot_entry': instance.slotEntryId,
'rir': instance.rir,
'rir_target': instance.rirTarget,
'repetitions': instance.repetitions,
'repetitions_target': instance.repetitionsTarget,
'repetitions_unit': instance.repetitionsUnitId,
'weight': numToString(instance.weight),
'weight_target': numToString(instance.weightTarget),
'weight_unit': instance.weightUnitId,
'date': dateToUtcIso8601(instance.date),
};
'id': instance.id,
'exercise': instance.exerciseId,
'routine': instance.routineId,
'session': instance.sessionId,
'iteration': instance.iteration,
'slot_entry': instance.slotEntryId,
'rir': instance.rir,
'rir_target': instance.rirTarget,
'repetitions': instance.repetitions,
'repetitions_target': instance.repetitionsTarget,
'repetitions_unit': instance.repetitionsUnitId,
'weight': numToString(instance.weight),
'weight_target': numToString(instance.weightTarget),
'weight_unit': instance.weightUnitId,
'date': dateToUtcIso8601(instance.date),
};

View File

@@ -7,10 +7,7 @@ part of 'repetition_unit.dart';
// **************************************************************************
RepetitionUnit _$RepetitionUnitFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'name'],
);
$checkKeys(json, requiredKeys: const ['id', 'name']);
return RepetitionUnit(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
@@ -18,6 +15,6 @@ RepetitionUnit _$RepetitionUnitFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$RepetitionUnitToJson(RepetitionUnit instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
};
'id': instance.id,
'name': instance.name,
};

View File

@@ -19,11 +19,13 @@ Routine _$RoutineFromJson(Map<String, dynamic> json) {
end: json['end'] == null ? null : DateTime.parse(json['end'] as String),
fitInWeek: json['fit_in_week'] as bool? ?? false,
description: json['description'] as String?,
days: (json['days'] as List<dynamic>?)
days:
(json['days'] as List<dynamic>?)
?.map((e) => Day.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
sessions: (json['sessions'] as List<dynamic>?)
sessions:
(json['sessions'] as List<dynamic>?)
?.map((e) => WorkoutSessionApi.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
@@ -31,10 +33,10 @@ Routine _$RoutineFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$RoutineToJson(Routine instance) => <String, dynamic>{
'created': dateToUtcIso8601(instance.created),
'name': instance.name,
'description': instance.description,
'fit_in_week': instance.fitInWeek,
'start': dateToYYYYMMDD(instance.start),
'end': dateToYYYYMMDD(instance.end),
};
'created': dateToUtcIso8601(instance.created),
'name': instance.name,
'description': instance.description,
'fit_in_week': instance.fitInWeek,
'start': dateToYYYYMMDD(instance.start),
'end': dateToYYYYMMDD(instance.end),
};

View File

@@ -19,7 +19,8 @@ WorkoutSession _$WorkoutSessionFromJson(Map<String, dynamic> json) {
notes: json['notes'] as String? ?? '',
timeStart: stringToTimeNull(json['time_start'] as String?),
timeEnd: stringToTimeNull(json['time_end'] as String?),
logs: (json['logs'] as List<dynamic>?)
logs:
(json['logs'] as List<dynamic>?)
?.map((e) => Log.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
@@ -28,12 +29,12 @@ WorkoutSession _$WorkoutSessionFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$WorkoutSessionToJson(WorkoutSession instance) => <String, dynamic>{
'id': instance.id,
'routine': instance.routineId,
'day': instance.dayId,
'date': dateToYYYYMMDD(instance.date),
'impression': numToString(instance.impression),
'notes': instance.notes,
'time_start': timeToString(instance.timeStart),
'time_end': timeToString(instance.timeEnd),
};
'id': instance.id,
'routine': instance.routineId,
'day': instance.dayId,
'date': dateToYYYYMMDD(instance.date),
'impression': numToString(instance.impression),
'notes': instance.notes,
'time_start': timeToString(instance.timeStart),
'time_end': timeToString(instance.timeEnd),
};

View File

@@ -7,13 +7,11 @@ part of 'session_api.dart';
// **************************************************************************
WorkoutSessionApi _$WorkoutSessionApiFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['session'],
);
$checkKeys(json, requiredKeys: const ['session']);
return WorkoutSessionApi(
session: WorkoutSession.fromJson(json['session'] as Map<String, dynamic>),
logs: (json['logs'] as List<dynamic>?)
logs:
(json['logs'] as List<dynamic>?)
?.map((e) => Log.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
@@ -21,5 +19,5 @@ WorkoutSessionApi _$WorkoutSessionApiFromJson(Map<String, dynamic> json) {
}
Map<String, dynamic> _$WorkoutSessionApiToJson(WorkoutSessionApi instance) => <String, dynamic>{
'session': instance.session,
};
'session': instance.session,
};

View File

@@ -1,13 +1,11 @@
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/models/exercises/category.dart';
import 'package:wger/models/exercises/equipment.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/exercises/exercise_submission.dart';
import 'package:wger/models/exercises/exercise_submission_images.dart';
import 'package:wger/models/exercises/language.dart';
import 'package:wger/models/exercises/muscle.dart';
import 'package:wger/models/exercises/variation.dart';
@@ -16,11 +14,16 @@ import 'base_provider.dart';
class AddExerciseProvider with ChangeNotifier {
final WgerBaseProvider baseProvider;
static final _logger = Logger('AddExerciseProvider');
AddExerciseProvider(this.baseProvider);
List<File> get exerciseImages => [..._exerciseImages];
List<File> _exerciseImages = [];
// Images and their metadata (license info, style)
final List<ExerciseSubmissionImage> _exerciseImages = [];
List<ExerciseSubmissionImage> get exerciseImages => [..._exerciseImages];
String author = '';
String? exerciseNameEn;
String? exerciseNameTrans;
String? descriptionEn;
@@ -32,7 +35,6 @@ class AddExerciseProvider with ChangeNotifier {
List<String> alternateNamesEn = [];
List<String> alternateNamesTrans = [];
ExerciseCategory? category;
List<Exercise> _variations = [];
List<Equipment> _equipment = [];
List<Muscle> _primaryMuscles = [];
List<Muscle> _secondaryMuscles = [];
@@ -42,7 +44,7 @@ class AddExerciseProvider with ChangeNotifier {
static const _checkLanguageUrlPath = 'check-language';
void clear() {
_exerciseImages = [];
_exerciseImages.clear();
languageTranslation = null;
category = null;
exerciseNameEn = null;
@@ -51,7 +53,6 @@ class AddExerciseProvider with ChangeNotifier {
descriptionTrans = null;
alternateNamesEn = [];
alternateNamesTrans = [];
_variations = [];
_equipment = [];
_primaryMuscles = [];
_secondaryMuscles = [];
@@ -135,53 +136,40 @@ class AddExerciseProvider with ChangeNotifier {
);
}
void addExerciseImages(List<File> exercises) {
_exerciseImages.addAll(exercises);
/// Add images with optional license metadata
void addExerciseImages(List<ExerciseSubmissionImage> images) {
_exerciseImages.addAll(images);
notifyListeners();
}
void removeExercise(String path) {
final file = _exerciseImages.where((element) => element.path == path).first;
void removeImage(String path) {
final file = _exerciseImages.where((element) => element.imageFile.path == path).first;
_exerciseImages.remove(file);
notifyListeners();
}
//Just to Debug Provider
void printValues() {
log('Collected exercise data');
log('------------------------');
/// Main method to submit exercise with images
///
/// Returns the ID of the created exercise
/// Throws exception if submission fails
Future<int> postExerciseToServer() async {
try {
// 1. Create the exercise
final exerciseId = await addExerciseSubmission();
log('Base data...');
log('Target area : $category');
log('Primary muscles: $_primaryMuscles');
log('Secondary muscles: $_secondaryMuscles');
log('Equipment: $_equipment');
log('Variations: $_variations');
// 2. Upload images if any exist
if (_exerciseImages.isNotEmpty) {
await addImages(exerciseId);
}
log('');
log('Language specific...');
log('Language: ${languageTranslation?.shortName}');
log('Name: en/$exerciseNameEn translation/$exerciseNameTrans');
log('Description: en/$descriptionEn translation/$descriptionTrans');
log('Alternate names: en/$alternateNamesEn translation/$alternateNamesTrans');
}
// 3. Clear all data after successful upload
clear();
Future<int> addExercise() async {
printValues();
// Create the variations if needed
// if (newVariation) {
// await addVariation();
// }
// Create the exercise
final exerciseId = await addExerciseSubmission();
// Clear everything
clear();
// Return exercise ID
return exerciseId;
return exerciseId;
} catch (e) {
// Don't clear on error so user can retry
rethrow;
}
}
Future<int> addExerciseSubmission() async {
@@ -194,16 +182,34 @@ class AddExerciseProvider with ChangeNotifier {
return result['id'];
}
/// Upload exercise images with license metadata
Future<void> addImages(int exerciseId) async {
for (final image in _exerciseImages) {
final request = http.MultipartRequest('POST', baseProvider.makeUrl(_imagesUrlPath));
request.headers.addAll(baseProvider.getDefaultHeaders(includeAuth: true));
request.files.add(await http.MultipartFile.fromPath('image', image.path));
request.files.add(await http.MultipartFile.fromPath('image', image.imageFile.path));
request.fields['exercise'] = exerciseId.toString();
request.fields['style'] = EXERCISE_IMAGE_ART_STYLE.PHOTO.index.toString();
request.fields['license'] = CC_BY_SA_4_ID.toString();
request.fields['is_main'] = 'false';
await request.send();
final details = image.toJson();
if (details.isNotEmpty) {
request.fields.addAll(details);
}
try {
final streamedResponse = await request.send();
if (streamedResponse.statusCode == 201 || streamedResponse.statusCode == 200) {
_logger.fine('Image uploaded successfully');
} else {
final response = await http.Response.fromStream(streamedResponse);
throw Exception('Upload failed: ${streamedResponse.statusCode}');
}
} catch (e) {
rethrow;
}
}
notifyListeners();
@@ -215,64 +221,7 @@ class AddExerciseProvider with ChangeNotifier {
'language_code': languageCode,
}, baseProvider.makeUrl(_checkLanguageUrlPath));
notifyListeners();
print(result);
return false;
}
/*
Note: all this logic is not needed now since we are using the /exercise-submission
endpoint, however, if we ever want to implement editing of exercises, we will
need basically all of it again, so this is kept here for reference.
Future<Variation> addVariation() async {
final Uri postUri = baseProvider.makeUrl(_exerciseVariationPath);
// We send an empty dictionary since at the moment the variations only have an ID
final Map<String, dynamic> variationMap = await baseProvider.post({}, postUri);
final Variation newVariation = Variation.fromJson(variationMap);
_variationId = newVariation.id;
notifyListeners();
return newVariation;
}
Future<void> addImages(Exercise exercise) async {
for (final image in _exerciseImages) {
final request = http.MultipartRequest('POST', baseProvider.makeUrl(_imagesUrlPath));
request.headers.addAll(baseProvider.getDefaultHeaders(includeAuth: true));
request.files.add(await http.MultipartFile.fromPath('image', image.path));
request.fields['exercise'] = exercise.id!.toString();
request.fields['style'] = EXERCISE_IMAGE_ART_STYLE.PHOTO.index.toString();
await request.send();
}
notifyListeners();
}
Future<Translation> addExerciseTranslation(Translation exercise) async {
final Uri postUri = baseProvider.makeUrl(_exerciseTranslationUrlPath);
final Map<String, dynamic> newTranslation = await baseProvider.post(exercise.toJson(), postUri);
final Translation newExercise = Translation.fromJson(newTranslation);
notifyListeners();
return newExercise;
}
Future<Alias> addExerciseAlias(String name, int exerciseId) async {
final alias = Alias(translationId: exerciseId, alias: name);
final Uri postUri = baseProvider.makeUrl(_exerciseAliasPath);
final Alias newAlias = Alias.fromJson(await baseProvider.post(alias.toJson(), postUri));
notifyListeners();
return newAlias;
}
*/
}

View File

@@ -251,7 +251,7 @@ class AuthProvider with ChangeNotifier {
headers: {
HttpHeaders.contentTypeHeader: 'application/json; charset=UTF-8',
HttpHeaders.userAgentHeader: getAppNameHeader(),
HttpHeaders.authorizationHeader: 'Token $token'
HttpHeaders.authorizationHeader: 'Token $token',
},
);
if (response.statusCode != 200) {
@@ -294,7 +294,8 @@ class AuthProvider with ChangeNotifier {
String getAppNameHeader() {
String out = '';
if (applicationVersion != null) {
out = '/${applicationVersion!.version} '
out =
'/${applicationVersion!.version} '
'(${applicationVersion!.packageName}; '
'build: ${applicationVersion!.buildNumber})'
' - https://github.com/wger-project';

View File

@@ -68,10 +68,12 @@ class BodyWeightProvider with ChangeNotifier {
_logger.info('Fetching all body weight entries');
// Process the response
final data = await baseProvider.fetchPaginated(baseProvider.makeUrl(
BODY_WEIGHT_URL,
query: {'ordering': '-date', 'limit': API_MAX_PAGE_SIZE},
));
final data = await baseProvider.fetchPaginated(
baseProvider.makeUrl(
BODY_WEIGHT_URL,
query: {'ordering': '-date', 'limit': API_MAX_PAGE_SIZE},
),
);
_entries = [];
for (final entry in data) {
_entries.add(WeightEntry.fromJson(entry));

View File

@@ -42,7 +42,7 @@ class ExercisesProvider with ChangeNotifier {
ExerciseDatabase database;
ExercisesProvider(this.baseProvider, {ExerciseDatabase? database})
: database = database ?? locator<ExerciseDatabase>();
: database = database ?? locator<ExerciseDatabase>();
static const EXERCISE_CACHE_DAYS = 7;
static const CACHE_VERSION = 4;
@@ -118,17 +118,13 @@ class ExercisesProvider with ChangeNotifier {
exerciseCategories: FilterCategory(
title: 'Category',
items: Map.fromEntries(
_categories.map(
(category) => MapEntry<ExerciseCategory, bool>(category, false),
),
_categories.map((category) => MapEntry<ExerciseCategory, bool>(category, false)),
),
),
equipment: FilterCategory(
title: 'Equipment',
items: Map.fromEntries(
_equipment.map(
(singleEquipment) => MapEntry<Equipment, bool>(singleEquipment, false),
),
_equipment.map((singleEquipment) => MapEntry<Equipment, bool>(singleEquipment, false)),
),
),
),
@@ -191,10 +187,7 @@ class ExercisesProvider with ChangeNotifier {
/// returned exercises. Since this is typically called by one exercise, we are
/// not interested in seeing that same exercise returned in the list of variations.
/// If this parameter is not passed, all exercises are returned.
List<Exercise> findExercisesByVariationId(
int? variationId, {
int? exerciseIdToExclude,
}) {
List<Exercise> findExercisesByVariationId(int? variationId, {int? exerciseIdToExclude}) {
if (variationId == null) {
return [];
}
@@ -317,18 +310,16 @@ class ExercisesProvider with ChangeNotifier {
/// -> yes: Do we need to re-fetch?
/// -> no: just return what we have in the DB
/// -> yes: fetch data and update if necessary
Future<Exercise> handleUpdateExerciseFromApi(
ExerciseDatabase database,
int exerciseId,
) async {
Future<Exercise> handleUpdateExerciseFromApi(ExerciseDatabase database, int exerciseId) async {
Exercise exercise;
// NOTE: this should not be necessary anymore. We had a bug that would
// create duplicate entries in the database and should be fixed now.
// However, we keep it here for now to be on the safe side.
// In the future this can be replaced by a .getSingleOrNull()
final exerciseResult =
await (database.select(database.exercises)..where((e) => e.id.equals(exerciseId))).get();
final exerciseResult = await (database.select(
database.exercises,
)..where((e) => e.id.equals(exerciseId))).get();
ExerciseTable? exerciseDb;
if (exerciseResult.isNotEmpty) {
@@ -383,7 +374,9 @@ class ExercisesProvider with ChangeNotifier {
exercise = Exercise.fromApiDataJson(exerciseData, _languages);
if (exerciseDb == null) {
await database.into(database.exercises).insert(
await database
.into(database.exercises)
.insert(
ExercisesCompanion.insert(
id: exercise.id!,
data: jsonEncode(exerciseData),
@@ -426,6 +419,7 @@ class ExercisesProvider with ChangeNotifier {
}
Future<void> clearAllCachesAndPrefs() async {
clear();
await database.deleteEverything();
await initCacheTimesLocalPrefs(forceInit: true);
}
@@ -480,13 +474,15 @@ class ExercisesProvider with ChangeNotifier {
// Insert new entries and update ones that have been edited
Future.forEach(data, (exerciseData) async {
final exercise = await (database.select(database.exercises)
..where((e) => e.id.equals(exerciseData['id'])))
.getSingleOrNull();
final exercise = await (database.select(
database.exercises,
)..where((e) => e.id.equals(exerciseData['id']))).getSingleOrNull();
// New exercise, insert
if (exercise == null) {
database.into(database.exercises).insert(
database
.into(database.exercises)
.insert(
ExercisesCompanion.insert(
id: exerciseData['id'],
data: jsonEncode(exerciseData),
@@ -537,15 +533,10 @@ class ExercisesProvider with ChangeNotifier {
await fetchAndSetMusclesFromApi();
await database.delete(database.muscles).go();
await Future.forEach(_muscles, (e) async {
await database.into(database.muscles).insert(
MusclesCompanion.insert(id: e.id, data: e),
);
await database.into(database.muscles).insert(MusclesCompanion.insert(id: e.id, data: e));
});
validTill = DateTime.now().add(const Duration(days: EXERCISE_CACHE_DAYS));
await prefs.setString(
PREFS_LAST_UPDATED_MUSCLES,
validTill.toIso8601String(),
);
await prefs.setString(PREFS_LAST_UPDATED_MUSCLES, validTill.toIso8601String());
_logger.fine('Saved ${_muscles.length} muscles to cache (valid till $validTill)');
}
@@ -571,15 +562,12 @@ class ExercisesProvider with ChangeNotifier {
await fetchAndSetCategoriesFromApi();
await database.delete(database.categories).go();
await Future.forEach(_categories, (e) async {
await database.into(database.categories).insert(
CategoriesCompanion.insert(id: e.id, data: e),
);
await database
.into(database.categories)
.insert(CategoriesCompanion.insert(id: e.id, data: e));
});
validTill = DateTime.now().add(const Duration(days: EXERCISE_CACHE_DAYS));
await prefs.setString(
PREFS_LAST_UPDATED_CATEGORIES,
validTill.toIso8601String(),
);
await prefs.setString(PREFS_LAST_UPDATED_CATEGORIES, validTill.toIso8601String());
_logger.fine('Saved ${_categories.length} categories to cache (valid till $validTill)');
}
@@ -605,16 +593,11 @@ class ExercisesProvider with ChangeNotifier {
await fetchAndSetLanguagesFromApi();
await database.delete(database.languages).go();
await Future.forEach(_languages, (e) async {
await database.into(database.languages).insert(
LanguagesCompanion.insert(id: e.id, data: e),
);
await database.into(database.languages).insert(LanguagesCompanion.insert(id: e.id, data: e));
});
validTill = DateTime.now().add(const Duration(days: EXERCISE_CACHE_DAYS));
await prefs.setString(
PREFS_LAST_UPDATED_LANGUAGES,
validTill.toIso8601String(),
);
await prefs.setString(PREFS_LAST_UPDATED_LANGUAGES, validTill.toIso8601String());
_logger.info('Saved ${languages.length} languages to cache (valid till $validTill)');
}
@@ -640,15 +623,12 @@ class ExercisesProvider with ChangeNotifier {
await fetchAndSetEquipmentsFromApi();
await database.delete(database.equipments).go();
await Future.forEach(_equipment, (e) async {
await database.into(database.equipments).insert(
EquipmentsCompanion.insert(id: e.id, data: e),
);
await database
.into(database.equipments)
.insert(EquipmentsCompanion.insert(id: e.id, data: e));
});
validTill = DateTime.now().add(const Duration(days: EXERCISE_CACHE_DAYS));
await prefs.setString(
PREFS_LAST_UPDATED_EQUIPMENT,
validTill.toIso8601String(),
);
await prefs.setString(PREFS_LAST_UPDATED_EQUIPMENT, validTill.toIso8601String());
_logger.fine('Saved ${_equipment.length} equipment entries to cache (valid till $validTill)');
}
@@ -703,17 +683,9 @@ class FilterCategory<T> {
List<T> get selected => [...items.keys].where((key) => items[key]!).toList();
FilterCategory({
required this.title,
required this.items,
this.isExpanded = false,
});
FilterCategory({required this.title, required this.items, this.isExpanded = false});
FilterCategory<T> copyWith({
bool? isExpanded,
Map<T, bool>? items,
String? title,
}) {
FilterCategory<T> copyWith({bool? isExpanded, Map<T, bool>? items, String? title}) {
return FilterCategory(
isExpanded: isExpanded ?? this.isExpanded,
items: items ?? this.items,

View File

@@ -8,8 +8,8 @@ const DEFAULT_DURATION = Duration(hours: 5);
final StateNotifierProvider<GymStateNotifier, GymState> gymStateProvider =
StateNotifierProvider<GymStateNotifier, GymState>((ref) {
return GymStateNotifier();
});
return GymStateNotifier();
});
class GymState {
final Map<Exercise, int> exercisePages;
@@ -19,13 +19,14 @@ class GymState {
late TimeOfDay startTime;
late DateTime validUntil;
GymState(
{this.exercisePages = const {},
this.showExercisePages = true,
this.currentPage = 0,
this.dayId,
DateTime? validUntil,
TimeOfDay? startTime}) {
GymState({
this.exercisePages = const {},
this.showExercisePages = true,
this.currentPage = 0,
this.dayId,
DateTime? validUntil,
TimeOfDay? startTime,
}) {
this.validUntil = validUntil ?? DateTime.now().add(DEFAULT_DURATION);
this.startTime = startTime ?? TimeOfDay.fromDateTime(clock.now());
}

View File

@@ -133,8 +133,9 @@ class MeasurementProvider with ChangeNotifier {
tempNewCategory.toJson(),
baseProvider.makeUrl(_categoryUrl, id: id),
);
final MeasurementCategory newCategory =
MeasurementCategory.fromJson(response).copyWith(entries: oldCategory.entries);
final MeasurementCategory newCategory = MeasurementCategory.fromJson(
response,
).copyWith(entries: oldCategory.entries);
_categories.removeAt(categoryIndex);
_categories.insert(categoryIndex, newCategory);
notifyListeners();
@@ -192,8 +193,10 @@ class MeasurementProvider with ChangeNotifier {
date: newDate,
);
final Map<String, dynamic> response =
await baseProvider.patch(tempNewEntry.toJson(), baseProvider.makeUrl(_entryUrl, id: id));
final Map<String, dynamic> response = await baseProvider.patch(
tempNewEntry.toJson(),
baseProvider.makeUrl(_entryUrl, id: id),
);
final MeasurementEntry newEntry = MeasurementEntry.fromJson(response);
category.entries.removeAt(entryIndex);

View File

@@ -49,9 +49,11 @@ class NutritionPlansProvider with ChangeNotifier {
List<NutritionalPlan> _plans = [];
List<Ingredient> ingredients = [];
NutritionPlansProvider(this.baseProvider, List<NutritionalPlan> entries,
{IngredientDatabase? database})
: _plans = entries {
NutritionPlansProvider(
this.baseProvider,
List<NutritionalPlan> entries, {
IngredientDatabase? database,
}) : _plans = entries {
this.database = database ?? locator<IngredientDatabase>();
}
@@ -73,8 +75,10 @@ class NutritionPlansProvider with ChangeNotifier {
NutritionalPlan? get currentPlan {
final now = DateTime.now();
return _plans
.where((plan) =>
plan.startDate.isBefore(now) && (plan.endDate == null || plan.endDate!.isAfter(now)))
.where(
(plan) =>
plan.startDate.isBefore(now) && (plan.endDate == null || plan.endDate!.isAfter(now)),
)
.toList()
.sorted((a, b) => b.creationDate.compareTo(a.creationDate))
.firstOrNull;
@@ -114,10 +118,9 @@ class NutritionPlansProvider with ChangeNotifier {
/// Fetches and sets all plans fully, i.e. with all corresponding child objects
Future<void> fetchAndSetAllPlansFull() async {
final data = await baseProvider.fetchPaginated(baseProvider.makeUrl(
_nutritionalPlansPath,
query: {'limit': API_MAX_PAGE_SIZE},
));
final data = await baseProvider.fetchPaginated(
baseProvider.makeUrl(_nutritionalPlansPath, query: {'limit': API_MAX_PAGE_SIZE}),
);
await Future.wait(data.map((e) => fetchAndSetPlanFull(e['id'])).toList());
}
@@ -225,10 +228,7 @@ class NutritionPlansProvider with ChangeNotifier {
/// Adds a meal to a plan
Future<Meal> addMeal(Meal meal, int planId) async {
final plan = findById(planId);
final data = await baseProvider.post(
meal.toJson(),
baseProvider.makeUrl(_mealPath),
);
final data = await baseProvider.post(meal.toJson(), baseProvider.makeUrl(_mealPath));
meal = Meal.fromJson(data);
plan.meals.add(meal);
@@ -269,10 +269,7 @@ class NutritionPlansProvider with ChangeNotifier {
/// Adds a meal item to a meal
Future<MealItem> addMealItem(MealItem mealItem, Meal meal) async {
final data = await baseProvider.post(
mealItem.toJson(),
baseProvider.makeUrl(_mealItemPath),
);
final data = await baseProvider.post(mealItem.toJson(), baseProvider.makeUrl(_mealItemPath));
mealItem = MealItem.fromJson(data);
mealItem.ingredient = await fetchIngredient(mealItem.ingredientId);
@@ -301,6 +298,7 @@ class NutritionPlansProvider with ChangeNotifier {
}
Future<void> clearIngredientCache() async {
ingredients = [];
await database.deleteEverything();
}
@@ -314,9 +312,9 @@ class NutritionPlansProvider with ChangeNotifier {
try {
ingredient = ingredients.firstWhere((e) => e.id == ingredientId);
} on StateError {
final ingredientDb = await (database.select(database.ingredients)
..where((e) => e.id.equals(ingredientId)))
.getSingleOrNull();
final ingredientDb = await (database.select(
database.ingredients,
)..where((e) => e.id.equals(ingredientId))).getSingleOrNull();
// Try to fetch from local db
if (ingredientDb != null) {
@@ -325,8 +323,9 @@ class NutritionPlansProvider with ChangeNotifier {
_logger.info("Loaded ingredient '${ingredient.name}' from db cache");
// Prune old entries
if (DateTime.now()
.isAfter(ingredientDb.lastFetched.add(const Duration(days: DAYS_TO_CACHE)))) {
if (DateTime.now().isAfter(
ingredientDb.lastFetched.add(const Duration(days: DAYS_TO_CACHE)),
)) {
(database.delete(database.ingredients)..where((i) => i.id.equals(ingredientId))).go();
}
} else {
@@ -336,7 +335,9 @@ class NutritionPlansProvider with ChangeNotifier {
ingredient = Ingredient.fromJson(data);
ingredients.add(ingredient);
database.into(database.ingredients).insert(
database
.into(database.ingredients)
.insert(
IngredientsCompanion.insert(
id: ingredientId,
data: jsonEncode(data),
@@ -415,10 +416,7 @@ class NutritionPlansProvider with ChangeNotifier {
final plan = findById(meal.planId);
final Log log = Log.fromMealItem(item, plan.id!, meal.id, mealDateTime);
final data = await baseProvider.post(
log.toJson(),
baseProvider.makeUrl(_nutritionDiaryPath),
);
final data = await baseProvider.post(log.toJson(), baseProvider.makeUrl(_nutritionDiaryPath));
log.id = data['id'];
plan.diaryEntries.add(log);
}
@@ -426,19 +424,12 @@ class NutritionPlansProvider with ChangeNotifier {
}
/// Log custom ingredient to nutrition diary
Future<void> logIngredientToDiary(
MealItem mealItem,
int planId, [
DateTime? dateTime,
]) async {
Future<void> logIngredientToDiary(MealItem mealItem, int planId, [DateTime? dateTime]) async {
final plan = findById(planId);
mealItem.ingredient = await fetchIngredient(mealItem.ingredientId);
final log = Log.fromMealItem(mealItem, plan.id!, null, dateTime);
final data = await baseProvider.post(
log.toJson(),
baseProvider.makeUrl(_nutritionDiaryPath),
);
final data = await baseProvider.post(log.toJson(), baseProvider.makeUrl(_nutritionDiaryPath));
log.id = data['id'];
plan.diaryEntries.add(log);
notifyListeners();
@@ -458,11 +449,7 @@ class NutritionPlansProvider with ChangeNotifier {
final data = await baseProvider.fetchPaginated(
baseProvider.makeUrl(
_nutritionDiaryPath,
query: {
'plan': plan.id?.toString(),
'limit': API_MAX_PAGE_SIZE,
'ordering': 'datetime',
},
query: {'plan': plan.id?.toString(), 'limit': API_MAX_PAGE_SIZE, 'ordering': 'datetime'},
),
);

View File

@@ -18,8 +18,8 @@ const PREFS_KEY_PLATES = 'selectedPlates';
final plateCalculatorProvider =
StateNotifierProvider<PlateCalculatorNotifier, PlateCalculatorState>((ref) {
return PlateCalculatorNotifier();
});
return PlateCalculatorNotifier();
});
class PlateCalculatorState {
final _logger = Logger('PlateWeightsState');
@@ -65,17 +65,18 @@ class PlateCalculatorState {
this.isMetric = true,
this.totalWeight = 0,
List<num>? selectedPlates,
}) : barWeight = barWeight ?? (isMetric ? DEFAULT_BAR_WEIGHT_KG : DEFAULT_BAR_WEIGHT_LB),
selectedPlates =
selectedPlates ?? (isMetric ? [...DEFAULT_KG_PLATES] : [...DEFAULT_LB_PLATES]);
}) : barWeight = barWeight ?? (isMetric ? DEFAULT_BAR_WEIGHT_KG : DEFAULT_BAR_WEIGHT_LB),
selectedPlates =
selectedPlates ?? (isMetric ? [...DEFAULT_KG_PLATES] : [...DEFAULT_LB_PLATES]);
PlateCalculatorState.fromJson(Map<String, dynamic> plateData)
: useColors = plateData['useColors'] ?? true,
isMetric = plateData['isMetric'] ?? true,
selectedPlates = plateData['selectedPlates']?.cast<num>() ?? [...DEFAULT_KG_PLATES],
barWeight = plateData['barWeight'] ??
((plateData['isMetric'] ?? true) ? DEFAULT_BAR_WEIGHT_KG : DEFAULT_BAR_WEIGHT_LB),
totalWeight = 0;
: useColors = plateData['useColors'] ?? true,
isMetric = plateData['isMetric'] ?? true,
selectedPlates = plateData['selectedPlates']?.cast<num>() ?? [...DEFAULT_KG_PLATES],
barWeight =
plateData['barWeight'] ??
((plateData['isMetric'] ?? true) ? DEFAULT_BAR_WEIGHT_KG : DEFAULT_BAR_WEIGHT_LB),
totalWeight = 0;
PlateCalculatorState copyWith({
bool? useColors,

View File

@@ -279,8 +279,9 @@ class RoutinesProvider with ChangeNotifier {
final dayDataEntriesGym = dayDataGym.map((entry) => DayData.fromJson(entry)).toList();
await setExercisesAndUnits(dayDataEntriesGym, exercises: exercises);
final sessionDataEntries =
sessionData.map((entry) => WorkoutSessionApi.fromJson(entry)).toList();
final sessionDataEntries = sessionData
.map((entry) => WorkoutSessionApi.fromJson(entry))
.toList();
for (final day in routine.days) {
for (final slot in day.slots) {
@@ -322,8 +323,9 @@ class RoutinesProvider with ChangeNotifier {
}
if (!exercises.containsKey(log.exerciseId)) {
exercises[log.exerciseId] =
(await _exerciseProvider.fetchAndSetExercise(log.exerciseId))!;
exercises[log.exerciseId] = (await _exerciseProvider.fetchAndSetExercise(
log.exerciseId,
))!;
}
log.exerciseBase = exercises[log.exerciseId]!;
@@ -378,8 +380,9 @@ class RoutinesProvider with ChangeNotifier {
/// Fetch and set weight units for workout (kg, lb, plate, etc.)
Future<void> fetchAndSetRepetitionUnits() async {
final response =
await baseProvider.fetchPaginated(baseProvider.makeUrl(_repetitionUnitUrlPath));
final response = await baseProvider.fetchPaginated(
baseProvider.makeUrl(_repetitionUnitUrlPath),
);
for (final unit in response) {
_repetitionUnits.add(RepetitionUnit.fromJson(unit));
}

View File

@@ -101,10 +101,12 @@ class UserProvider with ChangeNotifier {
/// Verify the user's email
Future<void> verifyEmail() async {
await baseProvider.fetch(baseProvider.makeUrl(
PROFILE_URL,
objectMethod: VERIFY_EMAIL,
));
await baseProvider.fetch(
baseProvider.makeUrl(
PROFILE_URL,
objectMethod: VERIFY_EMAIL,
),
);
//log(verificationData.toString());
}
}

View File

@@ -9,12 +9,12 @@ import 'package:wger/providers/add_exercise.dart';
import 'package:wger/providers/exercises.dart';
import 'package:wger/providers/user.dart';
import 'package:wger/screens/exercise_screen.dart';
import 'package:wger/widgets/add_exercise/steps/step1basics.dart';
import 'package:wger/widgets/add_exercise/steps/step2variations.dart';
import 'package:wger/widgets/add_exercise/steps/step3description.dart';
import 'package:wger/widgets/add_exercise/steps/step4translations.dart';
import 'package:wger/widgets/add_exercise/steps/step5images.dart';
import 'package:wger/widgets/add_exercise/steps/step6Overview.dart';
import 'package:wger/widgets/add_exercise/steps/step_1_basics.dart';
import 'package:wger/widgets/add_exercise/steps/step_2_variations.dart';
import 'package:wger/widgets/add_exercise/steps/step_3_description.dart';
import 'package:wger/widgets/add_exercise/steps/step_4_translations.dart';
import 'package:wger/widgets/add_exercise/steps/step_5_images.dart';
import 'package:wger/widgets/add_exercise/steps/step_6_overview.dart';
import 'package:wger/widgets/core/app_bar.dart';
import 'package:wger/widgets/user/forms.dart';
@@ -85,10 +85,8 @@ class _AddExerciseStepperState extends State<AddExerciseStepper> {
Exercise? exercise;
try {
final exerciseId = await addExerciseProvider.addExercise();
await addExerciseProvider.addImages(exerciseId);
final exerciseId = await addExerciseProvider.postExerciseToServer();
exercise = await exerciseProvider.fetchAndSetExercise(exerciseId);
addExerciseProvider.clear();
} on WgerHttpException catch (error) {
if (context.mounted) {
setState(() {

View File

@@ -182,11 +182,11 @@ class _AuthCardState extends State<AuthCard> {
late LoginActions res;
if (_authMode == AuthMode.Login) {
res = await context.read<AuthProvider>().login(
_authData['username']!,
_authData['password']!,
_authData['serverUrl']!,
_authData['apiToken'],
);
_authData['username']!,
_authData['password']!,
_authData['serverUrl']!,
_authData['apiToken'],
);
// Register new user
} else {
@@ -287,8 +287,9 @@ class _AuthCardState extends State<AuthCard> {
labelText: i18n.confirmPassword,
prefixIcon: const Icon(Icons.password),
suffixIcon: IconButton(
icon:
Icon(confirmIsObscure ? Icons.visibility_off : Icons.visibility),
icon: Icon(
confirmIsObscure ? Icons.visibility_off : Icons.visibility,
),
onPressed: () {
updateState(() {
confirmIsObscure = !confirmIsObscure;
@@ -374,8 +375,9 @@ class _AuthCardState extends State<AuthCard> {
Builder(
key: const Key('toggleActionButton'),
builder: (context) {
final String text =
_authMode != AuthMode.Register ? i18n.registerInstead : i18n.loginInstead;
final String text = _authMode != AuthMode.Register
? i18n.registerInstead
: i18n.loginInstead;
return GestureDetector(
onTap: () => _switchAuthMode(),

View File

@@ -40,8 +40,9 @@ class GymModeScreen extends StatelessWidget {
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 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;

View File

@@ -126,8 +126,10 @@ class NutritionalPlanScreen extends StatelessWidget {
);
break;
case NutritionalPlanOptions.delete:
Provider.of<NutritionPlansProvider>(context, listen: false)
.deletePlan(nutritionalPlan.id!);
Provider.of<NutritionPlansProvider>(
context,
listen: false,
).deletePlan(nutritionalPlan.id!);
Navigator.of(context).pop();
break;
}
@@ -165,22 +167,22 @@ class NutritionalPlanScreen extends StatelessWidget {
future: _loadFullPlan(context, nutritionalPlan.id!),
builder: (context, AsyncSnapshot<NutritionalPlan> snapshot) =>
snapshot.connectionState == ConnectionState.waiting
? SliverList(
delegate: SliverChildListDelegate(
[
const SizedBox(
height: 200,
child: Center(
child: CircularProgressIndicator(),
),
),
],
? SliverList(
delegate: SliverChildListDelegate(
[
const SizedBox(
height: 200,
child: Center(
child: CircularProgressIndicator(),
),
),
)
: Consumer<NutritionPlansProvider>(
builder: (context, value, child) =>
NutritionalPlanDetailWidget(nutritionalPlan),
),
],
),
)
: Consumer<NutritionPlansProvider>(
builder: (context, value, child) =>
NutritionalPlanDetailWidget(nutritionalPlan),
),
),
],
),

View File

@@ -33,8 +33,9 @@ class _AddExerciseMultiselectButtonState<T> extends State<AddExerciseMultiselect
child: MultiSelectDialogField(
initialValue: widget.initialItems,
onSaved: widget.onSaved,
items:
widget.items.map((item) => MultiSelectItem<T>(item, widget.displayName(item))).toList(),
items: widget.items
.map((item) => MultiSelectItem<T>(item, widget.displayName(item)))
.toList(),
onConfirm: (value) {
setState(() {
_selectedItems = value.cast<T>();

View File

@@ -1,22 +1,22 @@
import 'package:flutter/material.dart';
class AddExerciseTextArea extends StatelessWidget {
const AddExerciseTextArea({
AddExerciseTextArea({
super.key,
required this.onChange,
required this.title,
ValueChanged<String>? onChange,
this.helperText = '',
this.isRequired = true,
this.isMultiline = false,
this.initialValue = '',
this.validator,
this.onSaved,
});
}) : onChange = onChange ?? ((String value) {});
final ValueChanged<String> onChange;
final bool isRequired;
final bool isMultiline;
final String title;
final String helperText;
final String? initialValue;
final FormFieldValidator<String?>? validator;
final FormFieldSetter<String?>? onSaved;
@@ -28,6 +28,7 @@ class AddExerciseTextArea extends StatelessWidget {
return Padding(
padding: const EdgeInsets.all(8.0),
child: TextFormField(
initialValue: initialValue,
keyboardType: isMultiline ? TextInputType.multiline : TextInputType.text,
maxLines: isMultiline ? null : DEFAULT_LINES,
minLines: isMultiline ? MULTILINE_MIN_LINES : DEFAULT_LINES,
@@ -35,12 +36,11 @@ class AddExerciseTextArea extends StatelessWidget {
onSaved: onSaved,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
labelText: title,
alignLabelWithHint: true,
helperText: helperText,
helperMaxLines: 3,
),
onChanged: onChange,
),

View File

@@ -0,0 +1,261 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:wger/core/validators.dart';
import 'package:wger/helpers/exercises/validators.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise_submission_images.dart';
import 'package:wger/widgets/add_exercise/license_info_widget.dart';
import 'add_exercise_text_area.dart';
/// Form for collecting CC BY-SA 4.0 license metadata for exercise images
///
/// This form is displayed after image selection in Step 5 of exercise creation.
/// It collects all required and optional license attribution fields:
///
/// Required by CC BY-SA 4.0:
/// - Author name
/// - License type (implicitly CC BY-SA 4.0)
///
/// Optional but recommended:
/// - Title (helps identify the image)
/// - Source URL (where image was found)
/// - Author URL (author's website/profile)
/// - Derivative source URL (if modified from another work)
/// - Image style (PHOTO, 3D, LINE, LOW-POLY, OTHER)
///
/// All metadata is sent to the API's /exerciseimage endpoint along with
/// the image file when the exercise is submitted.
class ImageDetailsForm extends StatefulWidget {
final Function(ExerciseSubmissionImage image) onAdd;
final VoidCallback onCancel;
final ExerciseSubmissionImage submissionImage;
const ImageDetailsForm({
super.key,
required this.submissionImage,
required this.onAdd,
required this.onCancel,
});
@override
State<ImageDetailsForm> createState() => _ImageDetailsFormState();
}
class _ImageDetailsFormState extends State<ImageDetailsForm> {
final _formKey = GlobalKey<FormState>();
/// Currently selected image type
ImageType _selectedImageType = ImageType.photo;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
return SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).imageDetailsTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
ImagePreview(imageFile: widget.submissionImage.imageFile),
const SizedBox(height: 8),
// Author name - required for proper CC BY-SA attribution
AddExerciseTextArea(
title: '${AppLocalizations.of(context).author}*',
initialValue: widget.submissionImage.author,
onSaved: (value) => widget.submissionImage.author = value,
validator: (name) => validateAuthorName(name, context),
),
// License title field - helps identify the image
AddExerciseTextArea(
title: AppLocalizations.of(context).imageDetailsLicenseTitle,
helperText: AppLocalizations.of(context).imageDetailsLicenseTitleHint,
initialValue: widget.submissionImage.title,
onSaved: (value) => widget.submissionImage.title = value,
),
// Source URL - where the image was found (license_object_url in API)
AddExerciseTextArea(
title: AppLocalizations.of(context).imageDetailsSourceLink,
initialValue: widget.submissionImage.sourceUrl,
onSaved: (value) => widget.submissionImage.sourceUrl = value,
validator: (value) => validateUrl(value, i18n, required: false),
),
// Author's website/profile URL
AddExerciseTextArea(
title: AppLocalizations.of(context).imageDetailsAuthorLink,
initialValue: widget.submissionImage.authorUrl,
onSaved: (value) => widget.submissionImage.authorUrl = value,
validator: (value) => validateUrl(value, i18n, required: false),
),
// Original source if this is a derivative work (modified from another image)
AddExerciseTextArea(
title: AppLocalizations.of(context).imageDetailsDerivativeSource,
helperText: AppLocalizations.of(context).imageDetailsDerivativeHelp,
initialValue: widget.submissionImage.derivativeSourceUrl,
onSaved: (value) => widget.submissionImage.derivativeSourceUrl = value,
validator: (value) => validateUrl(value, i18n, required: false),
),
ImageTypeSelector(
selectedType: _selectedImageType,
onTypeSelected: (type) {
setState(() {
_selectedImageType = type;
});
},
),
const SizedBox(height: 16),
// License info as separate widget for better optimization
const LicenseInfoWidget(),
const SizedBox(height: 8),
_buildButtons(),
],
),
),
);
}
Widget _buildButtons() {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: widget.onCancel,
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
if (!_formKey.currentState!.validate()) {
return;
}
_formKey.currentState?.save();
// Pass image and metadata back to parent
widget.onAdd(widget.submissionImage);
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
),
child: Text(
AppLocalizations.of(context).add,
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
),
),
],
);
}
}
class ImagePreview extends StatelessWidget {
final File imageFile;
const ImagePreview({super.key, required this.imageFile});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 300, maxHeight: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(imageFile, fit: BoxFit.contain),
),
),
);
}
}
class ImageTypeSelector extends StatelessWidget {
final ImageType selectedType;
final ValueChanged<ImageType> onTypeSelected;
const ImageTypeSelector({super.key, required this.selectedType, required this.onTypeSelected});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final i18n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
i18n.imageDetailsImageType,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: ImageType.values.map((type) {
final isSelected = selectedType == type;
return InkWell(
onTap: () => onTypeSelected(type),
borderRadius: BorderRadius.circular(4),
child: Container(
width: 90,
height: 90,
decoration: BoxDecoration(
color: isSelected
? theme.buttonTheme.colorScheme!.primary
: theme.buttonTheme.colorScheme!.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
type.icon,
size: 32,
color: isSelected
? theme.buttonTheme.colorScheme!.onPrimary
: theme.buttonTheme.colorScheme!.primary,
),
const SizedBox(height: 8),
Text(
type.label,
style: TextStyle(
fontSize: 11,
color: isSelected
? theme.buttonTheme.colorScheme!.onPrimary
: theme.buttonTheme.colorScheme!.primary,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
textAlign: TextAlign.center,
),
],
),
),
);
}).toList(),
),
],
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
/// Static widget displaying CC BY-SA 4.0 license notice for image uploads
///
/// This widget informs users that by uploading images, they agree to release
/// them under the CC BY-SA 4.0 license. The license name is clickable and
/// opens the Creative Commons license page.
///
/// Being a separate widget allows Flutter to optimize rendering since
/// this content never changes.
class LicenseInfoWidget extends StatelessWidget {
const LicenseInfoWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: colorScheme.outline),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, size: 20, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: RichText(
text: TextSpan(
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
children: [
TextSpan(text: i18n.imageDetailsLicenseNotice),
WidgetSpan(
child: GestureDetector(
onTap: () async {
final url = Uri.parse('https://creativecommons.org/licenses/by-sa/4.0/');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
},
child: Text(
i18n.imageDetailsLicenseNoticeLinkToLicense,
style: TextStyle(
fontSize: 12,
color: colorScheme.primary,
fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
),
),
),
),
],
),
),
),
],
),
);
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import 'package:wger/models/exercises/exercise_submission_images.dart';
import 'package:wger/providers/add_exercise.dart';
const validFileExtensions = ['jpg', 'jpeg', 'png', 'webp'];
@@ -31,19 +32,21 @@ mixin ExerciseImagePickerMixin {
images = await imagePicker.pickMultiImage();
}
final selectedImages = <File>[];
final selectedImages = <ExerciseSubmissionImage>[];
if (images != null) {
selectedImages.addAll(images.map((e) => File(e.path)).toList());
selectedImages.addAll(
images.map((e) => ExerciseSubmissionImage(imageFile: File(e.path))).toList(),
);
for (final image in selectedImages) {
bool isFileValid = true;
String errorMessage = '';
if (!_validateFileType(image)) {
if (!_validateFileType(image.imageFile)) {
isFileValid = false;
errorMessage = "Select only 'jpg', 'jpeg', 'png', 'webp' files";
}
if (_validateFileSize(image.lengthSync())) {
if (_validateFileSize(image.imageFile.lengthSync())) {
isFileValid = true;
errorMessage = 'File Size should not be greater than 20 mb';
}

View File

@@ -2,72 +2,103 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/models/exercises/exercise_submission_images.dart';
import 'package:wger/providers/add_exercise.dart';
import 'mixins/image_picker_mixin.dart';
class PreviewExerciseImages extends StatelessWidget with ExerciseImagePickerMixin {
final List<File> selectedImages;
/// Widget to preview selected exercise images
///
/// Displays images in a horizontal scrollable list with thumbnails.
/// Each image shows a preview thumbnail and optionally a delete button.
/// Can optionally include an "add more" button at the end of the list.
class PreviewExerciseImages extends StatelessWidget {
final List<ExerciseSubmissionImage> selectedImages;
final VoidCallback? onAddMore;
final bool allowEdit;
const PreviewExerciseImages({super.key, required this.selectedImages, this.allowEdit = true});
const PreviewExerciseImages({
super.key,
required this.selectedImages,
this.onAddMore,
this.allowEdit = true,
});
@override
Widget build(BuildContext context) {
// Calculate item count: images + optional "add more" button
final itemCount = selectedImages.length + (allowEdit && onAddMore != null ? 1 : 0);
return SizedBox(
height: 300,
child: ListView(scrollDirection: Axis.horizontal, children: [
...selectedImages.map(
(file) => SizedBox(
height: 200,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Stack(
children: [
Image.file(file),
if (allowEdit)
Positioned(
bottom: 0,
right: 0,
child: Padding(
padding: const EdgeInsets.all(3.0),
child: Container(
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.5),
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: IconButton(
iconSize: 20,
onPressed: () =>
context.read<AddExerciseProvider>().removeExercise(file.path),
color: Colors.white,
icon: const Icon(Icons.delete),
),
),
),
),
],
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: itemCount,
itemBuilder: (context, index) {
// Show "add more" button at the end (only if editing is allowed)
if (index == selectedImages.length) {
return _buildAddMoreButton(context);
}
// Show image thumbnail
final image = selectedImages[index];
return _buildImageCard(context, image.imageFile);
},
),
);
}
Widget _buildImageCard(BuildContext context, File image) {
return Container(
width: 120,
margin: const EdgeInsets.only(right: 8),
child: Stack(
children: [
// Image thumbnail
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(image, width: 120, height: 120, fit: BoxFit.cover),
),
// Delete button overlay (only shown if editing is allowed)
if (allowEdit)
Positioned(
top: 4,
right: 4,
child: IconButton(
icon: const Icon(Icons.close),
color: Colors.white,
iconSize: 20,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withOpacity(0.6),
padding: const EdgeInsets.all(4),
),
onPressed: () {
context.read<AddExerciseProvider>().removeImage(image.path);
},
),
),
],
),
);
}
Widget _buildAddMoreButton(BuildContext context) {
return GestureDetector(
onTap: onAddMore,
child: Container(
width: 120,
height: 120,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 2,
style: BorderStyle.solid,
),
),
const SizedBox(width: 10),
if (allowEdit)
Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
color: Colors.grey,
height: 200,
width: 100,
child: Center(
child: IconButton(
icon: const Icon(Icons.add),
onPressed: () => pickImages(context),
),
),
),
),
]),
child: Icon(Icons.add, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
);
}
}

View File

@@ -1,53 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/add_exercise.dart';
import 'package:wger/widgets/add_exercise/mixins/image_picker_mixin.dart';
import 'package:wger/widgets/add_exercise/preview_images.dart';
class Step5Images extends StatefulWidget {
final GlobalKey<FormState> formkey;
const Step5Images({required this.formkey});
@override
State<Step5Images> createState() => _Step5ImagesState();
}
class _Step5ImagesState extends State<Step5Images> with ExerciseImagePickerMixin {
@override
Widget build(BuildContext context) {
return Form(
key: widget.formkey,
child: Column(
children: [
Text(
AppLocalizations.of(context).add_exercise_image_license,
style: Theme.of(context).textTheme.bodySmall,
),
Consumer<AddExerciseProvider>(
builder: (ctx, provider, __) => provider.exerciseImages.isNotEmpty
? PreviewExerciseImages(selectedImages: provider.exerciseImages)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () => pickImages(context, pickFromCamera: true),
icon: const Icon(Icons.camera_alt),
),
IconButton(
onPressed: () => pickImages(context),
icon: const Icon(Icons.collections),
),
],
),
),
Text(
'Only JPEG, PNG and WEBP files below 20 MB are supported',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/exercises/forms.dart';
import 'package:wger/helpers/exercises/validators.dart';
import 'package:wger/helpers/i18n.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/category.dart';
@@ -9,6 +9,7 @@ import 'package:wger/models/exercises/equipment.dart';
import 'package:wger/models/exercises/muscle.dart';
import 'package:wger/providers/add_exercise.dart';
import 'package:wger/providers/exercises.dart';
import 'package:wger/providers/user.dart';
import 'package:wger/widgets/add_exercise/add_exercise_multiselect_button.dart';
import 'package:wger/widgets/add_exercise/add_exercise_text_area.dart';
import 'package:wger/widgets/exercises/exercises.dart';
@@ -21,6 +22,7 @@ class Step1Basics extends StatelessWidget {
@override
Widget build(BuildContext context) {
final userProvider = context.read<UserProvider>();
final addExerciseProvider = context.read<AddExerciseProvider>();
final exerciseProvider = context.read<ExercisesProvider>();
final categories = exerciseProvider.categories;
@@ -38,21 +40,25 @@ class Step1Basics extends StatelessWidget {
builder: (context, provider, child) => Column(
children: [
AddExerciseTextArea(
onChange: (value) => {},
title: '${AppLocalizations.of(context).name}*',
helperText: AppLocalizations.of(context).baseNameEnglish,
isRequired: true,
validator: (name) => validateName(name, context),
onSaved: (String? name) => addExerciseProvider.exerciseNameEn = name!,
onSaved: (String? name) => addExerciseProvider.exerciseNameEn = name,
),
AddExerciseTextArea(
onChange: (value) => {},
title: AppLocalizations.of(context).alternativeNames,
isMultiline: true,
helperText: AppLocalizations.of(context).oneNamePerLine,
onSaved: (String? alternateName) =>
addExerciseProvider.alternateNamesEn = alternateName!.split('\n'),
),
AddExerciseTextArea(
title: '${AppLocalizations.of(context).author}*',
isMultiline: false,
validator: (name) => validateAuthorName(name, context),
initialValue: userProvider.profile!.username,
onSaved: (String? author) => addExerciseProvider.author = author!,
),
ExerciseCategoryInputWidget<ExerciseCategory>(
key: const Key('category-dropdown'),
entries: categories,

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/exercises/forms.dart';
import 'package:wger/helpers/exercises/validators.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/add_exercise.dart';
import 'package:wger/widgets/add_exercise/add_exercise_text_area.dart';
@@ -23,10 +23,9 @@ class Step3Description extends StatelessWidget {
onChange: (value) => {},
title: '${i18n.description}*',
helperText: i18n.enterTextInLanguage,
isRequired: true,
isMultiline: true,
validator: (name) => validateExerciseDescription(name, context),
onSaved: (String? description) => addExerciseProvider.descriptionEn = description!,
onSaved: (String? description) => addExerciseProvider.descriptionEn = description,
),
],
),

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/exercises/forms.dart';
import 'package:wger/helpers/exercises/validators.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/language.dart';
import 'package:wger/providers/add_exercise.dart';
@@ -59,14 +59,11 @@ class _Step4TranslationState extends State<Step4Translation> {
},
),
AddExerciseTextArea(
onChange: (value) => {},
title: '${i18n.name}*',
isRequired: true,
validator: (name) => validateName(name, context),
onSaved: (String? name) => addExerciseProvider.exerciseNameTrans = name!,
),
AddExerciseTextArea(
onChange: (value) => {},
title: i18n.alternativeNames,
isMultiline: true,
helperText: i18n.oneNamePerLine,
@@ -93,7 +90,6 @@ class _Step4TranslationState extends State<Step4Translation> {
onChange: (value) => {},
title: '${i18n.description}*',
helperText: i18n.enterTextInLanguage,
isRequired: true,
isMultiline: true,
validator: (name) => validateExerciseDescription(name, context),
onSaved: (String? description) =>

View File

@@ -0,0 +1,256 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise_submission_images.dart';
import 'package:wger/providers/add_exercise.dart';
import 'package:wger/providers/user.dart';
import 'package:wger/widgets/add_exercise/image_details_form.dart';
import 'package:wger/widgets/add_exercise/mixins/image_picker_mixin.dart';
import 'package:wger/widgets/add_exercise/preview_images.dart';
/// Step 5 of exercise creation wizard - Image upload with license metadata
///
/// This step allows users to add exercise images with proper CC BY-SA 4.0 license
/// attribution. Unlike the previous implementation that uploaded images directly,
/// this version collects license metadata (title, author, URLs) before adding images.
///
/// Flow:
/// 1. User picks image from camera/gallery
/// 2. ImageDetailsForm is shown to collect license metadata
/// 3. Image + metadata is stored in AddExerciseProvider
/// 4. Final upload happens in Step 6 when user clicks "Submit"
class Step5Images extends StatefulWidget {
final GlobalKey<FormState> formkey;
const Step5Images({required this.formkey});
@override
State<Step5Images> createState() => _Step5ImagesState();
}
class _Step5ImagesState extends State<Step5Images> with ExerciseImagePickerMixin {
/// Currently selected image waiting for metadata input
/// When non-null, ImageDetailsForm is displayed instead of image picker
ExerciseSubmissionImage? _currentImageToAdd;
/// Show dialog to choose between Camera and Gallery
Future<void> _showImageSourceDialog(BuildContext context) async {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context).selectImage),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: Text(AppLocalizations.of(context).takePicture),
onTap: () {
Navigator.of(context).pop();
_pickAndShowImageDetails(context, pickFromCamera: true);
},
),
ListTile(
leading: const Icon(Icons.collections),
title: Text(AppLocalizations.of(context).gallery),
onTap: () {
Navigator.of(context).pop();
_pickAndShowImageDetails(context, pickFromCamera: false);
},
),
],
),
);
},
);
}
/// Pick image from camera or gallery and show metadata collection form
///
/// Validates file format (jpg, jpeg, png, webp) and size (<20MB) before
/// showing the form. Invalid files are rejected with a snackbar message.
///
/// [pickFromCamera] - If true, opens camera; otherwise opens gallery
void _pickAndShowImageDetails(BuildContext context, {bool pickFromCamera = false}) async {
final userProvider = context.read<UserProvider>();
final imagePicker = ImagePicker();
XFile? selectedImage;
if (pickFromCamera) {
selectedImage = await imagePicker.pickImage(source: ImageSource.camera);
} else {
selectedImage = await imagePicker.pickImage(source: ImageSource.gallery);
}
if (selectedImage != null) {
final imageFile = File(selectedImage.path);
// Validate file type - only common image formats accepted
bool isFileValid = true;
String errorMessage = '';
final extension = imageFile.path.split('.').last;
const validFileExtensions = ['jpg', 'jpeg', 'png', 'webp'];
if (!validFileExtensions.any((ext) => extension.toLowerCase() == ext)) {
isFileValid = false;
errorMessage = "Select only 'jpg', 'jpeg', 'png', 'webp' files";
}
// Validate file size - 20MB limit matches server-side restriction
final fileSizeInMB = imageFile.lengthSync() / 1024 / 1024;
if (fileSizeInMB > 20) {
isFileValid = false;
errorMessage = 'File Size should not be greater than 20 MB';
}
if (!isFileValid) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(errorMessage)));
}
return;
}
// Show metadata collection form for valid image
setState(() {
_currentImageToAdd = ExerciseSubmissionImage(
imageFile: imageFile,
author: userProvider.profile?.username ?? '',
);
});
}
}
/// Add image with its license metadata to the provider
///
/// Called when user clicks "ADD" in ImageDetailsForm. The image and metadata
/// are stored locally in AddExerciseProvider and will be uploaded together
/// when the exercise is submitted in Step 6.
///
/// [image] - The image file to add
/// [details] - Map containing license fields (license_title, license_author, etc.)
void _addImageWithDetails(ExerciseSubmissionImage image) {
final provider = context.read<AddExerciseProvider>();
// Store image with metadata - actual upload happens in addExercise()
provider.addExerciseImages([image]);
// Reset form state - image is now visible in preview list
setState(() {
_currentImageToAdd = null;
});
}
/// Cancel metadata input and return to image picker
void _cancelImageAdd() {
setState(() {
_currentImageToAdd = null;
});
}
@override
Widget build(BuildContext context) {
return Form(
key: widget.formkey,
child: Column(
children: [
// License notice - shown when not entering metadata
if (_currentImageToAdd == null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text(
AppLocalizations.of(context).add_exercise_image_license,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
// Metadata collection form - shown when image is selected
if (_currentImageToAdd != null)
ImageDetailsForm(
submissionImage: _currentImageToAdd!,
onAdd: _addImageWithDetails,
onCancel: _cancelImageAdd,
),
// Image picker or preview - shown when not entering metadata
if (_currentImageToAdd == null)
Consumer<AddExerciseProvider>(
builder: (ctx, provider, __) {
if (provider.exerciseImages.isNotEmpty) {
// Show preview of images that have been added with metadata
return Column(
children: [
PreviewExerciseImages(
selectedImages: provider.exerciseImages,
onAddMore: () => _showImageSourceDialog(context),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => _showImageSourceDialog(context),
icon: const Icon(Icons.add_photo_alternate),
label: Text(AppLocalizations.of(context).addImage),
),
],
);
}
// Empty state - no images added yet
return Column(
children: [
const SizedBox(height: 20),
Icon(Icons.add_photo_alternate, size: 80, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'No images selected',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.grey.shade600),
),
const SizedBox(height: 24),
// Camera and Gallery buttons
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton.icon(
onPressed: () => _pickAndShowImageDetails(context, pickFromCamera: true),
icon: const Icon(Icons.camera_alt),
label: Text(AppLocalizations.of(context).takePicture),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: () => _pickAndShowImageDetails(context),
icon: const Icon(Icons.collections),
label: Text(AppLocalizations.of(context).gallery),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
),
),
],
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
'Only JPEG, PNG and WEBP files below 20 MB are supported',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
),
],
);
},
),
],
),
);
}
}

View File

@@ -11,13 +11,14 @@ class Step6Overview extends StatelessWidget {
final i18n = AppLocalizations.of(context);
return Consumer<AddExerciseProvider>(
builder: (ctx, provider, __) => Column(
builder: (ctx, provider, _) => Column(
spacing: 8,
children: [
Text(i18n.baseData, style: Theme.of(context).textTheme.headlineSmall),
Table(
columnWidths: const {0: FlexColumnWidth(2), 1: FlexColumnWidth(3)},
children: [
TableRow(children: [Text(i18n.author), Text(provider.author)]),
TableRow(children: [Text(i18n.name), Text(provider.exerciseNameEn ?? '...')]),
TableRow(
children: [

View File

@@ -1,36 +1,26 @@
import 'package:flutter/material.dart';
import 'package:wger/core/validators.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
class ServerField extends StatelessWidget {
final TextEditingController controller;
final Function(String?) onSaved;
const ServerField({
required this.controller,
required this.onSaved,
super.key,
});
const ServerField({required this.controller, required this.onSaved, super.key});
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
return TextFormField(
key: const Key('inputServer'),
decoration: InputDecoration(
labelText: AppLocalizations.of(context).customServerUrl,
helperText: AppLocalizations.of(context).customServerHint,
labelText: i18n.customServerUrl,
helperText: i18n.customServerHint,
helperMaxLines: 4,
),
controller: controller,
validator: (value) {
if (Uri.tryParse(value!) == null) {
return AppLocalizations.of(context).invalidUrl;
}
if (value.isEmpty || !value.contains('http')) {
return AppLocalizations.of(context).invalidUrl;
}
return null;
},
validator: (value) => validateUrl(value, i18n, required: true),
onSaved: onSaved,
);
}

View File

@@ -195,7 +195,8 @@ class AboutPage extends StatelessWidget {
showLicensePage(
context: context,
applicationName: 'wger',
applicationVersion: 'App: ${authProvider.applicationVersion?.version ?? 'N/A'} '
applicationVersion:
'App: ${authProvider.applicationVersion?.version ?? 'N/A'} '
'Server: ${authProvider.serverVersion ?? 'N/A'}',
applicationLegalese: '\u{a9} ${today.year} wger contributors',
applicationIcon: Padding(

View File

@@ -18,12 +18,11 @@
//import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/nutrition.dart';
import 'package:wger/providers/user.dart';
import 'package:wger/screens/configure_plates_screen.dart';
import 'package:wger/widgets/core/settings/exercise_cache.dart';
import 'package:wger/widgets/core/settings/ingredient_cache.dart';
import 'package:wger/widgets/core/settings/theme.dart';
class SettingsPage extends StatelessWidget {
static String routeName = '/SettingsPage';
@@ -33,73 +32,18 @@ class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
final nutritionProvider = Provider.of<NutritionPlansProvider>(context, listen: false);
final userProvider = Provider.of<UserProvider>(context);
return Scaffold(
appBar: AppBar(title: Text(i18n.settingsTitle)),
body: ListView(
children: [
ListTile(
title: Text(
i18n.settingsCacheTitle,
style: Theme.of(context).textTheme.headlineSmall,
),
title: Text(i18n.settingsCacheTitle, style: Theme.of(context).textTheme.headlineSmall),
),
const SettingsExerciseCache(),
ListTile(
title: Text(i18n.settingsIngredientCacheDescription),
trailing: IconButton(
key: const ValueKey('cacheIconIngredients'),
icon: const Icon(Icons.delete),
onPressed: () async {
await nutritionProvider.clearIngredientCache();
if (context.mounted) {
final snackBar = SnackBar(
content: Text(i18n.settingsCacheDeletedSnackbar),
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},
),
),
ListTile(
title: Text(
i18n.others,
style: Theme.of(context).textTheme.headlineSmall,
),
),
ListTile(
title: Text(i18n.themeMode),
trailing: DropdownButton<ThemeMode>(
key: const ValueKey('themeModeDropdown'),
value: userProvider.themeMode,
onChanged: (ThemeMode? newValue) {
if (newValue != null) {
userProvider.setThemeMode(newValue);
}
},
items: ThemeMode.values.map<DropdownMenuItem<ThemeMode>>((ThemeMode value) {
final label = (() {
switch (value) {
case ThemeMode.system:
return i18n.systemMode;
case ThemeMode.light:
return i18n.lightMode;
case ThemeMode.dark:
return i18n.darkMode;
}
})();
return DropdownMenuItem<ThemeMode>(
value: value,
child: Text(label),
);
}).toList(),
),
),
const SettingsIngredientCache(),
ListTile(title: Text(i18n.others, style: Theme.of(context).textTheme.headlineSmall)),
const SettingsTheme(),
ListTile(
title: Text(i18n.selectAvailablePlates),
onTap: () {

View File

@@ -22,68 +22,71 @@ class _SettingsExerciseCacheState extends State<SettingsExerciseCache> {
return ListTile(
enabled: !_isRefreshLoading,
title: Text(i18n.settingsExerciseCacheDescription),
subtitle: _subtitle.isNotEmpty ? Text(_subtitle) : null,
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
IconButton(
key: const ValueKey('cacheIconExercisesRefresh'),
icon: _isRefreshLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isRefreshLoading
? null
: () async {
setState(() => _isRefreshLoading = true);
subtitle: _subtitle.isNotEmpty
? Text(_subtitle)
: Text('${exerciseProvider.exercises.length} cached exercises'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: const ValueKey('cacheIconExercisesRefresh'),
icon: _isRefreshLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isRefreshLoading
? null
: () async {
setState(() => _isRefreshLoading = true);
// Note: status messages are currently left in English on purpose
try {
setState(() => _subtitle = 'Clearing cache...');
await exerciseProvider.clearAllCachesAndPrefs();
// Note: status messages are currently left in English on purpose
try {
setState(() => _subtitle = 'Clearing cache...');
await exerciseProvider.clearAllCachesAndPrefs();
if (mounted) {
setState(() => _subtitle = 'Loading languages and units...');
if (mounted) {
setState(() => _subtitle = 'Loading languages and units...');
}
await exerciseProvider.fetchAndSetInitialData();
if (mounted) {
setState(() => _subtitle = 'Loading all exercises from server...');
}
await exerciseProvider.fetchAndSetAllExercises();
if (mounted) {
setState(() => _subtitle = '');
}
} finally {
if (mounted) {
setState(() => _isRefreshLoading = false);
}
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(i18n.success)));
}
}
await exerciseProvider.fetchAndSetInitialData();
},
),
IconButton(
key: const ValueKey('cacheIconExercisesDelete'),
icon: const Icon(Icons.delete),
onPressed: () async {
await exerciseProvider.clearAllCachesAndPrefs();
if (mounted) {
setState(() => _subtitle = 'Loading all exercises from server...');
}
await exerciseProvider.fetchAndSetAllExercises();
if (context.mounted) {
final snackBar = SnackBar(content: Text(i18n.settingsCacheDeletedSnackbar));
if (mounted) {
setState(() => _subtitle = '');
}
} finally {
if (mounted) {
setState(() => _isRefreshLoading = false);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(i18n.success)),
);
}
}
},
),
IconButton(
key: const ValueKey('cacheIconExercisesDelete'),
icon: const Icon(Icons.delete),
onPressed: () async {
await exerciseProvider.clearAllCachesAndPrefs();
if (context.mounted) {
final snackBar = SnackBar(
content: Text(i18n.settingsCacheDeletedSnackbar),
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},
)
]),
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},
),
],
),
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/nutrition.dart';
class SettingsIngredientCache extends StatelessWidget {
const SettingsIngredientCache({super.key});
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
final nutritionProvider = Provider.of<NutritionPlansProvider>(context, listen: false);
return ListTile(
title: Text(i18n.settingsIngredientCacheDescription),
subtitle: Text('${nutritionProvider.ingredients.length} cached ingredients'),
trailing: IconButton(
key: const ValueKey('cacheIconIngredients'),
icon: const Icon(Icons.delete),
onPressed: () async {
await nutritionProvider.clearIngredientCache();
if (context.mounted) {
final snackBar = SnackBar(content: Text(i18n.settingsCacheDeletedSnackbar));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},
),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/user.dart';
class SettingsTheme extends StatelessWidget {
const SettingsTheme({super.key});
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
final userProvider = Provider.of<UserProvider>(context);
return ListTile(
title: Text(i18n.themeMode),
trailing: DropdownButton<ThemeMode>(
key: const ValueKey('themeModeDropdown'),
value: userProvider.themeMode,
onChanged: (ThemeMode? newValue) {
if (newValue != null) {
userProvider.setThemeMode(newValue);
}
},
items: ThemeMode.values.map<DropdownMenuItem<ThemeMode>>((ThemeMode value) {
final label = (() {
switch (value) {
case ThemeMode.system:
return i18n.systemMode;
case ThemeMode.light:
return i18n.lightMode;
case ThemeMode.dark:
return i18n.darkMode;
}
})();
return DropdownMenuItem<ThemeMode>(value: value, child: Text(label));
}).toList(),
),
);
}
}

View File

@@ -31,12 +31,7 @@ import 'package:wger/providers/routines.dart';
import 'package:wger/theme/theme.dart';
/// Types of events
enum EventType {
weight,
measurement,
session,
caloriesDiary,
}
enum EventType { weight, measurement, session, caloriesDiary }
/// An event in the dashboard calendar
class Event {
@@ -85,6 +80,22 @@ class _DashboardCalendarWidgetState extends State<DashboardCalendarWidget>
});
}
/// Loads and organizes all events from various providers into the calendar.
///
/// This method asynchronously fetches and processes data from multiple sources:
/// - **Weight entries**: Retrieves weight measurements from [BodyWeightProvider]
/// - **Measurements**: Retrieves body measurements from [MeasurementProvider]
/// - **Workout sessions**: Fetches workout session data from [RoutinesProvider]
/// - **Nutritional plans**: Retrieves calorie diary entries from [NutritionPlansProvider]
///
/// Each event is formatted according to the current locale and stored in the
/// [_events] map, keyed by date. The date format is determined by [DateFormatLists.format].
///
/// After loading all events, the [_selectedEvents] value is updated with events
/// for the currently selected day, if any.
///
/// **Note**: This method checks if the widget is still mounted before updating
/// the state after the async workout session fetch operation.
void loadEvents() async {
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
final i18n = AppLocalizations.of(context);
@@ -112,10 +123,12 @@ class _DashboardCalendarWidgetState extends State<DashboardCalendarWidget>
_events[date] = [];
}
_events[date]?.add(Event(
EventType.measurement,
'${category.name}: ${numberFormat.format(entry.value)} ${category.unit}',
));
_events[date]?.add(
Event(
EventType.measurement,
'${category.name}: ${numberFormat.format(entry.value)} ${category.unit}',
),
);
}
}
@@ -131,16 +144,20 @@ class _DashboardCalendarWidgetState extends State<DashboardCalendarWidget>
time = '(${timeToString(session.timeStart)} - ${timeToString(session.timeEnd)})';
// Add events to lists
_events[date]?.add(Event(
EventType.session,
'${i18n.impression}: ${session.impressionAsString} $time',
));
_events[date]?.add(
Event(EventType.session, '${i18n.impression}: ${session.impressionAsString} $time'),
);
}
});
if (!mounted) {
return;
}
// Process nutritional plans
final NutritionPlansProvider nutritionProvider =
Provider.of<NutritionPlansProvider>(context, listen: false);
final NutritionPlansProvider nutritionProvider = Provider.of<NutritionPlansProvider>(
context,
listen: false,
);
for (final plan in nutritionProvider.items) {
for (final entry in plan.logEntriesValues.entries) {
final date = DateFormatLists.format(entry.key);
@@ -149,10 +166,9 @@ class _DashboardCalendarWidgetState extends State<DashboardCalendarWidget>
}
// Add events to lists
_events[date]?.add(Event(
EventType.caloriesDiary,
i18n.kcalValue(entry.value.energy.toStringAsFixed(0)),
));
_events[date]?.add(
Event(EventType.caloriesDiary, i18n.kcalValue(entry.value.energy.toStringAsFixed(0))),
);
}
}
@@ -250,8 +266,10 @@ class _DashboardCalendarWidgetState extends State<DashboardCalendarWidget>
valueListenable: _selectedEvents,
builder: (context, value, _) => Column(
children: [
...value.map((event) => ListTile(
title: Text((() {
...value.map(
(event) => ListTile(
title: Text(
(() {
switch (event.type) {
case EventType.caloriesDiary:
return AppLocalizations.of(context).nutritionalDiary;
@@ -265,10 +283,12 @@ class _DashboardCalendarWidgetState extends State<DashboardCalendarWidget>
case EventType.measurement:
return AppLocalizations.of(context).measurement;
}
})()),
subtitle: Text(event.description),
//onTap: () => print('$event tapped!'),
)),
})(),
),
subtitle: Text(event.description),
//onTap: () => print('$event tapped!'),
),
),
],
),
),

View File

@@ -42,8 +42,9 @@ class _DashboardMeasurementWidgetState extends State<DashboardMeasurementWidget>
Widget build(BuildContext context) {
final provider = Provider.of<MeasurementProvider>(context, listen: false);
final items =
provider.categories.map<Widget>((item) => CategoriesCard(item, elevation: 0)).toList();
final items = provider.categories
.map<Widget>((item) => CategoriesCard(item, elevation: 0))
.toList();
if (items.isNotEmpty) {
items.add(
NothingFound(
@@ -115,10 +116,10 @@ class _DashboardMeasurementWidgetState extends State<DashboardMeasurementWidget>
),
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
Theme.of(context).textTheme.headlineSmall!.color!.withOpacity(
_current == entry.key ? 0.9 : 0.4,
),
color: Theme.of(context).textTheme.headlineSmall!.color!
.withOpacity(
_current == entry.key ? 0.9 : 0.4,
),
),
),
);

View File

@@ -60,8 +60,9 @@ class _DashboardNutritionWidgetState extends State<DashboardNutritionWidget> {
),
subtitle: Text(
_hasContent
? DateFormat.yMd(Localizations.localeOf(context).languageCode)
.format(_plan!.creationDate)
? DateFormat.yMd(
Localizations.localeOf(context).languageCode,
).format(_plan!.creationDate)
: '',
),
leading: Icon(

View File

@@ -175,14 +175,19 @@ class DetailContentWidget extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(s.exercise
.getTranslation(
Localizations.localeOf(context).languageCode)
.name),
Text(
s.exercise
.getTranslation(
Localizations.localeOf(context).languageCode,
)
.name,
),
const SizedBox(width: 10),
Expanded(
child:
MutedText(s.textRepr, overflow: TextOverflow.ellipsis),
child: MutedText(
s.textRepr,
overflow: TextOverflow.ellipsis,
),
),
],
),

View File

@@ -94,9 +94,12 @@ class DashboardWeightWidget extends StatelessWidget {
FormScreen.routeName,
arguments: FormScreenArguments(
AppLocalizations.of(context).newEntry,
WeightForm(weightProvider
.getNewestEntry()
?.copyWith(id: null, date: DateTime.now())),
WeightForm(
weightProvider.getNewestEntry()?.copyWith(
id: null,
date: DateTime.now(),
),
),
),
);
},

View File

@@ -85,30 +85,32 @@ class _ExerciseAutocompleterState extends State<ExerciseAutocompleter> {
return null;
}
return context.read<ExercisesProvider>().searchExercise(
pattern,
languageCode: Localizations.localeOf(context).languageCode,
searchEnglish: _searchEnglish,
);
pattern,
languageCode: Localizations.localeOf(context).languageCode,
searchEnglish: _searchEnglish,
);
},
itemBuilder: (
BuildContext context,
Exercise exerciseSuggestion,
) =>
ListTile(
key: Key('exercise-${exerciseSuggestion.id}'),
leading: SizedBox(
width: 45,
child: ExerciseImageWidget(
image: exerciseSuggestion.getMainImage,
itemBuilder:
(
BuildContext context,
Exercise exerciseSuggestion,
) => ListTile(
key: Key('exercise-${exerciseSuggestion.id}'),
leading: SizedBox(
width: 45,
child: ExerciseImageWidget(
image: exerciseSuggestion.getMainImage,
),
),
title: Text(
exerciseSuggestion
.getTranslation(Localizations.localeOf(context).languageCode)
.name,
),
subtitle: Text(
'${exerciseSuggestion.category!.name} / ${exerciseSuggestion.equipment.map((e) => e.name).join(', ')}',
),
),
),
title: Text(
exerciseSuggestion.getTranslation(Localizations.localeOf(context).languageCode).name,
),
subtitle: Text(
'${exerciseSuggestion.category!.name} / ${exerciseSuggestion.equipment.map((e) => e.name).join(', ')}',
),
),
emptyBuilder: (context) {
return Column(
mainAxisSize: MainAxisSize.min,
@@ -149,7 +151,7 @@ class _ExerciseAutocompleterState extends State<ExerciseAutocompleter> {
});
},
dense: true,
)
),
],
);
}

View File

@@ -81,21 +81,23 @@ class ExerciseDetail extends StatelessWidget {
}
List<Widget> getVariations(BuildContext context) {
final variations =
Provider.of<ExercisesProvider>(context, listen: false).findExercisesByVariationId(
_exercise.variationId,
exerciseIdToExclude: _exercise.id,
);
final variations = Provider.of<ExercisesProvider>(context, listen: false)
.findExercisesByVariationId(
_exercise.variationId,
exerciseIdToExclude: _exercise.id,
);
final List<Widget> out = [];
if (_exercise.variationId == null) {
return out;
}
out.add(Text(
AppLocalizations.of(context).variations,
style: Theme.of(context).textTheme.headlineSmall,
));
out.add(
Text(
AppLocalizations.of(context).variations,
style: Theme.of(context).textTheme.headlineSmall,
),
);
for (final element in variations) {
out.add(ExerciseListTile(exercise: element));
}
@@ -110,10 +112,12 @@ class ExerciseDetail extends StatelessWidget {
List<Widget> getNotes(BuildContext context) {
final List<Widget> out = [];
if (_translation.notes.isNotEmpty) {
out.add(Text(
AppLocalizations.of(context).notes,
style: Theme.of(context).textTheme.headlineSmall,
));
out.add(
Text(
AppLocalizations.of(context).notes,
style: Theme.of(context).textTheme.headlineSmall,
),
);
for (final e in _translation.notes) {
out.add(Text(e.comment));
}
@@ -125,36 +129,40 @@ class ExerciseDetail extends StatelessWidget {
List<Widget> getMuscles(BuildContext context) {
final List<Widget> out = [];
out.add(Text(
AppLocalizations.of(context).muscles,
style: Theme.of(context).textTheme.headlineSmall,
));
out.add(Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: PADDING),
child: MuscleWidget(
muscles: _exercise.muscles,
musclesSecondary: _exercise.musclesSecondary,
isFront: true,
out.add(
Text(
AppLocalizations.of(context).muscles,
style: Theme.of(context).textTheme.headlineSmall,
),
);
out.add(
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: PADDING),
child: MuscleWidget(
muscles: _exercise.muscles,
musclesSecondary: _exercise.musclesSecondary,
isFront: true,
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: PADDING),
child: MuscleWidget(
muscles: _exercise.muscles,
musclesSecondary: _exercise.musclesSecondary,
isFront: false,
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: PADDING),
child: MuscleWidget(
muscles: _exercise.muscles,
musclesSecondary: _exercise.musclesSecondary,
isFront: false,
),
),
),
),
],
));
],
),
);
out.add(
Column(
@@ -183,10 +191,12 @@ class ExerciseDetail extends StatelessWidget {
List<Widget> getDescription(BuildContext context) {
final List<Widget> out = [];
out.add(Text(
AppLocalizations.of(context).description,
style: Theme.of(context).textTheme.headlineSmall,
));
out.add(
Text(
AppLocalizations.of(context).description,
style: Theme.of(context).textTheme.headlineSmall,
),
);
out.add(Html(data: _translation.description));
return out;
@@ -195,9 +205,11 @@ class ExerciseDetail extends StatelessWidget {
List<Widget> getImages() {
final List<Widget> out = [];
if (_exercise.images.isNotEmpty) {
out.add(CarouselImages(
images: _exercise.images,
));
out.add(
CarouselImages(
images: _exercise.images,
),
);
// out.add(ExerciseImageWidget(
// image: _exercise.getMainImage,
@@ -225,14 +237,16 @@ class ExerciseDetail extends StatelessWidget {
);
if (_exercise.equipment.isNotEmpty) {
_exercise.equipment
.map((e) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Chip(
label: Text(getTranslation(e.name, context)),
padding: EdgeInsets.zero,
backgroundColor: theme.splashColor,
),
))
.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Chip(
label: Text(getTranslation(e.name, context)),
padding: EdgeInsets.zero,
backgroundColor: theme.splashColor,
),
),
)
.forEach((element) => out.add(element));
}
out.add(const SizedBox(height: PADDING));
@@ -255,11 +269,13 @@ class ExerciseDetail extends StatelessWidget {
List<Widget> getAliases(BuildContext context) {
final List<Widget> out = [];
if (_translation.aliases.isNotEmpty) {
out.add(MutedText(
AppLocalizations.of(context).alsoKnownAs(
_translation.aliases.map((e) => e.alias).toList().join(', '),
out.add(
MutedText(
AppLocalizations.of(context).alsoKnownAs(
_translation.aliases.map((e) => e.alias).toList().join(', '),
),
),
));
);
out.add(const SizedBox(height: PADDING));
}
@@ -355,9 +371,13 @@ class MuscleWidget extends StatelessWidget {
children: [
SvgPicture.asset('assets/images/muscles/$background.svg'),
...muscles.map((m) => SvgPicture.asset('assets/images/muscles/main/muscle-${m.id}.svg')),
...musclesSecondary.where((m) => !muscles.contains(m)).map((m) => SvgPicture.asset(
'assets/images/muscles/secondary/muscle-${m.id}.svg',
)),
...musclesSecondary
.where((m) => !muscles.contains(m))
.map(
(m) => SvgPicture.asset(
'assets/images/muscles/secondary/muscle-${m.id}.svg',
),
),
],
);
}

View File

@@ -68,22 +68,22 @@ class _ExerciseVideoWidgetState extends State<ExerciseVideoWidget> {
return hasError
? const GeneralErrorsWidget(
[
'An error happened while loading the video. If you can, please check the application logs.'
'An error happened while loading the video. If you can, please check the application logs.',
],
)
: _controller.value.isInitialized
? AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
VideoPlayer(_controller),
_ControlsOverlay(controller: _controller),
VideoProgressIndicator(_controller, allowScrubbing: true),
],
),
)
: Container();
? AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
VideoPlayer(_controller),
_ControlsOverlay(controller: _controller),
VideoProgressIndicator(_controller, allowScrubbing: true),
],
),
)
: Container();
}
}

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