From 88168dc3525a87dd481f053fc2b8984c7e4d2cc8 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Sun, 8 Feb 2026 14:53:46 +0100 Subject: [PATCH] Some polishing, add simple test --- lib/l10n/app_en.arb | 8 ++ lib/models/exercises/exercise_submission.dart | 16 ++- .../exercise_submission.freezed.dart | 16 +-- .../exercises/exercise_submission.g.dart | 4 +- .../add_exercise/add_exercise_text_area.dart | 45 +++++--- .../add_exercise/steps/step_1_basics.dart | 4 +- .../steps/step_4_translations.dart | 1 + .../add_exercise/steps/step_6_overview.dart | 42 +++++++- test/core/validators_test.mocks.dart | 55 ++++++++++ test/widgets/markdown_editor_test.dart | 101 ++++++++++++++++++ 10 files changed, 262 insertions(+), 30 deletions(-) create mode 100644 test/widgets/markdown_editor_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 81ac6e4c..7cba2f0d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/models/exercises/exercise_submission.dart b/lib/models/exercises/exercise_submission.dart index db678a7f..50f49681 100644 --- a/lib/models/exercises/exercise_submission.dart +++ b/lib/models/exercises/exercise_submission.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * 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 aliases, + @Default([]) List comments, }) = _ExerciseTranslationSubmissionApi; @@ -67,12 +72,19 @@ sealed class ExerciseTranslationSubmissionApi with _$ExerciseTranslationSubmissi sealed class ExerciseSubmissionApi with _$ExerciseSubmissionApi { const factory ExerciseSubmissionApi({ required int category, + required List muscles, + @JsonKey(name: 'muscles_secondary') required List musclesSecondary, + required List equipment, + @JsonKey(name: 'license_author') required String author, + @JsonKey(includeToJson: true) int? variation, + @JsonKey(includeToJson: true, name: 'variations_connect_to') int? variationConnectTo, + required List translations, }) = _ExerciseSubmissionApi; diff --git a/lib/models/exercises/exercise_submission.freezed.dart b/lib/models/exercises/exercise_submission.freezed.dart index 0b600807..3ab79e30 100644 --- a/lib/models/exercises/exercise_submission.freezed.dart +++ b/lib/models/exercises/exercise_submission.freezed.dart @@ -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 get aliases; List get comments; + String get name;@JsonKey(name: 'description_source') String get description; int get language;@JsonKey(name: 'license_author') String get author; List get aliases; List 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 aliases, List comments + String name,@JsonKey(name: 'description_source') String description, int language,@JsonKey(name: 'license_author') String author, List aliases, List comments }); @@ -669,7 +669,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String description, int language, @JsonKey(name: 'license_author') String author, List aliases, List comments)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, @JsonKey(name: 'description_source') String description, int language, @JsonKey(name: 'license_author') String author, List aliases, List 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 Function( String name, String description, int language, @JsonKey(name: 'license_author') String author, List aliases, List comments) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String name, @JsonKey(name: 'description_source') String description, int language, @JsonKey(name: 'license_author') String author, List aliases, List 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? Function( String name, String description, int language, @JsonKey(name: 'license_author') String author, List aliases, List comments)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, @JsonKey(name: 'description_source') String description, int language, @JsonKey(name: 'license_author') String author, List aliases, List 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 aliases = const [], final List 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 aliases = const [], final List comments = const []}): _aliases = aliases,_comments = comments; factory _ExerciseTranslationSubmissionApi.fromJson(Map 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 _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 aliases, List comments + String name,@JsonKey(name: 'description_source') String description, int language,@JsonKey(name: 'license_author') String author, List aliases, List comments }); diff --git a/lib/models/exercises/exercise_submission.g.dart b/lib/models/exercises/exercise_submission.g.dart index 16254107..225d167e 100644 --- a/lib/models/exercises/exercise_submission.g.dart +++ b/lib/models/exercises/exercise_submission.g.dart @@ -26,7 +26,7 @@ _ExerciseTranslationSubmissionApi _$ExerciseTranslationSubmissionApiFromJson( Map 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 _$ExerciseTranslationSubmissionApiToJson( _ExerciseTranslationSubmissionApi instance, ) => { 'name': instance.name, - 'description': instance.description, + 'description_source': instance.description, 'language': instance.language, 'license_author': instance.author, 'aliases': instance.aliases, diff --git a/lib/widgets/add_exercise/add_exercise_text_area.dart b/lib/widgets/add_exercise/add_exercise_text_area.dart index 0b86573b..dab6efc8 100644 --- a/lib/widgets/add_exercise/add_exercise_text_area.dart +++ b/lib/widgets/add_exercise/add_exercise_text_area.dart @@ -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 onChange; @@ -148,30 +149,52 @@ class _MarkdownEditorState extends State { 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 { 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 { 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 { 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)); }, ), ), diff --git a/lib/widgets/add_exercise/steps/step_1_basics.dart b/lib/widgets/add_exercise/steps/step_1_basics.dart index 2c444842..9fedb143 100644 --- a/lib/widgets/add_exercise/steps/step_1_basics.dart +++ b/lib/widgets/add_exercise/steps/step_1_basics.dart @@ -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(); final addExerciseProvider = context.read(); final exerciseProvider = context.read(); 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!, ), diff --git a/lib/widgets/add_exercise/steps/step_4_translations.dart b/lib/widgets/add_exercise/steps/step_4_translations.dart index 7bea268c..0a4f4446 100644 --- a/lib/widgets/add_exercise/steps/step_4_translations.dart +++ b/lib/widgets/add_exercise/steps/step_4_translations.dart @@ -107,6 +107,7 @@ class _Step4TranslationState extends State { ), Consumer( builder: (ctx, provider, __) => AddExerciseTextArea( + useMarkdownEditor: true, initialValue: provider.descriptionTrans ?? '', onChange: (value) => provider.descriptionTrans = value, title: '${i18n.description}*', diff --git a/lib/widgets/add_exercise/steps/step_6_overview.dart b/lib/widgets/add_exercise/steps/step_6_overview.dart index dda2f6c8..ea6e8c98 100644 --- a/lib/widgets/add_exercise/steps/step_6_overview.dart +++ b/lib/widgets/add_exercise/steps/step_6_overview.dart @@ -1,4 +1,24 @@ +/* + * This file is part of wger Workout Manager . + * 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 . + */ + 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), diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index b89be452..ca762d30 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -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( + 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( + this, + Invocation.getter(#useBasicMarkdown), + ), + ) + as String); + + @override + String get editorBold => + (super.noSuchMethod( + Invocation.getter(#editorBold), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#editorBold), + ), + ) + as String); + + @override + String get editorItalic => + (super.noSuchMethod( + Invocation.getter(#editorItalic), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#editorItalic), + ), + ) + as String); + + @override + String get editorList => + (super.noSuchMethod( + Invocation.getter(#editorList), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#editorList), + ), + ) + as String); + @override String get enterTextInLanguage => (super.noSuchMethod( diff --git a/test/widgets/markdown_editor_test.dart b/test/widgets/markdown_editor_test.dart new file mode 100644 index 00000000..ec2df15f --- /dev/null +++ b/test/widgets/markdown_editor_test.dart @@ -0,0 +1,101 @@ +/* + * This file is part of wger Workout Manager . + * 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 . + */ + +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(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(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); + }); +}