feat: add haptic feedback for various user interactions
Some checks failed
Pull Request Pipeline / test (pull_request) Successful in 45s
Pull Request Pipeline / lint (pull_request) Failing after 48s

This commit is contained in:
2026-05-10 23:04:43 +02:00
parent 699d4378b2
commit 1d20127af4
13 changed files with 131 additions and 17 deletions

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tallee/presentation/widgets/buttons/haptic_back_button.dart';
import 'package:tallee/presentation/widgets/buttons/haptic_close_button.dart';
/// Theme class that defines colors, border radius, padding, and decorations /// Theme class that defines colors, border radius, padding, and decorations
class CustomTheme { class CustomTheme {
@@ -83,6 +85,11 @@ class CustomTheme {
iconTheme: IconThemeData(color: textColor), iconTheme: IconThemeData(color: textColor),
); );
static final ActionIconThemeData actionIconTheme = ActionIconThemeData(
backButtonIconBuilder: (context) => const HapticBackButton(),
closeButtonIconBuilder: (context) => const HapticCloseButton(),
);
static const SearchBarThemeData searchBarTheme = SearchBarThemeData( static const SearchBarThemeData searchBarTheme = SearchBarThemeData(
textStyle: WidgetStatePropertyAll(TextStyle(color: textColor)), textStyle: WidgetStatePropertyAll(TextStyle(color: textColor)),
hintStyle: WidgetStatePropertyAll(TextStyle(color: hintColor)), hintStyle: WidgetStatePropertyAll(TextStyle(color: hintColor)),

View File

@@ -42,6 +42,7 @@ class GameTracker extends StatelessWidget {
scaffoldBackgroundColor: CustomTheme.backgroundColor, scaffoldBackgroundColor: CustomTheme.backgroundColor,
// themes // themes
appBarTheme: CustomTheme.appBarTheme, appBarTheme: CustomTheme.appBarTheme,
actionIconTheme: CustomTheme.actionIconTheme,
inputDecorationTheme: CustomTheme.inputDecorationTheme, inputDecorationTheme: CustomTheme.inputDecorationTheme,
searchBarTheme: CustomTheme.searchBarTheme, searchBarTheme: CustomTheme.searchBarTheme,
radioTheme: CustomTheme.radioTheme, radioTheme: CustomTheme.radioTheme,

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tallee/core/adaptive_page_route.dart'; import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/l10n/generated/app_localizations.dart';
@@ -55,8 +56,9 @@ class _CustomNavigationBarState extends State<CustomNavigationBar>
actions: [ actions: [
IconButton( IconButton(
onPressed: () async { onPressed: () async {
await Navigator.push( final navigator = Navigator.of(context);
context, await HapticFeedback.selectionClick();
await navigator.push(
adaptivePageRoute(builder: (_) => const SettingsView()), adaptivePageRoute(builder: (_) => const SettingsView()),
); );
setState(() { setState(() {
@@ -125,7 +127,8 @@ class _CustomNavigationBarState extends State<CustomNavigationBar>
} }
/// Handles tab tap events. Updates the current [index] state. /// Handles tab tap events. Updates the current [index] state.
void onTabTapped(int index) { void onTabTapped(int index) async {
await HapticFeedback.selectionClick();
setState(() { setState(() {
currentIndex = index; currentIndex = index;
}); });

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tallee/core/constants.dart'; import 'package:tallee/core/constants.dart';
import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/custom_theme.dart';
@@ -133,8 +134,10 @@ class _CreateGroupViewState extends State<CreateGroupView> {
if (!mounted) return; if (!mounted) return;
if (success) { if (success) {
await HapticFeedback.successNotification();
Navigator.pop(context, updatedGroup); Navigator.pop(context, updatedGroup);
} else { } else {
await HapticFeedback.errorNotification();
showSnackbar( showSnackbar(
message: widget.groupToEdit == null message: widget.groupToEdit == null
? loc.error_creating_group ? loc.error_creating_group

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tallee/core/adaptive_page_route.dart'; import 'package:tallee/core/adaptive_page_route.dart';
@@ -68,6 +69,7 @@ class _GroupDetailViewState extends State<GroupDetailView> {
IconButton( IconButton(
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
onPressed: () async { onPressed: () async {
await HapticFeedback.selectionClick();
showDialog<bool>( showDialog<bool>(
context: context, context: context,
builder: (context) => CustomAlertDialog( builder: (context) => CustomAlertDialog(
@@ -75,11 +77,17 @@ class _GroupDetailViewState extends State<GroupDetailView> {
content: Text(loc.this_cannot_be_undone), content: Text(loc.this_cannot_be_undone),
actions: [ actions: [
CustomDialogAction( CustomDialogAction(
onPressed: () => Navigator.of(context).pop(true), onPressed: () async {
await HapticFeedback.warningNotification();
Navigator.of(context).pop(true);
},
text: loc.delete, text: loc.delete,
), ),
CustomDialogAction( CustomDialogAction(
onPressed: () => Navigator.of(context).pop(false), onPressed: () async {
await HapticFeedback.selectionClick();
Navigator.of(context).pop(false);
},
buttonType: ButtonType.secondary, buttonType: ButtonType.secondary,
text: loc.cancel, text: loc.cancel,
), ),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tallee/core/adaptive_page_route.dart'; import 'package:tallee/core/adaptive_page_route.dart';
import 'package:tallee/core/constants.dart'; import 'package:tallee/core/constants.dart';
@@ -102,6 +103,7 @@ class _GroupViewState extends State<GroupView> {
text: loc.create_group, text: loc.create_group,
icon: Icons.group_add, icon: Icons.group_add,
onPressed: () async { onPressed: () async {
await HapticFeedback.selectionClick();
await Navigator.push( await Navigator.push(
context, context,
adaptivePageRoute( adaptivePageRoute(

View File

@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@@ -201,7 +202,8 @@ class _SettingsViewState extends State<SettingsView> {
children: [ children: [
GestureDetector( GestureDetector(
child: const Icon(Icons.language), child: const Icon(Icons.language),
onTap: () => { onTap: () async => {
await HapticFeedback.lightImpact(),
launchUrl( launchUrl(
Uri.parse('https://liquid-dev.de'), Uri.parse('https://liquid-dev.de'),
), ),
@@ -209,7 +211,8 @@ class _SettingsViewState extends State<SettingsView> {
), ),
GestureDetector( GestureDetector(
child: const FaIcon(FontAwesomeIcons.github), child: const FaIcon(FontAwesomeIcons.github),
onTap: () => { onTap: () async => {
await HapticFeedback.lightImpact(),
launchUrl( launchUrl(
Uri.parse( Uri.parse(
'https://github.com/liquiddevelopmentde', 'https://github.com/liquiddevelopmentde',
@@ -223,9 +226,12 @@ class _SettingsViewState extends State<SettingsView> {
? CupertinoIcons.mail_solid ? CupertinoIcons.mail_solid
: Icons.email, : Icons.email,
), ),
onTap: () => launchUrl( onTap: () async => {
Uri.parse('mailto:hi@liquid-dev.de'), await HapticFeedback.lightImpact(),
), launchUrl(
Uri.parse('mailto:hi@liquid-dev.de'),
),
},
), ),
], ],
), ),
@@ -266,20 +272,38 @@ class _SettingsViewState extends State<SettingsView> {
void showImportSnackBar({ void showImportSnackBar({
required BuildContext context, required BuildContext context,
required ImportResult result, required ImportResult result,
}) { }) async {
final loc = AppLocalizations.of(context); final loc = AppLocalizations.of(context);
switch (result) { switch (result) {
case ImportResult.success: case ImportResult.success:
if (context.mounted) {
await HapticFeedback.successNotification();
}
showSnackbar(context: context, message: loc.data_successfully_imported); showSnackbar(context: context, message: loc.data_successfully_imported);
case ImportResult.invalidSchema: case ImportResult.invalidSchema:
if (context.mounted) {
await HapticFeedback.errorNotification();
}
showSnackbar(context: context, message: loc.invalid_schema); showSnackbar(context: context, message: loc.invalid_schema);
case ImportResult.fileReadError: case ImportResult.fileReadError:
if (context.mounted) {
await HapticFeedback.errorNotification();
}
showSnackbar(context: context, message: loc.error_reading_file); showSnackbar(context: context, message: loc.error_reading_file);
case ImportResult.canceled: case ImportResult.canceled:
if (context.mounted) {
await HapticFeedback.errorNotification();
}
showSnackbar(context: context, message: loc.import_canceled); showSnackbar(context: context, message: loc.import_canceled);
case ImportResult.formatException: case ImportResult.formatException:
if (context.mounted) {
await HapticFeedback.errorNotification();
}
showSnackbar(context: context, message: loc.format_exception); showSnackbar(context: context, message: loc.format_exception);
case ImportResult.unknownException: case ImportResult.unknownException:
if (context.mounted) {
await HapticFeedback.errorNotification();
}
showSnackbar(context: context, message: loc.unknown_exception); showSnackbar(context: context, message: loc.unknown_exception);
} }
} }
@@ -291,13 +315,16 @@ class _SettingsViewState extends State<SettingsView> {
void showExportSnackBar({ void showExportSnackBar({
required BuildContext context, required BuildContext context,
required ExportResult result, required ExportResult result,
}) { }) async {
final loc = AppLocalizations.of(context); final loc = AppLocalizations.of(context);
switch (result) { switch (result) {
case ExportResult.success: case ExportResult.success:
await HapticFeedback.successNotification();
showSnackbar(context: context, message: loc.data_successfully_exported); showSnackbar(context: context, message: loc.data_successfully_exported);
case ExportResult.canceled: case ExportResult.canceled:
await HapticFeedback.errorNotification();
showSnackbar(context: context, message: loc.export_canceled); showSnackbar(context: context, message: loc.export_canceled);
await HapticFeedback.errorNotification();
case ExportResult.unknownException: case ExportResult.unknownException:
showSnackbar(context: context, message: loc.unknown_exception); showSnackbar(context: context, message: loc.unknown_exception);
} }

View File

@@ -0,0 +1,24 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class HapticBackButton extends StatelessWidget {
const HapticBackButton({super.key});
@override
Widget build(BuildContext context) {
final iconData = switch (defaultTargetPlatform) {
TargetPlatform.iOS ||
TargetPlatform.macOS => Icons.arrow_back_ios_new_rounded,
_ => Icons.arrow_back_rounded,
};
return IconButton(
icon: Icon(iconData),
onPressed: () async {
await HapticFeedback.mediumImpact();
Navigator.of(context).maybePop();
},
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class HapticCloseButton extends StatelessWidget {
const HapticCloseButton({super.key});
@override
Widget build(BuildContext context) {
final iconData = switch (defaultTargetPlatform) {
TargetPlatform.iOS || TargetPlatform.macOS => CupertinoIcons.xmark,
_ => Icons.close_rounded,
};
return IconButton(
icon: Icon(iconData),
onPressed: () async {
await HapticFeedback.mediumImpact();
Navigator.of(context).maybePop();
},
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tallee/core/common.dart'; import 'package:tallee/core/common.dart';
import 'package:tallee/core/constants.dart'; import 'package:tallee/core/constants.dart';
@@ -197,7 +198,8 @@ class _PlayerSelectionState extends State<PlayerSelection> {
text: suggestedPlayers[index].name, text: suggestedPlayers[index].name,
suffixText: getNameCountText(suggestedPlayers[index]), suffixText: getNameCountText(suggestedPlayers[index]),
icon: Icons.add, icon: Icons.add,
onPressed: () { onPressed: () async {
await HapticFeedback.selectionClick();
setState(() { setState(() {
// If the player is not already selected // If the player is not already selected
if (!selectedPlayers.contains( if (!selectedPlayers.contains(
@@ -294,8 +296,10 @@ class _PlayerSelectionState extends State<PlayerSelection> {
if (success) { if (success) {
_handleSuccessfulPlayerCreation(createdPlayer); _handleSuccessfulPlayerCreation(createdPlayer);
await HapticFeedback.successNotification();
showSnackBarMessage(loc.successfully_added_player(playerName)); showSnackBarMessage(loc.successfully_added_player(playerName));
} else { } else {
await HapticFeedback.errorNotification();
showSnackBarMessage(loc.could_not_add_player(playerName)); showSnackBarMessage(loc.could_not_add_player(playerName));
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tallee/core/common.dart'; import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/data/models/group.dart'; import 'package:tallee/data/models/group.dart';
@@ -33,7 +34,10 @@ class _GroupTileState extends State<GroupTile> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: widget.onTap, onTap: () async {
await HapticFeedback.selectionClick();
widget.onTap?.call();
},
child: AnimatedContainer( child: AnimatedContainer(
margin: CustomTheme.standardMargin, margin: CustomTheme.standardMargin,
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/presentation/views/main_menu/settings_view/licenses/license_detail_view.dart'; import 'package:tallee/presentation/views/main_menu/settings_view/licenses/license_detail_view.dart';
import 'package:tallee/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart'; import 'package:tallee/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart';
@@ -15,8 +16,10 @@ class LicenseTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () async {
Navigator.of(context).push( final navigator = Navigator.of(context);
await HapticFeedback.selectionClick();
navigator.push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => LicenseDetailView(package: package), builder: (context) => LicenseDetailView(package: package),
), ),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/presentation/widgets/colored_icon_container.dart'; import 'package:tallee/presentation/widgets/colored_icon_container.dart';
@@ -36,7 +37,10 @@ class SettingsListTile extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: MediaQuery.of(context).size.width * 0.95, width: MediaQuery.of(context).size.width * 0.95,
child: GestureDetector( child: GestureDetector(
onTap: onPressed ?? () {}, onTap: () async {
await HapticFeedback.selectionClick();
onPressed?.call();
},
child: Container( child: Container(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),