feat(chats): локальный архив чатов

This commit is contained in:
klockky
2026-07-10 00:12:04 +03:00
parent c81cd91ecd
commit 72c914634f
6 changed files with 325 additions and 95 deletions
+17 -8
View File
@@ -724,17 +724,26 @@ class AppDatabase {
);
}
static Future<int> sumUnread(int accountId, {int? excludeChatId}) async {
static Future<int> sumUnread(
int accountId, {
int? excludeChatId,
Set<int>? excludeChatIds,
}) async {
final db = await _instance;
final where = excludeChatId != null
? 'account_id = ? AND in_list = 1 AND id != ?'
: 'account_id = ? AND in_list = 1';
final args = excludeChatId != null
? [accountId, excludeChatId]
: [accountId];
final buffer = StringBuffer('account_id = ? AND in_list = 1');
final args = <Object?>[accountId];
if (excludeChatId != null) {
buffer.write(' AND id != ?');
args.add(excludeChatId);
}
if (excludeChatIds != null && excludeChatIds.isNotEmpty) {
final placeholders = List.filled(excludeChatIds.length, '?').join(', ');
buffer.write(' AND id NOT IN ($placeholders)');
args.addAll(excludeChatIds);
}
final result = await db.rawQuery(
'SELECT COALESCE(SUM(unread_count), 0) AS total '
'FROM chats_cache WHERE $where',
'FROM chats_cache WHERE $buffer',
args,
);
return (result.first['total'] as int?) ?? 0;
@@ -0,0 +1,31 @@
import 'per_chat_json_store.dart';
class ArchivedChatsStore extends PerChatJsonStore<bool> {
ArchivedChatsStore._()
: super(
prefsKey: 'archived_chats',
fromJson: (raw) => raw == true ? true : null,
toJson: (value) => value,
);
static final ArchivedChatsStore instance = ArchivedChatsStore._();
bool isArchived(int accountId, int chatId) =>
read(accountId, chatId) == true;
Future<void> setArchived(int accountId, int chatId, bool archived) =>
write(accountId, chatId, archived ? true : null);
Set<int> archivedChatIds(int accountId) {
if (accountId == 0) return const {};
final prefix = '$accountId/';
final ids = <int>{};
for (final entry in allEntries) {
if (entry.value != true) continue;
if (!entry.key.startsWith(prefix)) continue;
final id = int.tryParse(entry.key.substring(prefix.length));
if (id != null) ids.add(id);
}
return ids;
}
}
@@ -22,6 +22,9 @@ abstract class PerChatJsonStore<T> {
String _buildKey(int accountId, int chatId) => '$accountId/$chatId';
@protected
Iterable<MapEntry<String, T>> get allEntries => _values.entries;
@protected
void onBeforeWrite(String key, T? previous, T? next) {}
+270 -87
View File
@@ -45,6 +45,7 @@ import '../../../backend/modules/contacts.dart';
import '../../../backend/modules/folders.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/draft_store.dart';
import '../../../core/storage/archived_chats_store.dart';
import '../../../core/storage/token_storage.dart';
import '../../../core/storage/chat_activity_store.dart';
import '../../../main.dart'
@@ -116,12 +117,14 @@ class ChatListScreen extends StatefulWidget {
final ValueChanged<DesktopChatSelection>? onChatSelected;
final bool forwardMode;
final int forwardMessageCount;
final bool archiveMode;
const ChatListScreen({
super.key,
this.onChatSelected,
this.forwardMode = false,
this.forwardMessageCount = 1,
this.archiveMode = false,
});
@override
@@ -196,6 +199,9 @@ class _ChatListScreenState extends State<ChatListScreen>
ProfileData? _profile;
List<CachedChat> _chats = [];
int _archivedCount = 0;
int _archivedUnread = 0;
bool _archiveHadChats = false;
int _chatListRevision = 0;
final Set<String> _knownChatIds = {};
@@ -323,6 +329,26 @@ class _ChatListScreenState extends State<ChatListScreen>
_clearSelection();
}
Future<void> _onArchiveTap() async {
final selected = _selectedChatObjects();
if (selected.isEmpty) return;
final p = _profile;
if (p == null) return;
final archive = !widget.archiveMode;
for (final c in selected) {
await ArchivedChatsStore.instance.setArchived(p.id, c.id, archive);
}
if (!mounted) return;
_clearSelection();
final count = selected.length;
showCustomNotification(
context,
archive
? (count == 1 ? 'Чат в архиве' : 'Чаты в архиве ($count)')
: (count == 1 ? 'Чат возвращён' : 'Чаты возвращены ($count)'),
);
}
Future<void> _onDeleteTap() async {
final selectedBefore = _selectedChatObjects();
if (selectedBefore.isEmpty) return;
@@ -545,6 +571,7 @@ class _ChatListScreenState extends State<ChatListScreen>
}
});
chats.chatsChanged.addListener(_onChatsChanged);
ArchivedChatsStore.instance.revision.addListener(_onArchivedChanged);
DraftStore.instance.revision.addListener(_onDraftsChanged);
AppStories.current.addListener(_onStoriesEnabledChanged);
storiesModule.storiesChanged.addListener(_onStoriesDataChanged);
@@ -585,6 +612,10 @@ class _ChatListScreenState extends State<ChatListScreen>
if (mounted) _requestReload();
}
void _onArchivedChanged() {
if (mounted) _requestReload();
}
void _onStoriesEnabledChanged() {
if (!mounted) return;
if (!AppStories.current.value) {
@@ -712,8 +743,18 @@ class _ChatListScreenState extends State<ChatListScreen>
try {
final loadedChats = await chats.getChats(
p.id,
includeHidden: KometSettings.showHiddenChats.value,
includeHidden:
widget.archiveMode || KometSettings.showHiddenChats.value,
);
final archivedIds = ArchivedChatsStore.instance.archivedChatIds(p.id);
var archivedCount = 0;
var archivedUnread = 0;
for (final c in loadedChats) {
if (!archivedIds.contains(c.id)) continue;
if (CloudStorageModule.isCloudStorageGroup(c)) continue;
archivedCount++;
archivedUnread += c.unreadCount;
}
var folders = await FoldersModule.loadFolders(p.id);
final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id);
final contactIds = (await ContactsModule.getContacts(p.id))
@@ -728,15 +769,19 @@ class _ChatListScreenState extends State<ChatListScreen>
widgets: [],
);
final hasRealFolders = folders.any(
(f) => !FoldersModule.isAllChatsFolder(f),
);
if (KometSettings.hideAllChatsFolder.value && hasRealFolders) {
folders = folders
.where((f) => !FoldersModule.isAllChatsFolder(f))
.toList();
} else if (!folders.any((f) => FoldersModule.isAllChatsFolder(f))) {
folders = [allChatsFolder, ...folders];
if (widget.archiveMode) {
folders = const [];
} else {
final hasRealFolders = folders.any(
(f) => !FoldersModule.isAllChatsFolder(f),
);
if (KometSettings.hideAllChatsFolder.value && hasRealFolders) {
folders = folders
.where((f) => !FoldersModule.isAllChatsFolder(f))
.toList();
} else if (!folders.any((f) => FoldersModule.isAllChatsFolder(f))) {
folders = [allChatsFolder, ...folders];
}
}
final pageCount = folders.isEmpty ? 1 : folders.length;
@@ -744,6 +789,11 @@ class _ChatListScreenState extends State<ChatListScreen>
final filteredChats = loadedChats
.where((c) => !CloudStorageModule.isCloudStorageGroup(c))
.where(
(c) => widget.archiveMode
? archivedIds.contains(c.id)
: !archivedIds.contains(c.id),
)
.toList();
final newIds = filteredChats.map((c) => c.id.toString()).toSet();
@@ -759,6 +809,8 @@ class _ChatListScreenState extends State<ChatListScreen>
setState(() {
_profile = p;
_chats = filteredChats;
_archivedCount = archivedCount;
_archivedUnread = archivedUnread;
_contactIds = contactIds;
_enteringChatIds = entering;
_chatListRevision++;
@@ -780,6 +832,15 @@ class _ChatListScreenState extends State<ChatListScreen>
_isInitialLoading = false;
});
_prefetchContactsForChats(loadedChats);
if (widget.archiveMode) {
if (filteredChats.isNotEmpty) {
_archiveHadChats = true;
} else if (_archiveHadChats) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop();
});
}
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_jumpFolderPageToSelection();
@@ -1153,6 +1214,7 @@ class _ChatListScreenState extends State<ChatListScreen>
appRouteObserver.unsubscribe(this);
_settleTimer?.cancel();
chats.chatsChanged.removeListener(_onChatsChanged);
ArchivedChatsStore.instance.revision.removeListener(_onArchivedChanged);
DraftStore.instance.revision.removeListener(_onDraftsChanged);
AppStories.current.removeListener(_onStoriesEnabledChanged);
storiesModule.storiesChanged.removeListener(_onStoriesDataChanged);
@@ -1499,6 +1561,8 @@ class _ChatListScreenState extends State<ChatListScreen>
),
slivers: [
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (_shouldShowArchiveEntry(pageIndex))
SliverToBoxAdapter(child: _buildArchiveEntry(cs)),
if (chats.isEmpty && !_isInitialLoading)
SliverFillRemaining(
child: Center(
@@ -1819,6 +1883,9 @@ class _ChatListScreenState extends State<ChatListScreen>
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (widget.archiveMode) {
return _buildArchiveScaffold(cs);
}
return Scaffold(
backgroundColor: cs.surface,
body: SafeArea(
@@ -1990,85 +2057,201 @@ class _ChatListScreenState extends State<ChatListScreen>
);
},
),
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
top: _isSelectionMode ? 0 : -80,
left: 0,
right: 0,
child: Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: cs.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
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.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 (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,
),
],
);
},
),
_buildSelectionActionBar(cs),
],
);
},
),
),
);
}
Widget _buildArchiveScaffold(ColorScheme cs) {
return Scaffold(
backgroundColor: cs.surface,
body: SafeArea(
bottom: false,
child: Stack(
children: [
Column(
children: [
_buildArchiveAppBar(cs),
Expanded(child: _buildFolderChatPage(0)),
],
),
_buildSelectionActionBar(cs),
],
),
),
);
}
Widget _buildArchiveAppBar(ColorScheme cs) {
return SizedBox(
height: 52,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
onPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(width: 4),
Text(
'Архив',
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
],
),
),
);
}
bool _shouldShowArchiveEntry(int pageIndex) {
if (widget.archiveMode || widget.forwardMode) return false;
if (_isInitialLoading) return false;
if (_archivedCount <= 0) return false;
if (_folders.isEmpty) return pageIndex == 0;
final allIdx = _folders.indexWhere(
(f) => FoldersModule.isAllChatsFolder(f),
);
return pageIndex == (allIdx >= 0 ? allIdx : 0);
}
Widget _buildArchiveEntry(ColorScheme cs) {
return InkWell(
onTap: () {
if (_isSelectionMode) return;
pushSwipeable(
context,
(_) => const ChatListScreen(archiveMode: true),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
child: Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: cs.surfaceContainerHighest,
child: Icon(
Symbols.archive,
color: cs.onSurfaceVariant,
weight: 500,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Архив',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
if (_archivedUnread > 0)
Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: cs.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_archivedUnread > 99 ? '99+' : '$_archivedUnread',
style: TextStyle(
color: cs.onPrimary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
Icon(Symbols.chevron_right, color: cs.outline),
],
),
),
);
}
Widget _buildSelectionActionBar(ColorScheme cs) {
return AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
top: _isSelectionMode ? 0 : -80,
left: 0,
right: 0,
child: Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: cs.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
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.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 (deleteCategory != null)
IconButton(
icon: Icon(Symbols.delete, color: cs.onSurface),
onPressed: _onDeleteTap,
),
IconButton(
icon: Icon(
widget.archiveMode ? Symbols.unarchive : Symbols.archive,
color: cs.onSurface,
),
onPressed: selected.isEmpty ? null : _onArchiveTap,
),
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,
),
],
);
},
@@ -38,6 +38,7 @@ import '../../../core/storage/app_database.dart';
import '../../../core/storage/chat_activity_store.dart';
import '../../../core/storage/chat_wallpaper_store.dart';
import '../../../core/storage/draft_store.dart';
import '../../../core/storage/archived_chats_store.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/cache/message_session_cache.dart';
import '../../../core/utils/haptics.dart';
@@ -1151,6 +1152,7 @@ class _ChatScreenState extends State<ChatScreen>
final total = await AppDatabase.sumUnread(
_myId,
excludeChatId: widget.chatId,
excludeChatIds: ArchivedChatsStore.instance.archivedChatIds(_myId),
);
if (mounted) _otherUnread.value = total;
}
+2
View File
@@ -17,6 +17,7 @@ import 'core/utils/logger.dart';
import 'core/cache/self_presence.dart';
import 'core/storage/app_instance.dart';
import 'core/storage/draft_store.dart';
import 'core/storage/archived_chats_store.dart';
import 'core/config/app_accent.dart';
import 'core/config/app_amoled.dart';
import 'core/config/app_show_extra_info.dart';
@@ -209,6 +210,7 @@ void main(List<String> args) async {
final prefs = await prefsFuture;
await FileHistoryCache.load(prefs);
await DraftStore.instance.load();
await ArchivedChatsStore.instance.load();
await KometSettings.load();
if (KometSettings.ghostMode.value) SelfPresence.markOffline();
await ContactCache.load();