audit and optimize haptic feedback across the app

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 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-03-07 17:46:24 -08:00
parent 21cb79d2c2
commit 02f4cd417b
9 changed files with 70 additions and 26 deletions

View file

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

View file

@ -207,7 +207,12 @@ class _HomeState extends State<Home> {
),
],
selectedIndex: _homeStore.selectedIndex,
onDestinationSelected: _homeStore.handleTap,
onDestinationSelected: (index) {
if (index != _homeStore.selectedIndex) {
HapticFeedback.selectionClick();
}
_homeStore.handleTap(index);
},
),
),
),

View file

@ -128,7 +128,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
),
),
onLongPress: () {
HapticFeedback.lightImpact();
HapticFeedback.mediumImpact();
showModalBottomSheetWithProperFocus(
context: context,

View file

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

View file

@ -71,7 +71,7 @@ class _OtherSettingsState extends State<OtherSettings> {
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();

View file

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

View file

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

View file

@ -30,7 +30,7 @@ class _AccentColorPickerDialogState extends State<AccentColorPickerDialog> {
}
void _generateRandomColor() {
HapticFeedback.mediumImpact();
HapticFeedback.lightImpact();
final random = Random();
final newColor = Color.fromARGB(
255,

View file

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