From fc7a225b8af68e0da1fc68b57af06f7f45fe2025 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sat, 16 May 2026 21:10:21 +0700 Subject: [PATCH] =?UTF-8?q?=D0=BD=D1=83=20=D0=BD=D0=B5=D0=BC=D0=BD=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D1=87=D0=BA=D0=BE=20=D1=82=D0=B0=D0=BA=20=D0=BE?= =?UTF-8?q?=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BB=20=D1=87=D1=83=D1=82=D0=BA=D0=B0,=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=20'=D0=BF=D1=80=D0=BE=D0=B8=D0=B7=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C'?= =?UTF-8?q?=20=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82=D0=BE=D1=80=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8,=20=D0=BD=D0=B5=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20=D1=87=D1=83=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D1=83=20=D0=B2=20=D0=BA=D0=B0=D1=81=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D1=83=20=D0=BC=D0=B5=D0=BB=D0=BE=D1=87=D0=B5=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 7 +- lib/core/config/app_accent.dart | 34 + lib/core/config/app_bubble_shape.dart | 37 + lib/core/config/app_cache_extent.dart | 33 + lib/core/utils/serializer.dart | 0 .../screens/auth/register_screen.dart | 0 lib/frontend/screens/calls/call_screen.dart | 0 lib/frontend/screens/calls/calls_tab.dart | 2 + .../screens/chats/chat_info_screen.dart | 6 +- .../screens/chats/chat_list_screen.dart | 288 +++-- lib/frontend/screens/chats/chat_screen.dart | 266 ++-- .../screens/contacts/contacts_tab.dart | 2 + .../screens/profile/appearance_screen.dart | 526 ++++++++ .../screens/profile/customization_screen.dart | 10 + .../screens/profile/performance_screen.dart | 182 +++ .../screens/profile/profile_screen.dart | 0 .../screens/profile/settings_tab.dart | 15 + lib/frontend/widgets/avatar.dart | 0 lib/frontend/widgets/call_controls.dart | 0 lib/frontend/widgets/chat_tile.dart | 0 lib/frontend/widgets/message_bubble.dart | 1143 ++++++++--------- lib/main.dart | 147 ++- lib/models/call.dart | 0 lib/models/chat.dart | 0 lib/models/message.dart | 0 lib/models/session.dart | 0 lib/models/user.dart | 0 lib/state/account_state.dart | 0 lib/state/call_state.dart | 0 lib/state/chat_state.dart | 0 lib/state/message_state.dart | 0 lib/storage/cache.dart | 0 lib/storage/local_storage.dart | 0 pubspec.lock | 60 +- pubspec.yaml | 4 - 35 files changed, 1818 insertions(+), 944 deletions(-) create mode 100644 lib/core/config/app_accent.dart create mode 100644 lib/core/config/app_bubble_shape.dart create mode 100644 lib/core/config/app_cache_extent.dart delete mode 100644 lib/core/utils/serializer.dart delete mode 100644 lib/frontend/screens/auth/register_screen.dart delete mode 100644 lib/frontend/screens/calls/call_screen.dart create mode 100644 lib/frontend/screens/profile/appearance_screen.dart create mode 100644 lib/frontend/screens/profile/performance_screen.dart delete mode 100644 lib/frontend/screens/profile/profile_screen.dart delete mode 100644 lib/frontend/widgets/avatar.dart delete mode 100644 lib/frontend/widgets/call_controls.dart delete mode 100644 lib/frontend/widgets/chat_tile.dart delete mode 100644 lib/models/call.dart delete mode 100644 lib/models/chat.dart delete mode 100644 lib/models/message.dart delete mode 100644 lib/models/session.dart delete mode 100644 lib/models/user.dart delete mode 100644 lib/state/account_state.dart delete mode 100644 lib/state/call_state.dart delete mode 100644 lib/state/chat_state.dart delete mode 100644 lib/state/message_state.dart delete mode 100644 lib/storage/cache.dart delete mode 100644 lib/storage/local_storage.dart diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index e2251d2..44bf89b 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -29,6 +29,7 @@ class CachedChat { final int? lastMsgId; final int? lastMsgTime; final String? lastMsgText; + final String? lastMsgTextOneLine; final int? lastMsgSenderId; final int unreadCount; final int lastEventTime; @@ -40,7 +41,7 @@ class CachedChat { final Map participants; final Set options; - const CachedChat({ + CachedChat({ required this.id, required this.accountId, required this.type, @@ -59,7 +60,9 @@ class CachedChat { required this.seenTime, required this.participants, this.options = const {}, - }); + }) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n') + ? lastMsgText.replaceAll('\n', ' ') + : lastMsgText; bool get isOfficial => options.contains('OFFICIAL'); diff --git a/lib/core/config/app_accent.dart b/lib/core/config/app_accent.dart new file mode 100644 index 0000000..a8bd16f --- /dev/null +++ b/lib/core/config/app_accent.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppAccent { + static const prefKey = 'app_accent_seed'; + + static const List<({String label, Color? seed})> presets = [ + (label: 'Системный', seed: null), + (label: 'Сиреневый', seed: Color(0xFFC1C4FF)), + (label: 'Синий', seed: Color(0xFF4F8EFF)), + (label: 'Бирюзовый', seed: Color(0xFF00BFA5)), + (label: 'Зелёный', seed: Color(0xFF43A047)), + (label: 'Янтарный', seed: Color(0xFFFFB300)), + (label: 'Розовый', seed: Color(0xFFE91E63)), + (label: 'Красный', seed: Color(0xFFE53935)), + (label: 'Фиолетовый', seed: Color(0xFF7E57C2)), + ]; + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final val = prefs.getInt(prefKey); + if (val == null) return null; + return Color(val); + } + + static Future save(Color? color) async { + final prefs = await SharedPreferences.getInstance(); + if (color == null) { + await prefs.remove(prefKey); + } else { + await prefs.setInt(prefKey, color.toARGB32()); + } + } +} diff --git a/lib/core/config/app_bubble_shape.dart b/lib/core/config/app_bubble_shape.dart new file mode 100644 index 0000000..9a29c58 --- /dev/null +++ b/lib/core/config/app_bubble_shape.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum BubbleStyle { mobile, desktop } + +class AppBubbleShape { + static const prefKey = 'app_bubble_shape'; + static final ValueNotifier current = ValueNotifier( + BubbleStyle.mobile, + ); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final val = prefs.getString(prefKey); + return _parse(val); + } + + static Future save(BubbleStyle style) async { + current.value = style; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, style.name); + } + + static BubbleStyle _parse(String? val) { + if (val == BubbleStyle.desktop.name) return BubbleStyle.desktop; + return BubbleStyle.mobile; + } + + static String label(BubbleStyle style) { + switch (style) { + case BubbleStyle.mobile: + return 'TG Mobile'; + case BubbleStyle.desktop: + return 'TG Desktop'; + } + } +} diff --git a/lib/core/config/app_cache_extent.dart b/lib/core/config/app_cache_extent.dart new file mode 100644 index 0000000..5aeef36 --- /dev/null +++ b/lib/core/config/app_cache_extent.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppCacheExtent { + static const prefKey = 'app_cache_extent'; + static const double defaultValue = 5000; + static const double min = 1000; + static const double max = 10000; + static const double lowWarnThreshold = 2500; + static const double highWarnThreshold = 7000; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getDouble(prefKey); + if (raw == null) return defaultValue; + return clamp(raw); + } + + static Future save(double value) async { + final clamped = clamp(value); + current.value = clamped; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(prefKey, clamped); + } + + static double clamp(double v) { + if (v < min) return min; + if (v > max) return max; + return v; + } +} diff --git a/lib/core/utils/serializer.dart b/lib/core/utils/serializer.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/frontend/screens/auth/register_screen.dart b/lib/frontend/screens/auth/register_screen.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 164f175..e5f920b 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -170,6 +170,8 @@ class _CallsTabState extends State { ? CachedNetworkImage( imageUrl: call.avatarUrl!, fit: BoxFit.cover, + memCacheWidth: 144, + memCacheHeight: 144, fadeInDuration: const Duration(milliseconds: 120), errorWidget: (context, url, error) => _buildPlaceholderAvatar(cs, call.name), diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 5d3857d..7e93b79 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -299,6 +299,8 @@ class _ChatInfoScreenState extends State { child: CachedNetworkImage( imageUrl: widget.imageUrl, fit: BoxFit.cover, + memCacheWidth: 360, + memCacheHeight: 360, errorWidget: (context, error, stack) => _avatarLetters(cs), ), ) @@ -394,7 +396,7 @@ class _ChatInfoScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: const Color(0xFF007AFF), size: 22), + Icon(icon, color: cs.primary, size: 22), const SizedBox(height: 4), Text( label, @@ -806,7 +808,7 @@ class _ChatInfoScreenState extends State { (avatar != null && avatar.isNotEmpty) ? CircleAvatar( radius: 22, - backgroundImage: CachedNetworkImageProvider(avatar), + backgroundImage: CachedNetworkImageProvider(avatar, maxWidth: 144, maxHeight: 144), backgroundColor: cs.primaryContainer, ) : CircleAvatar( diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index b58e577..ab2714d 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -74,16 +74,24 @@ class _ChatListScreenState extends State double _navDragBaseLeft = 0; double _revealAnimBegin = 0.0; double _closeAnimBegin = 0.0; - double _pullRatio = 0.0; static const double _kStoriesPullTriggerPx = 16.0; + final _StoriesUi _storiesUi = _StoriesUi(); + double get _pullRatio => _storiesUi.pullRatio; + set _pullRatio(double v) => _storiesUi.pullRatio = v; + bool get _storiesDockedOpen => _storiesUi.dockedOpen; + set _storiesDockedOpen(bool v) => _storiesUi.dockedOpen = v; + bool get _storiesOverscrollRevealArmed => _storiesUi.overscrollRevealArmed; + set _storiesOverscrollRevealArmed(bool v) => + _storiesUi.overscrollRevealArmed = v; + bool get _shouldCollapseSearch => _storiesUi.shouldCollapseSearch; + set _shouldCollapseSearch(bool v) => _storiesUi.shouldCollapseSearch = v; + bool _navDragging = false; bool _isFabOpen = false; bool _showCacheWarning = false; bool _storiesAnimClosing = false; - bool _storiesDockedOpen = false; - bool _storiesOverscrollRevealArmed = true; - bool _shouldCollapseSearch = false; + Timer? _contactRebuildTimer; bool get _isSelectionMode => _selectedChats.isNotEmpty; bool? _foldersListKnown; @@ -370,11 +378,19 @@ class _ChatListScreenState extends State for (final id in ids) { messagesModule.searchContactById(id).whenComplete(() { _inflightContactIds.remove(id); - if (mounted) setState(() {}); + _scheduleContactRebuild(); }); } } + void _scheduleContactRebuild() { + if (!mounted) return; + _contactRebuildTimer?.cancel(); + _contactRebuildTimer = Timer(const Duration(milliseconds: 120), () { + if (mounted) setState(() {}); + }); + } + List _chatsForPageIndex(int pageIndex) { List base; if (_folders.isEmpty) { @@ -436,9 +452,8 @@ class _ChatListScreenState extends State if (!c.hasClients) return; final double offset = c.offset; if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) { - setState(() { - _shouldCollapseSearch = true; - }); + _shouldCollapseSearch = true; + _storiesUi.notify(); } if (offset < 0) { @@ -453,9 +468,8 @@ class _ChatListScreenState extends State _startStoriesAutoReveal(dragRatio); } else if (!_storiesDockedOpen) { if (dragRatio != _pullRatio) { - setState(() { - _pullRatio = dragRatio; - }); + _pullRatio = dragRatio; + _storiesUi.notify(); } } } else { @@ -470,14 +484,9 @@ class _ChatListScreenState extends State final disarm = offset > 3 && _storiesOverscrollRevealArmed; final clearPull = _pullRatio > 0; if (disarm || clearPull) { - setState(() { - if (disarm) { - _storiesOverscrollRevealArmed = false; - } - if (clearPull) { - _pullRatio = 0.0; - } - }); + if (disarm) _storiesOverscrollRevealArmed = false; + if (clearPull) _pullRatio = 0.0; + _storiesUi.notify(); } } } @@ -518,85 +527,81 @@ class _ChatListScreenState extends State animation: _shimmerController, builder: (context, child) { final opacity = 0.3 + 0.3 * sin(_shimmerController.value * pi * 2); - return Opacity( - opacity: opacity, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - Expanded( - child: SizedBox( - height: 48, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 120, - height: 14, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(7), - ), - ), - Container( - width: double.infinity, - height: 12, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); + return Opacity(opacity: opacity, child: child); }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 120, + height: 14, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(7), + ), + ), + Container( + width: double.infinity, + height: 12, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ), + ), + ], + ), + ), ); } void _onStoriesRevealTick() { if (!mounted) return; final t = Curves.easeOutCubic.transform(_storiesRevealController.value); - setState(() { - if (_storiesAnimClosing) { - _pullRatio = _closeAnimBegin * (1.0 - t); - } else { - _pullRatio = _revealAnimBegin + (1.0 - _revealAnimBegin) * t; - } - }); + if (_storiesAnimClosing) { + _pullRatio = _closeAnimBegin * (1.0 - t); + } else { + _pullRatio = _revealAnimBegin + (1.0 - _revealAnimBegin) * t; + } + _storiesUi.notify(); } void _onStoriesRevealStatus(AnimationStatus status) { if (!mounted) return; if (status == AnimationStatus.completed) { - setState(() { - if (_storiesAnimClosing) { - _pullRatio = 0.0; - _storiesDockedOpen = false; - _storiesAnimClosing = false; - _storiesOverscrollRevealArmed = true; - } else { - _pullRatio = 1.0; - _storiesDockedOpen = true; - _storiesRevealLayoutSettleUntil = DateTime.now().add( - const Duration(milliseconds: 520), - ); - } - }); + if (_storiesAnimClosing) { + _pullRatio = 0.0; + _storiesDockedOpen = false; + _storiesAnimClosing = false; + _storiesOverscrollRevealArmed = true; + } else { + _pullRatio = 1.0; + _storiesDockedOpen = true; + _storiesRevealLayoutSettleUntil = DateTime.now().add( + const Duration(milliseconds: 520), + ); + } + _storiesUi.notify(); } } @@ -607,10 +612,9 @@ class _ChatListScreenState extends State _storiesAnimClosing = false; final from = max(_pullRatio, suggestedFrom.clamp(0.0, 1.0)); if (from >= 1.0) { - setState(() { - _pullRatio = 1.0; - _storiesDockedOpen = true; - }); + _pullRatio = 1.0; + _storiesDockedOpen = true; + _storiesUi.notify(); _storiesRevealLayoutSettleUntil = DateTime.now().add( const Duration(milliseconds: 520), ); @@ -638,12 +642,11 @@ class _ChatListScreenState extends State _storiesAnimClosing = true; final from = _pullRatio.clamp(0.0, 1.0); if (from <= 0) { - setState(() { - _pullRatio = 0.0; - _storiesDockedOpen = false; - _storiesAnimClosing = false; - _storiesOverscrollRevealArmed = true; - }); + _pullRatio = 0.0; + _storiesDockedOpen = false; + _storiesAnimClosing = false; + _storiesOverscrollRevealArmed = true; + _storiesUi.notify(); return; } _closeAnimBegin = from; @@ -659,9 +662,8 @@ class _ChatListScreenState extends State if (n is ScrollEndNotification) { if (n.metrics.pixels <= 0.5) { - setState(() { - _storiesOverscrollRevealArmed = true; - }); + _storiesOverscrollRevealArmed = true; + _storiesUi.notify(); } return false; } @@ -714,6 +716,8 @@ class _ChatListScreenState extends State c.removeListener(fn); c.dispose(); } + _contactRebuildTimer?.cancel(); + _storiesUi.dispose(); super.dispose(); } @@ -774,15 +778,17 @@ class _ChatListScreenState extends State mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ClipRect( - clipBehavior: Clip.hardEdge, - child: AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: _shouldCollapseSearch - ? const SizedBox(width: double.infinity, height: 52) - : Column( + ListenableBuilder( + listenable: _storiesUi, + builder: (context, _) => ClipRect( + clipBehavior: Clip.hardEdge, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _shouldCollapseSearch + ? const SizedBox(width: double.infinity, height: 52) + : Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -933,7 +939,7 @@ class _ChatListScreenState extends State ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 3, 20, 14), + padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), child: Container( height: 44, decoration: BoxDecoration( @@ -974,13 +980,14 @@ class _ChatListScreenState extends State ), ], ), + ), ), ), if (_folders.length > 1) AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, - height: 48, + height: 34, color: cs.surface, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( @@ -1007,7 +1014,7 @@ class _ChatListScreenState extends State scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: 20, - vertical: 4, + vertical: 2, ), physics: const BouncingScrollPhysics(), children: [ @@ -1024,7 +1031,7 @@ class _ChatListScreenState extends State return Padding( padding: const EdgeInsets.symmetric( horizontal: 20, - vertical: 4, + vertical: 2, ), child: Row( children: [ @@ -1054,6 +1061,13 @@ class _ChatListScreenState extends State final chats = _chatsForPageIndex(pageIndex); final sc = _folderChatScrollControllers[pageIndex]; final cs = Theme.of(context).colorScheme; + final pinnedCount = _isInitialLoading + ? 0 + : chats.where((c) => (c.favIndex ?? 0) > 0).length; + final hasSeparator = pinnedCount > 0 && pinnedCount < chats.length; + final totalItems = _isInitialLoading + ? 10 + : chats.length + (hasSeparator ? 1 : 0); return NotificationListener( onNotification: (ScrollNotification n) { if (_currentNavIndex != 0) return false; @@ -1098,10 +1112,6 @@ class _ChatListScreenState extends State return _buildChatShimmer(); } - final pinnedCount = chats.where((c) => (c.favIndex ?? 0) > 0).length; - final hasSeparator = pinnedCount > 0 && pinnedCount < chats.length; - - // Insert separator row between pinned and regular sections if (hasSeparator && index == pinnedCount) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20), @@ -1131,7 +1141,7 @@ class _ChatListScreenState extends State return _buildChatItem( chat.id.toString(), name ?? "Пользователь", - chat.lastMsgText?.replaceAll('\n', ' ') ?? '', + chat.lastMsgTextOneLine ?? '', _formatTime(chat.lastMsgTime), avatar ?? "", isOnline: chat.isOnline, @@ -1172,7 +1182,7 @@ class _ChatListScreenState extends State chatType: chat.type, ); } - }, childCount: _isInitialLoading ? 10 : chats.length + (chats.any((c) => (c.favIndex ?? 0) > 0) && chats.any((c) => (c.favIndex ?? 0) <= 0) ? 1 : 0)), + }, childCount: totalItems), ), SliverPadding( padding: EdgeInsets.only( @@ -1673,7 +1683,7 @@ class _ChatListScreenState extends State ), child: CircleAvatar( radius: 26, - backgroundImage: CachedNetworkImageProvider(imageUrl), + backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), ), ), const SizedBox(height: 6), @@ -1742,33 +1752,36 @@ class _ChatListScreenState extends State final isSelected = _selectedFolderId == folderId; return GestureDetector( onTap: () { - final i = _folders.indexWhere((f) => f.id == folderId); - if (i < 0) return; + final target = _folders.indexWhere((f) => f.id == folderId); + if (target < 0) return; setState(() => _selectedFolderId = folderId); if (_folderPageController.hasClients) { - final cur = _folderPageController.page?.round(); - if (cur != i) { - _folderPageController.animateToPage( - i, - duration: const Duration(milliseconds: 320), - curve: Curves.easeOutCubic, - ); + final cur = _folderPageController.page?.round() ?? 0; + if (cur == target) return; + if ((target - cur).abs() > 1) { + final neighbor = target > cur ? target - 1 : target + 1; + _folderPageController.jumpToPage(neighbor); } + _folderPageController.animateToPage( + target, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + ); } }, child: Container( alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), child: Text( title, textAlign: TextAlign.center, style: TextStyle( color: isSelected ? cs.onPrimaryContainer : cs.primary, - fontSize: 14, + fontSize: 13, fontWeight: FontWeight.w500, ), ), @@ -1829,7 +1842,7 @@ Navigator.push( radius: 24, backgroundColor: cs.surfaceContainerHighest, backgroundImage: imageUrl.isNotEmpty - ? CachedNetworkImageProvider(imageUrl) + ? CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144) : null, child: imageUrl.isEmpty ? Text( @@ -2158,9 +2171,18 @@ Navigator.push( ), child: CircleAvatar( radius: 12, - backgroundImage: CachedNetworkImageProvider(imageUrl), + backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), ), ), ); } } + +class _StoriesUi extends ChangeNotifier { + double pullRatio = 0.0; + bool dockedOpen = false; + bool overscrollRevealArmed = true; + bool shouldCollapseSearch = false; + + void notify() => notifyListeners(); +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 18b991c..b865f9c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -10,6 +10,7 @@ import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/haptics.dart'; +import '../../../core/config/app_cache_extent.dart'; import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; import '../../widgets/attachment_panel.dart'; @@ -49,18 +50,18 @@ class _ChatScreenState extends State final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); final GlobalKey _listKey = GlobalKey(); - bool _hasText = false; + final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; - bool _showAttachmentPanel = false; + final ValueNotifier _showAttachmentPanel = ValueNotifier(false); late AnimationController _shimmerController; List _messages = []; int _myId = 0; CachedChat? chat; - DateTime? _floatingDate; - DateTime? _lastFloatingDate; + final ValueNotifier _floatingDate = ValueNotifier(null); Timer? _floatingDateTimer; late final AnimationController _floatingDateAnimController; + late final CurvedAnimation _floatingDateCurved; final Map _separatorKeys = {}; double _lastScrollOffset = 0; String? _lastSentId; @@ -79,6 +80,11 @@ class _ChatScreenState extends State duration: const Duration(milliseconds: 220), reverseDuration: const Duration(milliseconds: 380), ); + _floatingDateCurved = CurvedAnimation( + parent: _floatingDateAnimController, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ); _loadHistory(); } @@ -139,7 +145,11 @@ class _ChatScreenState extends State _messageController.removeListener(_onTextChanged); _scrollController.removeListener(_onScrollForDate); _floatingDateTimer?.cancel(); + _floatingDateCurved.dispose(); _floatingDateAnimController.dispose(); + _floatingDate.dispose(); + _hasText.dispose(); + _showAttachmentPanel.dispose(); _messageController.dispose(); _scrollController.dispose(); _shimmerController.dispose(); @@ -147,11 +157,9 @@ class _ChatScreenState extends State } void _onTextChanged() { - final bool newHasText = _messageController.text.trim().isNotEmpty; - if (newHasText != _hasText) { - setState(() { - _hasText = newHasText; - }); + final newHasText = _messageController.text.trim().isNotEmpty; + if (newHasText != _hasText.value) { + _hasText.value = newHasText; } } @@ -189,11 +197,11 @@ class _ChatScreenState extends State status: 'sending', ); + _hasText.value = false; setState(() { _lastSentId = tempId; _messages.add(tempMessage); _messageController.clear(); - _hasText = false; }); // Instant tactile "whoosh" the moment the message leaves the composer, @@ -386,12 +394,8 @@ class _ChatScreenState extends State if (result == null) return; - final bool dateChanged = result != _lastFloatingDate; - _lastFloatingDate = result; - - if (result != _floatingDate) { - setState(() => _floatingDate = result); - } + final bool dateChanged = result != _floatingDate.value; + _floatingDate.value = result; if (dateChanged) { _floatingDateAnimController.forward(from: 0); @@ -420,7 +424,7 @@ class _ChatScreenState extends State } Widget _buildDateSeparatorWidget(BuildContext context, DateTime date, - {Key? key}) { + {Key? key, bool floating = false}) { final cs = Theme.of(context).colorScheme; return Padding( key: key, @@ -429,7 +433,7 @@ class _ChatScreenState extends State child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.6), + color: cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: Text( @@ -437,7 +441,7 @@ class _ChatScreenState extends State style: TextStyle( color: cs.onSurfaceVariant, fontSize: 12, - fontStyle: FontStyle.italic, + fontStyle: floating ? FontStyle.normal : FontStyle.italic, ), ), ), @@ -482,7 +486,7 @@ class _ChatScreenState extends State if (widget.imageUrl.isNotEmpty) CircleAvatar( radius: 18, - backgroundImage: CachedNetworkImageProvider(widget.imageUrl), + backgroundImage: CachedNetworkImageProvider(widget.imageUrl, maxWidth: 144, maxHeight: 144), ) else CircleAvatar( @@ -563,16 +567,21 @@ class _ChatScreenState extends State _buildInputArea(context), ], ), - if (_showAttachmentPanel) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: AttachmentPanel( - chatId: widget.chatId, - onClose: () => setState(() => _showAttachmentPanel = false), - ), - ), + ValueListenableBuilder( + valueListenable: _showAttachmentPanel, + builder: (context, open, _) { + if (!open) return const SizedBox.shrink(); + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: AttachmentPanel( + chatId: widget.chatId, + onClose: () => _showAttachmentPanel.value = false, + ), + ); + }, + ), ], ), ); @@ -595,11 +604,13 @@ class _ChatScreenState extends State return Stack( key: _listKey, children: [ - ListView.builder( + ValueListenableBuilder( + valueListenable: AppCacheExtent.current, + builder: (context, cacheExtent, _) => ListView.builder( controller: _scrollController, reverse: true, padding: const EdgeInsets.symmetric(vertical: 8), - cacheExtent: 9999, + cacheExtent: cacheExtent, itemCount: items.length, itemBuilder: (context, index) { final item = items[items.length - 1 - index]; @@ -641,32 +652,34 @@ class _ChatScreenState extends State return bubble; }, ), - if (_lastFloatingDate != null) - Positioned( - top: 8, - left: 0, - right: 0, - child: IgnorePointer( - child: AnimatedBuilder( - animation: _floatingDateAnimController, - builder: (context, child) { - final t = CurvedAnimation( - parent: _floatingDateAnimController, - curve: Curves.easeOut, - reverseCurve: Curves.easeIn, - ).value; - return Opacity( - opacity: t, - child: Transform.scale( - scale: 0.82 + 0.18 * t, - child: child, - ), - ); - }, - child: _buildDateSeparatorWidget(context, _lastFloatingDate!), - ), + ), + Positioned( + top: 8, + left: 0, + right: 0, + child: IgnorePointer( + child: ValueListenableBuilder( + valueListenable: _floatingDate, + builder: (context, date, _) { + if (date == null) return const SizedBox.shrink(); + return AnimatedBuilder( + animation: _floatingDateCurved, + builder: (context, child) { + final t = _floatingDateCurved.value; + return Opacity( + opacity: t, + child: Transform.scale( + scale: 0.82 + 0.18 * t, + child: child, + ), + ); + }, + child: _buildDateSeparatorWidget(context, date, floating: true), + ); + }, ), ), + ), ], ); } @@ -842,7 +855,7 @@ class _ChatScreenState extends State if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter && !HardwareKeyboard.instance.isShiftPressed) { - if (_hasText) _sendMessage(); + if (_hasText.value) _sendMessage(); return KeyEventResult.handled; } return KeyEventResult.ignored; @@ -868,64 +881,35 @@ class _ChatScreenState extends State ), ), ), - AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: _hasText ? 0 : 36, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: _hasText ? 0 : 1, - child: _hasText - ? const SizedBox.shrink() - : GestureDetector( - onTap: _showAttachmentPanel ? null : () => setState(() => _showAttachmentPanel = true), - child: Padding( - padding: const EdgeInsets.only(left: 12), - child: Stack( - alignment: Alignment.center, - children: [ - if (_showAttachmentPanel) - SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.primary, - ), - ), - Icon( - Symbols.attachment, - color: _showAttachmentPanel - ? cs.onSurfaceVariant.withValues(alpha: 0.3) - : mutedIcon, - size: 24, - weight: 400, - ), - ], - ), - ), - ), - ), + _AttachButton( + hasText: _hasText, + panelOpen: _showAttachmentPanel, + mutedIcon: mutedIcon, + cs: cs, ), ], ), ), ), const SizedBox(width: 8), - Container( - width: 54, - height: 54, - alignment: Alignment.center, - decoration: BoxDecoration( - color: _hasText ? cs.primary : cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: GestureDetector( - onTap: _hasText ? _sendMessage : null, - child: Icon( - _hasText ? Symbols.send : Symbols.mic, - color: _hasText ? cs.onPrimary : cs.onSurface, - size: 24, - weight: 400, + ValueListenableBuilder( + valueListenable: _hasText, + builder: (context, hasText, _) => Container( + width: 54, + height: 54, + alignment: Alignment.center, + decoration: BoxDecoration( + color: hasText ? cs.primary : cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: GestureDetector( + onTap: hasText ? _sendMessage : null, + child: Icon( + hasText ? Symbols.send : Symbols.mic, + color: hasText ? cs.onPrimary : cs.onSurface, + size: 24, + weight: 400, + ), ), ), ), @@ -936,6 +920,70 @@ class _ChatScreenState extends State } } +class _AttachButton extends StatelessWidget { + final ValueNotifier hasText; + final ValueNotifier panelOpen; + final Color mutedIcon; + final ColorScheme cs; + + const _AttachButton({ + required this.hasText, + required this.panelOpen, + required this.mutedIcon, + required this.cs, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: hasText, + builder: (context, isText, _) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: isText ? 0 : 36, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: isText ? 0 : 1, + child: isText + ? const SizedBox.shrink() + : ValueListenableBuilder( + valueListenable: panelOpen, + builder: (context, open, _) => GestureDetector( + onTap: open ? null : () => panelOpen.value = true, + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Stack( + alignment: Alignment.center, + children: [ + if (open) + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ), + Icon( + Symbols.attachment, + color: open + ? cs.onSurfaceVariant.withValues(alpha: 0.3) + : mutedIcon, + size: 24, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} + class _SentMessageAnimation extends StatefulWidget { final Widget child; final VoidCallback onComplete; diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 8acc382..7c15683 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -88,6 +88,8 @@ class _ContactsTabState extends State { ? CachedNetworkImage( imageUrl: contact.baseUrl!, fit: BoxFit.cover, + memCacheWidth: 144, + memCacheHeight: 144, fadeInDuration: const Duration(milliseconds: 120), errorWidget: (context, url, error) => _buildPlaceholderAvatar(cs, nameToDisplay), diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart new file mode 100644 index 0000000..c74aaaf --- /dev/null +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -0,0 +1,526 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/config/app_bubble_shape.dart'; +import '../../../core/utils/haptics.dart'; +import '../../../main.dart'; + +class AppearanceScreen extends StatefulWidget { + const AppearanceScreen({super.key}); + + @override + State createState() => _AppearanceScreenState(); +} + +class _AppearanceScreenState extends State { + static const _fallback = Color(0xFFC1C4FF); + + final ValueNotifier _color = ValueNotifier(_fallback); + final ValueNotifier _isSystem = ValueNotifier(false); + bool _initialized = false; + bool _accentExpanded = false; + Timer? _debounce; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialized) { + _initialized = true; + final seed = KometApp.stateOf(context)?.accentSeed.value; + _isSystem.value = seed == null; + _color.value = seed ?? _fallback; + } + } + + @override + void dispose() { + _debounce?.cancel(); + _color.dispose(); + _isSystem.dispose(); + super.dispose(); + } + + void _onColorChanged(Color color) { + _color.value = color; + _isSystem.value = false; + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () { + if (mounted) KometApp.stateOf(context)?.applyAccentColor(color); + }); + } + + void _resetToSystem() { + Haptics.selection(); + _debounce?.cancel(); + _isSystem.value = true; + _color.value = _fallback; + KometApp.stateOf(context)?.applyAccentColor(null); + } + + void _toggleAccentExpanded() { + Haptics.tap(); + setState(() => _accentExpanded = !_accentExpanded); + } + + void _onStyleChanged(BubbleStyle style) { + Haptics.selection(); + AppBubbleShape.save(style); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBarM3E( + titleText: 'Внешний вид', + backgroundColor: cs.surface, + ), + body: SafeArea( + top: false, + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: [ + _PreviewSection(color: _color, isSystem: _isSystem), + const SizedBox(height: 16), + _ColorPickerCard( + color: _color, + isSystem: _isSystem, + expanded: _accentExpanded, + onToggle: _toggleAccentExpanded, + onColorChanged: _onColorChanged, + onReset: _resetToSystem, + ), + const SizedBox(height: 12), + _BubbleShapeCard(onChanged: _onStyleChanged), + ], + ), + ), + ); + } +} + +class _PreviewSection extends StatefulWidget { + final ValueNotifier color; + final ValueNotifier isSystem; + + const _PreviewSection({required this.color, required this.isSystem}); + + @override + State<_PreviewSection> createState() => _PreviewSectionState(); +} + +class _PreviewSectionState extends State<_PreviewSection> { + ColorScheme? _cachedScheme; + Color? _cachedColor; + Brightness? _cachedBrightness; + + ColorScheme _schemeFor(Color color, Brightness brightness) { + if (_cachedScheme != null && + _cachedColor == color && + _cachedBrightness == brightness) { + return _cachedScheme!; + } + _cachedColor = color; + _cachedBrightness = brightness; + _cachedScheme = ColorScheme.fromSeed( + seedColor: color, + brightness: brightness, + ); + return _cachedScheme!; + } + + @override + Widget build(BuildContext context) { + final outerCs = Theme.of(context).colorScheme; + final brightness = Theme.of(context).brightness; + + return ValueListenableBuilder( + valueListenable: widget.isSystem, + builder: (context, isSystem, _) { + if (isSystem) { + return Theme( + data: Theme.of(context).copyWith(colorScheme: outerCs), + child: const _ChatPreview(), + ); + } + return ValueListenableBuilder( + valueListenable: widget.color, + builder: (context, color, _) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: _schemeFor(color, brightness), + ), + child: const _ChatPreview(), + ); + }, + ); + }, + ); + } +} + +class _ChatPreview extends StatelessWidget { + const _ChatPreview(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return ValueListenableBuilder( + valueListenable: AppBubbleShape.current, + builder: (context, style, _) => Container( + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _PreviewBubble(text: 'Как тебе?', isMe: true, style: style), + const SizedBox(height: 6), + _PreviewBubble(text: 'отлично выглядит!', isMe: false, style: style), + ], + ), + ), + ); + } +} + +class _PreviewBubble extends StatelessWidget { + final String text; + final bool isMe; + final BubbleStyle style; + + const _PreviewBubble({ + required this.text, + required this.isMe, + required this.style, + }); + + BorderRadius get _radius { + const big = Radius.circular(20); + const small = Radius.circular(4); + final outside = style == BubbleStyle.mobile ? big : small; + return BorderRadius.only( + topLeft: isMe ? outside : big, + topRight: isMe ? big : outside, + bottomLeft: isMe ? outside : big, + bottomRight: isMe ? big : outside, + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final bg = isMe ? cs.primaryContainer : cs.surfaceContainerHighest; + final fg = Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black; + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 220), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration(color: bg, borderRadius: _radius), + child: Text( + text, + style: TextStyle(color: fg, fontSize: 15, height: 1.3), + ), + ), + ); + } +} + +class _ColorPickerCard extends StatelessWidget { + final ValueNotifier color; + final ValueNotifier isSystem; + final bool expanded; + final VoidCallback onToggle; + final ValueChanged onColorChanged; + final VoidCallback onReset; + + const _ColorPickerCard({ + required this.color, + required this.isSystem, + required this.expanded, + required this.onToggle, + required this.onColorChanged, + required this.onReset, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ValueListenableBuilder( + valueListenable: isSystem, + builder: (context, sys, _) { + return ValueListenableBuilder( + valueListenable: color, + builder: (context, col, _) => _buildBody(cs, col, sys), + ); + }, + ); + } + + Widget _buildBody(ColorScheme cs, Color col, bool sys) { + final swatchColor = sys ? cs.primary : col; + + return Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + InkWell( + onTap: onToggle, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 16, 18), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: swatchColor, + shape: BoxShape.circle, + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Акцентный цвет', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + sys + ? 'Системный' + : 'Основной цвет интерфейса и пузырей', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + AnimatedRotation( + duration: const Duration(milliseconds: 200), + turns: expanded ? 0.5 : 0, + child: Icon( + Symbols.expand_more, + color: cs.onSurfaceVariant, + size: 24, + ), + ), + ], + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: expanded + ? Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _HueStripPicker( + color: col, + onChanged: onColorChanged, + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: FilledButton.tonal( + onPressed: sys ? null : onReset, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.auto_awesome, size: 18, weight: 500), + const SizedBox(width: 8), + Text(sys + ? 'Системный цвет активен' + : 'Сбросить на системный'), + ], + ), + ), + ), + ], + ), + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ); + } +} + +class _BubbleShapeCard extends StatelessWidget { + final ValueChanged onChanged; + + const _BubbleShapeCard({required this.onChanged}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Форма сообщения', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Скругление углов пузырей', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: AppBubbleShape.current, + builder: (context, current, _) { + return SegmentedButton( + segments: const [ + ButtonSegment( + value: BubbleStyle.mobile, + label: Text('TG Mobile'), + icon: Icon(Symbols.smartphone), + ), + ButtonSegment( + value: BubbleStyle.desktop, + label: Text('TG Desktop'), + icon: Icon(Symbols.desktop_windows), + ), + ], + selected: {current}, + onSelectionChanged: (set) { + if (set.isNotEmpty) onChanged(set.first); + }, + ); + }, + ), + ], + ), + ), + ); + } +} + +class _HueStripPicker extends StatelessWidget { + final Color color; + final ValueChanged onChanged; + + const _HueStripPicker({required this.color, required this.onChanged}); + + static const _gradient = LinearGradient( + colors: [ + Color(0xFFFF0000), + Color(0xFFFFFF00), + Color(0xFF00FF00), + Color(0xFF00FFFF), + Color(0xFF0000FF), + Color(0xFFFF00FF), + Color(0xFFFF0000), + ], + ); + + @override + Widget build(BuildContext context) { + final hue = HSVColor.fromColor(color).hue; + const trackHeight = 26.0; + const thumbDiameter = 30.0; + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + void emit(double dx) { + final clamped = dx.clamp(0.0, width); + final newHue = (clamped / width) * 360; + onChanged(HSVColor.fromAHSV(1, newHue, 1, 1).toColor()); + } + + final thumbLeft = (hue / 360) * width - thumbDiameter / 2; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (d) => emit(d.localPosition.dx), + onPanUpdate: (d) => emit(d.localPosition.dx), + child: SizedBox( + height: thumbDiameter + 4, + child: Stack( + children: [ + Center( + child: Container( + height: trackHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(trackHeight / 2), + gradient: _gradient, + ), + ), + ), + Positioned( + left: thumbLeft.clamp(0, width - thumbDiameter), + top: (thumbDiameter + 4 - thumbDiameter) / 2, + child: Container( + width: thumbDiameter, + height: thumbDiameter, + decoration: BoxDecoration( + color: HSVColor.fromAHSV(1, hue, 1, 1).toColor(), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/frontend/screens/profile/customization_screen.dart b/lib/frontend/screens/profile/customization_screen.dart index 04a5411..f3bd4ee 100644 --- a/lib/frontend/screens/profile/customization_screen.dart +++ b/lib/frontend/screens/profile/customization_screen.dart @@ -3,6 +3,7 @@ import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/utils/haptics.dart'; +import 'appearance_screen.dart'; import 'font_settings_screen.dart'; class _CustomizationCategory { @@ -23,6 +24,12 @@ class CustomizationScreen extends StatelessWidget { const CustomizationScreen({super.key}); static const List<_CustomizationCategory> _categories = [ + _CustomizationCategory( + icon: Symbols.palette, + title: 'Внешний вид', + subtitle: 'Акцентный цвет интерфейса', + builder: _buildAppearance, + ), _CustomizationCategory( icon: Symbols.text_fields, title: 'Шрифты', @@ -31,6 +38,9 @@ class CustomizationScreen extends StatelessWidget { ), ]; + static Widget _buildAppearance(BuildContext context) => + const AppearanceScreen(); + static Widget _buildFontSettings(BuildContext context) => const FontSettingsScreen(); diff --git a/lib/frontend/screens/profile/performance_screen.dart b/lib/frontend/screens/profile/performance_screen.dart new file mode 100644 index 0000000..de98406 --- /dev/null +++ b/lib/frontend/screens/profile/performance_screen.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; + +import '../../../core/config/app_cache_extent.dart'; +import '../../../core/utils/haptics.dart'; + +class PerformanceScreen extends StatefulWidget { + const PerformanceScreen({super.key}); + + @override + State createState() => _PerformanceScreenState(); +} + +class _PerformanceScreenState extends State { + late double _value; + late double _preZoneValue; + bool _lowWarnDismissed = false; + bool _highWarnDismissed = false; + + @override + void initState() { + super.initState(); + _value = AppCacheExtent.current.value; + _preZoneValue = _value; + } + + bool _isInSafeZone(double v) => + v >= AppCacheExtent.lowWarnThreshold && v < AppCacheExtent.highWarnThreshold; + + void _onChanged(double v) { + setState(() { + _value = v; + if (_isInSafeZone(v)) _preZoneValue = v; + }); + } + + Future _onChangeEnd(double v) async { + Haptics.selection(); + final inLow = v < AppCacheExtent.lowWarnThreshold; + final inHigh = v >= AppCacheExtent.highWarnThreshold; + + if (inLow && !_lowWarnDismissed) { + final ok = await _showWarning( + text: + 'Производительность приложения может снизиться, вы уверены?', + ); + if (ok) { + _lowWarnDismissed = true; + await AppCacheExtent.save(v); + } else { + setState(() => _value = _preZoneValue); + await AppCacheExtent.save(_preZoneValue); + } + return; + } + + if (inHigh && !_highWarnDismissed) { + final ok = await _showWarning( + text: + 'Это врядли даст хотя-бы немного заметный прирост к FPS, ' + 'но может потреблять больше памяти. Вы уверены?', + ); + if (ok) { + _highWarnDismissed = true; + await AppCacheExtent.save(v); + } else { + setState(() => _value = _preZoneValue); + await AppCacheExtent.save(_preZoneValue); + } + return; + } + + await AppCacheExtent.save(v); + } + + Future _showWarning({required String text}) async { + final cs = Theme.of(context).colorScheme; + final res = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + content: Text( + text, + style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.35), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + 'Нет', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Да'), + ), + ], + ); + }, + ); + return res ?? false; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final hint = cs.onSurfaceVariant; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBarM3E( + titleText: 'Производительность', + backgroundColor: cs.surface, + ), + body: SafeArea( + top: false, + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: [ + Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Кеш сообщений', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Сколько пикселей сообщений держать построенными за пределами видимой области.', + style: TextStyle(color: hint, fontSize: 13, height: 1.3), + ), + const SizedBox(height: 18), + Text( + 'Текущий cacheExtent: ${_value.round()}', + style: TextStyle(color: hint, fontSize: 12), + ), + const SizedBox(height: 4), + Slider( + value: _value, + min: AppCacheExtent.min, + max: AppCacheExtent.max, + onChanged: _onChanged, + onChangeEnd: _onChangeEnd, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Меньше потребление', + style: TextStyle(color: hint, fontSize: 11), + ), + Text( + 'Больше FPS', + style: TextStyle(color: hint, fontSize: 11), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/profile_screen.dart b/lib/frontend/screens/profile/profile_screen.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 0d22199..f93a07b 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -10,6 +10,7 @@ import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../auth/proxy_settings_sheet.dart'; import 'customization_screen.dart'; +import 'performance_screen.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'edit_profile_screen.dart'; @@ -162,6 +163,18 @@ child: _buildSection( ); }, ), + _SettingsItem( + icon: Symbols.speed, + label: 'Производительность', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PerformanceScreen(), + ), + ); + }, + ), ], ), ), @@ -393,6 +406,8 @@ child: _buildSection( ? CachedNetworkImage( imageUrl: _profile!.baseUrl!, fit: BoxFit.cover, + memCacheWidth: 240, + memCacheHeight: 240, fadeInDuration: const Duration(milliseconds: 120), errorWidget: (context, url, error) => _buildPlaceholderAvatar(cs, name), diff --git a/lib/frontend/widgets/avatar.dart b/lib/frontend/widgets/avatar.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/frontend/widgets/call_controls.dart b/lib/frontend/widgets/call_controls.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/frontend/widgets/chat_tile.dart b/lib/frontend/widgets/chat_tile.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 3220021..aa2348f 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; +import '../../core/config/app_bubble_shape.dart'; import '../../core/utils/haptics.dart'; import '../../models/attachment.dart'; @@ -10,6 +11,27 @@ enum MessageType { text, attachment, voice, control } enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } +class _BubbleCtx { + final BuildContext context; + final ColorScheme cs; + final Color text; + final Color dim; + final BubbleShape shape; + final MessageType contentType; + final bool hasPhotoWithCaption; + final bool hasMultiplePhotosNoCaption; + + _BubbleCtx({ + required this.context, + required this.cs, + required this.text, + required this.shape, + required this.contentType, + required this.hasPhotoWithCaption, + required this.hasMultiplePhotosNoCaption, + }) : dim = text.withValues(alpha: 0.7); +} + class MessageBubble extends StatelessWidget { static const double photoMaxSize = 280.0; static const double photoMinSize = 100.0; @@ -19,23 +41,14 @@ class MessageBubble extends StatelessWidget { static const double captionPaddingRight = 4.0; static const double compactTimePadding = 8.0; - bool get _hasPhotoWithCaption { - if (message.attachments == null || message.attachments!.isEmpty) { - return false; - } - final hasPhoto = message.attachments!.any((a) => a is PhotoAttachment); - final hasCaption = message.text != null && message.text!.isNotEmpty; - return hasPhoto && hasCaption; - } + static const Radius _bigRadius = Radius.circular(bubbleBorderRadius); + static const Radius _smallRadius = Radius.circular(4); + static const Radius _photoRadius = Radius.circular(photoBorderRadius); - bool get _hasMultiplePhotosNoCaption { - if (message.attachments == null || message.attachments!.isEmpty) { - return false; - } - final photoCount = message.attachments!.whereType().length; - final hasCaption = message.text != null && message.text!.isNotEmpty; - return photoCount >= 2 && !hasCaption; - } + static Color bubbleTextColor(BuildContext context) => + Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black; final CachedMessage message; final bool isMe; @@ -56,51 +69,55 @@ class MessageBubble extends StatelessWidget { this.overrideStatus, }); - bool get isGroupedWithNext { - if (nextMessage == null) return false; - if (message.isControl) return false; - if (nextMessage!.senderId != message.senderId) return false; - final timeDiff = nextMessage!.time - message.time; - return timeDiff < 300000; + bool _computeHasPhotoWithCaption() { + final attachments = message.attachments; + if (attachments == null || attachments.isEmpty) return false; + final hasPhoto = attachments.any((a) => a is PhotoAttachment); + final hasCaption = message.text != null && message.text!.isNotEmpty; + return hasPhoto && hasCaption; } - BubbleShape get shape { - if (message.isControl) { - return BubbleShape.singleMiddle; - } + bool _computeHasMultiplePhotosNoCaption() { + final attachments = message.attachments; + if (attachments == null || attachments.isEmpty) return false; + final photoCount = attachments.whereType().length; + final hasCaption = message.text != null && message.text!.isNotEmpty; + return photoCount >= 2 && !hasCaption; + } - final hasPrevFromMe = prevMessage?.senderId == message.senderId && !prevMessage!.isControl; - final prevTimeDiff = hasPrevFromMe - ? message.time - prevMessage!.time - : 999999999; + BubbleShape _computeShape() { + if (message.isControl) return BubbleShape.singleMiddle; - final hasNextFromMe = nextMessage?.senderId == message.senderId && !nextMessage!.isControl; - final nextTimeDiff = hasNextFromMe - ? nextMessage!.time - message.time - : 999999999; + final hasPrevFromMe = + prevMessage?.senderId == message.senderId && !prevMessage!.isControl; + final prevTimeDiff = + hasPrevFromMe ? message.time - prevMessage!.time : 999999999; - final bool groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000; - final bool groupedWithNext = hasNextFromMe && nextTimeDiff < 300000; + final hasNextFromMe = + nextMessage?.senderId == message.senderId && !nextMessage!.isControl; + final nextTimeDiff = + hasNextFromMe ? nextMessage!.time - message.time : 999999999; + + final groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000; + final groupedWithNext = hasNextFromMe && nextTimeDiff < 300000; if (!groupedWithPrev && !groupedWithNext) return BubbleShape.singleMiddle; if (!groupedWithPrev && groupedWithNext) return BubbleShape.singleTop; if (groupedWithPrev && !groupedWithNext) return BubbleShape.singleBottom; - return BubbleShape.groupedMiddle; } - MessageType get contentType { + MessageType _computeContentType() { if (message.isControl) return MessageType.control; - if (message.attachments != null && message.attachments!.isNotEmpty) { - final first = message.attachments!.first; + final attachments = message.attachments; + if (attachments != null && attachments.isNotEmpty) { + final first = attachments.first; if (first is ForwardedMessageAttachment) { final fwd = first; final hasContact = fwd.originalContact != null; - final hasPhoto = - fwd.originalAttachments != null && + final hasPhoto = fwd.originalAttachments != null && fwd.originalAttachments!.any((a) => a is PhotoAttachment); - final hasOther = - fwd.originalAttachments != null && + final hasOther = fwd.originalAttachments != null && fwd.originalAttachments!.isNotEmpty; if (hasContact || hasPhoto || hasOther) return MessageType.attachment; return MessageType.text; @@ -113,288 +130,277 @@ class MessageBubble extends StatelessWidget { final payload = message.payload; if (payload == null) return MessageType.text; - - final voice = payload['voice']; - if (voice != null) return MessageType.voice; - + if (payload['voice'] != null) return MessageType.voice; return MessageType.text; } - BorderRadius get _borderRadius { - final topRadius = Radius.circular(bubbleBorderRadius); - final smallRadius = const Radius.circular(4); - - final cornerTL = topRadius; - final cornerTR = isMe ? topRadius : smallRadius; - final cornerBL = topRadius; - final cornerBR = isMe ? topRadius : smallRadius; - - if (_hasPhotoWithCaption && - (shape == BubbleShape.singleTop || - shape == BubbleShape.singleMiddle || - shape == BubbleShape.singleBottom)) { - return BorderRadius.only( - topLeft: topRadius, - topRight: topRadius, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: smallRadius, - ); - } - - if (_hasMultiplePhotosNoCaption && - (shape == BubbleShape.singleBottom || - shape == BubbleShape.singleMiddle)) { - return BorderRadius.only( - topLeft: isMe ? topRadius : smallRadius, - topRight: smallRadius, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: isMe ? smallRadius : topRadius, - ); - } - - switch (shape) { - case BubbleShape.singleTop: - return BorderRadius.only( - topLeft: cornerTL, - topRight: cornerTR, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: smallRadius, - ); - case BubbleShape.singleBottom: - return BorderRadius.only( - topLeft: isMe ? topRadius : smallRadius, - topRight: smallRadius, - bottomLeft: cornerBL, - bottomRight: cornerBR, - ); - case BubbleShape.singleMiddle: - return BorderRadius.only( - topLeft: cornerTL, - topRight: cornerTR, - bottomLeft: cornerBL, - bottomRight: cornerBR, - ); - case BubbleShape.groupedMiddle: - return BorderRadius.only( - topLeft: isMe ? cornerTL : smallRadius, - topRight: smallRadius, - bottomLeft: isMe ? cornerBL : smallRadius, - bottomRight: smallRadius, - ); - } - } - - // ВРОДЕ отступы между сообщениями, если они выглядят адекватно не трогайте пж я ради них кишку на шею намотал - // ОТСТУПЫ МЕЖДУ СООБЩЕНИЯМИ (top/bottom margin) - // Зависит от shape (группировка) И contentType (тип контента) - double get topMargin { + EdgeInsets _paddingFor(MessageType contentType, BubbleShape shape) { switch (contentType) { case MessageType.text: - switch (shape) { - case BubbleShape.singleTop: - return 6; - case BubbleShape.singleBottom: - return 1; - case BubbleShape.singleMiddle: - return 4; - case BubbleShape.groupedMiddle: - return 1; + if (shape == BubbleShape.groupedMiddle) { + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); } + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); case MessageType.attachment: - switch (shape) { - case BubbleShape.singleTop: - return 1; - case BubbleShape.singleBottom: - return 6; - case BubbleShape.singleMiddle: - return 4; - case BubbleShape.groupedMiddle: - return 1; - } + return EdgeInsets.zero; case MessageType.voice: - switch (shape) { - case BubbleShape.singleTop: - return 1; - case BubbleShape.singleBottom: - return 1; - case BubbleShape.singleMiddle: - return 4; - case BubbleShape.groupedMiddle: - return 1; - } - case MessageType.control: - return 4; - } - } - - double get bottomMargin { - switch (contentType) { - case MessageType.text: - switch (shape) { - case BubbleShape.singleTop: - return 1; - case BubbleShape.singleBottom: - return 1; - case BubbleShape.singleMiddle: - return 4; - case BubbleShape.groupedMiddle: - return 1; - } - case MessageType.attachment: - switch (shape) { - case BubbleShape.singleTop: - return 1; - case BubbleShape.singleBottom: - return 1; - case BubbleShape.singleMiddle: - return 4; - case BubbleShape.groupedMiddle: - return 1; - } - case MessageType.voice: - switch (shape) { - case BubbleShape.singleTop: - return 1; - case BubbleShape.singleBottom: - return 1; - case BubbleShape.singleMiddle: - return 4; - case BubbleShape.groupedMiddle: - return 1; - } - case MessageType.control: - return 4; - } - } - - // Внутренний отступ, размеры типа я хз - EdgeInsets get padding { - switch (contentType) { - case MessageType.text: - switch (shape) { - case BubbleShape.groupedMiddle: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); - case BubbleShape.singleTop: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); - case BubbleShape.singleBottom: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); - case BubbleShape.singleMiddle: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); - } - case MessageType.attachment: - return const EdgeInsets.all(0); - case MessageType.voice: - switch (shape) { - case BubbleShape.groupedMiddle: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); - case BubbleShape.singleTop: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); - case BubbleShape.singleBottom: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); - case BubbleShape.singleMiddle: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); + if (shape == BubbleShape.singleTop || + shape == BubbleShape.singleBottom) { + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); } + return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); case MessageType.control: return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); } } - @override - Widget build(BuildContext context) { - if (message.isControl) { - return Padding( - padding: EdgeInsets.only(top: topMargin, bottom: bottomMargin), - child: Center(child: _buildControlContent(context)), + double _topMarginFor(MessageType contentType, BubbleShape shape) { + switch (contentType) { + case MessageType.text: + switch (shape) { + case BubbleShape.singleTop: + return 6; + case BubbleShape.singleBottom: + case BubbleShape.groupedMiddle: + return 1; + case BubbleShape.singleMiddle: + return 4; + } + case MessageType.attachment: + switch (shape) { + case BubbleShape.singleBottom: + return 6; + case BubbleShape.singleTop: + case BubbleShape.groupedMiddle: + return 1; + case BubbleShape.singleMiddle: + return 4; + } + case MessageType.voice: + return shape == BubbleShape.singleMiddle ? 4 : 1; + case MessageType.control: + return 4; + } + } + + double _bottomMarginFor(MessageType contentType, BubbleShape shape) { + switch (contentType) { + case MessageType.text: + case MessageType.attachment: + case MessageType.voice: + return shape == BubbleShape.singleMiddle ? 4 : 1; + case MessageType.control: + return 4; + } + } + + BorderRadius _borderRadiusFor( + BubbleStyle bubbleStyle, + BubbleShape shape, + bool hasPhotoWithCaption, + bool hasMultiplePhotosNoCaption, + ) { + final outsideRadius = + bubbleStyle == BubbleStyle.mobile ? _bigRadius : _smallRadius; + + if (hasPhotoWithCaption && + (shape == BubbleShape.singleTop || + shape == BubbleShape.singleMiddle || + shape == BubbleShape.singleBottom)) { + return BorderRadius.only( + topLeft: _bigRadius, + topRight: _bigRadius, + bottomLeft: isMe ? _bigRadius : _smallRadius, + bottomRight: _smallRadius, ); } - final cs = Theme.of(context).colorScheme; - final isDark = cs.brightness == Brightness.dark; + if (hasMultiplePhotosNoCaption && + (shape == BubbleShape.singleBottom || + shape == BubbleShape.singleMiddle)) { + return BorderRadius.only( + topLeft: isMe ? _bigRadius : _smallRadius, + topRight: _smallRadius, + bottomLeft: isMe ? _bigRadius : _smallRadius, + bottomRight: isMe ? _smallRadius : _bigRadius, + ); + } - String? senderAvatar = ContactCache.getAvatar(message.senderId); - String? displaySender = ContactCache.get(message.senderId); + Radius cornerTL = isMe ? outsideRadius : _bigRadius; + Radius cornerTR = isMe ? _bigRadius : outsideRadius; + Radius cornerBL = isMe ? outsideRadius : _bigRadius; + Radius cornerBR = isMe ? _bigRadius : outsideRadius; + + switch (shape) { + case BubbleShape.singleTop: + if (isMe) { + cornerBR = _smallRadius; + } else { + cornerBL = _smallRadius; + } + case BubbleShape.singleBottom: + if (isMe) { + cornerTR = _smallRadius; + } else { + cornerTL = _smallRadius; + } + case BubbleShape.singleMiddle: + break; + case BubbleShape.groupedMiddle: + if (isMe) { + cornerTR = _smallRadius; + cornerBR = _smallRadius; + } else { + cornerTL = _smallRadius; + cornerBL = _smallRadius; + } + } + + return BorderRadius.only( + topLeft: cornerTL, + topRight: cornerTR, + bottomLeft: cornerBL, + bottomRight: cornerBR, + ); + } + + Widget _buildLeadingAvatar(ColorScheme cs) { + final senderAvatar = ContactCache.getAvatar(message.senderId); + final displaySender = ContactCache.get(message.senderId); + if (senderAvatar != null && senderAvatar.isNotEmpty) { + return CircleAvatar( + radius: 15, + backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundColor: cs.primaryContainer, + ); + } + return CircleAvatar( + radius: 15, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender != null && displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final contentType = _computeContentType(); + + if (message.isControl) { + const controlShape = BubbleShape.singleMiddle; + return Padding( + padding: EdgeInsets.only( + top: _topMarginFor(contentType, controlShape), + bottom: _bottomMarginFor(contentType, controlShape), + ), + child: Center(child: _buildControlContent(cs)), + ); + } + + final shape = _computeShape(); + final hasPhotoCap = _computeHasPhotoWithCaption(); + final hasMultiPhotos = _computeHasMultiplePhotosNoCaption(); + final textColor = bubbleTextColor(context); + + final ctx = _BubbleCtx( + context: context, + cs: cs, + text: textColor, + shape: shape, + contentType: contentType, + hasPhotoWithCaption: hasPhotoCap, + hasMultiplePhotosNoCaption: hasMultiPhotos, + ); + + final topMargin = _topMarginFor(contentType, shape); + final bottomMargin = _bottomMarginFor(contentType, shape); + final padding = _paddingFor(contentType, shape); + + final showAvatarSlot = !isMe; + final showAvatar = showAvatarSlot && + chatType == "CHAT" && + nextMessage?.senderId != message.senderId && + prevMessage?.senderId == message.senderId; return GestureDetector( - // TODO: действия с сообщением - onTap: () => Haptics.tap(), + onTap: Haptics.tap, child: Padding( padding: EdgeInsets.only( - left: isMe ? 12 : 12, - right: isMe ? 12 : 12, + left: 12, + right: 12, top: topMargin, bottom: bottomMargin, ), child: Align( child: Row( - mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: + isMe ? MainAxisAlignment.end : MainAxisAlignment.start, spacing: 8, - crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (!isMe && chatType == "CHAT" && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) - ...(senderAvatar != null && senderAvatar.isNotEmpty) - ? [ - CircleAvatar( - radius: 15, - backgroundImage: CachedNetworkImageProvider(senderAvatar), - backgroundColor: cs.primaryContainer, - ) - ] - : [ - CircleAvatar( - radius: 15, - backgroundColor: cs.primaryContainer, - child: Text( - displaySender != null && displaySender.isNotEmpty - ? displaySender[0].toUpperCase() - : '?', - style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), - ), - ) - ] - else if (!isMe && chatType != "CHAT") - SizedBox(width: 0) - else if (!isMe) - CircleAvatar(radius: 15, backgroundColor: Color(0x00000000)), - Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, + if (showAvatar) + _buildLeadingAvatar(cs) + else if (showAvatarSlot && chatType != "CHAT") + const SizedBox(width: 0) + else if (showAvatarSlot) + const CircleAvatar( + radius: 15, + backgroundColor: Color(0x00000000), ), - decoration: BoxDecoration( - color: isMe - ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) - : (isDark - ? cs.surfaceContainerHighest - : const Color(0xFFE9E9EB)), - borderRadius: _borderRadius, - ), - padding: padding, - child: _buildContent(context), + ValueListenableBuilder( + valueListenable: AppBubbleShape.current, + builder: (context, bubbleStyle, child) { + return Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? cs.primaryContainer + : cs.surfaceContainerHighest, + borderRadius: _borderRadiusFor( + bubbleStyle, + shape, + hasPhotoCap, + hasMultiPhotos, + ), + ), + padding: padding, + child: child, + ); + }, + child: _buildContent(ctx), ), ], - ) + ), ), - ) + ), ); } - Widget _buildContent(BuildContext context) { - switch (contentType) { + Widget _buildContent(_BubbleCtx ctx) { + switch (ctx.contentType) { case MessageType.control: - return _buildControlContent(context); + return _buildControlContent(ctx.cs); case MessageType.attachment: - return _buildAttachmentContent(context); + return _buildAttachmentContent(ctx); case MessageType.voice: - return _buildVoiceContent(context); + return _buildVoiceContent(ctx); case MessageType.text: - return _buildTextContent(context); + return _buildTextContent(ctx); } } - Widget _buildControlContent(BuildContext context) { - final cs = Theme.of(context).colorScheme; + Widget _buildControlContent(ColorScheme cs) { final attachments = message.attachments; - if (attachments == null || attachments.isEmpty) return const SizedBox.shrink(); + if (attachments == null || attachments.isEmpty) { + return const SizedBox.shrink(); + } final control = attachments.first; if (control is! ControlAttachment) return const SizedBox.shrink(); @@ -405,17 +411,23 @@ class MessageBubble extends StatelessWidget { text = control.title; break; case 'new': - text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} создал(а) чат'; + text = + '${ContactCache.get(message.senderId) ?? 'Пользователь'} создал(а) чат'; break; case 'add': - final names = (control.userIds ?? []).map((id) => ContactCache.get(id) ?? 'Пользователь').join(', '); - text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} добавил(а) $names'; + final names = (control.userIds ?? []) + .map((id) => ContactCache.get(id) ?? 'Пользователь') + .join(', '); + text = + '${ContactCache.get(message.senderId) ?? 'Пользователь'} добавил(а) $names'; break; case 'leave': - text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} покинул(а) чат'; + text = + '${ContactCache.get(message.senderId) ?? 'Пользователь'} покинул(а) чат'; break; case 'joinByLink': - text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату'; + text = + '${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату'; break; default: text = control.title; @@ -441,16 +453,9 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildTextContent(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final isDark = cs.brightness == Brightness.dark; - final textColor = isMe - ? Colors.white - : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); - + Widget _buildTextContent(_BubbleCtx ctx) { final attachments = message.attachments; - final isForwardedContact = - attachments != null && + final isForwardedContact = attachments != null && attachments.isNotEmpty && attachments.first is ForwardedMessageAttachment && (attachments.first as ForwardedMessageAttachment).originalContact != @@ -459,27 +464,33 @@ class MessageBubble extends StatelessWidget { final forwarded = _getForwardedAttachment(); final isForwarded = forwarded != null && !isForwardedContact; - String? displaySender = ContactCache.get(message.senderId); + final displaySender = ContactCache.get(message.senderId); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId && chatType == "CHAT") - Text( - displaySender ?? "", - textAlign: TextAlign.left, - style: TextStyle(color: cs.onPrimaryContainer) - ), + if (message.senderId != message.accountId && + prevMessage?.senderId != message.senderId && + chatType == "CHAT") + Text( + displaySender ?? "", + textAlign: TextAlign.left, + style: TextStyle(color: ctx.text), + ), Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( child: isForwarded - ? _buildForwardedInlineText(context, forwarded, textColor) + ? _buildForwardedInlineText(ctx, forwarded) : Text( message.text ?? '', - style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + style: TextStyle( + color: ctx.text, + fontSize: 16, + height: 1.3, + ), ), ), const SizedBox(width: 8), @@ -489,13 +500,13 @@ class MessageBubble extends StatelessWidget { message.status == 'EDITED' ? '${_formatTime(message.time)} ред.' : _formatTime(message.time), - style: TextStyle( - color: textColor.withValues(alpha: 0.7), - fontSize: 10, - ), + style: TextStyle(color: ctx.dim, fontSize: 10), ), ), - if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], + if (isMe) ...[ + const SizedBox(width: 4), + _buildStatusIcon(ctx), + ], ], ), ], @@ -503,15 +514,10 @@ class MessageBubble extends StatelessWidget { } Widget _buildForwardedInlineText( - BuildContext context, + _BubbleCtx ctx, ForwardedMessageAttachment forwarded, - Color textColor, ) { - final cs = Theme.of(context).colorScheme; - final headerColor = isMe - ? Colors.white.withValues(alpha: 0.7) - : cs.onSurfaceVariant; - + final headerColor = ctx.dim; final senderName = forwarded.originalSenderName; final displaySender = senderName ?? forwarded.originalSenderId.toString(); final senderAvatar = forwarded.originalSenderAvatar; @@ -530,18 +536,21 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar), - backgroundColor: cs.primaryContainer, + backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundColor: ctx.cs.primaryContainer, ) else CircleAvatar( radius: 10, - backgroundColor: cs.primaryContainer, + backgroundColor: ctx.cs.primaryContainer, child: Text( displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', - style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + style: TextStyle( + fontSize: 9, + color: ctx.cs.onPrimaryContainer, + ), ), ), const SizedBox(width: 6), @@ -559,7 +568,7 @@ class MessageBubble extends StatelessWidget { const SizedBox(height: 2), Text( origText, - style: TextStyle(color: textColor, fontSize: 14), + style: TextStyle(color: ctx.text, fontSize: 14), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -567,7 +576,7 @@ class MessageBubble extends StatelessWidget { const SizedBox(height: 2), Text( message.text ?? '', - style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), ), ], ], @@ -575,54 +584,52 @@ class MessageBubble extends StatelessWidget { } ForwardedMessageAttachment? _getForwardedAttachment() { - if (message.attachments == null || message.attachments!.isEmpty) { - return null; - } - for (final a in message.attachments!) { + final attachments = message.attachments; + if (attachments == null || attachments.isEmpty) return null; + for (final a in attachments) { if (a is ForwardedMessageAttachment) return a; } return null; } - Widget _buildAttachmentContent(BuildContext context) { + Widget _buildAttachmentContent(_BubbleCtx ctx) { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { - return _buildTextContent(context); + return _buildTextContent(ctx); } final first = attachments.first; if (first is ForwardedMessageAttachment) { final fwd = first; if (fwd.originalContact != null) { - return _buildForwardedContactContent(context, fwd); + return _buildForwardedContactContent(ctx, fwd); } - final photos = fwd.originalAttachments - ?.whereType() - .toList(); + final photos = + fwd.originalAttachments?.whereType().toList(); if (photos != null && photos.isNotEmpty) { - return _buildForwardedPhotoContent(context, fwd, photos); + return _buildForwardedPhotoContent(ctx, fwd, photos); } final files = fwd.originalAttachments; if (files != null && files.isNotEmpty) { - return _buildForwardedGenericContent(context, fwd, files); + return _buildForwardedGenericContent(ctx, fwd, files); } - return _buildTextContent(context); + return _buildTextContent(ctx); } final contacts = attachments.whereType().toList(); if (contacts.isNotEmpty) { - return _buildContactAttachment(context, contacts.first); + return _buildContactAttachment(ctx, contacts.first); } final photos = attachments.whereType().toList(); if (photos.isEmpty) { - return _buildGenericAttachment(context, attachments.first); + return _buildGenericAttachment(ctx, attachments.first); } - return _buildPhotoContent(context, photos); + return _buildPhotoContent(ctx, photos); } - Widget _buildPhotoContent(BuildContext ctx, List photos) { + Widget _buildPhotoContent(_BubbleCtx ctx, List photos) { final hasCaption = message.text != null && message.text!.isNotEmpty; final count = photos.length; @@ -642,7 +649,7 @@ class MessageBubble extends StatelessWidget { Positioned( bottom: compactTimePadding, right: compactTimePadding, - child: _buildCompactTime(ctx), + child: _buildCompactTime(), ), ], ); @@ -703,16 +710,13 @@ class MessageBubble extends StatelessWidget { } Widget _buildForwardedPhotoContent( - BuildContext context, + _BubbleCtx ctx, ForwardedMessageAttachment forwarded, List photos, ) { - final cs = Theme.of(context).colorScheme; - final headerColor = isMe - ? Colors.white.withValues(alpha: 0.7) - : cs.onSurfaceVariant; - final senderName = forwarded.originalSenderName; - final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final headerColor = ctx.dim; + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); final senderAvatar = forwarded.originalSenderAvatar; final hasCaption = message.text != null && message.text!.isNotEmpty; @@ -730,18 +734,21 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar), - backgroundColor: cs.primaryContainer, + backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundColor: ctx.cs.primaryContainer, ) else CircleAvatar( radius: 10, - backgroundColor: cs.primaryContainer, + backgroundColor: ctx.cs.primaryContainer, child: Text( displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', - style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + style: TextStyle( + fontSize: 9, + color: ctx.cs.onPrimaryContainer, + ), ), ), const SizedBox(width: 6), @@ -762,29 +769,22 @@ class MessageBubble extends StatelessWidget { padding: const EdgeInsets.only(left: 8), child: Text( message.text ?? '', - style: TextStyle( - color: isMe ? Colors.white : const Color(0xFF1C1C1E), - fontSize: 16, - height: 1.3, - ), + style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), ), ), const SizedBox(height: 6), ], - _buildPhotoContent(context, photos), + _buildPhotoContent(ctx, photos), ], ); } Widget _buildForwardedGenericContent( - BuildContext context, + _BubbleCtx ctx, ForwardedMessageAttachment forwarded, List attachments, ) { - final cs = Theme.of(context).colorScheme; - final headerColor = isMe - ? Colors.white.withValues(alpha: 0.7) - : cs.onSurfaceVariant; + final headerColor = ctx.dim; final displaySender = forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); final senderAvatar = forwarded.originalSenderAvatar; @@ -803,18 +803,21 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar), - backgroundColor: cs.primaryContainer, + backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundColor: ctx.cs.primaryContainer, ) else CircleAvatar( radius: 10, - backgroundColor: cs.primaryContainer, + backgroundColor: ctx.cs.primaryContainer, child: Text( displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', - style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + style: TextStyle( + fontSize: 9, + color: ctx.cs.onPrimaryContainer, + ), ), ), const SizedBox(width: 6), @@ -832,7 +835,7 @@ class MessageBubble extends StatelessWidget { const SizedBox(height: 4), ...attachments.map((a) { if (a is FileAttachment) { - return _buildFileAttachment(context, a); + return _buildFileAttachment(ctx, a); } return const SizedBox.shrink(); }), @@ -840,7 +843,7 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildSinglePhoto(BuildContext ctx, PhotoAttachment photo) { + Widget _buildSinglePhoto(_BubbleCtx ctx, PhotoAttachment photo) { final imageUrl = photo.baseUrl ?? ''; final width = photo.width?.toDouble() ?? 200; final height = photo.height?.toDouble() ?? 200; @@ -848,23 +851,21 @@ class MessageBubble extends StatelessWidget { final constrainedWidth = width.clamp(photoMinSize, photoMaxSize); final constrainedHeight = height.clamp(photoMinSize, photoMaxSize); - final bool matchTop = _hasPhotoWithCaption; - final bool matchBottom = !_hasPhotoWithCaption; + final matchTop = ctx.hasPhotoWithCaption; + final matchBottom = !ctx.hasPhotoWithCaption; + + final topR = matchTop ? _bigRadius : _photoRadius; + final bottomL = + matchBottom ? (isMe ? _bigRadius : _smallRadius) : _smallRadius; + final bottomR = + matchBottom ? (isMe ? _smallRadius : _bigRadius) : _smallRadius; return ClipRRect( borderRadius: BorderRadius.only( - topLeft: Radius.circular( - matchTop ? bubbleBorderRadius : photoBorderRadius, - ), - topRight: Radius.circular( - matchTop ? bubbleBorderRadius : photoBorderRadius, - ), - bottomLeft: Radius.circular( - matchBottom ? (isMe ? bubbleBorderRadius : 4) : 4, - ), - bottomRight: Radius.circular( - matchBottom ? (isMe ? 4 : bubbleBorderRadius) : 4, - ), + topLeft: topR, + topRight: topR, + bottomLeft: bottomL, + bottomRight: bottomR, ), child: Stack( children: [ @@ -874,19 +875,21 @@ class MessageBubble extends StatelessWidget { width: constrainedWidth, height: constrainedHeight, fit: BoxFit.cover, + memCacheWidth: (constrainedWidth * 2).round(), + memCacheHeight: (constrainedHeight * 2).round(), fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => _buildPhotoPlaceholder( - ctx, + ctx.cs, constrainedWidth, constrainedHeight, ), ) else - _buildPhotoPlaceholder(ctx, constrainedWidth, constrainedHeight), + _buildPhotoPlaceholder(ctx.cs, constrainedWidth, constrainedHeight), Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openPhotoViewer(ctx.context, photo), ), ), ], @@ -895,60 +898,60 @@ class MessageBubble extends StatelessWidget { } Widget _buildTwoPhotos( - BuildContext ctx, + _BubbleCtx ctx, PhotoAttachment p1, PhotoAttachment p2, ) { final matchTop = - _hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop; + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop; final matchBottom = - _hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom; + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom; + + final topR = matchTop ? _bigRadius : _photoRadius; + final bottomL = matchBottom ? _smallRadius : _photoRadius; + final bottomR = matchBottom + ? (isMe ? _smallRadius : _bigRadius) + : _photoRadius; return ClipRRect( borderRadius: BorderRadius.only( - topLeft: Radius.circular( - matchTop ? bubbleBorderRadius : photoBorderRadius, - ), - topRight: Radius.circular( - matchTop ? bubbleBorderRadius : photoBorderRadius, - ), - bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius), - bottomRight: Radius.circular( - matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius, - ), + topLeft: topR, + topRight: topR, + bottomLeft: bottomL, + bottomRight: bottomR, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildPhotoTile(ctx, p1), + Expanded(child: _buildPhotoTile(ctx, p1)), const SizedBox(width: 2), - _buildPhotoTile(ctx, p2), + Expanded(child: _buildPhotoTile(ctx, p2)), ], ), ); } - Widget _buildPhotoGrid(BuildContext ctx, List photos) { + Widget _buildPhotoGrid(_BubbleCtx ctx, List photos) { final displayCount = photos.length > 4 ? 4 : photos.length; final remaining = photos.length - 4; final matchTop = - _hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop; + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop; final matchBottom = - _hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom; + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom; + + final topR = matchTop ? _bigRadius : _photoRadius; + final bottomL = matchBottom ? _smallRadius : _photoRadius; + final bottomR = matchBottom + ? (isMe ? _smallRadius : _bigRadius) + : _photoRadius; return ClipRRect( borderRadius: BorderRadius.only( - topLeft: Radius.circular( - matchTop ? bubbleBorderRadius : photoBorderRadius, - ), - topRight: Radius.circular( - matchTop ? bubbleBorderRadius : photoBorderRadius, - ), - bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius), - bottomRight: Radius.circular( - matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius, - ), + topLeft: topR, + topRight: topR, + bottomLeft: bottomL, + bottomRight: bottomR, ), child: GridView.count( crossAxisCount: 2, @@ -966,88 +969,87 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildPhotoTile(BuildContext ctx, PhotoAttachment photo) { + Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) { final imageUrl = photo.baseUrl ?? ''; - return Expanded( - child: AspectRatio( - aspectRatio: 1, - child: Stack( - children: [ - if (imageUrl.isNotEmpty) - CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (_, _, _) => - _buildPhotoPlaceholder(ctx, 100, 100), - ) - else - _buildPhotoPlaceholder(ctx, 100, 100), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)), - ), + return AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + if (imageUrl.isNotEmpty) + CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + memCacheWidth: 280, + memCacheHeight: 280, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => + _buildPhotoPlaceholder(ctx.cs, 100, 100), + ) + else + _buildPhotoPlaceholder(ctx.cs, 100, 100), + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openPhotoViewer(ctx.context, photo), ), - ], - ), + ), + ], ), ); } Widget _buildPhotoTileWithOverlay( - BuildContext ctx, + _BubbleCtx ctx, PhotoAttachment photo, String overlay, ) { final imageUrl = photo.baseUrl ?? ''; - return Expanded( - child: AspectRatio( - aspectRatio: 1, - child: Stack( - children: [ - if (imageUrl.isNotEmpty) - CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (_, _, _) => - _buildPhotoPlaceholder(ctx, 100, 100), - ) - else - _buildPhotoPlaceholder(ctx, 100, 100), - Positioned.fill( - child: Container( - color: Colors.black45, - child: Center( - child: Text( - overlay, - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), + return AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + if (imageUrl.isNotEmpty) + CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + memCacheWidth: 280, + memCacheHeight: 280, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => + _buildPhotoPlaceholder(ctx.cs, 100, 100), + ) + else + _buildPhotoPlaceholder(ctx.cs, 100, 100), + Positioned.fill( + child: Container( + color: Colors.black45, + child: Center( + child: Text( + overlay, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, ), ), ), ), - ], - ), + ), + ], ), ); } Widget _buildPhotoPlaceholder( - BuildContext ctx, + ColorScheme cs, double w, double h, { VoidCallback? onRetry, }) { - final cs = Theme.of(ctx).colorScheme; return Container( width: w, height: h, @@ -1066,23 +1068,14 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildCaption(BuildContext ctx) { - final cs = Theme.of(ctx).colorScheme; - final isDark = cs.brightness == Brightness.dark; - final textColor = isMe - ? Colors.white - : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); - + Widget _buildCaption(_BubbleCtx ctx) { return Text( message.text ?? '', - style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), ); } - Widget _buildGenericAttachment( - BuildContext ctx, - MessageAttachment attachment, - ) { + Widget _buildGenericAttachment(_BubbleCtx ctx, MessageAttachment attachment) { switch (attachment.type) { case AttachmentType.video: return _buildVideoAttachment(ctx, attachment); @@ -1095,7 +1088,7 @@ class MessageBubble extends StatelessWidget { } } - Widget _buildVideoAttachment(BuildContext ctx, MessageAttachment video) { + Widget _buildVideoAttachment(_BubbleCtx ctx, MessageAttachment video) { return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -1106,17 +1099,17 @@ class MessageBubble extends StatelessWidget { Container( width: 200, height: 150, - color: Theme.of(ctx).colorScheme.surfaceContainerHighest, + color: ctx.cs.surfaceContainerHighest, child: Icon( Symbols.videocam, size: 48, - color: Theme.of(ctx).colorScheme.onSurfaceVariant, + color: ctx.cs.onSurfaceVariant, ), ), Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell(onTap: () {}), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, ), ), ], @@ -1128,18 +1121,10 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildFileAttachment(BuildContext ctx, MessageAttachment file) { - final cs = Theme.of(ctx).colorScheme; - final isDark = cs.brightness == Brightness.dark; + Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) { final name = (file as dynamic).name as String? ?? 'File'; final size = (file as dynamic).size as int? ?? 0; final sizeStr = _formatFileSize(size); - final textColor = isMe - ? Colors.white - : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); - final subtitleColor = isMe - ? Colors.white.withValues(alpha: 0.65) - : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); return Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), @@ -1152,13 +1137,13 @@ class MessageBubble extends StatelessWidget { height: 38, decoration: BoxDecoration( color: isMe - ? Colors.white.withValues(alpha: 0.2) - : cs.primaryContainer, + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.primaryContainer, borderRadius: BorderRadius.circular(10), ), child: Icon( Symbols.description, - color: isMe ? Colors.white : cs.primary, + color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, size: 20, ), ), @@ -1171,7 +1156,7 @@ class MessageBubble extends StatelessWidget { Text( name, style: TextStyle( - color: textColor, + color: ctx.text, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2, @@ -1183,7 +1168,7 @@ class MessageBubble extends StatelessWidget { Text( sizeStr, style: TextStyle( - color: subtitleColor, + color: ctx.dim, fontSize: 12, height: 1.2, ), @@ -1199,15 +1184,13 @@ class MessageBubble extends StatelessWidget { height: 34, decoration: BoxDecoration( color: isMe - ? Colors.white.withValues(alpha: 0.15) - : (isDark - ? cs.surfaceContainerHighest - : const Color(0xFFE5E5EA)), + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.surfaceContainerHighest, shape: BoxShape.circle, ), child: Icon( Symbols.download, - color: isMe ? Colors.white : cs.primary, + color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, size: 18, ), ), @@ -1223,7 +1206,7 @@ class MessageBubble extends StatelessWidget { return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} МБ'; } - Widget _buildStickerAttachment(BuildContext ctx, MessageAttachment sticker) { + Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) { final url = sticker.baseUrl ?? ''; final preview = sticker.previewData ?? ''; final imageUrl = url.isNotEmpty ? url : preview; @@ -1238,20 +1221,20 @@ class MessageBubble extends StatelessWidget { width: 150, height: 150, fit: BoxFit.contain, + memCacheWidth: 300, + memCacheHeight: 300, fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => - _buildPhotoPlaceholder(ctx, 150, 150), + _buildPhotoPlaceholder(ctx.cs, 150, 150), ) else - _buildPhotoPlaceholder(ctx, 150, 150), + _buildPhotoPlaceholder(ctx.cs, 150, 150), ], ), ); } - Widget _buildContactAttachment(BuildContext ctx, MessageAttachment contact) { - final cs = Theme.of(ctx).colorScheme; - final isDark = cs.brightness == Brightness.dark; + Widget _buildContactAttachment(_BubbleCtx ctx, MessageAttachment contact) { final contactData = contact as ContactAttachment; final firstName = contactData.firstName ?? ''; @@ -1265,15 +1248,9 @@ class MessageBubble extends StatelessWidget { : (contactData.name ?? 'Contact'); final photoUrl = contactData.photoUrl ?? contactData.baseUrl; - final textColor = isMe - ? Colors.white - : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); - final subtitleColor = isMe - ? Colors.white.withValues(alpha: 0.65) - : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); final bgColor = isMe - ? Colors.white.withValues(alpha: 0.2) - : (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA)); + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.surfaceContainerHighest; return Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), @@ -1294,17 +1271,22 @@ class MessageBubble extends StatelessWidget { child: CachedNetworkImage( imageUrl: photoUrl, fit: BoxFit.cover, + memCacheWidth: 144, + memCacheHeight: 144, fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => Icon( Symbols.person, - color: isMe ? Colors.white : cs.primary, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, size: 24, ), ), ) : Icon( Symbols.person, - color: isMe ? Colors.white : cs.primary, + color: + isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, size: 24, ), ), @@ -1317,7 +1299,7 @@ class MessageBubble extends StatelessWidget { Text( name.isNotEmpty ? name : 'Contact', style: TextStyle( - color: textColor, + color: ctx.text, fontSize: 15, fontWeight: FontWeight.w500, height: 1.2, @@ -1330,7 +1312,7 @@ class MessageBubble extends StatelessWidget { Text( contactData.phoneNumber!, style: TextStyle( - color: subtitleColor, + color: ctx.dim, fontSize: 12, height: 1.2, ), @@ -1345,11 +1327,9 @@ class MessageBubble extends StatelessWidget { } Widget _buildForwardedContactContent( - BuildContext context, + _BubbleCtx ctx, ForwardedMessageAttachment forwarded, ) { - final cs = Theme.of(context).colorScheme; - final isDark = cs.brightness == Brightness.dark; final contact = forwarded.originalContact!; final firstName = contact.firstName ?? ''; @@ -1363,18 +1343,10 @@ class MessageBubble extends StatelessWidget { : (contact.name ?? 'Contact'); final photoUrl = contact.photoUrl ?? contact.baseUrl; - final textColor = isMe - ? Colors.white - : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); - final subtitleColor = isMe - ? Colors.white.withValues(alpha: 0.65) - : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); final bgColor = isMe - ? Colors.white.withValues(alpha: 0.2) - : (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA)); - final headerColor = isMe - ? Colors.white.withValues(alpha: 0.7) - : cs.onSurfaceVariant; + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.surfaceContainerHighest; + final headerColor = ctx.dim; final displaySender = forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); @@ -1394,18 +1366,21 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar), - backgroundColor: cs.primaryContainer, + backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundColor: ctx.cs.primaryContainer, ) else CircleAvatar( radius: 10, - backgroundColor: cs.primaryContainer, + backgroundColor: ctx.cs.primaryContainer, child: Text( displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', - style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + style: TextStyle( + fontSize: 9, + color: ctx.cs.onPrimaryContainer, + ), ), ), const SizedBox(width: 6), @@ -1443,14 +1418,18 @@ class MessageBubble extends StatelessWidget { fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => Icon( Symbols.person, - color: isMe ? Colors.white : cs.primary, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, size: 24, ), ), ) : Icon( Symbols.person, - color: isMe ? Colors.white : cs.primary, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, size: 24, ), ), @@ -1463,7 +1442,7 @@ class MessageBubble extends StatelessWidget { Text( name.isNotEmpty ? name : 'Contact', style: TextStyle( - color: textColor, + color: ctx.text, fontSize: 15, fontWeight: FontWeight.w500, height: 1.2, @@ -1476,7 +1455,7 @@ class MessageBubble extends StatelessWidget { Text( contact.phoneNumber!, style: TextStyle( - color: subtitleColor, + color: ctx.dim, fontSize: 12, height: 1.2, ), @@ -1496,13 +1475,7 @@ class MessageBubble extends StatelessWidget { // TODO: Open photo viewer } - Widget _buildVoiceContent(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final isDark = cs.brightness == Brightness.dark; - final textColor = isMe - ? Colors.white - : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); - + Widget _buildVoiceContent(_BubbleCtx ctx) { int duration = 0; String url = ''; String? waveData; @@ -1533,11 +1506,11 @@ class MessageBubble extends StatelessWidget { return _VoiceMessageBubble( duration: duration, url: url, - textColor: textColor, + textColor: ctx.text, isMe: isMe, status: overrideStatus ?? message.status, time: message.time, - cs: cs, + cs: ctx.cs, waveData: waveData, chatId: message.chatId, messageId: message.id, @@ -1546,10 +1519,7 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildMeta(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final timeColor = isMe ? Colors.white70 : cs.onSurfaceVariant; - + Widget _buildMeta(_BubbleCtx ctx) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( @@ -1558,19 +1528,21 @@ class MessageBubble extends StatelessWidget { children: [ Text( _formatTime(message.time), - style: TextStyle(color: timeColor, fontSize: 11), + style: TextStyle(color: ctx.dim, fontSize: 11), ), - if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], + if (isMe) ...[ + const SizedBox(width: 4), + _buildStatusIcon(ctx), + ], ], ), ); } - Widget _buildCompactTime(BuildContext ctx) { + Widget _buildCompactTime() { final bgColor = isMe ? Colors.black.withValues(alpha: 0.4) : Colors.black.withValues(alpha: 0.5); - final textColor = Colors.white; return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), @@ -1580,8 +1552,8 @@ class MessageBubble extends StatelessWidget { ), child: Text( _formatTime(message.time), - style: TextStyle( - color: textColor, + style: const TextStyle( + color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500, ), @@ -1589,7 +1561,7 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildStatusIcon(BuildContext context) { + Widget _buildStatusIcon(_BubbleCtx ctx) { final status = overrideStatus ?? message.status; IconData icon; Color color; @@ -1598,14 +1570,14 @@ class MessageBubble extends StatelessWidget { case 'sending': case 'pending': icon = Symbols.schedule; - color = Colors.white70; + color = ctx.dim; case null: case 'sent': icon = Symbols.check; - color = Colors.white70; + color = ctx.dim; case 'delivered': icon = Symbols.done_all; - color = Colors.white70; + color = ctx.dim; case 'read': icon = Symbols.done_all; color = const Color(0xFF4FC3F7); @@ -1614,7 +1586,7 @@ class MessageBubble extends StatelessWidget { color = Colors.redAccent; default: icon = Symbols.check; - color = Colors.white70; + color = ctx.dim; } return Icon(icon, size: 14, color: color); @@ -1703,13 +1675,13 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { case 'sending': case 'pending': icon = Symbols.schedule; - color = Colors.white54; + color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); case 'sent': icon = Symbols.check; - color = Colors.white54; + color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); case 'delivered': icon = Symbols.done_all; - color = Colors.white54; + color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); case 'read': icon = Symbols.done_all; color = const Color(0xFF4FC3F7); @@ -1718,7 +1690,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { color = Colors.redAccent; default: icon = Symbols.check; - color = Colors.white54; + color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); } } @@ -1727,14 +1699,11 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { @override Widget build(BuildContext context) { - final isDark = widget.cs.brightness == Brightness.dark; final waveInactiveColor = widget.isMe - ? Colors.white.withValues(alpha: 0.35) - : (isDark - ? widget.cs.surfaceContainerHighest - : const Color(0xFFD1D1D6)); + ? widget.cs.onPrimaryContainer.withValues(alpha: 0.35) + : widget.cs.surfaceContainerHighest; final waveActiveColor = widget.isMe - ? Colors.white.withValues(alpha: 0.7) + ? widget.cs.onPrimaryContainer.withValues(alpha: 0.7) : widget.cs.primary; return SizedBox( @@ -1753,13 +1722,15 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { height: 32, decoration: BoxDecoration( color: widget.isMe - ? Colors.white.withValues(alpha: 0.2) + ? widget.cs.onPrimaryContainer.withValues(alpha: 0.12) : widget.cs.primaryContainer, shape: BoxShape.circle, ), child: Icon( _isPlaying ? Symbols.pause : Symbols.play_arrow, - color: widget.isMe ? Colors.white : widget.cs.primary, + color: widget.isMe + ? widget.cs.onPrimaryContainer + : widget.cs.primary, size: 18, ), ), diff --git a/lib/main.dart b/lib/main.dart index 9e03307..37d7504 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,9 @@ import 'package:m3e_collection/m3e_collection.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; +import 'core/config/app_accent.dart'; +import 'core/config/app_bubble_shape.dart'; +import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; import 'backend/modules/account.dart'; import 'backend/modules/contacts.dart'; @@ -65,6 +68,9 @@ void main() async { final initialFontScale = AppFonts.clampScale( prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale, ); + final initialAccentSeed = await AppAccent.load(); + AppBubbleShape.current.value = await AppBubbleShape.load(); + AppCacheExtent.current.value = await AppCacheExtent.load(); runApp( KometApp( initialLocale: initialLocale, @@ -72,6 +78,7 @@ void main() async { initialVpnBypass: initialVpnBypass, initialFontId: initialFontId, initialFontScale: initialFontScale, + initialAccentSeed: initialAccentSeed, ), ); } @@ -84,6 +91,7 @@ class KometApp extends StatefulWidget { this.initialVpnBypass = false, required this.initialFontId, required this.initialFontScale, + this.initialAccentSeed, }); final Locale initialLocale; @@ -91,6 +99,7 @@ class KometApp extends StatefulWidget { final bool initialVpnBypass; final String initialFontId; final double initialFontScale; + final Color? initialAccentSeed; static final navigatorKey = GlobalKey(); static KometAppState? stateOf(BuildContext context) { @@ -107,6 +116,9 @@ class KometAppState extends State { late Locale _locale; late String _fontId; bool _isLoggingOut = false; + late final ValueNotifier accentSeed = ValueNotifier( + widget.initialAccentSeed, + ); StreamSubscription? _sessionExpiredSub; StreamSubscription? _loginStatusSub; StreamSubscription? _vpnBypassSub; @@ -205,6 +217,7 @@ class KometAppState extends State { fpsOverlayEnabled.dispose(); vpnBypassEnabled.dispose(); fontScale.dispose(); + accentSeed.dispose(); super.dispose(); } @@ -237,6 +250,11 @@ class KometAppState extends State { String get fontId => _fontId; + Future applyAccentColor(Color? seed) async { + await AppAccent.save(seed); + accentSeed.value = seed; + } + Future applyAppFont(String fontId) async { if (_fontId == fontId) return; final prefs = await SharedPreferences.getInstance(); @@ -265,6 +283,28 @@ class KometAppState extends State { ThemeData? _lightTheme; ThemeData? _darkTheme; + Color? _seedCacheKey; + ColorScheme? _seedCacheLight; + ColorScheme? _seedCacheDark; + + ({ColorScheme light, ColorScheme dark}) _schemesForSeed(Color seed) { + if (_seedCacheKey == seed && + _seedCacheLight != null && + _seedCacheDark != null) { + return (light: _seedCacheLight!, dark: _seedCacheDark!); + } + _seedCacheKey = seed; + _seedCacheLight = ColorScheme.fromSeed( + seedColor: seed, + brightness: Brightness.light, + ); + _seedCacheDark = ColorScheme.fromSeed( + seedColor: seed, + brightness: Brightness.dark, + ); + return (light: _seedCacheLight!, dark: _seedCacheDark!); + } + void _rebuildThemesIfNeeded(ColorScheme light, ColorScheme dark) { if (_themeCacheFontId == _fontId && _themeCacheLight == light && @@ -334,65 +374,72 @@ class KometAppState extends State { Widget build(BuildContext context) { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - final lightBase = - lightDynamic ?? - ColorScheme.fromSeed( - seedColor: _fallbackSeed, - brightness: Brightness.light, - ); - final darkBase = - darkDynamic ?? - ColorScheme.fromSeed( - seedColor: _fallbackSeed, - brightness: Brightness.dark, - ); + return ValueListenableBuilder( + valueListenable: accentSeed, + builder: (context, seed, _) { + final ColorScheme lightBase; + final ColorScheme darkBase; + if (seed != null) { + final s = _schemesForSeed(seed); + lightBase = s.light; + darkBase = s.dark; + } else if (lightDynamic != null && darkDynamic != null) { + lightBase = lightDynamic; + darkBase = darkDynamic; + } else { + final s = _schemesForSeed(_fallbackSeed); + lightBase = lightDynamic ?? s.light; + darkBase = darkDynamic ?? s.dark; + } - final lightScheme = _adjustLightScheme(lightBase); - final darkScheme = _adjustDarkScheme(darkBase); + final lightScheme = _adjustLightScheme(lightBase); + final darkScheme = _adjustDarkScheme(darkBase); - _rebuildThemesIfNeeded(lightScheme, darkScheme); + _rebuildThemesIfNeeded(lightScheme, darkScheme); - return MaterialApp( - title: 'Komet', - debugShowCheckedModeBanner: false, - locale: _locale, - themeMode: ThemeMode.system, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: _lightTheme, - darkTheme: _darkTheme, - navigatorKey: KometApp.navigatorKey, - builder: (context, child) { - return ValueListenableBuilder( - valueListenable: fontScale, - child: child ?? const SizedBox.shrink(), - builder: (context, scale, appChild) { - Widget scaledChild = appChild!; - if ((scale - 1.0).abs() > 0.001) { - scaledChild = MediaQuery.withClampedTextScaling( - minScaleFactor: scale, - maxScaleFactor: scale, - child: scaledChild, - ); - } - return ValueListenableBuilder( - valueListenable: fpsOverlayEnabled, - child: scaledChild, - builder: (context, fpsOn, sChild) { - return Stack( - fit: StackFit.expand, - clipBehavior: Clip.none, - children: [ - sChild!, - if (fpsOn) const FpsOverlayLayer(), - ], + return MaterialApp( + title: 'Komet', + debugShowCheckedModeBanner: false, + locale: _locale, + themeMode: ThemeMode.system, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: _lightTheme, + darkTheme: _darkTheme, + navigatorKey: KometApp.navigatorKey, + builder: (context, child) { + return ValueListenableBuilder( + valueListenable: fontScale, + child: child ?? const SizedBox.shrink(), + builder: (context, scale, appChild) { + Widget scaledChild = appChild!; + if ((scale - 1.0).abs() > 0.001) { + scaledChild = MediaQuery.withClampedTextScaling( + minScaleFactor: scale, + maxScaleFactor: scale, + child: scaledChild, + ); + } + return ValueListenableBuilder( + valueListenable: fpsOverlayEnabled, + child: scaledChild, + builder: (context, fpsOn, sChild) { + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + sChild!, + if (fpsOn) const FpsOverlayLayer(), + ], + ); + }, ); }, ); }, + home: const _StartupScreen(), ); }, - home: const _StartupScreen(), ); }, ); diff --git a/lib/models/call.dart b/lib/models/call.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/models/chat.dart b/lib/models/chat.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/models/message.dart b/lib/models/message.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/models/session.dart b/lib/models/session.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/models/user.dart b/lib/models/user.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/state/account_state.dart b/lib/state/account_state.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/state/call_state.dart b/lib/state/call_state.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/state/chat_state.dart b/lib/state/chat_state.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/state/message_state.dart b/lib/state/message_state.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/storage/cache.dart b/lib/storage/cache.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/storage/local_storage.dart b/lib/storage/local_storage.dart deleted file mode 100644 index e69de29..0000000 diff --git a/pubspec.lock b/pubspec.lock index 2501c4e..e5f589c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -129,14 +129,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" - source: hosted - version: "1.0.9" dart_lz4: dependency: "direct main" description: @@ -355,54 +347,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.34" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 - url: "https://pub.dev" - source: hosted - version: "10.0.0" - flutter_secure_storage_darwin: - dependency: transitive - description: - name: flutter_secure_storage_darwin - sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" - url: "https://pub.dev" - source: hosted - version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -446,7 +390,7 @@ packages: source: hosted version: "1.0.2" http: - dependency: "direct main" + dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -454,7 +398,7 @@ packages: source: hosted version: "1.6.0" http_parser: - dependency: "direct main" + dependency: transitive description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" diff --git a/pubspec.yaml b/pubspec.yaml index a0d984f..80f23de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,6 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 dart_lz4: ^1.0.0 libcompress: ^1.0.0 msgpack_dart: ^1.0.1 @@ -44,9 +43,6 @@ dependencies: device_info_plus: 12.3.0 flutter_timezone: ^5.0.1 timezone: ^0.11.0 - flutter_secure_storage: ^10.0.0 - http: ^1.4.0 - http_parser: ^4.1.0 file_picker: ^8.0.0 sqflite: ^2.4.2 sqflite_common_ffi: ^2.4.0+2