diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 308b8a2..b1f4359 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -9,6 +9,7 @@ import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import '../api.dart'; +import 'folders.dart'; import 'messages.dart' show ContactCache; Map _parseParticipants(dynamic raw) { @@ -78,6 +79,12 @@ class CachedChat { bool iAmAdmin(int myId) => owner == myId || admins.contains(myId); + bool get isMuted { + if (dontDisturbUntil == ChatsModule.muteOff) return false; + if (dontDisturbUntil < 0) return true; + return dontDisturbUntil > DateTime.now().millisecondsSinceEpoch; + } + factory CachedChat.fromDbRow(Map row) => CachedChat( id: row['id'] as int, accountId: row['account_id'] as int, @@ -140,20 +147,36 @@ class CachedChat { } class ChatsModule { + static const int muteOff = 0; + static const int muteForever = -1; + static final ValueNotifier chatsChanged = ValueNotifier(0); static void _bump() => chatsChanged.value = chatsChanged.value + 1; static final Set _pendingContactUpdates = {}; static Timer? _contactFlushTimer; + static Future? _contactFlushFuture; static const _contactFlushDelay = Duration(milliseconds: 250); static void applyContactUpdate(int contactId) { _pendingContactUpdates.add(contactId); - _contactFlushTimer ??= Timer(_contactFlushDelay, _flushContactUpdates); + if (_contactFlushTimer != null) return; + if (_contactFlushFuture != null) return; + _contactFlushTimer = Timer(_contactFlushDelay, _kickFlush); + } + + static void _kickFlush() { + _contactFlushTimer = null; + if (_contactFlushFuture != null) return; + _contactFlushFuture = _flushContactUpdates().whenComplete(() { + _contactFlushFuture = null; + if (_pendingContactUpdates.isNotEmpty) { + _contactFlushTimer ??= Timer(_contactFlushDelay, _kickFlush); + } + }); } static Future _flushContactUpdates() async { - _contactFlushTimer = null; if (_pendingContactUpdates.isEmpty) return; final ids = _pendingContactUpdates.toList(); _pendingContactUpdates.clear(); @@ -161,18 +184,25 @@ class ChatsModule { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return; + final dialogRows = await AppDatabase.loadDialogChats(accountId); + final byParticipant = >>{}; + for (final row in dialogRows) { + final cached = CachedChat.fromDbRow(row); + for (final pid in cached.participants.keys) { + if (pid == accountId) continue; + byParticipant.putIfAbsent(pid, () => []).add(row); + } + } + final updates = >[]; for (final contactId in ids) { final name = ContactCache.get(contactId); if (name == null) continue; final avatar = ContactCache.getAvatar(contactId); final options = ContactCache.getOptions(contactId) ?? const {}; - - final rows = await AppDatabase.findDialogChatsByParticipant( - accountId, - contactId, - ); - for (final row in rows) { + final affected = byParticipant[contactId]; + if (affected == null) continue; + for (final row in affected) { final cached = CachedChat.fromDbRow(row); final sameTitle = cached.title == name; final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? ''); @@ -194,12 +224,15 @@ class ChatsModule { static Future cacheServerChat( Map chat, - int accountId, - ) async { + int accountId, { + Map? preloadedExisting, + }) async { final cachedAt = DateTime.now().millisecondsSinceEpoch; final id = chat['id']; Map existing = const {}; - if (id is int) { + if (preloadedExisting != null) { + existing = preloadedExisting; + } else if (id is int) { final rows = await AppDatabase.loadChat(accountId, id); if (rows.isNotEmpty) { existing = {id: CachedChat.fromDbRow(rows.first)}; @@ -219,11 +252,40 @@ class ChatsModule { logger.w('cacheServerChat: parse returned null for chat=${chat['id']}'); return null; } + final ex = existing[parsed.id]; + if (ex != null && _sameContent(ex, parsed)) { + return parsed; + } await AppDatabase.saveChats([parsed.toDbRow()]); _bump(); return parsed; } + static bool _sameContent(CachedChat a, CachedChat b) { + if (a.title != b.title) return false; + if (a.iconUrl != b.iconUrl) return false; + if (a.owner != b.owner) return false; + if (a.dontDisturbUntil != b.dontDisturbUntil) return false; + if (a.favIndex != b.favIndex) return false; + if (a.lastMsgId != b.lastMsgId) return false; + if (a.lastMsgTime != b.lastMsgTime) return false; + if (a.lastMsgText != b.lastMsgText) return false; + if (a.lastMsgSenderId != b.lastMsgSenderId) return false; + if (a.unreadCount != b.unreadCount) return false; + if (a.lastEventTime != b.lastEventTime) return false; + if (a.isOnline != b.isOnline) return false; + if (a.seenTime != b.seenTime) return false; + if (a.admins.length != b.admins.length) return false; + if (!a.admins.containsAll(b.admins)) return false; + if (a.options.length != b.options.length) return false; + if (!a.options.containsAll(b.options)) return false; + if (a.participants.length != b.participants.length) return false; + for (final e in a.participants.entries) { + if (b.participants[e.key] != e.value) return false; + } + return true; + } + /// Парсит и кэширует чаты из payload opcode 19. /// /// Для диалогов разрезолвит имя и аватар из списка [contacts] того же @@ -563,6 +625,95 @@ class ChatsModule { return packet.isOk; } + static Future togglePin( + Api api, { + required List chatIds, + required bool pin, + }) async { + if (chatIds.isEmpty) return null; + try { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return 'Нет активного аккаунта'; + final folders = await FoldersModule.loadFolders(accountId); + final allFolder = folders.firstWhere( + FoldersModule.isAllChatsFolder, + orElse: () => folders.isEmpty + ? throw StateError('Папка "Все" не найдена') + : folders.first, + ); + + final favorites = List.from(allFolder.favorites ?? const []); + if (pin) { + for (final id in chatIds) { + if (!favorites.contains(id)) favorites.add(id); + } + } else { + favorites.removeWhere((id) => chatIds.contains(id)); + } + + await FoldersModule.setFolderFavorites(api, accountId, allFolder, favorites); + + final existingRows = await AppDatabase.loadChatsByIds(accountId, chatIds); + final updates = >[]; + for (final row in existingRows) { + final id = row['id'] as int; + final isFav = favorites.contains(id); + final currentFav = row['fav_index'] as int?; + final newFav = isFav + ? ((currentFav ?? 0) > 0 ? currentFav : favorites.indexOf(id) + 1) + : 0; + if (currentFav == newFav) continue; + final newRow = Map.from(row); + newRow['fav_index'] = newFav; + updates.add(newRow); + } + if (updates.isNotEmpty) { + await AppDatabase.saveChats(updates); + _bump(); + } + return null; + } on PacketError catch (e) { + logger.w('togglePin: ${e.message}'); + return e.message; + } catch (e) { + logger.w('togglePin: $e'); + return 'Не удалось изменить закрепление'; + } + } + + static Future setChatMute( + Api api, { + required int chatId, + required int dontDisturbUntil, + }) async { + try { + await api.sendRequest(Opcode.config, { + 'settings': { + 'chats': { + chatId: {'dontDisturbUntil': dontDisturbUntil}, + }, + }, + }); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isNotEmpty) { + final row = Map.from(rows.first); + row['dont_disturb_until'] = dontDisturbUntil; + await AppDatabase.saveChats([row]); + _bump(); + } + } + return null; + } on PacketError catch (e) { + logger.w('setChatMute $chatId: ${e.message}'); + return e.message; + } catch (e) { + logger.w('setChatMute $chatId: $e'); + return 'Не удалось изменить уведомления'; + } + } + static Future deleteChat( Api api, { required int chatId, @@ -605,10 +756,19 @@ class ChatsModule { if (list is! List) return const []; final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return const []; + final existingRows = await AppDatabase.loadChatsByIds(accountId, chatIds); + final preloadedExisting = { + for (final row in existingRows) + row['id'] as int: CachedChat.fromDbRow(row), + }; final out = []; for (final c in list) { if (c is Map) { - final cached = await cacheServerChat(c, accountId); + final cached = await cacheServerChat( + c, + accountId, + preloadedExisting: preloadedExisting, + ); if (cached != null) out.add(cached); } } diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart index 7f66fd2..b2856d8 100644 --- a/lib/backend/modules/folders.dart +++ b/lib/backend/modules/folders.dart @@ -179,6 +179,57 @@ class FoldersModule { await markFoldersListReady(accountId); } + static Future setFolderFavorites( + Api api, + int accountId, + ChatFolder folder, + List favorites, + ) async { + final packet = await api.sendRequest(Opcode.foldersUpdate, { + 'id': folder.id, + 'title': folder.title, + 'include': folder.include ?? const [], + 'favorites': favorites, + 'filters': folder.filters, + 'options': folder.options ?? const [], + }); + if (packet.isError) { + throw PacketError(messageFromErrorPayload(packet.payload)); + } + final data = packet.payload; + if (data is! Map) return null; + final folderJson = data['folder']; + if (folderJson is! Map) return null; + final updated = ChatFolder.fromJson( + folderJson is Map + ? folderJson + : Map.from(folderJson), + ); + + final currentRaw = await AppDatabase.getSyncValue(accountId, _syncKey); + final snapshot = (currentRaw != null && currentRaw.isNotEmpty) + ? jsonDecode(currentRaw) as Map + : {}; + final existing = (snapshot['folders'] as List?) + ?.map((e) { + final m = e is Map + ? e + : Map.from(e as Map); + return ChatFolder.fromJson(m); + }) + .toList() ?? + []; + final idx = existing.indexWhere((f) => f.id == updated.id); + if (idx >= 0) { + existing[idx] = updated; + } else { + existing.add(updated); + } + final order = snapshot['foldersOrder'] as List?; + await _persist(accountId, existing, order); + return updated; + } + static Future syncFromServer(Api api, int accountId) async { try { final packet = await api.sendRequest(Opcode.foldersGet, { diff --git a/lib/core/config/app_bubble_behavior.dart b/lib/core/config/app_bubble_behavior.dart new file mode 100644 index 0000000..9ea84cf --- /dev/null +++ b/lib/core/config/app_bubble_behavior.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum BubbleBehavior { mutable, immutable } + +class AppBubbleBehavior { + static const prefKey = 'app_bubble_behavior'; + static final ValueNotifier current = ValueNotifier( + BubbleBehavior.mutable, + ); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final val = prefs.getString(prefKey); + return _parse(val); + } + + static Future save(BubbleBehavior behavior) async { + current.value = behavior; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, behavior.name); + } + + static BubbleBehavior _parse(String? val) { + if (val == BubbleBehavior.immutable.name) return BubbleBehavior.immutable; + return BubbleBehavior.mutable; + } + + static String label(BubbleBehavior behavior) { + switch (behavior) { + case BubbleBehavior.mutable: + return 'Изменяемая'; + case BubbleBehavior.immutable: + return 'Неизменяемая'; + } + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 06dab07..3b61d69 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -471,15 +471,26 @@ class AppDatabase { ); } - static Future>> findDialogChatsByParticipant( - int accountId, - int contactId, - ) async { + static Future>> loadDialogChats(int accountId) async { final db = await _instance; return db.query( 'chats_cache', - where: "account_id = ? AND type = 'DIALOG' AND participants LIKE ?", - whereArgs: [accountId, '%"$contactId":%'], + where: "account_id = ? AND type = 'DIALOG'", + whereArgs: [accountId], + ); + } + + static Future>> loadChatsByIds( + int accountId, + List ids, + ) async { + if (ids.isEmpty) return const []; + final db = await _instance; + final placeholders = List.filled(ids.length, '?').join(','); + return db.query( + 'chats_cache', + where: 'account_id = ? AND id IN ($placeholders)', + whereArgs: [accountId, ...ids], ); } diff --git a/lib/core/utils/bubble_radius.dart b/lib/core/utils/bubble_radius.dart new file mode 100644 index 0000000..7c45ca3 --- /dev/null +++ b/lib/core/utils/bubble_radius.dart @@ -0,0 +1,81 @@ +import 'package:flutter/widgets.dart'; + +import '../config/app_bubble_behavior.dart'; +import '../config/app_bubble_shape.dart'; + +const double kBubbleBigRadius = 20; +const double kBubbleSmallRadius = 4; + +const Radius _big = Radius.circular(kBubbleBigRadius); +const Radius _small = Radius.circular(kBubbleSmallRadius); + +BorderRadius computeBubbleRadius({ + required bool isMe, + required bool isTop, + required bool isBottom, + required BubbleStyle style, + required BubbleBehavior behavior, + bool hasPhotoWithCaption = false, + bool hasMultiplePhotosNoCaption = false, +}) { + final isSingle = isTop && isBottom; + + if (hasPhotoWithCaption && (isTop || isBottom)) { + return BorderRadius.only( + topLeft: _big, + topRight: _big, + bottomLeft: isMe ? _big : _small, + bottomRight: _small, + ); + } + + if (hasMultiplePhotosNoCaption && isBottom) { + return BorderRadius.only( + topLeft: isMe ? _big : _small, + topRight: _small, + bottomLeft: isMe ? _big : _small, + bottomRight: isMe ? _small : _big, + ); + } + + final base = style == BubbleStyle.desktop ? _small : _big; + Radius tl = base, tr = base, bl = base, br = base; + + if (behavior == BubbleBehavior.immutable || isSingle) { + return BorderRadius.only( + topLeft: tl, + topRight: tr, + bottomLeft: bl, + bottomRight: br, + ); + } + + if (isTop) { + if (isMe) { + br = _small; + } else { + bl = _small; + } + } else if (isBottom) { + if (isMe) { + tr = _small; + } else { + tl = _small; + } + } else { + if (isMe) { + tr = _small; + br = _small; + } else { + tl = _small; + bl = _small; + } + } + + return BorderRadius.only( + topLeft: tl, + topRight: tr, + bottomLeft: bl, + bottomRight: br, + ); +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 1597438..60b4d53 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -183,11 +183,10 @@ class _ChatListScreenState extends State return _DeleteKind.blocked; } - _DeleteKind? _selectionDeleteCategory() { + _DeleteKind? _selectionDeleteCategoryFor(List selected) { if (_sessionState != SessionState.online) return null; final myId = _profile?.id; if (myId == null) return null; - final selected = _selectedChatObjects(); if (selected.isEmpty) return null; final cats = selected.map((c) => _categorizeChat(c, myId)).toSet(); if (cats.contains(_DeleteKind.blocked)) return null; @@ -195,6 +194,47 @@ class _ChatListScreenState extends State return cats.single; } + Future _onPinTap() async { + final selected = _selectedChatObjects(); + if (selected.isEmpty) return; + final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0); + final err = await ChatsModule.togglePin( + api, + chatIds: selected.map((c) => c.id).toList(), + pin: !anyPinned, + ); + if (!mounted) return; + if (err != null) showCustomNotification(context, err); + _clearSelection(); + } + + Future _onMuteTap() async { + final selected = _selectedChatObjects(); + if (selected.isEmpty) return; + final anyMuted = selected.any((c) => c.isMuted); + final targetDDU = anyMuted ? ChatsModule.muteOff : ChatsModule.muteForever; + + final errors = []; + for (final c in selected) { + final err = await ChatsModule.setChatMute( + api, + chatId: c.id, + dontDisturbUntil: targetDDU, + ); + if (err != null) errors.add(err); + } + if (!mounted) return; + if (errors.isNotEmpty) { + showCustomNotification( + context, + errors.length == 1 + ? errors.first + : 'Не удалось изменить ${errors.length} чат(ов): ${errors.first}', + ); + } + _clearSelection(); + } + Future _onDeleteTap() async { final selectedBefore = _selectedChatObjects(); if (selectedBefore.isEmpty) return; @@ -1328,7 +1368,7 @@ class _ChatListScreenState extends State avatar ?? "", isOnline: chat.isOnline, unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, + isMuted: chat.isMuted, isVerified: isVerified, isPinned: isPinned, chatType: "DIALOG", @@ -1358,7 +1398,7 @@ class _ChatListScreenState extends State : '', isOnline: chat.isOnline, unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, + isMuted: chat.isMuted, isVerified: chat.isOfficial, isPinned: isPinned, chatType: chat.type, @@ -1803,37 +1843,53 @@ class _ChatListScreenState extends State ), ], ), - child: Row( - children: [ - IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: _clearSelection, - ), - const SizedBox(width: 8), - Text( - _selectedChats.length.toString(), - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - if (_selectionDeleteCategory() != null) + child: Builder(builder: (_) { + final selected = _selectedChatObjects(); + final deleteCategory = _selectionDeleteCategoryFor(selected); + final anyMuted = selected.any((c) => c.isMuted); + final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0); + return Row( + children: [ IconButton( - icon: Icon(Symbols.delete, color: cs.onSurface), - onPressed: _onDeleteTap, + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: _clearSelection, ), - IconButton( - icon: Icon(Symbols.archive, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon(Symbols.volume_off, color: cs.onSurface), - onPressed: () {}, - ), - ], - ), + const SizedBox(width: 8), + Text( + _selectedChats.length.toString(), + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (deleteCategory != null) + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: _onDeleteTap, + ), + IconButton( + icon: Icon(Symbols.archive, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon( + anyPinned ? Symbols.keep_off : Symbols.keep, + color: cs.onSurface, + ), + onPressed: selected.isEmpty ? null : _onPinTap, + ), + IconButton( + icon: Icon( + anyMuted ? Symbols.volume_up : Symbols.volume_off, + color: cs.onSurface, + ), + onPressed: selected.isEmpty ? null : _onMuteTap, + ), + ], + ); + }), ), ), ], diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index c74aaaf..09c96fd 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/config/app_bubble_behavior.dart'; import '../../../core/config/app_bubble_shape.dart'; +import '../../../core/utils/bubble_radius.dart'; import '../../../core/utils/haptics.dart'; import '../../../main.dart'; @@ -70,6 +72,11 @@ class _AppearanceScreenState extends State { AppBubbleShape.save(style); } + void _onBehaviorChanged(BubbleBehavior behavior) { + Haptics.selection(); + AppBubbleBehavior.save(behavior); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -98,6 +105,8 @@ class _AppearanceScreenState extends State { ), const SizedBox(height: 12), _BubbleShapeCard(onChanged: _onStyleChanged), + const SizedBox(height: 12), + _BubbleBehaviorCard(onChanged: _onBehaviorChanged), ], ), ), @@ -168,55 +177,85 @@ class _PreviewSectionState extends State<_PreviewSection> { class _ChatPreview extends StatelessWidget { const _ChatPreview(); + static const _messages = <_PreviewMsg>[ + _PreviewMsg('Привет!', true, true, false), + _PreviewMsg('Как тебе?', true, false, true), + _PreviewMsg('Привет!', false, true, false), + _PreviewMsg('хм...', false, false, false), + _PreviewMsg('Вполне неплохо!', false, false, true), + ]; + + BorderRadius _radiusFor( + _PreviewMsg msg, + BubbleStyle style, + BubbleBehavior behavior, + ) { + return computeBubbleRadius( + isMe: msg.isMe, + isTop: msg.isTop, + isBottom: msg.isBottom, + style: style, + behavior: behavior, + ); + } + @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), - ], - ), + return ListenableBuilder( + listenable: Listenable.merge( + [AppBubbleShape.current, AppBubbleBehavior.current], ), + builder: (context, _) { + final style = AppBubbleShape.current.value; + final behavior = AppBubbleBehavior.current.value; + return 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: [ + for (var i = 0; i < _messages.length; i++) ...[ + if (i > 0) + SizedBox(height: _messages[i].isTop ? 8 : 2), + _PreviewBubble( + text: _messages[i].text, + isMe: _messages[i].isMe, + radius: _radiusFor(_messages[i], style, behavior), + ), + ], + ], + ), + ); + }, ); } } +class _PreviewMsg { + final String text; + final bool isMe; + final bool isTop; + final bool isBottom; + const _PreviewMsg(this.text, this.isMe, this.isTop, this.isBottom); +} + class _PreviewBubble extends StatelessWidget { final String text; final bool isMe; - final BubbleStyle style; + final BorderRadius radius; const _PreviewBubble({ required this.text, required this.isMe, - required this.style, + required this.radius, }); - 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; @@ -230,7 +269,7 @@ class _PreviewBubble extends StatelessWidget { child: Container( constraints: const BoxConstraints(maxWidth: 220), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration(color: bg, borderRadius: _radius), + decoration: BoxDecoration(color: bg, borderRadius: radius), child: Text( text, style: TextStyle(color: fg, fontSize: 15, height: 1.3), @@ -444,6 +483,67 @@ class _BubbleShapeCard extends StatelessWidget { } } +class _BubbleBehaviorCard extends StatelessWidget { + final ValueChanged onChanged; + + const _BubbleBehaviorCard({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: AppBubbleBehavior.current, + builder: (context, current, _) { + return SegmentedButton( + segments: const [ + ButtonSegment( + value: BubbleBehavior.mutable, + label: Text('Изменяемая'), + icon: Icon(Symbols.auto_fix), + ), + ButtonSegment( + value: BubbleBehavior.immutable, + label: Text('Неизменяемая'), + icon: Icon(Symbols.lock), + ), + ], + selected: {current}, + onSelectionChanged: (set) { + if (set.isNotEmpty) onChanged(set.first); + }, + ); + }, + ), + ], + ), + ), + ); + } +} + class _HueStripPicker extends StatelessWidget { final Color color; final ValueChanged onChanged; diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index cc8c460..dfe82b3 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -3,7 +3,9 @@ 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_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; +import '../../core/utils/bubble_radius.dart'; import '../../core/utils/haptics.dart'; import '../../models/attachment.dart'; @@ -196,71 +198,21 @@ class MessageBubble extends StatelessWidget { BorderRadius _borderRadiusFor( BubbleStyle bubbleStyle, + BubbleBehavior bubbleBehavior, 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, - ); - } - - 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, - ); - } - - 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, + final isTop = shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle; + final isBottom = shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle; + return computeBubbleRadius( + isMe: isMe, + isTop: isTop, + isBottom: isBottom, + style: bubbleStyle, + behavior: bubbleBehavior, + hasPhotoWithCaption: hasPhotoWithCaption, + hasMultiplePhotosNoCaption: hasMultiplePhotosNoCaption, ); } @@ -352,9 +304,11 @@ class MessageBubble extends StatelessWidget { radius: 15, backgroundColor: Color(0x00000000), ), - ValueListenableBuilder( - valueListenable: AppBubbleShape.current, - builder: (context, bubbleStyle, child) { + ListenableBuilder( + listenable: Listenable.merge( + [AppBubbleShape.current, AppBubbleBehavior.current], + ), + builder: (context, child) { return Container( constraints: BoxConstraints( maxWidth: MediaQuery.sizeOf(context).width * 0.75, @@ -364,7 +318,8 @@ class MessageBubble extends StatelessWidget { ? cs.primaryContainer : cs.surfaceContainerHighest, borderRadius: _borderRadiusFor( - bubbleStyle, + AppBubbleShape.current.value, + AppBubbleBehavior.current.value, shape, hasPhotoCap, hasMultiPhotos, diff --git a/lib/main.dart b/lib/main.dart index 1f4c250..951c017 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ 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_behavior.dart'; import 'core/config/app_bubble_shape.dart'; import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; @@ -75,6 +76,7 @@ void main() async { ); final initialAccentSeed = await AppAccent.load(); AppBubbleShape.current.value = await AppBubbleShape.load(); + AppBubbleBehavior.current.value = await AppBubbleBehavior.load(); AppCacheExtent.current.value = await AppCacheExtent.load(); runApp( KometApp(