From 02f4cd417b9d8927013bdae6cc2b1a02819666e2 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Mar 2026 17:46:24 -0800 Subject: [PATCH] audit and optimize haptic feedback across the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add selectionClick to settings switches, discrete slider steps, bottom nav tabs, and expandable section headers. Fix intensity levels for cache clear and random color (medium → light). Add lightImpact to thumbnail long-press. Fix inconsistent search result long-press level. Replace blanket lightImpact in updateNotification with targeted haptics at error/warning call sites to eliminate double-firing and match severity. Co-Authored-By: Claude Opus 4.6 --- .../channel/chat/stores/chat_store.dart | 6 +-- lib/screens/home/home.dart | 7 +++- .../home/search/search_results_channels.dart | 2 +- lib/screens/home/stream_list/stream_card.dart | 19 +++++---- lib/screens/settings/other_settings.dart | 2 +- .../widgets/settings_list_slider.dart | 42 +++++++++++++++---- .../widgets/settings_list_switch.dart | 8 +++- lib/widgets/accent_color_picker_dialog.dart | 2 +- lib/widgets/expandable_section_header.dart | 8 +++- 9 files changed, 70 insertions(+), 26 deletions(-) diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 395e1817..3bf996c7 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -570,6 +570,7 @@ abstract class ChatStoreBase with Store { // Show notification with Twitch's rejection message if (parsedIRCMessage.message != null) { + HapticFeedback.heavyImpact(); updateNotification(parsedIRCMessage.message!); } } @@ -1138,6 +1139,7 @@ abstract class ChatStoreBase with Store { // Prevent double-sending while waiting for server acknowledgment if (_isWaitingForAck) { + HapticFeedback.mediumImpact(); updateNotification('Please wait, sending previous message...'); return; } @@ -1173,6 +1175,7 @@ abstract class ChatStoreBase with Store { if (_isWaitingForAck) { _isWaitingForAck = false; toSend = null; + HapticFeedback.heavyImpact(); updateNotification('Message may not have been sent. Please try again.'); } }); @@ -1247,9 +1250,6 @@ abstract class ChatStoreBase with Store { // when copying messages repeatedly. _notificationTimer?.cancel(); - // Provide subtle haptic feedback when notification is triggered - HapticFeedback.lightImpact(); - // Set the new notification message and create a new timer that will dismiss it after 3 seconds. _notification = notificationMessage; _notificationTimer = Timer( diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index f3ecdcaf..696eca3b 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -207,7 +207,12 @@ class _HomeState extends State { ), ], selectedIndex: _homeStore.selectedIndex, - onDestinationSelected: _homeStore.handleTap, + onDestinationSelected: (index) { + if (index != _homeStore.selectedIndex) { + HapticFeedback.selectionClick(); + } + _homeStore.handleTap(index); + }, ), ), ), diff --git a/lib/screens/home/search/search_results_channels.dart b/lib/screens/home/search/search_results_channels.dart index 4bde37a7..53461c80 100644 --- a/lib/screens/home/search/search_results_channels.dart +++ b/lib/screens/home/search/search_results_channels.dart @@ -128,7 +128,7 @@ class _SearchResultsChannelsState extends State { ), ), onLongPress: () { - HapticFeedback.lightImpact(); + HapticFeedback.mediumImpact(); showModalBottomSheetWithProperFocus( context: context, diff --git a/lib/screens/home/stream_list/stream_card.dart b/lib/screens/home/stream_list/stream_card.dart index 50603ced..57c7bac2 100644 --- a/lib/screens/home/stream_list/stream_card.dart +++ b/lib/screens/home/stream_list/stream_card.dart @@ -86,14 +86,17 @@ class StreamCard extends StatelessWidget { alignment: AlignmentDirectional.bottomEnd, children: [ GestureDetector( - onLongPress: () => showDialog( - context: context, - builder: (context) => FrostyPhotoViewDialog( - imageUrl: streamInfo - .thumbnailUrl, // Pass original URL with {width}x{height} placeholder - cacheKey: cacheKey, - ), - ), + onLongPress: () { + HapticFeedback.lightImpact(); + showDialog( + context: context, + builder: (context) => FrostyPhotoViewDialog( + imageUrl: streamInfo + .thumbnailUrl, // Pass original URL with {width}x{height} placeholder + cacheKey: cacheKey, + ), + ); + }, child: thumbnail, ), Container( diff --git a/lib/screens/settings/other_settings.dart b/lib/screens/settings/other_settings.dart index 2244b0fe..5d43b60c 100644 --- a/lib/screens/settings/other_settings.dart +++ b/lib/screens/settings/other_settings.dart @@ -71,7 +71,7 @@ class _OtherSettingsState extends State { leading: const Icon(Icons.delete_outline_rounded), title: const Text('Clear image cache'), onTap: () async { - HapticFeedback.mediumImpact(); + HapticFeedback.lightImpact(); await CustomCacheManager.instance.emptyCache(); await CustomCacheManager.removeOrphanedCacheFiles(); diff --git a/lib/screens/settings/widgets/settings_list_slider.dart b/lib/screens/settings/widgets/settings_list_slider.dart index e720ef8d..cf9979f4 100644 --- a/lib/screens/settings/widgets/settings_list_slider.dart +++ b/lib/screens/settings/widgets/settings_list_slider.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; /// A custom-styled adaptive [Slider]. -class SettingsListSlider extends StatelessWidget { +class SettingsListSlider extends StatefulWidget { final String title; final String trailing; final String? subtitle; @@ -23,15 +24,37 @@ class SettingsListSlider extends StatelessWidget { this.divisions, }); + @override + State createState() => _SettingsListSliderState(); +} + +class _SettingsListSliderState extends State { + int? _previousStep; + + void _handleChanged(double newValue) { + if (widget.divisions != null) { + final step = + ((newValue - widget.min) / + (widget.max - widget.min) * + widget.divisions!) + .round(); + if (_previousStep != null && step != _previousStep) { + HapticFeedback.selectionClick(); + } + _previousStep = step; + } + widget.onChanged?.call(newValue); + } + @override Widget build(BuildContext context) { return ListTile( title: Row( children: [ - Text(title), + Text(widget.title), const Spacer(), Text( - trailing, + widget.trailing, style: const TextStyle( fontFeatures: [FontFeature.tabularFigures()], ), @@ -45,13 +68,14 @@ class SettingsListSlider extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Slider.adaptive( - value: value, - min: min, - max: max, - divisions: divisions, - onChanged: onChanged, + value: widget.value, + min: widget.min, + max: widget.max, + divisions: widget.divisions, + onChanged: + widget.onChanged != null ? _handleChanged : null, ), - if (subtitle != null) Text(subtitle!), + if (widget.subtitle != null) Text(widget.subtitle!), ], ), ), diff --git a/lib/screens/settings/widgets/settings_list_switch.dart b/lib/screens/settings/widgets/settings_list_switch.dart index bcad439f..97669e5b 100644 --- a/lib/screens/settings/widgets/settings_list_switch.dart +++ b/lib/screens/settings/widgets/settings_list_switch.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; /// A custom-styled adaptive [SwitchListTile]. class SettingsListSwitch extends StatelessWidget { @@ -21,7 +22,12 @@ class SettingsListSwitch extends StatelessWidget { title: Text(title), subtitle: subtitle, value: value, - onChanged: onChanged, + onChanged: onChanged != null + ? (newValue) { + HapticFeedback.selectionClick(); + onChanged!(newValue); + } + : null, ); } } diff --git a/lib/widgets/accent_color_picker_dialog.dart b/lib/widgets/accent_color_picker_dialog.dart index 2fab7781..5c97b3ac 100644 --- a/lib/widgets/accent_color_picker_dialog.dart +++ b/lib/widgets/accent_color_picker_dialog.dart @@ -30,7 +30,7 @@ class _AccentColorPickerDialogState extends State { } void _generateRandomColor() { - HapticFeedback.mediumImpact(); + HapticFeedback.lightImpact(); final random = Random(); final newColor = Color.fromARGB( 255, diff --git a/lib/widgets/expandable_section_header.dart b/lib/widgets/expandable_section_header.dart index 12934c8e..64935101 100644 --- a/lib/widgets/expandable_section_header.dart +++ b/lib/widgets/expandable_section_header.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:frosty/utils/context_extensions.dart'; class ExpandableSectionHeader extends StatelessWidget { @@ -24,7 +25,12 @@ class ExpandableSectionHeader extends StatelessWidget { final isLandscape = context.isLandscape; return InkWell( - onTap: onToggle, + onTap: onToggle != null + ? () { + HapticFeedback.selectionClick(); + onToggle!(); + } + : null, child: Padding( padding: padding ??