онэмашке

This commit is contained in:
Jganenok
2026-05-29 18:38:33 +07:00
parent 0bf02db213
commit d5854e1aa8
5 changed files with 284 additions and 26 deletions
+20
View File
@@ -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<bool> current = ValueNotifier(defaultValue);
static Future<bool> load() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(prefKey) ?? defaultValue;
}
static Future<void> save(bool value) async {
current.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(prefKey, value);
}
}
@@ -26,7 +26,8 @@ import '../../../backend/modules/cloud_storage.dart';
import '../../../backend/modules/folders.dart'; import '../../../backend/modules/folders.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.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 { class _StoriesScrollPhysics extends BouncingScrollPhysics {
final bool Function() blockPositive; final bool Function() blockPositive;
@@ -73,7 +74,7 @@ class ChatListScreen extends StatefulWidget {
enum _DeleteKind { personalLike, ownerGroup, blocked } enum _DeleteKind { personalLike, ownerGroup, blocked }
class _ChatListScreenState extends State<ChatListScreen> class _ChatListScreenState extends State<ChatListScreen>
with TickerProviderStateMixin { with TickerProviderStateMixin, RouteAware {
String? _selectedFolderId; String? _selectedFolderId;
List<ChatFolder> _folders = []; List<ChatFolder> _folders = [];
@@ -103,6 +104,9 @@ class _ChatListScreenState extends State<ChatListScreen>
bool _isFabOpen = false; bool _isFabOpen = false;
bool _storiesAnimClosing = false; bool _storiesAnimClosing = false;
Timer? _contactRebuildTimer; Timer? _contactRebuildTimer;
bool _deferReloads = false;
bool _reloadQueued = false;
Timer? _settleTimer;
bool get _isSelectionMode => _selectedChats.isNotEmpty; bool get _isSelectionMode => _selectedChats.isNotEmpty;
bool? _foldersListKnown; bool? _foldersListKnown;
@@ -447,22 +451,58 @@ class _ChatListScreenState extends State<ChatListScreen>
_sessionState = state; _sessionState = state;
}); });
if (state == SessionState.online) { if (state == SessionState.online) {
_reloadChatsAndFolders(); _requestReload();
} }
} }
}); });
_loginSub = accountModule.loginStatusStream.listen((status) { _loginSub = accountModule.loginStatusStream.listen((status) {
if (status == LoginStatus.success) { if (status == LoginStatus.success) {
_reloadChatsAndFolders(); _requestReload();
} }
}); });
ChatsModule.chatsChanged.addListener(_onChatsChanged); ChatsModule.chatsChanged.addListener(_onChatsChanged);
_reloadChatsAndFolders(); _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() { void _onChatsChanged() {
if (mounted) _reloadChatsAndFolders(); _requestReload();
} }
Future<void> _reloadChatsAndFolders() async { Future<void> _reloadChatsAndFolders() async {
@@ -934,6 +974,8 @@ class _ChatListScreenState extends State<ChatListScreen>
@override @override
void dispose() { void dispose() {
appRouteObserver.unsubscribe(this);
_settleTimer?.cancel();
ChatsModule.chatsChanged.removeListener(_onChatsChanged); ChatsModule.chatsChanged.removeListener(_onChatsChanged);
_loginSub?.cancel(); _loginSub?.cancel();
_stateSub?.cancel(); _stateSub?.cancel();
+156 -21
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show File; import 'dart:io' show File;
import 'dart:math' as math;
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.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_cache_extent.dart';
import '../../../core/config/app_message_actions_style.dart'; import '../../../core/config/app_message_actions_style.dart';
import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_swipe_back_desktop.dart';
import '../../../core/config/app_pranks.dart';
import '../../../models/attachment.dart'; import '../../../models/attachment.dart';
import '../../widgets/message_bubble.dart'; import '../../widgets/message_bubble.dart';
import '../../widgets/theme_reveal.dart';
import '../../widgets/message_actions_overlay.dart'; import '../../widgets/message_actions_overlay.dart';
import '../../widgets/attachment_panel.dart'; import '../../widgets/attachment_panel.dart';
import '../../widgets/swipe_to_pop.dart'; import '../../widgets/swipe_to_pop.dart';
@@ -115,6 +118,14 @@ class _ChatScreenState extends State<ChatScreen>
int _otherStatus = 0; int _otherStatus = 0;
int? _otherSeenTime; int? _otherSeenTime;
int? _participantsCount; int? _participantsCount;
bool _prankActive = false;
String? _prankBubbleId;
final GlobalKey _prankBubbleKey = GlobalKey();
final GlobalKey _prankCaptureKey = GlobalKey();
OverlayEntry? _prankRevealEntry;
AnimationController? _prankRevealController;
ui.Image? _prankRevealImage;
final ValueNotifier<String> _headerStatusNotifier = ValueNotifier(''); final ValueNotifier<String> _headerStatusNotifier = ValueNotifier('');
final ValueNotifier<int> _otherReadTime = ValueNotifier(0); final ValueNotifier<int> _otherReadTime = ValueNotifier(0);
int _tempIdCounter = 0; int _tempIdCounter = 0;
@@ -413,6 +424,7 @@ class _ChatScreenState extends State<ChatScreen>
_typingTimers.clear(); _typingTimers.clear();
_headerStatusNotifier.dispose(); _headerStatusNotifier.dispose();
_otherReadTime.dispose(); _otherReadTime.dispose();
_finishPrankReveal();
_uploadStatus.dispose(); _uploadStatus.dispose();
_attachAnim.dispose(); _attachAnim.dispose();
_messageController.dispose(); _messageController.dispose();
@@ -454,6 +466,102 @@ class _ChatScreenState extends State<ChatScreen>
if (_otherReadTime.value != t) _otherReadTime.value = t; 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) { String? _effectiveStatus(CachedMessage msg) {
if (msg.senderId != _myId) return null; if (msg.senderId != _myId) return null;
if (msg.status == 'sending' || msg.status == 'error') return msg.status; if (msg.status == 'sending' || msg.status == 'error') return msg.status;
@@ -486,6 +594,7 @@ class _ChatScreenState extends State<ChatScreen>
_clearTyping(message.senderId); _clearTyping(message.senderId);
Haptics.tap(); Haptics.tap();
_scrollToBottom(); _scrollToBottom();
_checkPrankTrigger(message);
case MessageEditedEvent(:final message): case MessageEditedEvent(:final message):
final idx = _messages.indexWhere((m) => m.id == message.id); final idx = _messages.indexWhere((m) => m.id == message.id);
if (idx == -1) return; if (idx == -1) return;
@@ -615,28 +724,32 @@ class _ChatScreenState extends State<ChatScreen>
_messages.add(tempMessage); _messages.add(tempMessage);
_messageController.clear(); _messageController.clear();
}); });
unawaited(_persistOutgoing(tempMessage));
// Instant tactile "whoosh" the moment the message leaves the composer, // Instant tactile "whoosh" the moment the message leaves the composer,
// not after the network round-trip — feedback must feel immediate. // not after the network round-trip — feedback must feel immediate.
Haptics.send(); Haptics.send();
_scrollToBottom(); _scrollToBottom();
_checkPrankTrigger(tempMessage);
final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text);
final index = _messages.indexWhere((m) => m.id == tempId); final index = _messages.indexWhere((m) => m.id == tempId);
if (index != -1 && mounted) { 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(() { setState(() {
_messages[index] = CachedMessage( _messages[index] = sent;
id: actualId.isNotEmpty ? actualId : tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: 'sent',
);
}); });
unawaited(_persistOutgoing(sent, removeId: tempId));
} }
if (chat == null) { if (chat == null) {
@@ -652,21 +765,32 @@ class _ChatScreenState extends State<ChatScreen>
Haptics.error(); Haptics.error();
final index = _messages.indexWhere((m) => m.id == tempId); final index = _messages.indexWhere((m) => m.id == tempId);
if (index != -1 && mounted) { if (index != -1 && mounted) {
final failed = CachedMessage(
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: 'error',
);
setState(() { setState(() {
_messages[index] = CachedMessage( _messages[index] = failed;
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: 'error',
);
}); });
unawaited(_persistOutgoing(failed));
} }
} }
} }
Future<void> _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<void> _loadForwardedSenderNames() async { Future<void> _loadForwardedSenderNames() async {
final forwardIds = <int>{}; final forwardIds = <int>{};
for (final msg in _messages) { for (final msg in _messages) {
@@ -879,11 +1003,17 @@ class _ChatScreenState extends State<ChatScreen>
@override @override
Widget build(BuildContext context) { 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: Локализация
// TODO: Cклонения // TODO: Cклонения
return ValueListenableBuilder<bool>( return Theme(
data: theme,
child: RepaintBoundary(
key: _prankCaptureKey,
child: ValueListenableBuilder<bool>(
valueListenable: AppSwipeBackDesktop.current, valueListenable: AppSwipeBackDesktop.current,
builder: (context, desktopSwipe, child) => SwipeToPop( builder: (context, desktopSwipe, child) => SwipeToPop(
enabled: widget.embedded && desktopSwipe, enabled: widget.embedded && desktopSwipe,
@@ -1039,6 +1169,8 @@ class _ChatScreenState extends State<ChatScreen>
], ],
), ),
), ),
),
),
); );
} }
@@ -1114,10 +1246,13 @@ class _ChatScreenState extends State<ChatScreen>
) )
: pressable; : pressable;
return RepaintBoundary( final builtItem = RepaintBoundary(
key: ValueKey('msg_${message.id}'), key: ValueKey('msg_${message.id}'),
child: child, child: child,
); );
return message.id == _prankBubbleId
? KeyedSubtree(key: _prankBubbleKey, child: builtItem)
: builtItem;
}, },
), ),
), ),
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/chats.dart'; import '../../../backend/modules/chats.dart';
import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_swipe_back_desktop.dart';
import '../../../core/config/app_pranks.dart';
import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart'; import '../../../core/protocol/packet.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
@@ -398,6 +399,60 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
), ),
), ),
), ),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: ValueListenableBuilder<bool>(
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( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
+6
View File
@@ -19,6 +19,7 @@ import 'core/config/app_cache_extent.dart';
import 'core/config/app_fonts.dart'; import 'core/config/app_fonts.dart';
import 'core/config/app_message_actions_style.dart'; import 'core/config/app_message_actions_style.dart';
import 'core/config/app_swipe_back_desktop.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_mode.dart';
import 'core/config/app_theme_schedule.dart'; import 'core/config/app_theme_schedule.dart';
import 'backend/modules/account.dart'; import 'backend/modules/account.dart';
@@ -43,6 +44,8 @@ final api = Api();
final accountModule = AccountModule(api); final accountModule = AccountModule(api);
final messagesModule = MessagesModule(api); final messagesModule = MessagesModule(api);
final fileUploader = FileUploader(api: api, messages: messagesModule); final fileUploader = FileUploader(api: api, messages: messagesModule);
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
RouteObserver<PageRoute<dynamic>>();
Future<Locale> _loadInitialLocale() async { Future<Locale> _loadInitialLocale() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -80,6 +83,7 @@ void main() async {
final themeScheduleFuture = AppThemeSchedule.load(); final themeScheduleFuture = AppThemeSchedule.load();
final messageActionsFuture = AppMessageActionsStyle.load(); final messageActionsFuture = AppMessageActionsStyle.load();
final swipeBackFuture = AppSwipeBackDesktop.load(); final swipeBackFuture = AppSwipeBackDesktop.load();
final pranksFuture = AppPranks.load();
await api.connect(); await api.connect();
@@ -111,6 +115,7 @@ void main() async {
AppThemeSchedule.current.value = await themeScheduleFuture; AppThemeSchedule.current.value = await themeScheduleFuture;
AppMessageActionsStyle.current.value = await messageActionsFuture; AppMessageActionsStyle.current.value = await messageActionsFuture;
AppSwipeBackDesktop.current.value = await swipeBackFuture; AppSwipeBackDesktop.current.value = await swipeBackFuture;
AppPranks.current.value = await pranksFuture;
runApp( runApp(
KometApp( KometApp(
initialLocale: initialLocale, initialLocale: initialLocale,
@@ -643,6 +648,7 @@ class KometAppState extends State<KometApp>
theme: _lightTheme, theme: _lightTheme,
darkTheme: _darkTheme, darkTheme: _darkTheme,
navigatorKey: KometApp.navigatorKey, navigatorKey: KometApp.navigatorKey,
navigatorObservers: [appRouteObserver],
builder: (context, child) { builder: (context, child) {
return ValueListenableBuilder<double>( return ValueListenableBuilder<double>(
valueListenable: fontScale, valueListenable: fontScale,