From 5db3b2d13569a30fba4035d11d3cbbf19182d2eb Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 15 May 2026 07:26:27 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=82=D0=B0=D0=BA=D1=82=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BE=D1=82=D0=B4=D0=B0=D1=87=D0=B0?= =?UTF-8?q?=20+=20=D1=82=D1=83=D0=BC=D0=B1=D0=BB=D0=B5=D1=80=20=D0=B2=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/utils/haptics.dart | 83 +++++++++++++++++++ .../screens/chats/chat_list_screen.dart | 5 ++ lib/frontend/screens/chats/chat_screen.dart | 6 ++ .../screens/profile/settings_tab.dart | 52 ++++++++++-- lib/frontend/widgets/message_bubble.dart | 3 +- lib/main.dart | 3 + 6 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 lib/core/utils/haptics.dart diff --git a/lib/core/utils/haptics.dart b/lib/core/utils/haptics.dart new file mode 100644 index 0000000..b21328d --- /dev/null +++ b/lib/core/utils/haptics.dart @@ -0,0 +1,83 @@ +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Centralized tactile feedback for Komet. +/// +/// Wraps Flutter's [HapticFeedback] so the whole app speaks one tactile +/// "language": the same gesture always feels the same. Composite patterns +/// chain impacts with short delays to produce richer, more memorable +/// sensations than a single buzz. +/// +/// Every call is best-effort and silent on failure — a device without a +/// vibrator (or with system haptics disabled) must never crash the UI. +class Haptics { + Haptics._(); + + static const String _prefKey = 'haptics_enabled'; + + /// Master switch. Silences every haptic app-wide when `false`. + /// Controlled by the user via Settings; persisted across launches. + static bool enabled = true; + + /// Restores the saved preference. Call once during app startup, + /// before the first frame. Defaults to enabled when never set. + static Future load() async { + try { + final prefs = await SharedPreferences.getInstance(); + enabled = prefs.getBool(_prefKey) ?? true; + } catch (_) { + enabled = true; + } + } + + /// Updates the master switch and persists it. + static Future setEnabled(bool value) async { + enabled = value; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefKey, value); + } catch (_) { + // Persistence is best-effort; the in-memory switch still applies. + } + } + + static Future _fire(Future Function() effect) async { + if (!enabled) return; + try { + await effect(); + } catch (_) { + // Intentionally swallowed: haptics are a nicety, never a hard dependency. + } + } + + /// A crisp, light tick — taps, toggles, opening panels. + static Future tap() => _fire(HapticFeedback.lightImpact); + + /// A firmer press — confirmations, entering a mode. + static Future medium() => _fire(HapticFeedback.mediumImpact); + + /// A strong thud — destructive or weighty actions. + static Future heavy() => _fire(HapticFeedback.heavyImpact); + + /// The subtle detent of moving between discrete options — tabs, selection. + static Future selection() => _fire(HapticFeedback.selectionClick); + + /// Message sent: a quick, instant tick (the "whoosh"). + static Future send() => tap(); + + /// A two-beat rising pulse — success, completion, "it landed". + static Future success() async { + if (!enabled) return; + await _fire(HapticFeedback.lightImpact); + await Future.delayed(const Duration(milliseconds: 90)); + await _fire(HapticFeedback.mediumImpact); + } + + /// A double thud — errors, rejected or failed actions. + static Future error() async { + if (!enabled) return; + await _fire(HapticFeedback.heavyImpact); + await Future.delayed(const Duration(milliseconds: 120)); + await _fire(HapticFeedback.heavyImpact); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 57eb64e..2de522b 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -12,6 +12,7 @@ import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; import '../profile/settings_tab.dart'; import '../../../backend/api.dart'; +import '../../../core/utils/haptics.dart'; import '../../../backend/models/chat_folder.dart'; import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; @@ -139,6 +140,7 @@ class _ChatListScreenState extends State } void _toggleSelection(String chatId) { + Haptics.selection(); setState(() { if (_selectedChats.contains(chatId)) { _selectedChats.remove(chatId); @@ -729,6 +731,8 @@ class _ChatListScreenState extends State if (index == _currentNavIndex && !_navPageAnimController.isAnimating) { return; } + // Detent "click" when crossing into a different tab. + Haptics.selection(); double fromT; if (_navPageAnimController.isAnimating) { final t = Curves.easeOutCubic.transform(_navPageAnimController.value); @@ -743,6 +747,7 @@ class _ChatListScreenState extends State } void _toggleFab() { + Haptics.tap(); setState(() { _isFabOpen = !_isFabOpen; if (_isFabOpen) { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 655506a..068939c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -8,6 +8,7 @@ import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/haptics.dart'; import '../../../models/attachment.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../widgets/message_bubble.dart'; @@ -153,6 +154,10 @@ class _ChatScreenState extends State _hasText = false; }); + // Instant tactile "whoosh" the moment the message leaves the composer, + // not after the network round-trip — feedback must feel immediate. + Haptics.send(); + _scrollToBottom(); await messagesModule.sendMessage(_myId, widget.chatId, text); @@ -173,6 +178,7 @@ class _ChatScreenState extends State } } catch (e) { debugPrint('Error sending message: $e'); + Haptics.error(); } finally { setState(() { _isSending = false; diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index f38811b..313abef 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../auth/proxy_settings_sheet.dart'; @@ -30,6 +31,7 @@ class _SettingsTabState extends State { int _versionSecretTapCount = 0; Timer? _versionSecretTapResetTimer; StreamSubscription? _profileUpdateSub; + bool _hapticsEnabled = Haptics.enabled; @override void initState() { @@ -83,6 +85,13 @@ class _SettingsTabState extends State { }); } + Future _setHaptics(bool value) async { + await Haptics.setEnabled(value); + // Let the user *feel* the confirmation the instant they switch it on. + if (value) Haptics.success(); + if (mounted) setState(() => _hapticsEnabled = value); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -144,6 +153,12 @@ child: _buildSection( icon: Symbols.notifications_active, label: 'Уведомления и звук', ), + _SettingsItem( + icon: Symbols.vibration, + label: 'Тактильная отдача', + toggleValue: _hapticsEnabled, + onToggle: _setHaptics, + ), _SettingsItem( icon: Symbols.vpn_lock, label: 'Прокси', @@ -450,7 +465,9 @@ child: _buildSection( Material( color: Colors.transparent, child: InkWell( - onTap: item.onTap ?? () {}, + onTap: item.isToggle + ? () => item.onToggle!(!(item.toggleValue ?? false)) + : (item.onTap ?? () {}), borderRadius: isLast ? const BorderRadius.vertical(bottom: Radius.circular(20)) : null, @@ -475,12 +492,18 @@ child: _buildSection( ), ), ), - Icon( - Symbols.chevron_right, - color: cs.outline, - size: 20, - weight: 400, - ), + if (item.isToggle) + Switch.adaptive( + value: item.toggleValue ?? false, + onChanged: item.onToggle, + ) + else + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), ], ), ), @@ -505,7 +528,20 @@ class _SettingsItem { final String label; final VoidCallback? onTap; - const _SettingsItem({required this.icon, required this.label, this.onTap}); + /// When [onToggle] is set the row renders a trailing switch instead of a + /// chevron, and [toggleValue] reflects its current state. + final bool? toggleValue; + final ValueChanged? onToggle; + + const _SettingsItem({ + required this.icon, + required this.label, + this.onTap, + this.toggleValue, + this.onToggle, + }); + + bool get isToggle => onToggle != null; } class _PhoneSpoiler extends StatefulWidget { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index a945ccc..fdbe793 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -6,6 +6,7 @@ import 'package:komet/main.dart'; import 'package:flutter/foundation.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; +import '../../core/utils/haptics.dart'; import '../../models/attachment.dart'; enum MessageType { text, attachment, voice, control } @@ -329,7 +330,7 @@ class MessageBubble extends StatelessWidget { return GestureDetector( // TODO: действия с сообщением - onTap: () => print("test"), + onTap: () => Haptics.tap(), child: Padding( padding: EdgeInsets.only( left: isMe ? 12 : 12, diff --git a/lib/main.dart b/lib/main.dart index 37f1354..e78be9a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'backend/modules/contacts.dart'; import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; +import 'core/utils/haptics.dart'; import 'core/protocol/packet.dart'; import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; @@ -44,6 +45,8 @@ void main() async { await api.connect(); final initialLocale = await _loadInitialLocale(); + await Haptics.load(); + final prefs = await SharedPreferences.getInstance(); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; runApp(