Merge branch 'feature/180-Spielerprofile-implementieren' into feature/193-statisticsview-rework

This commit is contained in:
2026-05-24 01:10:45 +02:00
24 changed files with 1073 additions and 158 deletions

View File

@@ -89,6 +89,7 @@ class _CreateGroupViewState extends State<CreateGroupView> {
Expanded(
child: PlayerSelection(
initialSelectedPlayers: initialSelectedPlayers,
onPlayerCreated: () => widget.onMembersChanged?.call(),
onChanged: (value) {
setState(() {
selectedPlayers = [...value];
@@ -134,6 +135,7 @@ class _CreateGroupViewState extends State<CreateGroupView> {
if (!mounted) return;
if (success) {
widget.onMembersChanged?.call();
await HapticFeedback.successNotification();
if (mounted) {
Navigator.pop(context, updatedGroup);
@@ -157,7 +159,6 @@ class _CreateGroupViewState extends State<CreateGroupView> {
final success = await db.groupDao.addGroup(
group: Group(name: groupName, members: selectedPlayers),
);
return success;
}

View File

@@ -77,6 +77,7 @@ class _GroupViewState extends State<GroupView> {
);
}
return GroupTile(
onPlayerChanged: loadGroups,
group: groups[index],
onTap: () async {
await Navigator.push(
@@ -106,13 +107,10 @@ class _GroupViewState extends State<GroupView> {
context,
adaptivePageRoute(
builder: (context) {
return const CreateGroupView();
return CreateGroupView(onMembersChanged: loadGroups);
},
),
);
setState(() {
loadGroups();
});
},
),
),

View File

@@ -196,6 +196,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
child: PlayerSelection(
key: ValueKey(selectedGroup?.id ?? 'no_group'),
initialSelectedPlayers: selectedPlayers,
onPlayerCreated: () => widget.onMatchesUpdated?.call(),
onChanged: (value) {
setState(() {
selectedPlayers = value;

View File

@@ -97,6 +97,7 @@ class _MatchViewState extends State<MatchView> {
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: MatchTile(
onPlayerEdited: loadMatches,
width: MediaQuery.sizeOf(context).width * 0.95,
onTap: () async {
Navigator.push(

View File

@@ -0,0 +1,394 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/game.dart';
import 'package:tallee/data/models/group.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/app_skeleton.dart';
import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart';
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
import 'package:tallee/presentation/widgets/colored_icon_container.dart';
import 'package:tallee/presentation/widgets/dialog/custom_alert_dialog.dart';
import 'package:tallee/presentation/widgets/dialog/custom_dialog_action.dart';
import 'package:tallee/presentation/widgets/text_input/text_input_field.dart';
import 'package:tallee/presentation/widgets/tiles/info_tile.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
class PlayerDetailView extends StatefulWidget {
const PlayerDetailView({
super.key,
required this.player,
required this.callback,
});
/// The player to display
final Player player;
final VoidCallback callback;
@override
State<PlayerDetailView> createState() => _PlayerDetailViewState();
}
class _PlayerDetailViewState extends State<PlayerDetailView> {
late final AppDatabase db;
late Player _player;
late String playerNameCount;
bool isLoading = true;
/// Total matches played by this player
int totalMatches = 0;
/// Total matches won by this player
int matchesWon = 0;
/// Total groups this player belongs to
int totalGroups = 0;
/// Full list of groups this player belongs to
List<Group> playerGroups = List.filled(
4,
Group(name: 'Skeleton group', members: []),
);
/// Full list of matches this player played in
List<Match> playerMatches = List.filled(
4,
Match(
name: 'Skeleton match',
game: Game(name: 'Game name', ruleset: Ruleset.singleWinner),
players: [],
),
);
TextEditingController nameController = TextEditingController();
@override
void initState() {
super.initState();
_player = widget.player;
db = Provider.of<AppDatabase>(context, listen: false);
playerNameCount = getNameCountText(_player);
_loadData();
}
@override
void dispose() {
nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(loc.player_profile),
actions: [
HapticIconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
showDialog<bool>(
context: context,
builder: (context) => CustomAlertDialog(
title: loc.delete_player,
content: Text(loc.this_cannot_be_undone),
actions: [
CustomDialogAction(
onPressed: () => Navigator.of(context).pop(true),
text: loc.delete,
),
CustomDialogAction(
onPressed: () => Navigator.of(context).pop(false),
buttonType: ButtonType.secondary,
text: loc.cancel,
),
],
),
).then((confirmed) async {
if (confirmed! && context.mounted) {
//TODO: implement player deletion in db
if (!context.mounted) return;
Navigator.pop(context);
widget.callback();
}
});
},
),
],
),
body: SafeArea(
child: Stack(
alignment: Alignment.center,
children: [
ListView(
padding: const EdgeInsets.only(
left: 12,
right: 12,
top: 20,
bottom: 100,
),
children: [
const Center(
child: ColoredIconContainer(
icon: Icons.person,
containerSize: 55,
iconSize: 38,
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_player.name,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: CustomTheme.textColor,
),
textAlign: TextAlign.center,
),
Text(
playerNameCount,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: CustomTheme.textColor.withAlpha(120),
),
textAlign: TextAlign.center,
),
],
),
const SizedBox(height: 5),
Text(
'${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(_player.createdAt)}',
style: const TextStyle(
fontSize: 12,
color: CustomTheme.textColor,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
InfoTile(
title: '${loc.matches_part_of} ($totalMatches)',
icon: Icons.sports_esports,
horizontalAlignment: CrossAxisAlignment.start,
content: AppSkeleton(
enabled: isLoading,
fixLayoutBuilder: true,
alignment: Alignment.topLeft,
child: playerMatches.isNotEmpty
? Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: 12,
runSpacing: 8,
children: playerMatches.map((match) {
return TextIconTile(
text: match.name,
iconEnabled: false,
);
}).toList(),
)
: Text(
loc.no_matches_played_yet,
style: const TextStyle(
fontSize: 14,
color: CustomTheme.textColor,
),
),
),
),
const SizedBox(height: 15),
InfoTile(
title: '${loc.groups_part_of} ($totalGroups)',
icon: Icons.people,
horizontalAlignment: CrossAxisAlignment.start,
content: AppSkeleton(
enabled: isLoading,
fixLayoutBuilder: true,
alignment: Alignment.topLeft,
child: playerGroups.isNotEmpty
? Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: 12,
runSpacing: 8,
children: playerGroups.map((group) {
return TextIconTile(
text: group.name,
iconEnabled: false,
);
}).toList(),
)
: Text(
loc.not_part_of_any_group,
style: const TextStyle(
fontSize: 14,
color: CustomTheme.textColor,
),
),
),
),
const SizedBox(height: 15),
InfoTile(
title: loc.statistics,
icon: Icons.bar_chart,
content: AppSkeleton(
enabled: isLoading,
fixLayoutBuilder: true,
child: Column(
children: [
_buildStatRow(
loc.matches_played,
totalMatches.toString(),
),
_buildStatRow(loc.matches_won, matchesWon.toString()),
_buildStatRow(
loc.winrate,
'${totalMatches == 0 ? 0 : ((matchesWon / totalMatches) * 100).round()}%',
),
],
),
),
),
],
),
Positioned(
bottom: MediaQuery.paddingOf(context).bottom,
child: MainMenuButton(
text: loc.edit_player,
icon: Icons.edit,
onPressed: () async {
nameController.text = _player.name;
showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) {
return CustomAlertDialog(
title: loc.edit_name,
content: TextInputField(
controller: nameController,
hintText: loc.set_name,
onChanged: (_) => setDialogState(() {}),
),
actions: [
CustomDialogAction(
onPressed: isConfirmButtonEnabled()
? () => Navigator.of(context).pop(true)
: null,
text: loc.confirm,
),
CustomDialogAction(
onPressed: () => Navigator.of(context).pop(false),
buttonType: ButtonType.secondary,
text: loc.cancel,
),
],
);
},
),
).then((confirmed) async {
if (confirmed! && context.mounted) {
final newName = nameController.text.trim();
if (newName != _player.name) {
final fetchedPlayerNameCount = await db.playerDao
.getNameCount(name: newName);
await db.playerDao.updatePlayerName(
playerId: _player.id,
name: newName,
);
widget.callback.call();
setState(() {
_player = Player(
name: newName,
createdAt: _player.createdAt,
id: _player.id,
nameCount: _player.nameCount,
description: _player.description,
);
// If there is already a player with the same name,
// the count of that player is 0, so we start counting from 2 to get the correct count for this player. If there are no players with the same name, we just show the name without a count.
playerNameCount = fetchedPlayerNameCount == 0
? ''
: ' #${fetchedPlayerNameCount + 1}';
});
}
}
});
},
),
),
],
),
),
);
}
/// Loads statistics for this player
Future<void> _loadData() async {
isLoading = true;
final fetchedMatches = await db.matchDao.getMatchesByPlayer(
playerId: _player.id,
);
final fetchedGroups = await db.groupDao.getGroupsByPlayer(
playerId: _player.id,
);
if (!mounted) return;
setState(() {
playerMatches = fetchedMatches;
totalMatches = fetchedMatches.length;
matchesWon = fetchedMatches
.where((match) => match.mvp.any((mvp) => mvp.id == _player.id))
.length;
playerGroups = fetchedGroups;
totalGroups = fetchedGroups.length;
isLoading = false;
});
}
/// Builds a single statistic row with a label and value
/// - [label]: The label of the statistic
/// - [value]: The value of the statistic
Widget _buildStatRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
label,
style: const TextStyle(
fontSize: 16,
color: CustomTheme.textColor,
),
),
],
),
Text(
value,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
);
}
bool isConfirmButtonEnabled() {
return nameController.text.trim().isNotEmpty;
}
}

View File

@@ -6,11 +6,13 @@ class AppSkeleton extends StatefulWidget {
/// - [child]: The widget tree to apply the skeleton effect to.
/// - [enabled]: A boolean to enable or disable the skeleton effect.
/// - [fixLayoutBuilder]: A boolean to fix the layout builder for AnimatedSwitcher.
/// - [alignment]: The alignment used for the custom layout builder and optional Align wrapper. Defaults to [Alignment.center].
const AppSkeleton({
super.key,
required this.child,
this.enabled = true,
this.fixLayoutBuilder = false,
this.alignment = Alignment.center,
});
/// The widget tree to apply the skeleton effect to.
@@ -22,6 +24,9 @@ class AppSkeleton extends StatefulWidget {
/// A boolean to fix the layout builder for AnimatedSwitcher.
final bool fixLayoutBuilder;
/// The alignment used for the custom layout builder and optional Align wrapper
final Alignment alignment;
@override
State<AppSkeleton> createState() => _AppSkeletonState();
}
@@ -45,13 +50,14 @@ class _AppSkeletonState extends State<AppSkeleton> {
layoutBuilder: !widget.fixLayoutBuilder
? AnimatedSwitcher.defaultLayoutBuilder
: (Widget? currentChild, List<Widget> previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: [...previousChildren, ?currentChild],
);
final children = <Widget>[...previousChildren];
if (currentChild != null) children.add(currentChild);
return Stack(alignment: widget.alignment, children: children);
},
),
child: widget.child,
child: widget.fixLayoutBuilder
? Align(alignment: widget.alignment, child: widget.child)
: widget.child,
);
}
}

View File

@@ -11,7 +11,7 @@ class AnimatedDialogButton extends StatefulWidget {
const AnimatedDialogButton({
super.key,
required this.buttonText,
required this.onPressed,
this.onPressed,
this.buttonConstraints,
this.buttonType = ButtonType.primary,
this.isDescructive = false,
@@ -19,7 +19,7 @@ class AnimatedDialogButton extends StatefulWidget {
final String buttonText;
final VoidCallback onPressed;
final VoidCallback? onPressed;
final BoxConstraints? buttonConstraints;
@@ -38,28 +38,38 @@ class _AnimatedDialogButtonState extends State<AnimatedDialogButton> {
Widget build(BuildContext context) {
final textStyling = _getTextStyling();
final buttonDecoration = _getButtonDecoration();
bool isDisabled = widget.onPressed == null;
return GestureDetector(
onTapDown: (_) => setState(() => _isPressed = true),
onTapUp: (_) => setState(() => _isPressed = false),
onTapCancel: () => setState(() => _isPressed = false),
onTap: widget.onPressed,
child: AnimatedScale(
scale: _isPressed ? 0.95 : 1.0,
duration: const Duration(milliseconds: 100),
child: AnimatedOpacity(
opacity: _isPressed ? 0.6 : 1.0,
duration: const Duration(milliseconds: 100),
child: Center(
child: Container(
constraints: widget.buttonConstraints,
decoration: buttonDecoration,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
margin: const EdgeInsets.symmetric(vertical: 8),
child: Text(
widget.buttonText,
style: textStyling,
textAlign: TextAlign.center,
return IgnorePointer(
ignoring: isDisabled,
child: Opacity(
opacity: isDisabled ? 0.5 : 1.0,
child: GestureDetector(
onTapDown: (_) => setState(() => _isPressed = true),
onTapUp: (_) => setState(() => _isPressed = false),
onTapCancel: () => setState(() => _isPressed = false),
onTap: widget.onPressed,
child: AnimatedScale(
scale: _isPressed ? 0.95 : 1.0,
duration: const Duration(milliseconds: 100),
child: AnimatedOpacity(
opacity: _isPressed ? 0.6 : 1.0,
duration: const Duration(milliseconds: 100),
child: Center(
child: Container(
constraints: widget.buttonConstraints,
decoration: buttonDecoration,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
margin: const EdgeInsets.symmetric(vertical: 8),
child: Text(
widget.buttonText,
style: textStyling,
textAlign: TextAlign.center,
),
),
),
),
),

View File

@@ -19,7 +19,6 @@ class CustomAlertDialog extends StatelessWidget {
final String title;
final Widget content;
final List<CustomDialogAction> actions;
@override
Widget build(BuildContext context) {
return AlertDialog(

View File

@@ -10,7 +10,7 @@ class CustomDialogAction extends StatelessWidget {
/// - [onPressed]: Callback function that is triggered when the button is pressed.
const CustomDialogAction({
super.key,
required this.onPressed,
this.onPressed,
required this.text,
this.buttonType = ButtonType.primary,
this.isDestructive = false,
@@ -20,17 +20,18 @@ class CustomDialogAction extends StatelessWidget {
final ButtonType buttonType;
final VoidCallback onPressed;
final VoidCallback? onPressed;
final bool isDestructive;
@override
Widget build(BuildContext context) {
return AnimatedDialogButton(
onPressed: () async {
await HapticFeedback.selectionClick();
onPressed.call();
},
onPressed: onPressed != null
? () async {
await HapticFeedback.selectionClick();
onPressed?.call();
}
: null,
buttonText: text,
buttonType: buttonType,
isDescructive: isDestructive,

View File

@@ -26,6 +26,7 @@ class PlayerSelection extends StatefulWidget {
this.availablePlayers,
this.initialSelectedPlayers,
required this.onChanged,
this.onPlayerCreated,
});
/// An optional list of players to choose from. If null, all players from the database are used.
@@ -37,6 +38,9 @@ class PlayerSelection extends StatefulWidget {
/// A callback function that is invoked whenever the selection changes,
final Function(List<Player> value) onChanged;
/// A callback function that is invoked when a player was created in this widget
final VoidCallback? onPlayerCreated;
@override
State<PlayerSelection> createState() => _PlayerSelectionState();
}
@@ -323,6 +327,7 @@ class _PlayerSelectionState extends State<PlayerSelection> {
/// Updates the state after successfully adding a new player.
void _handleSuccessfulPlayerCreation(Player player) {
widget.onPlayerCreated?.call();
selectedPlayers.insert(0, player);
widget.onChanged([...selectedPlayers]);
allPlayers.add(player);

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/data/models/group.dart';
import 'package:tallee/presentation/views/main_menu/player_detail_view.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
class GroupTile extends StatefulWidget {
@@ -15,6 +17,7 @@ class GroupTile extends StatefulWidget {
required this.group,
this.isHighlighted = false,
this.onTap,
this.onPlayerChanged,
});
/// The group data to be displayed.
@@ -26,6 +29,9 @@ class GroupTile extends StatefulWidget {
/// Callback function to be executed when the tile is tapped.
final VoidCallback? onTap;
/// Callback function to be executed when the players in the group are changed.
final VoidCallback? onPlayerChanged;
@override
State<GroupTile> createState() => _GroupTileState();
}
@@ -92,6 +98,19 @@ class _GroupTileState extends State<GroupTile> {
text: member.name,
suffixText: getNameCountText(member),
iconEnabled: false,
onTileTap: () {
Navigator.push(
context,
adaptivePageRoute(
builder: (context) => PlayerDetailView(
player: member,
callback: () {
widget.onPlayerChanged?.call();
},
),
),
);
},
),
],
),

View File

@@ -3,11 +3,13 @@ import 'dart:core' hide Match;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/views/main_menu/player_detail_view.dart';
import 'package:tallee/presentation/widgets/game_label.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
@@ -24,6 +26,7 @@ class MatchTile extends StatefulWidget {
required this.onTap,
this.width,
this.compact = false,
this.onPlayerEdited,
});
/// The match data to be displayed.
@@ -32,6 +35,9 @@ class MatchTile extends StatefulWidget {
/// The callback invoked when the tile is tapped.
final VoidCallback onTap;
/// The callback invoked when the players are edited
final VoidCallback? onPlayerEdited;
/// Optional width for the tile.
final double? width;
@@ -224,6 +230,19 @@ class _MatchTileState extends State<MatchTile> {
text: player.name,
suffixText: getNameCountText(player),
iconEnabled: false,
onTileTap: () {
Navigator.push(
context,
adaptivePageRoute(
builder: (context) => PlayerDetailView(
player: player,
callback: () {
widget.onPlayerEdited?.call();
},
),
),
);
},
);
}).toList(),
),

View File

@@ -12,6 +12,7 @@ class TextIconTile extends StatelessWidget {
this.suffixText = '',
this.iconEnabled = true,
this.onIconTap,
this.onTileTap,
});
/// The text to display in the tile.
@@ -25,52 +26,58 @@ class TextIconTile extends StatelessWidget {
/// The callback to be invoked when the icon is tapped.
final VoidCallback? onIconTap;
/// The callback to be invoked when the tile is tapped.
final VoidCallback? onTileTap;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: CustomTheme.onBoxColor,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
if (iconEnabled) const SizedBox(width: 3),
Flexible(
child: RichText(
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: [
TextSpan(
text: text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
return GestureDetector(
onTap: onTileTap,
child: Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: CustomTheme.onBoxColor,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
if (iconEnabled) const SizedBox(width: 3),
Flexible(
child: RichText(
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: [
TextSpan(
text: text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
TextSpan(
text: suffixText,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: CustomTheme.textColor.withAlpha(120),
TextSpan(
text: suffixText,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: CustomTheme.textColor.withAlpha(120),
),
),
),
],
],
),
),
),
),
if (iconEnabled) ...<Widget>[
const SizedBox(width: 3),
GestureDetector(
onTap: onIconTap,
child: const Icon(Icons.close, size: 20),
),
if (iconEnabled) ...<Widget>[
const SizedBox(width: 3),
GestureDetector(
onTap: onIconTap,
child: const Icon(Icons.close, size: 20),
),
],
],
],
),
),
);
}