♻️ Use sqlite to replace hive #5
| @@ -683,5 +683,9 @@ | ||||
|   "postChannelRealm": "Realms", | ||||
|   "postFilterReset": "Reset Filter", | ||||
|   "postFilterResetDescription": "Clear filter and show all posts.", | ||||
|   "postFilterWithCategory": "Viewing posts in {}" | ||||
|   "postFilterWithCategory": "Viewing posts in {}", | ||||
|   "databaseSize": "Database Size", | ||||
|   "databaseDelete": "Delete Database", | ||||
|   "databaseDeleteDescription": "Remove the database on your local disk, the content will be fetched from server again.", | ||||
|   "databaseDeleted": "The local database has been deleted." | ||||
| } | ||||
|   | ||||
| @@ -681,5 +681,9 @@ | ||||
|   "postChannelRealm": "领域", | ||||
|   "postFilterReset": "重置过滤器", | ||||
|   "postFilterResetDescription": "清除过滤器并显示所有帖子。", | ||||
|   "postFilterWithCategory": "查看{}区中的帖子" | ||||
|   "postFilterWithCategory": "查看{}区中的帖子", | ||||
|   "databaseSize": "数据库大小", | ||||
|   "databaseDelete": "删除数据库", | ||||
|   "databaseDeleteDescription": "删除本地数据库,内容将从服务器重新获取。", | ||||
|   "databaseDeleted": "本地数据库已被删除。" | ||||
| } | ||||
|   | ||||
| @@ -681,5 +681,9 @@ | ||||
|   "postChannelRealm": "領域", | ||||
|   "postFilterReset": "重置過濾器", | ||||
|   "postFilterResetDescription": "清除過濾器並顯示所有帖子。", | ||||
|   "postFilterWithCategory": "查看{}區中的帖子" | ||||
|   "postFilterWithCategory": "查看{}區中的帖子", | ||||
|   "databaseSize": "數據庫大小", | ||||
|   "databaseDelete": "刪除數據庫", | ||||
|   "databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。", | ||||
|   "databaseDeleted": "本地數據庫已被刪除。" | ||||
| } | ||||
|   | ||||
| @@ -681,5 +681,9 @@ | ||||
|   "postChannelRealm": "領域", | ||||
|   "postFilterReset": "重置過濾器", | ||||
|   "postFilterResetDescription": "清除過濾器並顯示所有帖子。", | ||||
|   "postFilterWithCategory": "查看{}區中的帖子" | ||||
|   "postFilterWithCategory": "查看{}區中的帖子", | ||||
|   "databaseSize": "數據庫大小", | ||||
|   "databaseDelete": "刪除數據庫", | ||||
|   "databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。", | ||||
|   "databaseDeleted": "本地數據庫已被刪除。" | ||||
| } | ||||
|   | ||||
| @@ -2,11 +2,12 @@ import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:drift/drift.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/database/database.dart'; | ||||
| import 'package:surface/providers/database.dart'; | ||||
| import 'package:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/user_directory.dart'; | ||||
| @@ -16,13 +17,13 @@ import 'package:surface/types/websocket.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
|  | ||||
| class ChatMessageController extends ChangeNotifier { | ||||
|   static const kChatMessageBoxPrefix = 'nex_chat_messages_'; | ||||
|   static const kSingleBatchLoadLimit = 100; | ||||
|  | ||||
|   late final SnNetworkProvider _sn; | ||||
|   late final UserDirectoryProvider _ud; | ||||
|   late final WebSocketProvider _ws; | ||||
|   late final SnAttachmentProvider _attach; | ||||
|   late final DatabaseProvider _dt; | ||||
|  | ||||
|   StreamSubscription? _wsSubscription; | ||||
|  | ||||
| @@ -31,6 +32,7 @@ class ChatMessageController extends ChangeNotifier { | ||||
|     _ud = context.read<UserDirectoryProvider>(); | ||||
|     _ws = context.read<WebSocketProvider>(); | ||||
|     _attach = context.read<SnAttachmentProvider>(); | ||||
|     _dt = context.read<DatabaseProvider>(); | ||||
|   } | ||||
|  | ||||
|   bool isPending = true; | ||||
| @@ -38,9 +40,9 @@ class ChatMessageController extends ChangeNotifier { | ||||
|  | ||||
|   int? messageTotal; | ||||
|  | ||||
|   bool get isAllLoaded => messageTotal != null && messages.length >= messageTotal!; | ||||
|   bool get isAllLoaded => | ||||
|       messageTotal != null && messages.length >= messageTotal!; | ||||
|  | ||||
|   String? _boxKey; | ||||
|   SnChannel? channel; | ||||
|   SnChannelMember? profile; | ||||
|  | ||||
| @@ -51,25 +53,17 @@ class ChatMessageController extends ChangeNotifier { | ||||
|   /// Stored as a list of nonce to provide the loading state | ||||
|   final List<String> unconfirmedMessages = List.empty(growable: true); | ||||
|  | ||||
|   Box<SnChatMessage>? get _box => (_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!); | ||||
|  | ||||
|   final List<SnChannelMember> typingMembers = List.empty(growable: true); | ||||
|   final Map<int, Timer> typingInactiveTimer = {}; | ||||
|  | ||||
|   Future<void> initialize(SnChannel chan) async { | ||||
|     channel = chan; | ||||
|  | ||||
|     // Initialize local data | ||||
|     _boxKey = '$kChatMessageBoxPrefix${chan.id}'; | ||||
|     await Hive.openBox<SnChatMessage>(_boxKey!); | ||||
|  | ||||
|     // Fetch channel profile | ||||
|     final resp = await _sn.client.get( | ||||
|       '/cgi/im/channels/${chan.keyPath}/me', | ||||
|     ); | ||||
|     profile = SnChannelMember.fromJson( | ||||
|       resp.data as Map<String, dynamic>, | ||||
|     ); | ||||
|     profile = SnChannelMember.fromJson(resp.data); | ||||
|  | ||||
|     _wsSubscription = _ws.pk.stream.listen((event) { | ||||
|       switch (event.method) { | ||||
| @@ -87,7 +81,8 @@ class ChatMessageController extends ChangeNotifier { | ||||
|             notifyListeners(); | ||||
|           } | ||||
|           typingInactiveTimer[member.id]?.cancel(); | ||||
|           typingInactiveTimer[member.id] = Timer(const Duration(seconds: 3), () { | ||||
|           typingInactiveTimer[member.id] = | ||||
|               Timer(const Duration(seconds: 3), () { | ||||
|             typingMembers.removeWhere((x) => x.id == member.id); | ||||
|             typingInactiveTimer.remove(member.id); | ||||
|             notifyListeners(); | ||||
| @@ -129,10 +124,16 @@ class ChatMessageController extends ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async { | ||||
|     if (_box == null) return; | ||||
|     await _box!.putAll({ | ||||
|       for (final message in messages) message.id: message, | ||||
|     }); | ||||
|     await _dt.db.snLocalChatMessage.insertAll( | ||||
|         messages.map( | ||||
|           (ele) => SnLocalChatMessageCompanion.insert( | ||||
|             id: Value(ele.id), | ||||
|             content: ele, | ||||
|             channelId: channel!.id, | ||||
|             createdAt: Value(ele.createdAt), | ||||
|           ), | ||||
|         ), | ||||
|         onConflict: DoNothing()); | ||||
|   } | ||||
|  | ||||
|   Future<void> _addUnconfirmedMessage(SnChatMessage message) async { | ||||
| @@ -184,8 +185,21 @@ class ChatMessageController extends ChangeNotifier { | ||||
|     await _applyMessage(message); | ||||
|     notifyListeners(); | ||||
|  | ||||
|     if (_box == null) return; | ||||
|     await _box!.put(message.id, message); | ||||
|     if (isCheckedUpdate) { | ||||
|       await _dt.db.snLocalChatMessage.insertOne( | ||||
|         SnLocalChatMessageCompanion.insert( | ||||
|           id: Value(message.id), | ||||
|           content: message, | ||||
|           channelId: channel!.id, | ||||
|           createdAt: Value(message.createdAt), | ||||
|         ), | ||||
|         onConflict: DoUpdate((_) => SnLocalChatMessageCompanion.custom( | ||||
|               content: Constant(jsonEncode(message.toJson())), | ||||
|             )), | ||||
|       ); | ||||
|     } else { | ||||
|       incomeStrandedQueue.add(message); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _applyMessage(SnChatMessage message) async { | ||||
| @@ -194,7 +208,8 @@ class ChatMessageController extends ChangeNotifier { | ||||
|     switch (message.type) { | ||||
|       case 'messages.edit': | ||||
|         if (message.relatedEventId != null) { | ||||
|           final idx = messages.indexWhere((x) => x.id == message.relatedEventId); | ||||
|           final idx = | ||||
|               messages.indexWhere((x) => x.id == message.relatedEventId); | ||||
|           if (idx != -1) { | ||||
|             final newBody = message.body; | ||||
|             newBody.remove('related_event'); | ||||
| @@ -202,16 +217,24 @@ class ChatMessageController extends ChangeNotifier { | ||||
|               body: newBody, | ||||
|               updatedAt: message.updatedAt, | ||||
|             ); | ||||
|             if (_box!.containsKey(message.relatedEventId)) { | ||||
|               await _box!.put(message.relatedEventId, messages[idx]); | ||||
|             if (message.relatedEventId != null) { | ||||
|               await (_dt.db.snLocalChatMessage.update() | ||||
|                     ..where((e) => e.id.equals(message.relatedEventId!))) | ||||
|                   .write( | ||||
|                 SnLocalChatMessageCompanion.custom( | ||||
|                   content: Constant(jsonEncode(messages[idx].toJson())), | ||||
|                 ), | ||||
|               ); | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       case 'messages.delete': | ||||
|         if (message.relatedEventId != null) { | ||||
|           messages.removeWhere((x) => x.id == message.relatedEventId); | ||||
|           if (_box!.containsKey(message.relatedEventId)) { | ||||
|             await _box!.delete(message.relatedEventId); | ||||
|           if (message.relatedEventId != null) { | ||||
|             await (_dt.db.snLocalChatMessage.delete() | ||||
|                   ..where((e) => e.id.equals(message.relatedEventId!))) | ||||
|                 .go(); | ||||
|           } | ||||
|         } | ||||
|     } | ||||
| @@ -233,7 +256,8 @@ class ChatMessageController extends ChangeNotifier { | ||||
|       'algorithm': 'plain', | ||||
|       if (quoteId != null) 'quote_event': quoteId, | ||||
|       if (relatedId != null) 'related_event': relatedId, | ||||
|       if (attachments != null && attachments.isNotEmpty) 'attachments': attachments, | ||||
|       if (attachments != null && attachments.isNotEmpty) | ||||
|         'attachments': attachments, | ||||
|     }; | ||||
|  | ||||
|     // Mock the message locally | ||||
| @@ -287,20 +311,34 @@ class ChatMessageController extends ChangeNotifier { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   bool isCheckedUpdate = false; | ||||
|   List<SnChatMessage> incomeStrandedQueue = List.empty(growable: true); | ||||
|  | ||||
|   /// Check the local storage is up to date with the server. | ||||
|   /// If the local storage is not up to date, it will be updated. | ||||
|   Future<void> checkUpdate() async { | ||||
|     if (_box == null) return; | ||||
|     if (_box!.isEmpty) return; | ||||
|  | ||||
|     isLoading = true; | ||||
|     notifyListeners(); | ||||
|  | ||||
|     final mostRecentMessage = await (_dt.db.snLocalChatMessage.select() | ||||
|           ..limit(1) | ||||
|           ..orderBy([ | ||||
|             (e) => | ||||
|                 OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc) | ||||
|           ])) | ||||
|         .getSingleOrNull(); | ||||
|     if (mostRecentMessage == null) { | ||||
|       // Initial load | ||||
|       await loadMessages(take: 20); | ||||
|       isCheckedUpdate = true; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       final resp = await _sn.client.get( | ||||
|         '/cgi/im/channels/${channel!.keyPath}/events/update', | ||||
|         queryParameters: { | ||||
|           'pivot': _box!.values.last.id, | ||||
|           'pivot': mostRecentMessage.content.id, | ||||
|         }, | ||||
|       ); | ||||
|       if (resp.data['up_to_date'] == true) return; | ||||
| @@ -316,6 +354,12 @@ class ChatMessageController extends ChangeNotifier { | ||||
|     } finally { | ||||
|       await loadMessages(); | ||||
|       isLoading = false; | ||||
|  | ||||
|       isCheckedUpdate = true; | ||||
|       _saveMessageToLocal(incomeStrandedQueue).then((_) { | ||||
|         incomeStrandedQueue.clear(); | ||||
|       }); | ||||
|  | ||||
|       notifyListeners(); | ||||
|     } | ||||
|   } | ||||
| @@ -324,13 +368,18 @@ class ChatMessageController extends ChangeNotifier { | ||||
|   /// If it was not found in local storage we will look it up in remote | ||||
|   Future<SnChatMessage?> getMessage(int id) async { | ||||
|     SnChatMessage? out; | ||||
|     if (_box != null && _box!.containsKey(id)) { | ||||
|       out = _box!.get(id); | ||||
|     final local = await (_dt.db.snLocalChatMessage.select() | ||||
|           ..limit(1) | ||||
|           ..where((e) => e.id.equals(id))) | ||||
|         .getSingleOrNull(); | ||||
|     if (local != null) { | ||||
|       out = local.content; | ||||
|     } | ||||
|  | ||||
|     if (out == null) { | ||||
|       try { | ||||
|         final resp = await _sn.client.get('/cgi/im/channels/${channel!.keyPath}/events/$id'); | ||||
|         final resp = await _sn.client | ||||
|             .get('/cgi/im/channels/${channel!.keyPath}/events/$id'); | ||||
|         out = SnChatMessage.fromJson(resp.data); | ||||
|         _saveMessageToLocal([out]); | ||||
|       } catch (_) { | ||||
| @@ -364,16 +413,21 @@ class ChatMessageController extends ChangeNotifier { | ||||
|     bool forceLocal = false, | ||||
|     bool forceRemote = false, | ||||
|   }) async { | ||||
|     final localTotal = await _dt.db.snLocalChatMessage | ||||
|         .count(where: (e) => e.channelId.equals(channel!.id)) | ||||
|         .getSingle(); | ||||
|  | ||||
|     late List<SnChatMessage> out; | ||||
|     if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) { | ||||
|       out = _box!.keys | ||||
|           .toList() | ||||
|           .cast<int>() | ||||
|           .sorted((a, b) => b.compareTo(a)) | ||||
|           .skip(offset) | ||||
|           .take(take) | ||||
|           .map((key) => _box!.get(key)!) | ||||
|           .toList(); | ||||
|     if ((localTotal >= take + offset || forceLocal) && !forceRemote) { | ||||
|       final result = await (_dt.db.snLocalChatMessage.select() | ||||
|             ..where((e) => e.channelId.equals(channel!.id)) | ||||
|             ..orderBy([ | ||||
|               (e) => | ||||
|                   OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc) | ||||
|             ]) | ||||
|             ..limit(take, offset: offset)) | ||||
|           .get(); | ||||
|       out = result.map((e) => e.content).toList(); | ||||
|     } else { | ||||
|       final resp = await _sn.client.get( | ||||
|         '/cgi/im/channels/${channel!.keyPath}/events', | ||||
| @@ -408,7 +462,8 @@ class ChatMessageController extends ChangeNotifier { | ||||
|           quoteEvent: quoteEvent, | ||||
|           attachments: attachments | ||||
|               .where( | ||||
|                 (ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false, | ||||
|                 (ele) => | ||||
|                     out[i].body['attachments']?.contains(ele?.rid) ?? false, | ||||
|               ) | ||||
|               .toList(), | ||||
|         ), | ||||
| @@ -416,7 +471,10 @@ class ChatMessageController extends ChangeNotifier { | ||||
|     } | ||||
|  | ||||
|     // Preload sender accounts | ||||
|     final accountId = out.where((ele) => ele.sender.accountId >= 0).map((ele) => ele.sender.accountId).toSet(); | ||||
|     final accountId = out | ||||
|         .where((ele) => ele.sender.accountId >= 0) | ||||
|         .map((ele) => ele.sender.accountId) | ||||
|         .toSet(); | ||||
|     await _ud.listAccount(accountId); | ||||
|  | ||||
|     return out; | ||||
| @@ -443,7 +501,6 @@ class ChatMessageController extends ChangeNotifier { | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _box?.close(); | ||||
|     _wsSubscription?.cancel(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|   | ||||
| @@ -1,26 +1,74 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:drift/drift.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
|  | ||||
| class SnChannelConverter extends TypeConverter<SnChannel, String> | ||||
|     with JsonTypeConverter2<SnChannel, String, Map<String, Object?>> { | ||||
|   const SnChannelConverter(); | ||||
|  | ||||
|   @override | ||||
|   SnChannel fromSql(String fromDb) { | ||||
|     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toSql(SnChannel value) { | ||||
|     return jsonEncode(toJson(value)); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   SnChannel fromJson(Map<String, Object?> json) { | ||||
|     return SnChannel.fromJson(json); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Map<String, Object?> toJson(SnChannel value) { | ||||
|     return value.toJson(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class SnLocalChatChannel extends Table { | ||||
|   IntColumn get id => integer().autoIncrement()(); | ||||
|  | ||||
|   BlobColumn get content => blob().map(TypeConverter.jsonb( | ||||
|     fromJson: (json) => SnChannel.fromJson(json as Map<String, Object?>), | ||||
|     toJson: (pref) => pref.toJson(), | ||||
|   ))(); | ||||
|   TextColumn get alias => text()(); | ||||
|  | ||||
|   TextColumn get content => text().map(const SnChannelConverter())(); | ||||
|  | ||||
|   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||
| } | ||||
|  | ||||
| class SnMessageConverter extends TypeConverter<SnChatMessage, String> | ||||
|     with JsonTypeConverter2<SnChatMessage, String, Map<String, Object?>> { | ||||
|   const SnMessageConverter(); | ||||
|  | ||||
|   @override | ||||
|   SnChatMessage fromSql(String fromDb) { | ||||
|     return fromJson(jsonDecode(fromDb) as Map<String, dynamic>); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toSql(SnChatMessage value) { | ||||
|     return jsonEncode(toJson(value)); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   SnChatMessage fromJson(Map<String, Object?> json) { | ||||
|     return SnChatMessage.fromJson(json); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Map<String, Object?> toJson(SnChatMessage value) { | ||||
|     return value.toJson(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class SnLocalChatMessage extends Table { | ||||
|   IntColumn get id => integer().autoIncrement()(); | ||||
|  | ||||
|   IntColumn get channelId => integer()(); | ||||
|  | ||||
|   BlobColumn get content => blob().map( TypeConverter.jsonb( | ||||
|     fromJson: (json) => SnChatMessage.fromJson(json as Map<String, Object?>), | ||||
|     toJson: (pref) => pref.toJson(), | ||||
|   ))(); | ||||
|   TextColumn get content => text().map(const SnMessageConverter())(); | ||||
|  | ||||
|   DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -18,12 +18,17 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel | ||||
|       requiredDuringInsert: false, | ||||
|       defaultConstraints: | ||||
|           GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); | ||||
|   static const VerificationMeta _aliasMeta = const VerificationMeta('alias'); | ||||
|   @override | ||||
|   late final GeneratedColumn<String> alias = GeneratedColumn<String>( | ||||
|       'alias', aliasedName, false, | ||||
|       type: DriftSqlType.string, requiredDuringInsert: true); | ||||
|   static const VerificationMeta _contentMeta = | ||||
|       const VerificationMeta('content'); | ||||
|   @override | ||||
|   late final GeneratedColumnWithTypeConverter<SnChannel, Uint8List> content = | ||||
|       GeneratedColumn<Uint8List>('content', aliasedName, false, | ||||
|               type: DriftSqlType.blob, requiredDuringInsert: true) | ||||
|   late final GeneratedColumnWithTypeConverter<SnChannel, String> content = | ||||
|       GeneratedColumn<String>('content', aliasedName, false, | ||||
|               type: DriftSqlType.string, requiredDuringInsert: true) | ||||
|           .withConverter<SnChannel>($SnLocalChatChannelTable.$convertercontent); | ||||
|   static const VerificationMeta _createdAtMeta = | ||||
|       const VerificationMeta('createdAt'); | ||||
| @@ -34,7 +39,7 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel | ||||
|       requiredDuringInsert: false, | ||||
|       defaultValue: currentDateAndTime); | ||||
|   @override | ||||
|   List<GeneratedColumn> get $columns => [id, content, createdAt]; | ||||
|   List<GeneratedColumn> get $columns => [id, alias, content, createdAt]; | ||||
|   @override | ||||
|   String get aliasedName => _alias ?? actualTableName; | ||||
|   @override | ||||
| @@ -49,6 +54,12 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel | ||||
|     if (data.containsKey('id')) { | ||||
|       context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); | ||||
|     } | ||||
|     if (data.containsKey('alias')) { | ||||
|       context.handle( | ||||
|           _aliasMeta, alias.isAcceptableOrUnknown(data['alias']!, _aliasMeta)); | ||||
|     } else if (isInserting) { | ||||
|       context.missing(_aliasMeta); | ||||
|     } | ||||
|     context.handle(_contentMeta, const VerificationResult.success()); | ||||
|     if (data.containsKey('created_at')) { | ||||
|       context.handle(_createdAtMeta, | ||||
| @@ -65,9 +76,11 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel | ||||
|     return SnLocalChatChannelData( | ||||
|       id: attachedDatabase.typeMapping | ||||
|           .read(DriftSqlType.int, data['${effectivePrefix}id'])!, | ||||
|       alias: attachedDatabase.typeMapping | ||||
|           .read(DriftSqlType.string, data['${effectivePrefix}alias'])!, | ||||
|       content: $SnLocalChatChannelTable.$convertercontent.fromSql( | ||||
|           attachedDatabase.typeMapping | ||||
|               .read(DriftSqlType.blob, data['${effectivePrefix}content'])!), | ||||
|               .read(DriftSqlType.string, data['${effectivePrefix}content'])!), | ||||
|       createdAt: attachedDatabase.typeMapping | ||||
|           .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, | ||||
|     ); | ||||
| @@ -78,25 +91,28 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel | ||||
|     return $SnLocalChatChannelTable(attachedDatabase, alias); | ||||
|   } | ||||
|  | ||||
|   static JsonTypeConverter2<SnChannel, Uint8List, Object?> $convertercontent = | ||||
|       TypeConverter.jsonb( | ||||
|           fromJson: (json) => SnChannel.fromJson(json as Map<String, Object?>), | ||||
|           toJson: (pref) => pref.toJson()); | ||||
|   static JsonTypeConverter2<SnChannel, String, Map<String, Object?>> | ||||
|       $convertercontent = const SnChannelConverter(); | ||||
| } | ||||
|  | ||||
| class SnLocalChatChannelData extends DataClass | ||||
|     implements Insertable<SnLocalChatChannelData> { | ||||
|   final int id; | ||||
|   final String alias; | ||||
|   final SnChannel content; | ||||
|   final DateTime createdAt; | ||||
|   const SnLocalChatChannelData( | ||||
|       {required this.id, required this.content, required this.createdAt}); | ||||
|       {required this.id, | ||||
|       required this.alias, | ||||
|       required this.content, | ||||
|       required this.createdAt}); | ||||
|   @override | ||||
|   Map<String, Expression> toColumns(bool nullToAbsent) { | ||||
|     final map = <String, Expression>{}; | ||||
|     map['id'] = Variable<int>(id); | ||||
|     map['alias'] = Variable<String>(alias); | ||||
|     { | ||||
|       map['content'] = Variable<Uint8List>( | ||||
|       map['content'] = Variable<String>( | ||||
|           $SnLocalChatChannelTable.$convertercontent.toSql(content)); | ||||
|     } | ||||
|     map['created_at'] = Variable<DateTime>(createdAt); | ||||
| @@ -106,6 +122,7 @@ class SnLocalChatChannelData extends DataClass | ||||
|   SnLocalChatChannelCompanion toCompanion(bool nullToAbsent) { | ||||
|     return SnLocalChatChannelCompanion( | ||||
|       id: Value(id), | ||||
|       alias: Value(alias), | ||||
|       content: Value(content), | ||||
|       createdAt: Value(createdAt), | ||||
|     ); | ||||
| @@ -116,8 +133,9 @@ class SnLocalChatChannelData extends DataClass | ||||
|     serializer ??= driftRuntimeOptions.defaultSerializer; | ||||
|     return SnLocalChatChannelData( | ||||
|       id: serializer.fromJson<int>(json['id']), | ||||
|       alias: serializer.fromJson<String>(json['alias']), | ||||
|       content: $SnLocalChatChannelTable.$convertercontent | ||||
|           .fromJson(serializer.fromJson<Object?>(json['content'])), | ||||
|           .fromJson(serializer.fromJson<Map<String, Object?>>(json['content'])), | ||||
|       createdAt: serializer.fromJson<DateTime>(json['createdAt']), | ||||
|     ); | ||||
|   } | ||||
| @@ -126,22 +144,25 @@ class SnLocalChatChannelData extends DataClass | ||||
|     serializer ??= driftRuntimeOptions.defaultSerializer; | ||||
|     return <String, dynamic>{ | ||||
|       'id': serializer.toJson<int>(id), | ||||
|       'content': serializer.toJson<Object?>( | ||||
|       'alias': serializer.toJson<String>(alias), | ||||
|       'content': serializer.toJson<Map<String, Object?>>( | ||||
|           $SnLocalChatChannelTable.$convertercontent.toJson(content)), | ||||
|       'createdAt': serializer.toJson<DateTime>(createdAt), | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   SnLocalChatChannelData copyWith( | ||||
|           {int? id, SnChannel? content, DateTime? createdAt}) => | ||||
|           {int? id, String? alias, SnChannel? content, DateTime? createdAt}) => | ||||
|       SnLocalChatChannelData( | ||||
|         id: id ?? this.id, | ||||
|         alias: alias ?? this.alias, | ||||
|         content: content ?? this.content, | ||||
|         createdAt: createdAt ?? this.createdAt, | ||||
|       ); | ||||
|   SnLocalChatChannelData copyWithCompanion(SnLocalChatChannelCompanion data) { | ||||
|     return SnLocalChatChannelData( | ||||
|       id: data.id.present ? data.id.value : this.id, | ||||
|       alias: data.alias.present ? data.alias.value : this.alias, | ||||
|       content: data.content.present ? data.content.value : this.content, | ||||
|       createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, | ||||
|     ); | ||||
| @@ -151,6 +172,7 @@ class SnLocalChatChannelData extends DataClass | ||||
|   String toString() { | ||||
|     return (StringBuffer('SnLocalChatChannelData(') | ||||
|           ..write('id: $id, ') | ||||
|           ..write('alias: $alias, ') | ||||
|           ..write('content: $content, ') | ||||
|           ..write('createdAt: $createdAt') | ||||
|           ..write(')')) | ||||
| @@ -158,12 +180,13 @@ class SnLocalChatChannelData extends DataClass | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => Object.hash(id, content, createdAt); | ||||
|   int get hashCode => Object.hash(id, alias, content, createdAt); | ||||
|   @override | ||||
|   bool operator ==(Object other) => | ||||
|       identical(this, other) || | ||||
|       (other is SnLocalChatChannelData && | ||||
|           other.id == this.id && | ||||
|           other.alias == this.alias && | ||||
|           other.content == this.content && | ||||
|           other.createdAt == this.createdAt); | ||||
| } | ||||
| @@ -171,34 +194,44 @@ class SnLocalChatChannelData extends DataClass | ||||
| class SnLocalChatChannelCompanion | ||||
|     extends UpdateCompanion<SnLocalChatChannelData> { | ||||
|   final Value<int> id; | ||||
|   final Value<String> alias; | ||||
|   final Value<SnChannel> content; | ||||
|   final Value<DateTime> createdAt; | ||||
|   const SnLocalChatChannelCompanion({ | ||||
|     this.id = const Value.absent(), | ||||
|     this.alias = const Value.absent(), | ||||
|     this.content = const Value.absent(), | ||||
|     this.createdAt = const Value.absent(), | ||||
|   }); | ||||
|   SnLocalChatChannelCompanion.insert({ | ||||
|     this.id = const Value.absent(), | ||||
|     required String alias, | ||||
|     required SnChannel content, | ||||
|     this.createdAt = const Value.absent(), | ||||
|   }) : content = Value(content); | ||||
|   })  : alias = Value(alias), | ||||
|         content = Value(content); | ||||
|   static Insertable<SnLocalChatChannelData> custom({ | ||||
|     Expression<int>? id, | ||||
|     Expression<Uint8List>? content, | ||||
|     Expression<String>? alias, | ||||
|     Expression<String>? content, | ||||
|     Expression<DateTime>? createdAt, | ||||
|   }) { | ||||
|     return RawValuesInsertable({ | ||||
|       if (id != null) 'id': id, | ||||
|       if (alias != null) 'alias': alias, | ||||
|       if (content != null) 'content': content, | ||||
|       if (createdAt != null) 'created_at': createdAt, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   SnLocalChatChannelCompanion copyWith( | ||||
|       {Value<int>? id, Value<SnChannel>? content, Value<DateTime>? createdAt}) { | ||||
|       {Value<int>? id, | ||||
|       Value<String>? alias, | ||||
|       Value<SnChannel>? content, | ||||
|       Value<DateTime>? createdAt}) { | ||||
|     return SnLocalChatChannelCompanion( | ||||
|       id: id ?? this.id, | ||||
|       alias: alias ?? this.alias, | ||||
|       content: content ?? this.content, | ||||
|       createdAt: createdAt ?? this.createdAt, | ||||
|     ); | ||||
| @@ -210,8 +243,11 @@ class SnLocalChatChannelCompanion | ||||
|     if (id.present) { | ||||
|       map['id'] = Variable<int>(id.value); | ||||
|     } | ||||
|     if (alias.present) { | ||||
|       map['alias'] = Variable<String>(alias.value); | ||||
|     } | ||||
|     if (content.present) { | ||||
|       map['content'] = Variable<Uint8List>( | ||||
|       map['content'] = Variable<String>( | ||||
|           $SnLocalChatChannelTable.$convertercontent.toSql(content.value)); | ||||
|     } | ||||
|     if (createdAt.present) { | ||||
| @@ -224,6 +260,7 @@ class SnLocalChatChannelCompanion | ||||
|   String toString() { | ||||
|     return (StringBuffer('SnLocalChatChannelCompanion(') | ||||
|           ..write('id: $id, ') | ||||
|           ..write('alias: $alias, ') | ||||
|           ..write('content: $content, ') | ||||
|           ..write('createdAt: $createdAt') | ||||
|           ..write(')')) | ||||
| @@ -255,9 +292,9 @@ class $SnLocalChatMessageTable extends SnLocalChatMessage | ||||
|   static const VerificationMeta _contentMeta = | ||||
|       const VerificationMeta('content'); | ||||
|   @override | ||||
|   late final GeneratedColumnWithTypeConverter<SnChatMessage, Uint8List> | ||||
|       content = GeneratedColumn<Uint8List>('content', aliasedName, false, | ||||
|               type: DriftSqlType.blob, requiredDuringInsert: true) | ||||
|   late final GeneratedColumnWithTypeConverter<SnChatMessage, String> content = | ||||
|       GeneratedColumn<String>('content', aliasedName, false, | ||||
|               type: DriftSqlType.string, requiredDuringInsert: true) | ||||
|           .withConverter<SnChatMessage>( | ||||
|               $SnLocalChatMessageTable.$convertercontent); | ||||
|   static const VerificationMeta _createdAtMeta = | ||||
| @@ -310,7 +347,7 @@ class $SnLocalChatMessageTable extends SnLocalChatMessage | ||||
|           .read(DriftSqlType.int, data['${effectivePrefix}channel_id'])!, | ||||
|       content: $SnLocalChatMessageTable.$convertercontent.fromSql( | ||||
|           attachedDatabase.typeMapping | ||||
|               .read(DriftSqlType.blob, data['${effectivePrefix}content'])!), | ||||
|               .read(DriftSqlType.string, data['${effectivePrefix}content'])!), | ||||
|       createdAt: attachedDatabase.typeMapping | ||||
|           .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, | ||||
|     ); | ||||
| @@ -321,11 +358,8 @@ class $SnLocalChatMessageTable extends SnLocalChatMessage | ||||
|     return $SnLocalChatMessageTable(attachedDatabase, alias); | ||||
|   } | ||||
|  | ||||
|   static JsonTypeConverter2<SnChatMessage, Uint8List, Object?> | ||||
|       $convertercontent = TypeConverter.jsonb( | ||||
|           fromJson: (json) => | ||||
|               SnChatMessage.fromJson(json as Map<String, Object?>), | ||||
|           toJson: (pref) => pref.toJson()); | ||||
|   static JsonTypeConverter2<SnChatMessage, String, Map<String, Object?>> | ||||
|       $convertercontent = const SnMessageConverter(); | ||||
| } | ||||
|  | ||||
| class SnLocalChatMessageData extends DataClass | ||||
| @@ -345,7 +379,7 @@ class SnLocalChatMessageData extends DataClass | ||||
|     map['id'] = Variable<int>(id); | ||||
|     map['channel_id'] = Variable<int>(channelId); | ||||
|     { | ||||
|       map['content'] = Variable<Uint8List>( | ||||
|       map['content'] = Variable<String>( | ||||
|           $SnLocalChatMessageTable.$convertercontent.toSql(content)); | ||||
|     } | ||||
|     map['created_at'] = Variable<DateTime>(createdAt); | ||||
| @@ -368,7 +402,7 @@ class SnLocalChatMessageData extends DataClass | ||||
|       id: serializer.fromJson<int>(json['id']), | ||||
|       channelId: serializer.fromJson<int>(json['channelId']), | ||||
|       content: $SnLocalChatMessageTable.$convertercontent | ||||
|           .fromJson(serializer.fromJson<Object?>(json['content'])), | ||||
|           .fromJson(serializer.fromJson<Map<String, Object?>>(json['content'])), | ||||
|       createdAt: serializer.fromJson<DateTime>(json['createdAt']), | ||||
|     ); | ||||
|   } | ||||
| @@ -378,7 +412,7 @@ class SnLocalChatMessageData extends DataClass | ||||
|     return <String, dynamic>{ | ||||
|       'id': serializer.toJson<int>(id), | ||||
|       'channelId': serializer.toJson<int>(channelId), | ||||
|       'content': serializer.toJson<Object?>( | ||||
|       'content': serializer.toJson<Map<String, Object?>>( | ||||
|           $SnLocalChatMessageTable.$convertercontent.toJson(content)), | ||||
|       'createdAt': serializer.toJson<DateTime>(createdAt), | ||||
|     }; | ||||
| @@ -449,7 +483,7 @@ class SnLocalChatMessageCompanion | ||||
|   static Insertable<SnLocalChatMessageData> custom({ | ||||
|     Expression<int>? id, | ||||
|     Expression<int>? channelId, | ||||
|     Expression<Uint8List>? content, | ||||
|     Expression<String>? content, | ||||
|     Expression<DateTime>? createdAt, | ||||
|   }) { | ||||
|     return RawValuesInsertable({ | ||||
| @@ -483,7 +517,7 @@ class SnLocalChatMessageCompanion | ||||
|       map['channel_id'] = Variable<int>(channelId.value); | ||||
|     } | ||||
|     if (content.present) { | ||||
|       map['content'] = Variable<Uint8List>( | ||||
|       map['content'] = Variable<String>( | ||||
|           $SnLocalChatMessageTable.$convertercontent.toSql(content.value)); | ||||
|     } | ||||
|     if (createdAt.present) { | ||||
| @@ -522,12 +556,14 @@ abstract class _$AppDatabase extends GeneratedDatabase { | ||||
| typedef $$SnLocalChatChannelTableCreateCompanionBuilder | ||||
|     = SnLocalChatChannelCompanion Function({ | ||||
|   Value<int> id, | ||||
|   required String alias, | ||||
|   required SnChannel content, | ||||
|   Value<DateTime> createdAt, | ||||
| }); | ||||
| typedef $$SnLocalChatChannelTableUpdateCompanionBuilder | ||||
|     = SnLocalChatChannelCompanion Function({ | ||||
|   Value<int> id, | ||||
|   Value<String> alias, | ||||
|   Value<SnChannel> content, | ||||
|   Value<DateTime> createdAt, | ||||
| }); | ||||
| @@ -544,7 +580,10 @@ class $$SnLocalChatChannelTableFilterComposer | ||||
|   ColumnFilters<int> get id => $composableBuilder( | ||||
|       column: $table.id, builder: (column) => ColumnFilters(column)); | ||||
|  | ||||
|   ColumnWithTypeConverterFilters<SnChannel, SnChannel, Uint8List> get content => | ||||
|   ColumnFilters<String> get alias => $composableBuilder( | ||||
|       column: $table.alias, builder: (column) => ColumnFilters(column)); | ||||
|  | ||||
|   ColumnWithTypeConverterFilters<SnChannel, SnChannel, String> get content => | ||||
|       $composableBuilder( | ||||
|           column: $table.content, | ||||
|           builder: (column) => ColumnWithTypeConverterFilters(column)); | ||||
| @@ -565,7 +604,10 @@ class $$SnLocalChatChannelTableOrderingComposer | ||||
|   ColumnOrderings<int> get id => $composableBuilder( | ||||
|       column: $table.id, builder: (column) => ColumnOrderings(column)); | ||||
|  | ||||
|   ColumnOrderings<Uint8List> get content => $composableBuilder( | ||||
|   ColumnOrderings<String> get alias => $composableBuilder( | ||||
|       column: $table.alias, builder: (column) => ColumnOrderings(column)); | ||||
|  | ||||
|   ColumnOrderings<String> get content => $composableBuilder( | ||||
|       column: $table.content, builder: (column) => ColumnOrderings(column)); | ||||
|  | ||||
|   ColumnOrderings<DateTime> get createdAt => $composableBuilder( | ||||
| @@ -584,7 +626,10 @@ class $$SnLocalChatChannelTableAnnotationComposer | ||||
|   GeneratedColumn<int> get id => | ||||
|       $composableBuilder(column: $table.id, builder: (column) => column); | ||||
|  | ||||
|   GeneratedColumnWithTypeConverter<SnChannel, Uint8List> get content => | ||||
|   GeneratedColumn<String> get alias => | ||||
|       $composableBuilder(column: $table.alias, builder: (column) => column); | ||||
|  | ||||
|   GeneratedColumnWithTypeConverter<SnChannel, String> get content => | ||||
|       $composableBuilder(column: $table.content, builder: (column) => column); | ||||
|  | ||||
|   GeneratedColumn<DateTime> get createdAt => | ||||
| @@ -621,21 +666,25 @@ class $$SnLocalChatChannelTableTableManager extends RootTableManager< | ||||
|                   $db: db, $table: table), | ||||
|           updateCompanionCallback: ({ | ||||
|             Value<int> id = const Value.absent(), | ||||
|             Value<String> alias = const Value.absent(), | ||||
|             Value<SnChannel> content = const Value.absent(), | ||||
|             Value<DateTime> createdAt = const Value.absent(), | ||||
|           }) => | ||||
|               SnLocalChatChannelCompanion( | ||||
|             id: id, | ||||
|             alias: alias, | ||||
|             content: content, | ||||
|             createdAt: createdAt, | ||||
|           ), | ||||
|           createCompanionCallback: ({ | ||||
|             Value<int> id = const Value.absent(), | ||||
|             required String alias, | ||||
|             required SnChannel content, | ||||
|             Value<DateTime> createdAt = const Value.absent(), | ||||
|           }) => | ||||
|               SnLocalChatChannelCompanion.insert( | ||||
|             id: id, | ||||
|             alias: alias, | ||||
|             content: content, | ||||
|             createdAt: createdAt, | ||||
|           ), | ||||
| @@ -692,7 +741,7 @@ class $$SnLocalChatMessageTableFilterComposer | ||||
|   ColumnFilters<int> get channelId => $composableBuilder( | ||||
|       column: $table.channelId, builder: (column) => ColumnFilters(column)); | ||||
|  | ||||
|   ColumnWithTypeConverterFilters<SnChatMessage, SnChatMessage, Uint8List> | ||||
|   ColumnWithTypeConverterFilters<SnChatMessage, SnChatMessage, String> | ||||
|       get content => $composableBuilder( | ||||
|           column: $table.content, | ||||
|           builder: (column) => ColumnWithTypeConverterFilters(column)); | ||||
| @@ -716,7 +765,7 @@ class $$SnLocalChatMessageTableOrderingComposer | ||||
|   ColumnOrderings<int> get channelId => $composableBuilder( | ||||
|       column: $table.channelId, builder: (column) => ColumnOrderings(column)); | ||||
|  | ||||
|   ColumnOrderings<Uint8List> get content => $composableBuilder( | ||||
|   ColumnOrderings<String> get content => $composableBuilder( | ||||
|       column: $table.content, builder: (column) => ColumnOrderings(column)); | ||||
|  | ||||
|   ColumnOrderings<DateTime> get createdAt => $composableBuilder( | ||||
| @@ -738,7 +787,7 @@ class $$SnLocalChatMessageTableAnnotationComposer | ||||
|   GeneratedColumn<int> get channelId => | ||||
|       $composableBuilder(column: $table.channelId, builder: (column) => column); | ||||
|  | ||||
|   GeneratedColumnWithTypeConverter<SnChatMessage, Uint8List> get content => | ||||
|   GeneratedColumnWithTypeConverter<SnChatMessage, String> get content => | ||||
|       $composableBuilder(column: $table.content, builder: (column) => column); | ||||
|  | ||||
|   GeneratedColumn<DateTime> get createdAt => | ||||
|   | ||||
| @@ -1,7 +1,11 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:drift/drift.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/controllers/chat_message_controller.dart'; | ||||
| import 'package:surface/database/database.dart'; | ||||
| import 'package:surface/providers/database.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/user_directory.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| @@ -12,12 +16,12 @@ class ChatChannelProvider extends ChangeNotifier { | ||||
|  | ||||
|   late final SnNetworkProvider _sn; | ||||
|   late final UserDirectoryProvider _ud; | ||||
|  | ||||
|   Box<SnChannel>? get _channelBox => Hive.box<SnChannel>(kChatChannelBoxName); | ||||
|   late final DatabaseProvider _dt; | ||||
|  | ||||
|   ChatChannelProvider(BuildContext context) { | ||||
|     _sn = context.read<SnNetworkProvider>(); | ||||
|     _ud = context.read<UserDirectoryProvider>(); | ||||
|     _dt = context.read<DatabaseProvider>(); | ||||
|     _initializeLocalData(); | ||||
|   } | ||||
|  | ||||
| @@ -26,10 +30,23 @@ class ChatChannelProvider extends ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async { | ||||
|     if (_channelBox == null) return; | ||||
|     await _channelBox!.putAll({ | ||||
|       for (final channel in channels) channel.key: channel, | ||||
|     }); | ||||
|     await Future.wait( | ||||
|       channels.map( | ||||
|         (ele) => _dt.db.snLocalChatChannel.insertOne( | ||||
|           SnLocalChatChannelCompanion.insert( | ||||
|             id: Value(ele.id), | ||||
|             alias: ele.key, | ||||
|             content: ele, | ||||
|             createdAt: Value(ele.createdAt), | ||||
|           ), | ||||
|           onConflict: DoUpdate( | ||||
|             (_) => SnLocalChatChannelCompanion.custom( | ||||
|               content: Constant(jsonEncode(ele.toJson())), | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Future<List<SnChannel>> _fetchChannelsFromServer({ | ||||
| @@ -54,12 +71,13 @@ class ChatChannelProvider extends ChangeNotifier { | ||||
|   /// It will use the local storage as much as possible. | ||||
|   /// The alias should include the scope, formatted as `scope:alias`. | ||||
|   Future<SnChannel> getChannel(String key) async { | ||||
|     if (_channelBox != null) { | ||||
|       final local = _channelBox!.get(key); | ||||
|       if (local != null) return local; | ||||
|     } | ||||
|     final local = await (_dt.db.snLocalChatChannel.select() | ||||
|           ..where((e) => e.alias.equals(key))) | ||||
|         .getSingleOrNull(); | ||||
|     if (local != null) return local.content; | ||||
|  | ||||
|     var resp = await _sn.client.get('/cgi/im/channels/$key'); | ||||
|     var resp = | ||||
|         await _sn.client.get('/cgi/im/channels/${key.replaceAll(':', '/')}'); | ||||
|     var out = SnChannel.fromJson(resp.data); | ||||
|  | ||||
|     // Preload realm of the channel | ||||
| @@ -77,8 +95,19 @@ class ChatChannelProvider extends ChangeNotifier { | ||||
|   /// And the second time is when the data was fetched from the server. | ||||
|   /// But there is some exception that will only cause one of them to be emitted. | ||||
|   /// Like the local storage is broken or the server is down. | ||||
|   Stream<List<SnChannel>> fetchChannels() async* { | ||||
|     if (_channelBox != null) yield _channelBox!.values.toList(); | ||||
|   Stream<List<SnChannel>> fetchChannels( | ||||
|       {bool noRemote = false, bool noLocal = false}) async* { | ||||
|     if (!noLocal) { | ||||
|       final local = await (_dt.db.snLocalChatChannel.select() | ||||
|             ..orderBy([ | ||||
|               (e) => | ||||
|                   OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc) | ||||
|             ])) | ||||
|           .get(); | ||||
|       yield local.map((e) => e.content).toList(); | ||||
|     } | ||||
|  | ||||
|     if (noRemote) return; | ||||
|  | ||||
|     var resp = await _sn.client.get('/cgi/id/realms/me/available'); | ||||
|     final realms = List<SnRealm>.from( | ||||
| @@ -120,23 +149,23 @@ class ChatChannelProvider extends ChangeNotifier { | ||||
|   Future<List<SnChatMessage>> getLastMessages( | ||||
|     Iterable<SnChannel> channels, | ||||
|   ) async { | ||||
|     final result = List<SnChatMessage>.empty(growable: true); | ||||
|     final result = List<Future<SnLocalChatMessageData?>>.empty(growable: true); | ||||
|     for (final channel in channels) { | ||||
|       final channelBox = await Hive.openBox<SnChatMessage>( | ||||
|         '${ChatMessageController.kChatMessageBoxPrefix}${channel.id}', | ||||
|       ); | ||||
|       final lastMessage = | ||||
|           channelBox.isNotEmpty ? channelBox.values.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b) : null; | ||||
|       if (lastMessage != null) result.add(lastMessage); | ||||
|       channelBox.close(); | ||||
|       final out = (_dt.db.snLocalChatMessage.select() | ||||
|             ..where((e) => e.channelId.equals(channel.id)) | ||||
|             ..orderBy([ | ||||
|               (e) => | ||||
|                   OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc) | ||||
|             ]) | ||||
|             ..limit(1)) | ||||
|           .getSingleOrNull(); | ||||
|       result.add(out); | ||||
|     } | ||||
|     await _ud.listAccount(result.map((ele) => ele.sender.accountId).toSet()); | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _channelBox?.close(); | ||||
|     super.dispose(); | ||||
|     final out = (await Future.wait(result)) | ||||
|         .where((e) => e != null) | ||||
|         .map((e) => e!.content) | ||||
|         .toList(); | ||||
|     await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet()); | ||||
|     return out; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,31 @@ | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:path/path.dart' show join; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:surface/database/database.dart'; | ||||
|  | ||||
| class DatabaseProvider { | ||||
|   late final AppDatabase db; | ||||
|   late AppDatabase db; | ||||
|  | ||||
|   DatabaseProvider(BuildContext context) { | ||||
|     db = AppDatabase(); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   Future<int> getDatabaseSize() async { | ||||
|     if (kIsWeb) return 0; | ||||
|     final basepath = await getApplicationSupportDirectory(); | ||||
|     return await File(join(basepath.path, 'solar_network_data.sqlite')) | ||||
|         .length(); | ||||
|   } | ||||
|  | ||||
|   Future<void> removeDatabase() async { | ||||
|     if (kIsWeb) return; | ||||
|     final basepath = await getApplicationSupportDirectory(); | ||||
|     final file = File(join(basepath.path, 'solar_network_data.sqlite')); | ||||
|     db.close(); | ||||
|     await file.delete(); | ||||
|     db = AppDatabase(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -6,7 +6,9 @@ import 'package:go_router/go_router.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/channel.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/user_directory.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/account/account_select.dart'; | ||||
| @@ -17,9 +19,6 @@ import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
| import 'package:surface/widgets/unauthorized_hint.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
|  | ||||
| import '../providers/sn_network.dart'; | ||||
| import '../providers/userinfo.dart'; | ||||
|  | ||||
| class ChatScreen extends StatefulWidget { | ||||
|   const ChatScreen({super.key}); | ||||
|  | ||||
| @@ -35,7 +34,7 @@ class _ChatScreenState extends State<ChatScreen> { | ||||
|   List<SnChannel>? _channels; | ||||
|   Map<int, SnChatMessage>? _lastMessages; | ||||
|  | ||||
|   void _refreshChannels() { | ||||
|   void _refreshChannels({bool noRemote = false}) { | ||||
|     final ua = context.read<UserProvider>(); | ||||
|     if (!ua.isAuthorized) { | ||||
|       setState(() => _isBusy = false); | ||||
| @@ -43,12 +42,15 @@ class _ChatScreenState extends State<ChatScreen> { | ||||
|     } | ||||
|  | ||||
|     final chan = context.read<ChatChannelProvider>(); | ||||
|     chan.fetchChannels().listen((channels) async { | ||||
|     chan.fetchChannels(noRemote: noRemote).listen((channels) async { | ||||
|       final lastMessages = await chan.getLastMessages(channels); | ||||
|       _lastMessages = {for (final val in lastMessages) val.channelId: val}; | ||||
|       channels.sort((a, b) { | ||||
|         if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) { | ||||
|           return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt); | ||||
|         if (_lastMessages!.containsKey(a.id) && | ||||
|             _lastMessages!.containsKey(b.id)) { | ||||
|           return _lastMessages![b.id]! | ||||
|               .createdAt | ||||
|               .compareTo(_lastMessages![a.id]!.createdAt); | ||||
|         } | ||||
|         if (_lastMessages!.containsKey(a.id)) return -1; | ||||
|         if (_lastMessages!.containsKey(b.id)) return 1; | ||||
| @@ -86,7 +88,8 @@ class _ChatScreenState extends State<ChatScreen> { | ||||
|   void _newDirectMessage() async { | ||||
|     final user = await showModalBottomSheet( | ||||
|       context: context, | ||||
|       builder: (context) => AccountSelect(title: 'channelNewDirectMessage'.tr()), | ||||
|       builder: (context) => | ||||
|           AccountSelect(title: 'channelNewDirectMessage'.tr()), | ||||
|     ); | ||||
|     if (user == null) return; | ||||
|     if (!mounted) return; | ||||
| @@ -98,7 +101,8 @@ class _ChatScreenState extends State<ChatScreen> { | ||||
|       await sn.client.post('/cgi/im/channels/global/dm', data: { | ||||
|         'alias': uuid.v4().replaceAll('-', '').substring(0, 12), | ||||
|         'name': 'DM', | ||||
|         'description': 'A direct message channel between @${ua.user?.name} and @${user.name}', | ||||
|         'description': | ||||
|             'A direct message channel between @${ua.user?.name} and @${user.name}', | ||||
|         'related_user': user.id, | ||||
|       }); | ||||
|       _fabKey.currentState!.toggle(); | ||||
| @@ -144,20 +148,27 @@ class _ChatScreenState extends State<ChatScreen> { | ||||
|         type: ExpandableFabType.up, | ||||
|         childrenAnimation: ExpandableFabAnimation.none, | ||||
|         overlayStyle: ExpandableFabOverlayStyle( | ||||
|           color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()), | ||||
|           color: Theme.of(context) | ||||
|               .colorScheme | ||||
|               .surface | ||||
|               .withAlpha((255 * 0.5).round()), | ||||
|         ), | ||||
|         openButtonBuilder: RotateFloatingActionButtonBuilder( | ||||
|           child: const Icon(Symbols.add, size: 28), | ||||
|           fabSize: ExpandableFabSize.regular, | ||||
|           foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||
|           backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||
|           foregroundColor: | ||||
|               Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||
|           backgroundColor: | ||||
|               Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||
|           shape: const CircleBorder(), | ||||
|         ), | ||||
|         closeButtonBuilder: DefaultFloatingActionButtonBuilder( | ||||
|           child: const Icon(Symbols.close, size: 28), | ||||
|           fabSize: ExpandableFabSize.regular, | ||||
|           foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||
|           backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||
|           foregroundColor: | ||||
|               Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||
|           backgroundColor: | ||||
|               Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||
|           shape: const CircleBorder(), | ||||
|         ), | ||||
|         children: [ | ||||
| @@ -208,13 +219,17 @@ class _ChatScreenState extends State<ChatScreen> { | ||||
|                     final lastMessage = _lastMessages?[channel.id]; | ||||
|  | ||||
|                     if (channel.type == 1) { | ||||
|                       final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere( | ||||
|                             (ele) => ele?.accountId != ua.user?.id, | ||||
|                             orElse: () => null, | ||||
|                           ); | ||||
|                       final otherMember = | ||||
|                           channel.members?.cast<SnChannelMember?>().firstWhere( | ||||
|                                 (ele) => ele?.accountId != ua.user?.id, | ||||
|                                 orElse: () => null, | ||||
|                               ); | ||||
|  | ||||
|                       return ListTile( | ||||
|                         title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name), | ||||
|                         title: Text(ud | ||||
|                                 .getAccountFromCache(otherMember?.accountId) | ||||
|                                 ?.nick ?? | ||||
|                             channel.name), | ||||
|                         subtitle: lastMessage != null | ||||
|                             ? Text( | ||||
|                                 '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', | ||||
| @@ -228,9 +243,12 @@ class _ChatScreenState extends State<ChatScreen> { | ||||
|                                 maxLines: 1, | ||||
|                                 overflow: TextOverflow.ellipsis, | ||||
|                               ), | ||||
|                         contentPadding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|                         contentPadding: | ||||
|                             const EdgeInsets.symmetric(horizontal: 16), | ||||
|                         leading: AccountImage( | ||||
|                           content: ud.getAccountFromCache(otherMember?.accountId)?.avatar, | ||||
|                           content: ud | ||||
|                               .getAccountFromCache(otherMember?.accountId) | ||||
|                               ?.avatar, | ||||
|                         ), | ||||
|                         onTap: () { | ||||
|                           GoRouter.of(context).pushNamed( | ||||
| @@ -240,7 +258,7 @@ class _ChatScreenState extends State<ChatScreen> { | ||||
|                               'alias': channel.alias, | ||||
|                             }, | ||||
|                           ).then((value) { | ||||
|                             if (mounted) _refreshChannels(); | ||||
|                             if (mounted) _refreshChannels(noRemote: true); | ||||
|                           }); | ||||
|                         }, | ||||
|                       ); | ||||
| @@ -259,7 +277,8 @@ class _ChatScreenState extends State<ChatScreen> { | ||||
|                               maxLines: 1, | ||||
|                               overflow: TextOverflow.ellipsis, | ||||
|                             ), | ||||
|                       contentPadding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|                       contentPadding: | ||||
|                           const EdgeInsets.symmetric(horizontal: 16), | ||||
|                       leading: AccountImage( | ||||
|                         content: null, | ||||
|                         fallbackWidget: const Icon(Symbols.chat, size: 20), | ||||
|   | ||||
| @@ -243,7 +243,7 @@ class _ExploreScreenState extends State<ExploreScreen> with SingleTickerProvider | ||||
|                         children: [ | ||||
|                           Icon(Symbols.globe, size: 20, color: Theme.of(context).appBarTheme.foregroundColor), | ||||
|                           const Gap(8), | ||||
|                           Expanded( | ||||
|                           Flexible( | ||||
|                             child: Text( | ||||
|                               'postChannelGlobal', | ||||
|                               maxLines: 1, | ||||
| @@ -259,10 +259,11 @@ class _ExploreScreenState extends State<ExploreScreen> with SingleTickerProvider | ||||
|                         children: [ | ||||
|                           Icon(Symbols.group, size: 20, color: Theme.of(context).appBarTheme.foregroundColor), | ||||
|                           const Gap(8), | ||||
|                           Expanded( | ||||
|                           Flexible( | ||||
|                             child: Text( | ||||
|                               'postChannelFriends', | ||||
|                               maxLines: 1, | ||||
|                               textAlign: TextAlign.center, | ||||
|                             ).tr().textColor(Theme.of(context).appBarTheme.foregroundColor), | ||||
|                           ), | ||||
|                         ], | ||||
| @@ -275,7 +276,7 @@ class _ExploreScreenState extends State<ExploreScreen> with SingleTickerProvider | ||||
|                         children: [ | ||||
|                           Icon(Symbols.subscriptions, size: 20, color: Theme.of(context).appBarTheme.foregroundColor), | ||||
|                           const Gap(8), | ||||
|                           Expanded( | ||||
|                           Flexible( | ||||
|                             child: Text( | ||||
|                               'postChannelFollowing', | ||||
|                               maxLines: 1, | ||||
| @@ -291,7 +292,7 @@ class _ExploreScreenState extends State<ExploreScreen> with SingleTickerProvider | ||||
|                         children: [ | ||||
|                           Icon(Symbols.workspaces, size: 20, color: Theme.of(context).appBarTheme.foregroundColor), | ||||
|                           const Gap(8), | ||||
|                           Expanded( | ||||
|                           Flexible( | ||||
|                             child: Text( | ||||
|                               'postChannelRealm', | ||||
|                               maxLines: 1, | ||||
|   | ||||
| @@ -5,8 +5,10 @@ import 'package:dropdown_button2/dropdown_button2.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_colorpicker/flutter_colorpicker.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:google_fonts/google_fonts.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| @@ -14,6 +16,7 @@ import 'package:provider/provider.dart'; | ||||
| import 'package:shared_preferences/shared_preferences.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/config.dart'; | ||||
| import 'package:surface/providers/database.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/theme.dart'; | ||||
| import 'package:surface/theme.dart'; | ||||
| @@ -67,6 +70,7 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|     final dt = context.read<DatabaseProvider>(); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
| @@ -81,7 +85,11 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|             Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Text('settingsAppearance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), | ||||
|                 Text('settingsAppearance') | ||||
|                     .bold() | ||||
|                     .fontSize(17) | ||||
|                     .tr() | ||||
|                     .padding(horizontal: 20, bottom: 4), | ||||
|                 ListTile( | ||||
|                   title: Text('settingsDisplayLanguage').tr(), | ||||
|                   subtitle: Text('settingsDisplayLanguageDescription').tr(), | ||||
| @@ -91,15 +99,21 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                     child: DropdownButton2<Locale?>( | ||||
|                       isExpanded: true, | ||||
|                       items: [ | ||||
|                         ...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) { | ||||
|                         ...EasyLocalization.of(context)! | ||||
|                             .supportedLocales | ||||
|                             .mapIndexed((idx, ele) { | ||||
|                           return DropdownMenuItem<Locale?>( | ||||
|                             value: ele, | ||||
|                             child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14), | ||||
|                             child: | ||||
|                                 Text('${ele.languageCode}-${ele.countryCode}') | ||||
|                                     .fontSize(14), | ||||
|                           ); | ||||
|                         }), | ||||
|                         DropdownMenuItem<Locale?>( | ||||
|                           value: null, | ||||
|                           child: Text('settingsDisplayLanguageSystem').tr().fontSize(14), | ||||
|                           child: Text('settingsDisplayLanguageSystem') | ||||
|                               .tr() | ||||
|                               .fontSize(14), | ||||
|                         ), | ||||
|                       ], | ||||
|                       value: EasyLocalization.of(context)!.currentLocale, | ||||
| @@ -132,10 +146,12 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                     leading: const Icon(Symbols.image), | ||||
|                     trailing: const Icon(Symbols.chevron_right), | ||||
|                     onTap: () async { | ||||
|                       final image = await ImagePicker().pickImage(source: ImageSource.gallery); | ||||
|                       final image = await ImagePicker() | ||||
|                           .pickImage(source: ImageSource.gallery); | ||||
|                       if (image == null) return; | ||||
|  | ||||
|                       await File(image.path).copy('$_docBasepath/app_background_image'); | ||||
|                       await File(image.path) | ||||
|                           .copy('$_docBasepath/app_background_image'); | ||||
|                       _prefs.setBool(kAppBackgroundStoreKey, true); | ||||
|  | ||||
|                       setState(() {}); | ||||
| @@ -143,7 +159,8 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                   ), | ||||
|                 if (!kIsWeb) | ||||
|                   FutureBuilder<bool>( | ||||
|                       future: File('$_docBasepath/app_background_image').exists(), | ||||
|                       future: | ||||
|                           File('$_docBasepath/app_background_image').exists(), | ||||
|                       builder: (context, snapshot) { | ||||
|                         if (!snapshot.hasData || !snapshot.data!) { | ||||
|                           return const SizedBox.shrink(); | ||||
| @@ -151,12 +168,16 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|  | ||||
|                         return ListTile( | ||||
|                           title: Text('settingsBackgroundImageClear').tr(), | ||||
|                           subtitle: Text('settingsBackgroundImageClearDescription').tr(), | ||||
|                           contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                           subtitle: | ||||
|                               Text('settingsBackgroundImageClearDescription') | ||||
|                                   .tr(), | ||||
|                           contentPadding: | ||||
|                               const EdgeInsets.symmetric(horizontal: 24), | ||||
|                           leading: const Icon(Symbols.texture), | ||||
|                           trailing: const Icon(Symbols.chevron_right), | ||||
|                           onTap: () { | ||||
|                             File('$_docBasepath/app_background_image').deleteSync(); | ||||
|                             File('$_docBasepath/app_background_image') | ||||
|                                 .deleteSync(); | ||||
|                             _prefs.remove(kAppBackgroundStoreKey); | ||||
|                             setState(() {}); | ||||
|                           }, | ||||
| @@ -186,34 +207,35 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   trailing: const Icon(Symbols.chevron_right), | ||||
|                   onTap: () async { | ||||
|                     Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value); | ||||
|                     Color pickerColor = Color( | ||||
|                         _prefs.getInt(kAppColorSchemeStoreKey) ?? | ||||
|                             Colors.indigo.value); | ||||
|                     final color = await showDialog<Color?>( | ||||
|                       context: context, | ||||
|                       builder: (context) => | ||||
|                           AlertDialog( | ||||
|                             content: SingleChildScrollView( | ||||
|                               child: ColorPicker( | ||||
|                                 pickerColor: pickerColor, | ||||
|                                 onColorChanged: (color) => pickerColor = color, | ||||
|                                 enableAlpha: false, | ||||
|                                 hexInputBar: true, | ||||
|                               ), | ||||
|                             ), | ||||
|                             actions: <Widget>[ | ||||
|                               TextButton( | ||||
|                                 child: const Text('dialogDismiss').tr(), | ||||
|                                 onPressed: () { | ||||
|                                   Navigator.of(context).pop(); | ||||
|                                 }, | ||||
|                               ), | ||||
|                               TextButton( | ||||
|                                 child: const Text('dialogConfirm').tr(), | ||||
|                                 onPressed: () { | ||||
|                                   Navigator.of(context).pop(pickerColor); | ||||
|                                 }, | ||||
|                               ), | ||||
|                             ], | ||||
|                       builder: (context) => AlertDialog( | ||||
|                         content: SingleChildScrollView( | ||||
|                           child: ColorPicker( | ||||
|                             pickerColor: pickerColor, | ||||
|                             onColorChanged: (color) => pickerColor = color, | ||||
|                             enableAlpha: false, | ||||
|                             hexInputBar: true, | ||||
|                           ), | ||||
|                         ), | ||||
|                         actions: <Widget>[ | ||||
|                           TextButton( | ||||
|                             child: const Text('dialogDismiss').tr(), | ||||
|                             onPressed: () { | ||||
|                               Navigator.of(context).pop(); | ||||
|                             }, | ||||
|                           ), | ||||
|                           TextButton( | ||||
|                             child: const Text('dialogConfirm').tr(), | ||||
|                             onPressed: () { | ||||
|                               Navigator.of(context).pop(pickerColor); | ||||
|                             }, | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ); | ||||
|  | ||||
|                     if (color == null || !context.mounted) return; | ||||
| @@ -248,16 +270,17 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                       ], | ||||
|                       value: _prefs.getInt(kAppColorSchemeStoreKey) == null | ||||
|                           ? 1 | ||||
|                           : kColorSchemes.values | ||||
|                           .toList() | ||||
|                           .indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)), | ||||
|                           : kColorSchemes.values.toList().indexWhere((ele) => | ||||
|                               ele.value == | ||||
|                               _prefs.getInt(kAppColorSchemeStoreKey)), | ||||
|                       onChanged: (int? value) { | ||||
|                         if (value != null && value != -1) { | ||||
|                           _prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values | ||||
|                               .elementAt(value) | ||||
|                               .value); | ||||
|                           _prefs.setInt(kAppColorSchemeStoreKey, | ||||
|                               kColorSchemes.values.elementAt(value).value); | ||||
|                           final th = context.read<ThemeProvider>(); | ||||
|                           th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value)); | ||||
|                           th.reloadTheme( | ||||
|                               seedColorOverride: | ||||
|                                   kColorSchemes.values.elementAt(value)); | ||||
|                           setState(() {}); | ||||
|  | ||||
|                           context.showSnackbar('colorSchemeApplied'.tr()); | ||||
| @@ -293,7 +316,8 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                 CheckboxListTile( | ||||
|                   secondary: const Icon(Symbols.left_panel_close), | ||||
|                   title: Text('settingsDrawerPreferCollapse').tr(), | ||||
|                   subtitle: Text('settingsDrawerPreferCollapseDescription').tr(), | ||||
|                   subtitle: | ||||
|                       Text('settingsDrawerPreferCollapseDescription').tr(), | ||||
|                   contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||
|                   value: _prefs.getBool(kAppDrawerPreferCollapse) ?? false, | ||||
|                   onChanged: (value) { | ||||
| @@ -308,7 +332,11 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|             Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Text('settingsFeatures').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), | ||||
|                 Text('settingsFeatures') | ||||
|                     .bold() | ||||
|                     .fontSize(17) | ||||
|                     .tr() | ||||
|                     .padding(horizontal: 20, bottom: 4), | ||||
|                 CheckboxListTile( | ||||
|                   secondary: const Icon(Symbols.vibration), | ||||
|                   contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||
| @@ -350,7 +378,11 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|             Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Text('settingsNetwork').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), | ||||
|                 Text('settingsNetwork') | ||||
|                     .bold() | ||||
|                     .fontSize(17) | ||||
|                     .tr() | ||||
|                     .padding(horizontal: 20, bottom: 4), | ||||
|                 TextField( | ||||
|                   controller: _serverUrlController, | ||||
|                   decoration: InputDecoration( | ||||
| @@ -371,7 +403,8 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                   onTapOutside: (_) => | ||||
|                       FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ).padding(horizontal: 16, top: 8, bottom: 4), | ||||
|                 ListTile( | ||||
|                   title: Text('settingsNetworkServerPreset').tr(), | ||||
| @@ -383,12 +416,13 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                       isExpanded: true, | ||||
|                       items: [ | ||||
|                         ...kNetworkServerDirectory, | ||||
|                         if (!kNetworkServerDirectory.map((ele) => ele.$2).contains(_serverUrlController.text)) | ||||
|                         if (!kNetworkServerDirectory | ||||
|                             .map((ele) => ele.$2) | ||||
|                             .contains(_serverUrlController.text)) | ||||
|                           ('Custom', _serverUrlController.text), | ||||
|                       ] | ||||
|                           .map( | ||||
|                             (item) => | ||||
|                             DropdownMenuItem<String>( | ||||
|                             (item) => DropdownMenuItem<String>( | ||||
|                               value: item.$2, | ||||
|                               child: Column( | ||||
|                                 mainAxisSize: MainAxisSize.max, | ||||
| @@ -396,11 +430,12 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                                 children: [ | ||||
|                                   Text(item.$1).fontSize(14), | ||||
|                                   Text(item.$2, overflow: TextOverflow.ellipsis).fontSize(11) | ||||
|                                   Text(item.$2, overflow: TextOverflow.ellipsis) | ||||
|                                       .fontSize(11) | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ), | ||||
|                       ) | ||||
|                           ) | ||||
|                           .toList(), | ||||
|                       value: _serverUrlController.text, | ||||
|                       onChanged: (String? value) { | ||||
| @@ -442,7 +477,11 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|             Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Text('settingsPerformance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), | ||||
|                 Text('settingsPerformance') | ||||
|                     .bold() | ||||
|                     .fontSize(17) | ||||
|                     .tr() | ||||
|                     .padding(horizontal: 20, bottom: 4), | ||||
|                 ListTile( | ||||
|                   title: Text('settingsImageQuality').tr(), | ||||
|                   subtitle: Text('settingsImageQualityDescription').tr(), | ||||
| @@ -450,21 +489,22 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                   leading: const Icon(Symbols.image), | ||||
|                   trailing: DropdownButtonHideUnderline( | ||||
|                     child: DropdownButton2<FilterQuality>( | ||||
|                       value: kImageQualityLevel.values.elementAtOrNull(_prefs.getInt('app_image_quality') ?? 3) ?? | ||||
|                       value: kImageQualityLevel.values.elementAtOrNull( | ||||
|                               _prefs.getInt('app_image_quality') ?? 3) ?? | ||||
|                           FilterQuality.high, | ||||
|                       isExpanded: true, | ||||
|                       items: kImageQualityLevel.entries | ||||
|                           .map( | ||||
|                             (item) => | ||||
|                             DropdownMenuItem<FilterQuality>( | ||||
|                             (item) => DropdownMenuItem<FilterQuality>( | ||||
|                               value: item.value, | ||||
|                               child: Text(item.key).tr().fontSize(14), | ||||
|                             ), | ||||
|                       ) | ||||
|                           ) | ||||
|                           .toList(), | ||||
|                       onChanged: (FilterQuality? value) { | ||||
|                         if (value == null) return; | ||||
|                         _prefs.setInt('app_image_quality', kImageQualityLevel.values.toList().indexOf(value)); | ||||
|                         _prefs.setInt('app_image_quality', | ||||
|                             kImageQualityLevel.values.toList().indexOf(value)); | ||||
|                         setState(() {}); | ||||
|                       }, | ||||
|                       buttonStyleData: const ButtonStyleData( | ||||
| @@ -486,7 +526,42 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|             Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Text('settingsMisc').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), | ||||
|                 Text('settingsMisc') | ||||
|                     .bold() | ||||
|                     .fontSize(17) | ||||
|                     .tr() | ||||
|                     .padding(horizontal: 20, bottom: 4), | ||||
|                 ListTile( | ||||
|                   leading: const Icon(Symbols.database), | ||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   title: Text('databaseSize').tr(), | ||||
|                   subtitle: FutureBuilder( | ||||
|                     future: dt.getDatabaseSize(), | ||||
|                     builder: (context, snapshot) { | ||||
|                       if (!snapshot.hasData || kIsWeb) { | ||||
|                         return Text('unknown').tr(); | ||||
|                       } | ||||
|                       return Text( | ||||
|                         snapshot.data!.formatBytes(), | ||||
|                         style: GoogleFonts.robotoMono(), | ||||
|                       ); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|                 ListTile( | ||||
|                   leading: const Icon(Symbols.database_off), | ||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   title: Text('databaseDelete').tr(), | ||||
|                   subtitle: Text('databaseDeleteDescription').tr(), | ||||
|                   trailing: const Icon(Symbols.chevron_right), | ||||
|                   onTap: () async { | ||||
|                     await dt.removeDatabase(); | ||||
|                     if (!context.mounted) return; | ||||
|                     HapticFeedback.heavyImpact(); | ||||
|                     context.showSnackbar('databaseDeleted'.tr()); | ||||
|                     setState(() {}); | ||||
|                   }, | ||||
|                 ), | ||||
|                 ListTile( | ||||
|                   title: Text('settingsMiscAbout').tr(), | ||||
|                   subtitle: Text('settingsMiscAboutDescription').tr(), | ||||
|   | ||||
		Reference in New Issue
	
	Block a user