diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 9107bff..4033cfe 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -55,6 +55,7 @@ "error_deleting_group": "Fehler beim Löschen der Gruppe, bitte erneut versuchen", "error_editing_group": "Fehler beim Bearbeiten der Gruppe, bitte erneut versuchen", "error_reading_file": "Fehler beim Lesen der Datei", + "exit_view": "Ansicht verlassen", "export_canceled": "Export abgebrochen", "export_data": "Daten exportieren", "format_exception": "Formatfehler (siehe Konsole)", @@ -73,6 +74,7 @@ "legal": "Rechtliches", "legal_notice": "Impressum", "licenses": "Lizenzen", + "live_edit_mode": "Live-Bearbeitungsmodus", "match_in_progress": "Spiel läuft...", "match_name": "Spieltitel", "match_profile": "Spielprofil", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 11a908e..9bc7318 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -56,6 +56,7 @@ "error_deleting_group": "Error while deleting group, please try again", "error_editing_group": "Error while editing group, please try again", "error_reading_file": "Error reading file", + "exit_view": "Exit View", "export_canceled": "Export canceled", "export_data": "Export data", "format_exception": "Format Exception (see console)", @@ -74,6 +75,7 @@ "legal": "Legal", "legal_notice": "Legal Notice", "licenses": "Licenses", + "live_edit_mode": "Live Edit Mode", "match_in_progress": "Match in progress...", "match_name": "Match name", "match_profile": "Match Profile", diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 3666d11..4883272 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -168,6 +168,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get error_reading_file => 'Fehler beim Lesen der Datei'; + @override + String get exit_view => 'Ansicht verlassen'; + @override String get export_canceled => 'Export abgebrochen'; @@ -222,6 +225,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get licenses => 'Lizenzen'; + @override + String get live_edit_mode => 'Live-Bearbeitungsmodus'; + @override String get match_in_progress => 'Spiel läuft...'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index ae7d813..b107caa 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -168,6 +168,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get error_reading_file => 'Error reading file'; + @override + String get exit_view => 'Exit View'; + @override String get export_canceled => 'Export canceled'; @@ -222,6 +225,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get licenses => 'Licenses'; + @override + String get live_edit_mode => 'Live Edit Mode'; + @override String get match_in_progress => 'Match in progress...'; diff --git a/lib/presentation/views/main_menu/match_view/match_detail_view.dart b/lib/presentation/views/main_menu/match_view/match_detail_view.dart index a0f8760..26f0f2b 100644 --- a/lib/presentation/views/main_menu/match_view/match_detail_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_detail_view.dart @@ -240,7 +240,9 @@ class _MatchDetailViewState extends State { match: match, onWinnerChanged: () { widget.onMatchUpdate.call(); - setState(() {}); + setState(() { + updateScoresForCurrentMatch(); + }); }, ), ), @@ -368,4 +370,10 @@ class _MatchDetailViewState extends State { return match.game.ruleset == Ruleset.singleWinner || match.game.ruleset == Ruleset.singleLoser; } + + void updateScoresForCurrentMatch() { + db.scoreEntryDao + .getAllMatchScores(matchId: match.id) + .then((scores) => match.scores = scores); + } } diff --git a/lib/presentation/views/main_menu/match_view/match_result_view.dart b/lib/presentation/views/main_menu/match_view/match_result_view.dart index 1fd6780..2c6976e 100644 --- a/lib/presentation/views/main_menu/match_view/match_result_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_result_view.dart @@ -8,8 +8,9 @@ import 'package:tallee/data/models/player.dart'; import 'package:tallee/data/models/score_entry.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart'; -import 'package:tallee/presentation/widgets/tiles/custom_radio_list_tile.dart'; -import 'package:tallee/presentation/widgets/tiles/score_list_tile.dart'; +import 'package:tallee/presentation/widgets/tiles/match_result_view/custom_radio_list_tile.dart'; +import 'package:tallee/presentation/widgets/tiles/match_result_view/live_edit_list_tile.dart'; +import 'package:tallee/presentation/widgets/tiles/match_result_view/score_list_tile.dart'; class MatchResultView extends StatefulWidget { /// A view that allows selecting and saving the winner of a match @@ -30,6 +31,8 @@ class MatchResultView extends StatefulWidget { class _MatchResultViewState extends State { late final AppDatabase db; + bool isLiveEditMode = false; + late final Ruleset ruleset; /// List of all players who participated in the match @@ -38,6 +41,7 @@ class _MatchResultViewState extends State { /// List of text controllers for score entry, one for each player late final List controller; + /// Flag to indicate if the save button should be enabled late bool canSave; /// Currently selected winner player @@ -46,7 +50,7 @@ class _MatchResultViewState extends State { @override void initState() { db = Provider.of(context, listen: false); - ruleset = widget.match.game.ruleset; + ruleset = Ruleset.highestScore; //widget.match.game.ruleset; canSave = !rulesetSupportsScoreEntry(); allPlayers = widget.match.players; @@ -57,6 +61,7 @@ class _MatchResultViewState extends State { (index) => TextEditingController()..addListener(() => onTextEnter()), ); + // Prefill fields if (widget.match.mvp.isNotEmpty) { if (rulesetSupportsWinnerSelection()) { _selectedPlayer = allPlayers.firstWhere( @@ -101,86 +106,125 @@ class _MatchResultViewState extends State { child: Column( children: [ Expanded( - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 10, - ), - decoration: BoxDecoration( - color: CustomTheme.boxColor, - border: Border.all(color: CustomTheme.boxBorderColor), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${getTitleForRuleset(loc)}:', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - if (rulesetSupportsWinnerSelection()) - Expanded( - child: RadioGroup( - groupValue: _selectedPlayer, - onChanged: (Player? value) async { + child: isLiveEditMode && rulesetSupportsScoreEntry() + // Live Edit Mode + ? ListView.builder( + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return LiveEditListTile( + title: allPlayers[index].name, + onChanged: (value) { setState(() { - _selectedPlayer = value; + controller[index].text = value.toString(); }); }, - child: ListView.builder( - itemCount: allPlayers.length, - itemBuilder: (context, index) { - return CustomRadioListTile( - text: allPlayers[index].name, - value: allPlayers[index], - onContainerTap: (value) async { + value: int.tryParse(controller[index].text) ?? 0, + ); + }, + ) + // Normal Mode + : Container( + margin: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 10, + ), + decoration: BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorderColor), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + getTitleForRuleset(loc), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + + // Show player selection + if (rulesetSupportsWinnerSelection()) + Expanded( + child: RadioGroup( + groupValue: _selectedPlayer, + onChanged: (Player? value) async { setState(() { - // Check if the already selected player is the same as the newly tapped player. - if (_selectedPlayer == value) { - // If yes deselected the player by setting it to null. - _selectedPlayer = null; - } else { - // If no assign the newly tapped player to the selected player. - (_selectedPlayer = value); - } + _selectedPlayer = value; }); }, - ); - }, - ), - ), + child: ListView.builder( + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return CustomRadioListTile( + text: allPlayers[index].name, + value: allPlayers[index], + onContainerTap: (value) async { + setState(() { + // Check if the already selected player is the same as the newly tapped player. + if (_selectedPlayer == value) { + // If yes deselected the player by setting it to null. + _selectedPlayer = null; + } else { + // If no assign the newly tapped player to the selected player. + (_selectedPlayer = value); + } + }); + }, + ); + }, + ), + ), + ), + // Show score entry + if (rulesetSupportsScoreEntry()) + Expanded( + child: ListView.separated( + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return ScoreListTile( + text: allPlayers[index].name, + controller: controller[index], + ); + }, + separatorBuilder: + (BuildContext context, int index) { + return const Padding( + padding: EdgeInsets.symmetric( + vertical: 8.0, + ), + child: Divider(indent: 20), + ); + }, + ), + ), + ], ), - if (rulesetSupportsScoreEntry()) - Expanded( - child: ListView.separated( - itemCount: allPlayers.length, - itemBuilder: (context, index) { - return ScoreListTile( - text: allPlayers[index].name, - controller: controller[index], - ); - }, - separatorBuilder: (BuildContext context, int index) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), - child: Divider(indent: 20), - ); - }, - ), - ), - ], - ), - ), + ), ), + + if (rulesetSupportsScoreEntry()) + // Button to switch to live edit mode + ...[ + CustomWidthButton( + text: isLiveEditMode ? loc.exit_view : loc.live_edit_mode, + sizeRelativeToWidth: 0.95, + buttonType: ButtonType.secondary, + onPressed: () => setState(() { + isLiveEditMode = !isLiveEditMode; + }), + ), + const SizedBox(height: 10), + ], + + // Save Changes Button CustomWidthButton( text: loc.save_changes, sizeRelativeToWidth: 0.95, diff --git a/lib/presentation/widgets/buttons/custom_width_button.dart b/lib/presentation/widgets/buttons/custom_width_button.dart index 489ceae..4fde6f8 100644 --- a/lib/presentation/widgets/buttons/custom_width_button.dart +++ b/lib/presentation/widgets/buttons/custom_width_button.dart @@ -89,7 +89,7 @@ class CustomWidthButton extends StatelessWidget { MediaQuery.sizeOf(context).width * sizeRelativeToWidth, 60, ), - side: BorderSide(color: borderSideColor, width: 2), + side: BorderSide(color: borderSideColor, width: 3), shape: RoundedRectangleBorder( borderRadius: CustomTheme.standardBorderRadiusAll, ), diff --git a/lib/presentation/widgets/buttons/main_menu_button.dart b/lib/presentation/widgets/buttons/main_menu_button.dart index c583456..c5c7a34 100644 --- a/lib/presentation/widgets/buttons/main_menu_button.dart +++ b/lib/presentation/widgets/buttons/main_menu_button.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; class MainMenuButton extends StatefulWidget { @@ -10,6 +12,7 @@ class MainMenuButton extends StatefulWidget { required this.onPressed, required this.icon, this.text, + this.onLongPressed, }); /// The callback to be invoked when the button is pressed. @@ -21,6 +24,8 @@ class MainMenuButton extends StatefulWidget { /// The text of the button. final String? text; + final void Function()? onLongPressed; + @override State createState() => _MainMenuButtonState(); } @@ -30,6 +35,14 @@ class _MainMenuButtonState extends State late AnimationController _animationController; late Animation _scaleAnimation; + /// How long the button needs to be pressed to register it as long press + Timer? _longPressTimer; + + /// How much time between two onLongPressed calls + Timer? _repeatTimer; + + bool _isLongPressing = false; + @override void initState() { super.initState(); @@ -51,14 +64,29 @@ class _MainMenuButtonState extends State child: GestureDetector( onTapDown: (_) { _animationController.forward(); - }, - onTapUp: (_) async { - await _animationController.reverse(); - if (mounted) { - widget.onPressed(); + if (widget.onLongPressed != null) { + _longPressTimer = Timer(const Duration(milliseconds: 400), () { + _isLongPressing = true; + widget.onLongPressed?.call(); + _repeatTimer = Timer.periodic( + const Duration(milliseconds: 250), + (_) => widget.onLongPressed?.call(), + ); + }); } }, + onTapUp: (_) async { + _cancelTimers(); + if (mounted && !_isLongPressing) { + widget.onPressed(); + } + _isLongPressing = false; + await Future.delayed(const Duration(milliseconds: 100)); + await _animationController.reverse(); + }, onTapCancel: () { + _isLongPressing = false; + _cancelTimers(); _animationController.reverse(); }, child: Container( @@ -92,7 +120,15 @@ class _MainMenuButtonState extends State @override void dispose() { + _cancelTimers(); _animationController.dispose(); super.dispose(); } + + void _cancelTimers() { + _longPressTimer?.cancel(); + _longPressTimer = null; + _repeatTimer?.cancel(); + _repeatTimer = null; + } } diff --git a/lib/presentation/widgets/tiles/custom_radio_list_tile.dart b/lib/presentation/widgets/tiles/match_result_view/custom_radio_list_tile.dart similarity index 100% rename from lib/presentation/widgets/tiles/custom_radio_list_tile.dart rename to lib/presentation/widgets/tiles/match_result_view/custom_radio_list_tile.dart diff --git a/lib/presentation/widgets/tiles/match_result_view/live_edit_list_tile.dart b/lib/presentation/widgets/tiles/match_result_view/live_edit_list_tile.dart new file mode 100644 index 0000000..d663efc --- /dev/null +++ b/lib/presentation/widgets/tiles/match_result_view/live_edit_list_tile.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_numeric_text/flutter_numeric_text.dart'; +import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; + +class LiveEditListTile extends StatefulWidget { + const LiveEditListTile({ + super.key, + required this.title, + required this.value, + this.onChanged, + }); + + final String title; + + final int value; + + final void Function(int newValue)? onChanged; + + @override + State createState() => _LiveEditListTileState(); +} + +class _LiveEditListTileState extends State { + int _score = 0; + final int maxScore = 9999; + final int minScore = -9999; + + @override + void initState() { + _score = widget.value; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + decoration: CustomTheme.standardBoxDecoration, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + MainMenuButton( + onPressed: () => _score > minScore + ? { + setState(() { + _score--; + if (widget.onChanged != null) { + widget.onChanged!(_score); + } + }), + } + : null, + onLongPressed: () => _score > minScore + ? { + setState(() { + _score -= 10; + if (widget.onChanged != null) { + widget.onChanged!(_score); + } + }), + } + : null, + icon: Icons.remove_rounded, + ), + Expanded( + child: Column( + children: [ + Text( + widget.title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + SizedBox( + width: 150, + child: NumericText( + _score.toString(), + maxLines: 1, + textAlign: TextAlign.center, + textWidthBasis: TextWidthBasis.longestLine, + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ), + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + MainMenuButton( + onPressed: () => _score < maxScore + ? { + setState(() { + _score++; + if (widget.onChanged != null) { + widget.onChanged!(_score); + } + }), + } + : null, + onLongPressed: () => _score > minScore + ? { + setState(() { + _score += 10; + if (widget.onChanged != null) { + widget.onChanged!(_score); + } + }), + } + : null, + icon: Icons.add_rounded, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/widgets/tiles/score_list_tile.dart b/lib/presentation/widgets/tiles/match_result_view/score_list_tile.dart similarity index 75% rename from lib/presentation/widgets/tiles/score_list_tile.dart rename to lib/presentation/widgets/tiles/match_result_view/score_list_tile.dart index 52103fa..e4cfff9 100644 --- a/lib/presentation/widgets/tiles/score_list_tile.dart +++ b/lib/presentation/widgets/tiles/match_result_view/score_list_tile.dart @@ -40,9 +40,13 @@ class ScoreListTile extends StatelessWidget { height: 40, child: TextField( controller: controller, - keyboardType: TextInputType.number, - maxLength: 4, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], + keyboardType: const TextInputType.numberWithOptions(signed: true), + maxLength: 5, + inputFormatters: [ + TextInputFormatter.withFunction((oldValue, newValue) { + return isValidScoreInput(newValue.text) ? newValue : oldValue; + }), + ], textAlign: TextAlign.center, style: const TextStyle( fontSize: 16, @@ -62,7 +66,7 @@ class ScoreListTile extends StatelessWidget { enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide( - color: CustomTheme.textColor.withAlpha(100), + color: CustomTheme.textColor.withAlpha(250), width: 2, ), ), @@ -80,4 +84,21 @@ class ScoreListTile extends StatelessWidget { ), ); } + + /// Validates the input for the score text field. + bool isValidScoreInput(String text) { + if (text.isEmpty || text == '-') { + return true; + } + + final isNegative = text.startsWith('-'); + final digits = isNegative ? text.substring(1) : text; + + if (digits.isEmpty || digits.length > 4) { + return false; + } + + // CHeck if all characters are digits 0 <= x <= 9 + return digits.codeUnits.every((unit) => unit >= 48 && unit <= 57); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 5e3ec4c..1b3d91c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + flutter_numeric_text: ^1.3.3 flutter_popup: ^3.3.9 fluttericon: ^2.0.0 font_awesome_flutter: ^11.0.0