diff --git a/lib/core/config/app_pranks.dart b/lib/core/config/app_pranks.dart new file mode 100644 index 0000000..033a23a --- /dev/null +++ b/lib/core/config/app_pranks.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppPranks { + static const prefKey = 'dev_pranks'; + static const bool defaultValue = false; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? defaultValue; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 4a3e97a..6790abd 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -26,7 +26,8 @@ import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; -import '../../../main.dart' show accountModule, api, messagesModule; +import '../../../main.dart' + show accountModule, api, messagesModule, appRouteObserver; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -73,7 +74,7 @@ class ChatListScreen extends StatefulWidget { enum _DeleteKind { personalLike, ownerGroup, blocked } class _ChatListScreenState extends State - with TickerProviderStateMixin { + with TickerProviderStateMixin, RouteAware { String? _selectedFolderId; List _folders = []; @@ -103,6 +104,9 @@ class _ChatListScreenState extends State bool _isFabOpen = false; bool _storiesAnimClosing = false; Timer? _contactRebuildTimer; + bool _deferReloads = false; + bool _reloadQueued = false; + Timer? _settleTimer; bool get _isSelectionMode => _selectedChats.isNotEmpty; bool? _foldersListKnown; @@ -447,22 +451,58 @@ class _ChatListScreenState extends State _sessionState = state; }); if (state == SessionState.online) { - _reloadChatsAndFolders(); + _requestReload(); } } }); _loginSub = accountModule.loginStatusStream.listen((status) { if (status == LoginStatus.success) { - _reloadChatsAndFolders(); + _requestReload(); } }); ChatsModule.chatsChanged.addListener(_onChatsChanged); _reloadChatsAndFolders(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is PageRoute) { + appRouteObserver.subscribe(this, route); + } + } + + @override + void didPushNext() { + _deferReloads = true; + } + + @override + void didPopNext() { + _settleTimer?.cancel(); + _settleTimer = Timer(const Duration(milliseconds: 420), () { + if (!mounted) return; + _deferReloads = false; + if (_reloadQueued) { + _reloadQueued = false; + _reloadChatsAndFolders(); + } + }); + } + + void _requestReload() { + if (!mounted) return; + if (_deferReloads) { + _reloadQueued = true; + return; + } + _reloadChatsAndFolders(); + } + void _onChatsChanged() { - if (mounted) _reloadChatsAndFolders(); + _requestReload(); } Future _reloadChatsAndFolders() async { @@ -934,6 +974,8 @@ class _ChatListScreenState extends State @override void dispose() { + appRouteObserver.unsubscribe(this); + _settleTimer?.cancel(); ChatsModule.chatsChanged.removeListener(_onChatsChanged); _loginSub?.cancel(); _stateSub?.cancel(); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 31ddbef..41e0cbe 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io' show File; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; @@ -22,8 +23,10 @@ import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_message_actions_style.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; +import '../../../core/config/app_pranks.dart'; import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; +import '../../widgets/theme_reveal.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; import '../../widgets/swipe_to_pop.dart'; @@ -115,6 +118,14 @@ class _ChatScreenState extends State int _otherStatus = 0; int? _otherSeenTime; int? _participantsCount; + + bool _prankActive = false; + String? _prankBubbleId; + final GlobalKey _prankBubbleKey = GlobalKey(); + final GlobalKey _prankCaptureKey = GlobalKey(); + OverlayEntry? _prankRevealEntry; + AnimationController? _prankRevealController; + ui.Image? _prankRevealImage; final ValueNotifier _headerStatusNotifier = ValueNotifier(''); final ValueNotifier _otherReadTime = ValueNotifier(0); int _tempIdCounter = 0; @@ -413,6 +424,7 @@ class _ChatScreenState extends State _typingTimers.clear(); _headerStatusNotifier.dispose(); _otherReadTime.dispose(); + _finishPrankReveal(); _uploadStatus.dispose(); _attachAnim.dispose(); _messageController.dispose(); @@ -454,6 +466,102 @@ class _ChatScreenState extends State if (_otherReadTime.value != t) _otherReadTime.value = t; } + void _checkPrankTrigger(CachedMessage msg) { + if (!AppPranks.current.value || _prankActive || _prankBubbleId != null) { + return; + } + if ((msg.text ?? '').trim().toUpperCase() != 'THE WORLD') return; + setState(() => _prankBubbleId = msg.id); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _runPrankReveal(); + }); + } + + ThemeData _prankPinkTheme(ThemeData base) { + final cs = base.colorScheme; + return base.copyWith( + scaffoldBackgroundColor: const Color(0xFFFFF0F5), + colorScheme: cs.copyWith( + surface: const Color(0xFFFFF0F5), + surfaceContainerHigh: const Color(0xFFFFE3EC), + surfaceContainerHighest: const Color(0xFFFFD9E6), + primary: const Color(0xFFE8579A), + primaryContainer: const Color(0xFFFFD6E5), + onPrimaryContainer: const Color(0xFF7A1F4B), + ), + ); + } + + void _runPrankReveal() { + if (_prankActive) return; + final overlay = Navigator.of(context).overlay; + final captureCtx = _prankCaptureKey.currentContext; + final renderObject = captureCtx?.findRenderObject(); + if (overlay == null || renderObject is! RenderRepaintBoundary) { + setState(() => _prankActive = true); + return; + } + + Offset center; + final bubbleBox = + _prankBubbleKey.currentContext?.findRenderObject() as RenderBox?; + if (bubbleBox != null && bubbleBox.attached) { + center = bubbleBox.localToGlobal(bubbleBox.size.center(Offset.zero)); + } else { + final size = MediaQuery.sizeOf(context); + center = Offset(size.width / 2, size.height / 2); + } + + final ui.Image snapshot; + try { + final dpr = math.min(MediaQuery.of(context).devicePixelRatio, 2.0); + snapshot = renderObject.toImageSync(pixelRatio: dpr); + } catch (_) { + setState(() => _prankActive = true); + return; + } + + _finishPrankReveal(); + + final controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 650), + ); + final entry = ThemeRevealOverlay.build( + snapshot: snapshot, + center: center, + animation: controller, + ); + + _prankRevealController = controller; + _prankRevealEntry = entry; + _prankRevealImage = snapshot; + + overlay.insert(entry); + setState(() => _prankActive = true); + Haptics.success(); + + WidgetsBinding.instance.endOfFrame.then((_) { + if (_prankRevealController != controller) return; + controller.forward().then((_) { + if (_prankRevealController != controller) return; + _finishPrankReveal(); + }, onError: (_) {}); + }); + } + + void _finishPrankReveal() { + _prankRevealEntry?.remove(); + _prankRevealEntry = null; + _prankRevealController?.dispose(); + _prankRevealController = null; + final img = _prankRevealImage; + _prankRevealImage = null; + if (img != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose()); + } + } + String? _effectiveStatus(CachedMessage msg) { if (msg.senderId != _myId) return null; if (msg.status == 'sending' || msg.status == 'error') return msg.status; @@ -486,6 +594,7 @@ class _ChatScreenState extends State _clearTyping(message.senderId); Haptics.tap(); _scrollToBottom(); + _checkPrankTrigger(message); case MessageEditedEvent(:final message): final idx = _messages.indexWhere((m) => m.id == message.id); if (idx == -1) return; @@ -615,28 +724,32 @@ class _ChatScreenState extends State _messages.add(tempMessage); _messageController.clear(); }); + unawaited(_persistOutgoing(tempMessage)); // Instant tactile "whoosh" the moment the message leaves the composer, // not after the network round-trip — feedback must feel immediate. Haptics.send(); _scrollToBottom(); + _checkPrankTrigger(tempMessage); final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { + final sent = CachedMessage( + id: actualId.isNotEmpty ? actualId : tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'sent', + ); setState(() { - _messages[index] = CachedMessage( - id: actualId.isNotEmpty ? actualId : tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - text: text, - time: now, - status: 'sent', - ); + _messages[index] = sent; }); + unawaited(_persistOutgoing(sent, removeId: tempId)); } if (chat == null) { @@ -652,21 +765,32 @@ class _ChatScreenState extends State Haptics.error(); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { + final failed = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'error', + ); setState(() { - _messages[index] = CachedMessage( - id: tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - text: text, - time: now, - status: 'error', - ); + _messages[index] = failed; }); + unawaited(_persistOutgoing(failed)); } } } + Future _persistOutgoing(CachedMessage msg, {String? removeId}) async { + try { + if (removeId != null && removeId != msg.id) { + await AppDatabase.deleteMessage(_myId, widget.chatId, removeId); + } + await AppDatabase.saveMessages([msg.toDbRow()]); + } catch (_) {} + } + Future _loadForwardedSenderNames() async { final forwardIds = {}; for (final msg in _messages) { @@ -879,11 +1003,17 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final theme = + _prankActive ? _prankPinkTheme(Theme.of(context)) : Theme.of(context); + final cs = theme.colorScheme; // TODO: Локализация // TODO: Cклонения - return ValueListenableBuilder( + return Theme( + data: theme, + child: RepaintBoundary( + key: _prankCaptureKey, + child: ValueListenableBuilder( valueListenable: AppSwipeBackDesktop.current, builder: (context, desktopSwipe, child) => SwipeToPop( enabled: widget.embedded && desktopSwipe, @@ -1039,6 +1169,8 @@ class _ChatScreenState extends State ], ), ), + ), + ), ); } @@ -1114,10 +1246,13 @@ class _ChatScreenState extends State ) : pressable; - return RepaintBoundary( + final builtItem = RepaintBoundary( key: ValueKey('msg_${message.id}'), child: child, ); + return message.id == _prankBubbleId + ? KeyedSubtree(key: _prankBubbleKey, child: builtItem) + : builtItem; }, ), ), diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 3d409a7..0fcfd9e 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; +import '../../../core/config/app_pranks.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; @@ -398,6 +399,60 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppPranks.current, + builder: (context, pranksOn, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.auto_awesome, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Приколь4ики', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Switch( + value: pranksOn, + onChanged: (v) { + AppPranks.save(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/main.dart b/lib/main.dart index 529d616..9f611f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; import 'core/config/app_message_actions_style.dart'; import 'core/config/app_swipe_back_desktop.dart'; +import 'core/config/app_pranks.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; @@ -43,6 +44,8 @@ final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); final fileUploader = FileUploader(api: api, messages: messagesModule); +final RouteObserver> appRouteObserver = + RouteObserver>(); Future _loadInitialLocale() async { final prefs = await SharedPreferences.getInstance(); @@ -80,6 +83,7 @@ void main() async { final themeScheduleFuture = AppThemeSchedule.load(); final messageActionsFuture = AppMessageActionsStyle.load(); final swipeBackFuture = AppSwipeBackDesktop.load(); + final pranksFuture = AppPranks.load(); await api.connect(); @@ -111,6 +115,7 @@ void main() async { AppThemeSchedule.current.value = await themeScheduleFuture; AppMessageActionsStyle.current.value = await messageActionsFuture; AppSwipeBackDesktop.current.value = await swipeBackFuture; + AppPranks.current.value = await pranksFuture; runApp( KometApp( initialLocale: initialLocale, @@ -643,6 +648,7 @@ class KometAppState extends State theme: _lightTheme, darkTheme: _darkTheme, navigatorKey: KometApp.navigatorKey, + navigatorObservers: [appRouteObserver], builder: (context, child) { return ValueListenableBuilder( valueListenable: fontScale,