diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 81ac6e4cd..7cba2f0d0 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 db678a7fc..50f496812 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 0b6008075..3ab79e30a 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 16254107e..225d167eb 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/providers/add_exercise.dart b/lib/providers/add_exercise.dart index a813744f9..41d316a0f 100644 --- a/lib/providers/add_exercise.dart +++ b/lib/providers/add_exercise.dart @@ -1,3 +1,21 @@ +/* + * 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/foundation.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; @@ -100,7 +118,7 @@ class AddExerciseProvider with ChangeNotifier { ExerciseSubmissionApi get exerciseApiObject { return ExerciseSubmissionApi( - author: '', + author: author, variation: _variationId, variationConnectTo: _variationConnectToExercise, category: category!.id, @@ -110,7 +128,7 @@ class AddExerciseProvider with ChangeNotifier { translations: [ // Base language (English) ExerciseTranslationSubmissionApi( - author: '', + author: author, language: languageEn!.id, name: exerciseNameEn!, description: descriptionEn!, @@ -123,7 +141,7 @@ class AddExerciseProvider with ChangeNotifier { // Optional translation if (languageTranslation != null) ExerciseTranslationSubmissionApi( - author: '', + author: author, language: languageTranslation!.id, name: exerciseNameTrans!, description: descriptionTrans!, @@ -190,8 +208,9 @@ class AddExerciseProvider with ChangeNotifier { request.files.add(await http.MultipartFile.fromPath('image', image.imageFile.path)); request.fields['exercise'] = exerciseId.toString(); - request.fields['license'] = CC_BY_SA_4_ID.toString(); request.fields['is_main'] = 'false'; + request.fields['license'] = CC_BY_SA_4_ID.toString(); + request.fields['license_author'] = author; final details = image.toJson(); if (details.isNotEmpty) { diff --git a/lib/widgets/add_exercise/add_exercise_text_area.dart b/lib/widgets/add_exercise/add_exercise_text_area.dart index 03579a1b7..dab6efc81 100644 --- a/lib/widgets/add_exercise/add_exercise_text_area.dart +++ b/lib/widgets/add_exercise/add_exercise_text_area.dart @@ -1,30 +1,63 @@ +/* + * 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:wger/l10n/generated/app_localizations.dart'; class AddExerciseTextArea extends StatelessWidget { + final ValueChanged onChange; + final bool isMultiline; + final String title; + final String helperText; + final String? initialValue; + final bool useMarkdownEditor; + final FormFieldValidator? validator; + final FormFieldSetter? onSaved; + AddExerciseTextArea({ super.key, required this.title, ValueChanged? onChange, this.helperText = '', this.isMultiline = false, + this.useMarkdownEditor = false, this.initialValue = '', this.validator, this.onSaved, }) : onChange = onChange ?? ((String value) {}); - final ValueChanged onChange; - final bool isMultiline; - final String title; - final String helperText; - final String? initialValue; - final FormFieldValidator? validator; - final FormFieldSetter? onSaved; - static const MULTILINE_MIN_LINES = 4; static const DEFAULT_LINES = 1; @override Widget build(BuildContext context) { + if (useMarkdownEditor) { + return MarkdownEditor( + initialValue: initialValue ?? '', + onChanged: onChange, + validator: validator, + onSaved: onSaved, + helperText: helperText, + ); + } + return Padding( padding: const EdgeInsets.all(8.0), child: TextFormField( @@ -47,3 +80,194 @@ class AddExerciseTextArea extends StatelessWidget { ); } } + +/// Lightweight Markdown editor with a small toolbar and a raw Markdown toggle. +/// +/// Designed for small documents with basic formatting (bold/italic/code). +/// No image or link insertion is provided. +class MarkdownEditor extends StatefulWidget { + const MarkdownEditor({ + super.key, + this.initialValue = '', + required this.onChanged, + this.validator, + this.onSaved, + this.readOnly = false, + this.showToolbar = true, + this.helperText = '', + }); + + final String initialValue; + final ValueChanged onChanged; + final FormFieldValidator? validator; + final FormFieldSetter? onSaved; + final bool readOnly; + final bool showToolbar; + final String helperText; + + @override + State createState() => _MarkdownEditorState(); +} + +class _MarkdownEditorState extends State { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + // Notify parent and rebuild preview on every controller change. + _controller.addListener(() { + widget.onChanged(_controller.text); + if (mounted) setState(() {}); + }); + } + + @override + void didUpdateWidget(covariant MarkdownEditor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialValue != widget.initialValue && widget.initialValue != _controller.text) { + _controller.text = widget.initialValue; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _surroundSelection(String left, String right) { + final sel = _controller.selection; + final full = _controller.text; + final start = sel.start < 0 ? 0 : sel.start; + final end = sel.end < 0 ? 0 : sel.end; + final before = full.substring(0, start); + final selected = full.substring(start, end); + final after = full.substring(end); + + final newText = '$before$left$selected$right$after'; + _controller.text = newText; + + // 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: i18n.editorBold, + onPressed: () => _surroundSelection('**', '**'), + ), + IconButton( + icon: const Icon(Icons.format_italic), + 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( + length: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Tab bar (Edit / Preview) + TabBar( + labelColor: Theme.of(context).colorScheme.primary, + unselectedLabelColor: Theme.of(context).textTheme.bodySmall?.color, + tabs: [ + Tab(text: i18n.edit), + Tab(text: i18n.preview), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 240, + child: TabBarView( + children: [ + // Edit tab: toolbar + editor + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.showToolbar) _buildToolbar(), + const SizedBox(height: 8), + Expanded( + child: TextFormField( + controller: _controller, + maxLines: null, + minLines: 6, + validator: widget.validator, + onSaved: widget.onSaved, + decoration: InputDecoration( + hintText: i18n.useBasicMarkdown, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + helperText: widget.helperText, + helperMaxLines: 3, + ), + ), + ), + ], + ), + + // Preview tab: rendered HTML + Container( + constraints: const BoxConstraints(minHeight: 120), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withAlpha((0.08 * 255).round()), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Theme.of(context).colorScheme.outline), + ), + child: SingleChildScrollView( + child: Builder( + builder: (ctx) { + final raw = _controller.text; + 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 63365bade..9fedb1433 100644 --- a/lib/widgets/add_exercise/steps/step_1_basics.dart +++ b/lib/widgets/add_exercise/steps/step_1_basics.dart @@ -1,3 +1,21 @@ +/* + * 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:provider/provider.dart'; import 'package:wger/helpers/consts.dart'; @@ -9,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'; @@ -22,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; @@ -40,15 +56,19 @@ class Step1Basics extends StatelessWidget { builder: (context, provider, child) => Column( children: [ AddExerciseTextArea( + initialValue: addExerciseProvider.exerciseNameEn ?? '', title: '${AppLocalizations.of(context).name}*', helperText: AppLocalizations.of(context).baseNameEnglish, validator: (name) => validateName(name, context), + onChange: (value) => addExerciseProvider.exerciseNameEn = value, onSaved: (String? name) => addExerciseProvider.exerciseNameEn = name, ), AddExerciseTextArea( title: AppLocalizations.of(context).alternativeNames, isMultiline: true, + initialValue: addExerciseProvider.alternateNamesEn.join('\n'), helperText: AppLocalizations.of(context).oneNamePerLine, + onChange: (value) => addExerciseProvider.alternateNamesEn = value.split('\n'), onSaved: (String? alternateName) => addExerciseProvider.alternateNamesEn = alternateName!.split('\n'), ), @@ -56,7 +76,8 @@ class Step1Basics extends StatelessWidget { title: '${AppLocalizations.of(context).author}*', isMultiline: false, validator: (name) => validateAuthorName(name, context), - initialValue: userProvider.profile!.username, + initialValue: addExerciseProvider.author, + onChange: (v) => addExerciseProvider.author = v, onSaved: (String? author) => addExerciseProvider.author = author!, ), ExerciseCategoryInputWidget( diff --git a/lib/widgets/add_exercise/steps/step_3_description.dart b/lib/widgets/add_exercise/steps/step_3_description.dart index 2905df173..61364da6b 100644 --- a/lib/widgets/add_exercise/steps/step_3_description.dart +++ b/lib/widgets/add_exercise/steps/step_3_description.dart @@ -1,3 +1,21 @@ +/* + * 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:provider/provider.dart'; import 'package:wger/helpers/exercises/validators.dart'; @@ -20,7 +38,9 @@ class Step3Description extends StatelessWidget { child: Column( children: [ AddExerciseTextArea( - onChange: (value) => {}, + useMarkdownEditor: true, + initialValue: addExerciseProvider.descriptionEn ?? '', + onChange: (value) => addExerciseProvider.descriptionEn = value, title: '${i18n.description}*', helperText: i18n.enterTextInLanguage, isMultiline: true, diff --git a/lib/widgets/add_exercise/steps/step_4_translations.dart b/lib/widgets/add_exercise/steps/step_4_translations.dart index d8f4a778f..0a4f44467 100644 --- a/lib/widgets/add_exercise/steps/step_4_translations.dart +++ b/lib/widgets/add_exercise/steps/step_4_translations.dart @@ -1,3 +1,21 @@ +/* + * 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:provider/provider.dart'; import 'package:wger/helpers/exercises/validators.dart'; @@ -59,8 +77,10 @@ class _Step4TranslationState extends State { }, ), AddExerciseTextArea( + initialValue: addExerciseProvider.exerciseNameTrans ?? '', title: '${i18n.name}*', validator: (name) => validateName(name, context), + onChange: (v) => addExerciseProvider.exerciseNameTrans = v, onSaved: (String? name) => addExerciseProvider.exerciseNameTrans = name!, ), AddExerciseTextArea( @@ -87,7 +107,9 @@ class _Step4TranslationState extends State { ), Consumer( builder: (ctx, provider, __) => AddExerciseTextArea( - onChange: (value) => {}, + useMarkdownEditor: true, + initialValue: provider.descriptionTrans ?? '', + onChange: (value) => provider.descriptionTrans = value, title: '${i18n.description}*', helperText: i18n.enterTextInLanguage, isMultiline: true, diff --git a/lib/widgets/add_exercise/steps/step_6_overview.dart b/lib/widgets/add_exercise/steps/step_6_overview.dart index dda2f6c82..ea6e8c981 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/pubspec.lock b/pubspec.lock index 77750a639..0c6f728e0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -805,7 +805,7 @@ packages: source: hosted version: "1.3.0" markdown: - dependency: transitive + dependency: "direct main" description: name: markdown sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" diff --git a/pubspec.yaml b/pubspec.yaml index 604e1bc7b..c1620e6f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: intl: ^0.20.0 json_annotation: ^4.8.1 logging: ^1.3.0 + markdown: ^7.3.0 multi_select_flutter: ^4.1.3 package_info_plus: ^9.0.0 path: ^1.9.0 diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index b89be4522..ca762d306 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 000000000..ec2df15f5 --- /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); + }); +}