закрепление и мьют чатов
This commit is contained in:
+172
-12
@@ -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<int, int> _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<String, dynamic> 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<int> chatsChanged = ValueNotifier(0);
|
||||
static void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
||||
|
||||
static final Set<int> _pendingContactUpdates = {};
|
||||
static Timer? _contactFlushTimer;
|
||||
static Future<void>? _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<void> _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 = <int, List<Map<String, dynamic>>>{};
|
||||
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 = <Map<String, dynamic>>[];
|
||||
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 <String>{};
|
||||
|
||||
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<CachedChat?> cacheServerChat(
|
||||
Map<dynamic, dynamic> chat,
|
||||
int accountId,
|
||||
) async {
|
||||
int accountId, {
|
||||
Map<int, CachedChat>? preloadedExisting,
|
||||
}) async {
|
||||
final cachedAt = DateTime.now().millisecondsSinceEpoch;
|
||||
final id = chat['id'];
|
||||
Map<int, CachedChat> 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<String?> togglePin(
|
||||
Api api, {
|
||||
required List<int> 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<int>.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 = <Map<String, dynamic>>[];
|
||||
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<String, dynamic>.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<String?> 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<String, dynamic>.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<String?> 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 = <CachedChat>[];
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,57 @@ class FoldersModule {
|
||||
await markFoldersListReady(accountId);
|
||||
}
|
||||
|
||||
static Future<ChatFolder?> setFolderFavorites(
|
||||
Api api,
|
||||
int accountId,
|
||||
ChatFolder folder,
|
||||
List<int> 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<String, dynamic>
|
||||
? folderJson
|
||||
: Map<String, dynamic>.from(folderJson),
|
||||
);
|
||||
|
||||
final currentRaw = await AppDatabase.getSyncValue(accountId, _syncKey);
|
||||
final snapshot = (currentRaw != null && currentRaw.isNotEmpty)
|
||||
? jsonDecode(currentRaw) as Map<String, dynamic>
|
||||
: <String, dynamic>{};
|
||||
final existing = (snapshot['folders'] as List?)
|
||||
?.map((e) {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
})
|
||||
.toList() ??
|
||||
<ChatFolder>[];
|
||||
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<dynamic>?;
|
||||
await _persist(accountId, existing, order);
|
||||
return updated;
|
||||
}
|
||||
|
||||
static Future<void> syncFromServer(Api api, int accountId) async {
|
||||
try {
|
||||
final packet = await api.sendRequest(Opcode.foldersGet, {
|
||||
|
||||
@@ -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<BubbleBehavior> current = ValueNotifier(
|
||||
BubbleBehavior.mutable,
|
||||
);
|
||||
|
||||
static Future<BubbleBehavior> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final val = prefs.getString(prefKey);
|
||||
return _parse(val);
|
||||
}
|
||||
|
||||
static Future<void> 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 'Неизменяемая';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,15 +471,26 @@ class AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> findDialogChatsByParticipant(
|
||||
int accountId,
|
||||
int contactId,
|
||||
) async {
|
||||
static Future<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> loadChatsByIds(
|
||||
int accountId,
|
||||
List<int> 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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -183,11 +183,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
return _DeleteKind.blocked;
|
||||
}
|
||||
|
||||
_DeleteKind? _selectionDeleteCategory() {
|
||||
_DeleteKind? _selectionDeleteCategoryFor(List<CachedChat> 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<ChatListScreen>
|
||||
return cats.single;
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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 = <String>[];
|
||||
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<void> _onDeleteTap() async {
|
||||
final selectedBefore = _selectedChatObjects();
|
||||
if (selectedBefore.isEmpty) return;
|
||||
@@ -1328,7 +1368,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
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<ChatListScreen>
|
||||
: '',
|
||||
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<ChatListScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<AppearanceScreen> {
|
||||
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<AppearanceScreen> {
|
||||
),
|
||||
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<BubbleStyle>(
|
||||
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<BubbleBehavior> 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<BubbleBehavior>(
|
||||
valueListenable: AppBubbleBehavior.current,
|
||||
builder: (context, current, _) {
|
||||
return SegmentedButton<BubbleBehavior>(
|
||||
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<Color> onChanged;
|
||||
|
||||
@@ -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<BubbleStyle>(
|
||||
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user