Merge pull request 'Live-Edit Modus' (#207) from feature/202-live-edit-modus into development
All checks were successful
Push Pipeline / test (push) Successful in 48s
Push Pipeline / update_version (push) Successful in 6s
Push Pipeline / generate_licenses (push) Successful in 37s
Push Pipeline / format (push) Successful in 55s
Push Pipeline / build (push) Successful in 5m31s

Reviewed-on: #207

ich habe schwere depressionen wegen merge conflicts :(((
This commit was merged in pull request #207.
This commit is contained in:
2026-05-09 17:58:37 +00:00
12 changed files with 337 additions and 86 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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...';

View File

@@ -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...';

View File

@@ -240,7 +240,9 @@ class _MatchDetailViewState extends State<MatchDetailView> {
match: match,
onWinnerChanged: () {
widget.onMatchUpdate.call();
setState(() {});
setState(() {
updateScoresForCurrentMatch();
});
},
),
),
@@ -368,4 +370,10 @@ class _MatchDetailViewState extends State<MatchDetailView> {
return match.game.ruleset == Ruleset.singleWinner ||
match.game.ruleset == Ruleset.singleLoser;
}
void updateScoresForCurrentMatch() {
db.scoreEntryDao
.getAllMatchScores(matchId: match.id)
.then((scores) => match.scores = scores);
}
}

View File

@@ -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<MatchResultView> {
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<MatchResultView> {
/// List of text controllers for score entry, one for each player
late final List<TextEditingController> 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<MatchResultView> {
@override
void initState() {
db = Provider.of<AppDatabase>(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<MatchResultView> {
(index) => TextEditingController()..addListener(() => onTextEnter()),
);
// Prefill fields
if (widget.match.mvp.isNotEmpty) {
if (rulesetSupportsWinnerSelection()) {
_selectedPlayer = allPlayers.firstWhere(
@@ -101,86 +106,125 @@ class _MatchResultViewState extends State<MatchResultView> {
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<Player>(
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<Player>(
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,

View File

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

View File

@@ -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<MainMenuButton> createState() => _MainMenuButtonState();
}
@@ -30,6 +35,14 @@ class _MainMenuButtonState extends State<MainMenuButton>
late AnimationController _animationController;
late Animation<double> _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<MainMenuButton>
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<MainMenuButton>
@override
void dispose() {
_cancelTimers();
_animationController.dispose();
super.dispose();
}
void _cancelTimers() {
_longPressTimer?.cancel();
_longPressTimer = null;
_repeatTimer?.cancel();
_repeatTimer = null;
}
}

View File

@@ -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<LiveEditListTile> createState() => _LiveEditListTileState();
}
class _LiveEditListTileState extends State<LiveEditListTile> {
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,
),
],
),
);
}
}

View File

@@ -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);
}
}

View File

@@ -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