Some polishing, add simple test

This commit is contained in:
Roland Geider
2026-02-08 14:53:46 +01:00
parent 04be95634b
commit 88168dc352
10 changed files with 262 additions and 30 deletions

View File

@@ -618,6 +618,7 @@
},
"edit": "Edit",
"@edit": {},
"preview": "Preview",
"loadingText": "Loading...",
"@loadingText": {
"description": "Text to show when entries are being loaded in the background: Loading..."
@@ -913,6 +914,13 @@
"@baseData": {
"description": "The base data for an exercise such as category, trained muscles, etc."
},
"useBasicMarkdown": "You can use basic Markdown to format the text",
"editorBold": "Bold",
"@editorBold": {
"description": "Label for bold formatting"
},
"editorItalic": "Italic",
"editorList": "List",
"enterTextInLanguage": "Please enter the text in the correct language!",
"settingsTitle": "Settings",
"settingsCacheTitle": "Cache",

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
* Copyright (c) 2020 - 2026 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
@@ -51,10 +51,15 @@ sealed class ExerciseCommentSubmissionApi with _$ExerciseCommentSubmissionApi {
sealed class ExerciseTranslationSubmissionApi with _$ExerciseTranslationSubmissionApi {
const factory ExerciseTranslationSubmissionApi({
required String name,
required String description,
@JsonKey(name: 'description_source') required String description,
required int language,
@JsonKey(name: 'license_author') required String author,
@Default([]) List<ExerciseAliasSubmissionApi> aliases,
@Default([]) List<ExerciseCommentSubmissionApi> comments,
}) = _ExerciseTranslationSubmissionApi;
@@ -67,12 +72,19 @@ sealed class ExerciseTranslationSubmissionApi with _$ExerciseTranslationSubmissi
sealed class ExerciseSubmissionApi with _$ExerciseSubmissionApi {
const factory ExerciseSubmissionApi({
required int category,
required List<int> muscles,
@JsonKey(name: 'muscles_secondary') required List<int> musclesSecondary,
required List<int> equipment,
@JsonKey(name: 'license_author') required String author,
@JsonKey(includeToJson: true) int? variation,
@JsonKey(includeToJson: true, name: 'variations_connect_to') int? variationConnectTo,
required List<ExerciseTranslationSubmissionApi> translations,
}) = _ExerciseSubmissionApi;

View File

@@ -529,7 +529,7 @@ as String,
/// @nodoc
mixin _$ExerciseTranslationSubmissionApi {
String get name; String get description; int get language;@JsonKey(name: 'license_author') String get author; List<ExerciseAliasSubmissionApi> get aliases; List<ExerciseCommentSubmissionApi> get comments;
String get name;@JsonKey(name: 'description_source') String get description; int get language;@JsonKey(name: 'license_author') String get author; List<ExerciseAliasSubmissionApi> get aliases; List<ExerciseCommentSubmissionApi> get comments;
/// Create a copy of ExerciseTranslationSubmissionApi
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -562,7 +562,7 @@ abstract mixin class $ExerciseTranslationSubmissionApiCopyWith<$Res> {
factory $ExerciseTranslationSubmissionApiCopyWith(ExerciseTranslationSubmissionApi value, $Res Function(ExerciseTranslationSubmissionApi) _then) = _$ExerciseTranslationSubmissionApiCopyWithImpl;
@useResult
$Res call({
String name, String description, int language,@JsonKey(name: 'license_author') String author, List<ExerciseAliasSubmissionApi> aliases, List<ExerciseCommentSubmissionApi> comments
String name,@JsonKey(name: 'description_source') String description, int language,@JsonKey(name: 'license_author') String author, List<ExerciseAliasSubmissionApi> aliases, List<ExerciseCommentSubmissionApi> comments
});
@@ -669,7 +669,7 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String description, int language, @JsonKey(name: 'license_author') String author, List<ExerciseAliasSubmissionApi> aliases, List<ExerciseCommentSubmissionApi> comments)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, @JsonKey(name: 'description_source') String description, int language, @JsonKey(name: 'license_author') String author, List<ExerciseAliasSubmissionApi> aliases, List<ExerciseCommentSubmissionApi> comments)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ExerciseTranslationSubmissionApi() when $default != null:
return $default(_that.name,_that.description,_that.language,_that.author,_that.aliases,_that.comments);case _:
@@ -690,7 +690,7 @@ return $default(_that.name,_that.description,_that.language,_that.author,_that.a
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String description, int language, @JsonKey(name: 'license_author') String author, List<ExerciseAliasSubmissionApi> aliases, List<ExerciseCommentSubmissionApi> comments) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, @JsonKey(name: 'description_source') String description, int language, @JsonKey(name: 'license_author') String author, List<ExerciseAliasSubmissionApi> aliases, List<ExerciseCommentSubmissionApi> comments) $default,) {final _that = this;
switch (_that) {
case _ExerciseTranslationSubmissionApi():
return $default(_that.name,_that.description,_that.language,_that.author,_that.aliases,_that.comments);}
@@ -707,7 +707,7 @@ return $default(_that.name,_that.description,_that.language,_that.author,_that.a
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String description, int language, @JsonKey(name: 'license_author') String author, List<ExerciseAliasSubmissionApi> aliases, List<ExerciseCommentSubmissionApi> comments)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, @JsonKey(name: 'description_source') String description, int language, @JsonKey(name: 'license_author') String author, List<ExerciseAliasSubmissionApi> aliases, List<ExerciseCommentSubmissionApi> comments)? $default,) {final _that = this;
switch (_that) {
case _ExerciseTranslationSubmissionApi() when $default != null:
return $default(_that.name,_that.description,_that.language,_that.author,_that.aliases,_that.comments);case _:
@@ -722,11 +722,11 @@ return $default(_that.name,_that.description,_that.language,_that.author,_that.a
@JsonSerializable()
class _ExerciseTranslationSubmissionApi implements ExerciseTranslationSubmissionApi {
const _ExerciseTranslationSubmissionApi({required this.name, required this.description, required this.language, @JsonKey(name: 'license_author') required this.author, final List<ExerciseAliasSubmissionApi> aliases = const [], final List<ExerciseCommentSubmissionApi> comments = const []}): _aliases = aliases,_comments = comments;
const _ExerciseTranslationSubmissionApi({required this.name, @JsonKey(name: 'description_source') required this.description, required this.language, @JsonKey(name: 'license_author') required this.author, final List<ExerciseAliasSubmissionApi> aliases = const [], final List<ExerciseCommentSubmissionApi> comments = const []}): _aliases = aliases,_comments = comments;
factory _ExerciseTranslationSubmissionApi.fromJson(Map<String, dynamic> json) => _$ExerciseTranslationSubmissionApiFromJson(json);
@override final String name;
@override final String description;
@override@JsonKey(name: 'description_source') final String description;
@override final int language;
@override@JsonKey(name: 'license_author') final String author;
final List<ExerciseAliasSubmissionApi> _aliases;
@@ -777,7 +777,7 @@ abstract mixin class _$ExerciseTranslationSubmissionApiCopyWith<$Res> implements
factory _$ExerciseTranslationSubmissionApiCopyWith(_ExerciseTranslationSubmissionApi value, $Res Function(_ExerciseTranslationSubmissionApi) _then) = __$ExerciseTranslationSubmissionApiCopyWithImpl;
@override @useResult
$Res call({
String name, String description, int language,@JsonKey(name: 'license_author') String author, List<ExerciseAliasSubmissionApi> aliases, List<ExerciseCommentSubmissionApi> comments
String name,@JsonKey(name: 'description_source') String description, int language,@JsonKey(name: 'license_author') String author, List<ExerciseAliasSubmissionApi> aliases, List<ExerciseCommentSubmissionApi> comments
});

View File

@@ -26,7 +26,7 @@ _ExerciseTranslationSubmissionApi _$ExerciseTranslationSubmissionApiFromJson(
Map<String, dynamic> json,
) => _ExerciseTranslationSubmissionApi(
name: json['name'] as String,
description: json['description'] as String,
description: json['description_source'] as String,
language: (json['language'] as num).toInt(),
author: json['license_author'] as String,
aliases:
@@ -51,7 +51,7 @@ Map<String, dynamic> _$ExerciseTranslationSubmissionApiToJson(
_ExerciseTranslationSubmissionApi instance,
) => <String, dynamic>{
'name': instance.name,
'description': instance.description,
'description_source': instance.description,
'language': instance.language,
'license_author': instance.author,
'aliases': instance.aliases,

View File

@@ -19,6 +19,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:wger/l10n/generated/app_localizations.dart';
class AddExerciseTextArea extends StatelessWidget {
final ValueChanged<String> onChange;
@@ -148,30 +149,52 @@ class _MarkdownEditorState extends State<MarkdownEditor> {
final newText = '$before$left$selected$right$after';
_controller.text = newText;
final cursorPos = start + left.length + selected.length + right.length;
_controller.selection = TextSelection.collapsed(offset: cursorPos);
// No selection: place cursor between the inserted markers
if (selected.isEmpty) {
final cursorPos = start + left.length;
_controller.selection = TextSelection.collapsed(offset: cursorPos);
} else {
// otherwise: keep the text selected
final base = start + left.length;
final extent = base + selected.length;
_controller.selection = TextSelection(baseOffset: base, extentOffset: extent);
}
}
Widget _buildToolbar() {
final i18n = AppLocalizations.of(context);
if (!widget.showToolbar || widget.readOnly) return const SizedBox.shrink();
return Row(
children: [
IconButton(
icon: const Icon(Icons.format_bold),
tooltip: 'Bold',
tooltip: i18n.editorBold,
onPressed: () => _surroundSelection('**', '**'),
),
IconButton(
icon: const Icon(Icons.format_italic),
tooltip: 'Cursive',
tooltip: i18n.editorItalic,
onPressed: () => _surroundSelection('*', '*'),
),
IconButton(
icon: const Icon(Icons.format_list_bulleted),
tooltip: i18n.editorList,
onPressed: () => _surroundSelection('\n- ', '\n- \n- '),
),
IconButton(
icon: const Icon(Icons.format_list_numbered),
tooltip: i18n.editorList,
onPressed: () => _surroundSelection('\n1. ', '\n1. \n1. '),
),
],
);
}
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.all(8.0),
child: DefaultTabController(
@@ -183,9 +206,9 @@ class _MarkdownEditorState extends State<MarkdownEditor> {
TabBar(
labelColor: Theme.of(context).colorScheme.primary,
unselectedLabelColor: Theme.of(context).textTheme.bodySmall?.color,
tabs: const [
Tab(text: 'Edit'),
Tab(text: 'Preview'),
tabs: [
Tab(text: i18n.edit),
Tab(text: i18n.preview),
],
),
const SizedBox(height: 8),
@@ -207,7 +230,7 @@ class _MarkdownEditorState extends State<MarkdownEditor> {
validator: widget.validator,
onSaved: widget.onSaved,
decoration: InputDecoration(
hintText: 'You can use basic Markdown formatting here',
hintText: i18n.useBasicMarkdown,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
@@ -234,11 +257,7 @@ class _MarkdownEditorState extends State<MarkdownEditor> {
child: Builder(
builder: (ctx) {
final raw = _controller.text;
final html = md.markdownToHtml(raw);
if (html.trim().isEmpty) {
return SelectableText(raw.isEmpty ? '(empty)' : raw);
}
return Html(data: html);
return Html(data: md.markdownToHtml(raw));
},
),
),

View File

@@ -27,7 +27,6 @@ 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';
@@ -40,7 +39,6 @@ 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;
@@ -78,7 +76,7 @@ class Step1Basics extends StatelessWidget {
title: '${AppLocalizations.of(context).author}*',
isMultiline: false,
validator: (name) => validateAuthorName(name, context),
initialValue: addExerciseProvider.author ?? userProvider.profile!.username,
initialValue: addExerciseProvider.author,
onChange: (v) => addExerciseProvider.author = v,
onSaved: (String? author) => addExerciseProvider.author = author!,
),

View File

@@ -107,6 +107,7 @@ class _Step4TranslationState extends State<Step4Translation> {
),
Consumer<AddExerciseProvider>(
builder: (ctx, provider, __) => AddExerciseTextArea(
useMarkdownEditor: true,
initialValue: provider.descriptionTrans ?? '',
onChange: (value) => provider.descriptionTrans = value,
title: '${i18n.description}*',

View File

@@ -1,4 +1,24 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2026 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:provider/provider.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/add_exercise.dart';
@@ -14,6 +34,7 @@ class Step6Overview extends StatelessWidget {
builder: (ctx, provider, _) => Column(
spacing: 8,
children: [
// Base data
Text(i18n.baseData, style: Theme.of(context).textTheme.headlineSmall),
Table(
columnWidths: const {0: FlexColumnWidth(2), 1: FlexColumnWidth(3)},
@@ -30,7 +51,12 @@ class Step6Overview extends StatelessWidget {
),
],
),
TableRow(children: [Text(i18n.description), Text(provider.descriptionEn ?? '...')]),
TableRow(
children: [
Text(i18n.description),
Html(data: md.markdownToHtml(provider.descriptionEn ?? '...')),
],
),
TableRow(children: [Text(i18n.category), Text(provider.category?.name ?? '...')]),
TableRow(
children: [
@@ -76,6 +102,8 @@ class Step6Overview extends StatelessWidget {
),
],
),
// Translation
Text(i18n.translation, style: Theme.of(context).textTheme.headlineSmall),
Table(
columnWidths: const {0: FlexColumnWidth(2), 1: FlexColumnWidth(3)},
@@ -92,7 +120,15 @@ class Step6Overview extends StatelessWidget {
],
),
TableRow(
children: [Text(i18n.description), Text(provider.descriptionTrans ?? '...')],
children: [
Text(i18n.description),
// for consistent formatting, the thml element has other padding, etc
if (provider.descriptionTrans == null)
const Text('...')
else
Html(data: md.markdownToHtml(provider.descriptionTrans!)),
],
),
TableRow(
children: [
@@ -102,6 +138,8 @@ class Step6Overview extends StatelessWidget {
),
],
),
// Images
if (provider.exerciseImages.isNotEmpty)
PreviewExerciseImages(selectedImages: provider.exerciseImages, allowEdit: false),
InfoCard(text: i18n.checkInformationBeforeSubmitting),

View File

@@ -2259,6 +2259,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations {
)
as String);
@override
String get preview =>
(super.noSuchMethod(
Invocation.getter(#preview),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#preview),
),
)
as String);
@override
String get loadingText =>
(super.noSuchMethod(
@@ -3144,6 +3155,50 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations {
)
as String);
@override
String get useBasicMarkdown =>
(super.noSuchMethod(
Invocation.getter(#useBasicMarkdown),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#useBasicMarkdown),
),
)
as String);
@override
String get editorBold =>
(super.noSuchMethod(
Invocation.getter(#editorBold),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#editorBold),
),
)
as String);
@override
String get editorItalic =>
(super.noSuchMethod(
Invocation.getter(#editorItalic),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#editorItalic),
),
)
as String);
@override
String get editorList =>
(super.noSuchMethod(
Invocation.getter(#editorList),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#editorList),
),
)
as String);
@override
String get enterTextInLanguage =>
(super.noSuchMethod(

View File

@@ -0,0 +1,101 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2026 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/widgets/add_exercise/add_exercise_text_area.dart';
void main() {
Widget makeTestable({required Widget child}) {
return MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('en')],
home: Scaffold(body: child),
);
}
testWidgets('inserts markers when no selection', (tester) async {
await tester.pumpWidget(
makeTestable(
child: MarkdownEditor(initialValue: '', onChanged: (_) {}),
),
);
await tester.pumpAndSettle();
final textFieldFinder = find.byType(TextFormField);
expect(textFieldFinder, findsOneWidget);
// Ensure initial is empty and then tap Bold button
await tester.enterText(textFieldFinder, '');
await tester.pump();
final boldButton = find.widgetWithIcon(IconButton, Icons.format_bold);
expect(boldButton, findsOneWidget);
await tester.tap(boldButton);
await tester.pump();
final tf = tester.widget<TextFormField>(textFieldFinder);
final controller = tf.controller!;
expect(controller.text, '****');
expect(controller.selection.isCollapsed, isTrue);
expect(controller.selection.baseOffset, 2);
});
testWidgets('wraps selected text when selection exists', (tester) async {
await tester.pumpWidget(
makeTestable(
child: MarkdownEditor(initialValue: '', onChanged: (_) {}),
),
);
await tester.pumpAndSettle();
final textFieldFinder = find.byType(TextFormField);
expect(textFieldFinder, findsOneWidget);
// Enter text and set selection
await tester.enterText(textFieldFinder, 'hello');
await tester.pump();
final tf = tester.widget<TextFormField>(textFieldFinder);
final controller = tf.controller!;
// select whole text
controller.selection = const TextSelection(baseOffset: 0, extentOffset: 5);
await tester.pump();
final boldButton = find.widgetWithIcon(IconButton, Icons.format_bold);
expect(boldButton, findsOneWidget);
await tester.tap(boldButton);
await tester.pump();
expect(controller.text, '**hello**');
// selection should cover the inner text (shifted by left.length)
expect(controller.selection.baseOffset, 2);
expect(controller.selection.extentOffset, 7);
});
}