From 3c0e4046a4cd2a37f1b71765e135529c639493f7 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 22 Feb 2025 20:43:24 +0800 Subject: [PATCH] :recycle: Refactor to replace Hive with Sqlite --- assets/translations/en-US.json | 6 +- assets/translations/zh-CN.json | 6 +- assets/translations/zh-HK.json | 6 +- assets/translations/zh-TW.json | 6 +- lib/controllers/chat_message_controller.dart | 149 ++++++++++----- lib/database/chat.dart | 66 ++++++- lib/database/database.g.dart | 127 ++++++++---- lib/providers/channel.dart | 89 ++++++--- lib/providers/database.dart | 25 ++- lib/screens/chat.dart | 65 ++++--- lib/screens/explore.dart | 9 +- lib/screens/settings.dart | 191 +++++++++++++------ 12 files changed, 530 insertions(+), 215 deletions(-) diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 24cc89e..5567db3 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -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." } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 6991c3c..13ee771 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -681,5 +681,9 @@ "postChannelRealm": "领域", "postFilterReset": "重置过滤器", "postFilterResetDescription": "清除过滤器并显示所有帖子。", - "postFilterWithCategory": "查看{}区中的帖子" + "postFilterWithCategory": "查看{}区中的帖子", + "databaseSize": "数据库大小", + "databaseDelete": "删除数据库", + "databaseDeleteDescription": "删除本地数据库,内容将从服务器重新获取。", + "databaseDeleted": "本地数据库已被删除。" } diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index aee2f60..4819293 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -681,5 +681,9 @@ "postChannelRealm": "領域", "postFilterReset": "重置過濾器", "postFilterResetDescription": "清除過濾器並顯示所有帖子。", - "postFilterWithCategory": "查看{}區中的帖子" + "postFilterWithCategory": "查看{}區中的帖子", + "databaseSize": "數據庫大小", + "databaseDelete": "刪除數據庫", + "databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。", + "databaseDeleted": "本地數據庫已被刪除。" } diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index dd1a928..314a56a 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -681,5 +681,9 @@ "postChannelRealm": "領域", "postFilterReset": "重置過濾器", "postFilterResetDescription": "清除過濾器並顯示所有帖子。", - "postFilterWithCategory": "查看{}區中的帖子" + "postFilterWithCategory": "查看{}區中的帖子", + "databaseSize": "數據庫大小", + "databaseDelete": "刪除數據庫", + "databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。", + "databaseDeleted": "本地數據庫已被刪除。" } diff --git a/lib/controllers/chat_message_controller.dart b/lib/controllers/chat_message_controller.dart index d093737..47bcfcc 100644 --- a/lib/controllers/chat_message_controller.dart +++ b/lib/controllers/chat_message_controller.dart @@ -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(); _ws = context.read(); _attach = context.read(); + _dt = context.read(); } 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 unconfirmedMessages = List.empty(growable: true); - Box? get _box => (_boxKey == null || isPending) ? null : Hive.box(_boxKey!); - final List typingMembers = List.empty(growable: true); final Map typingInactiveTimer = {}; Future initialize(SnChannel chan) async { channel = chan; - // Initialize local data - _boxKey = '$kChatMessageBoxPrefix${chan.id}'; - await Hive.openBox(_boxKey!); - // Fetch channel profile final resp = await _sn.client.get( '/cgi/im/channels/${chan.keyPath}/me', ); - profile = SnChannelMember.fromJson( - resp.data as Map, - ); + 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 _saveMessageToLocal(Iterable 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 _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 _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 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 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 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 out; - if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) { - out = _box!.keys - .toList() - .cast() - .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(); } diff --git a/lib/database/chat.dart b/lib/database/chat.dart index 5c08322..96cb229 100644 --- a/lib/database/chat.dart +++ b/lib/database/chat.dart @@ -1,26 +1,74 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; import 'package:surface/types/chat.dart'; +class SnChannelConverter extends TypeConverter + with JsonTypeConverter2> { + const SnChannelConverter(); + + @override + SnChannel fromSql(String fromDb) { + return fromJson(jsonDecode(fromDb) as Map); + } + + @override + String toSql(SnChannel value) { + return jsonEncode(toJson(value)); + } + + @override + SnChannel fromJson(Map json) { + return SnChannel.fromJson(json); + } + + @override + Map 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), - 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 + with JsonTypeConverter2> { + const SnMessageConverter(); + + @override + SnChatMessage fromSql(String fromDb) { + return fromJson(jsonDecode(fromDb) as Map); + } + + @override + String toSql(SnChatMessage value) { + return jsonEncode(toJson(value)); + } + + @override + SnChatMessage fromJson(Map json) { + return SnChatMessage.fromJson(json); + } + + @override + Map 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), - toJson: (pref) => pref.toJson(), - ))(); + TextColumn get content => text().map(const SnMessageConverter())(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); -} \ No newline at end of file +} diff --git a/lib/database/database.g.dart b/lib/database/database.g.dart index af4d454..dee6ef8 100644 --- a/lib/database/database.g.dart +++ b/lib/database/database.g.dart @@ -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 alias = GeneratedColumn( + 'alias', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); static const VerificationMeta _contentMeta = const VerificationMeta('content'); @override - late final GeneratedColumnWithTypeConverter content = - GeneratedColumn('content', aliasedName, false, - type: DriftSqlType.blob, requiredDuringInsert: true) + late final GeneratedColumnWithTypeConverter content = + GeneratedColumn('content', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) .withConverter($SnLocalChatChannelTable.$convertercontent); static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); @@ -34,7 +39,7 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel requiredDuringInsert: false, defaultValue: currentDateAndTime); @override - List get $columns => [id, content, createdAt]; + List 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 $convertercontent = - TypeConverter.jsonb( - fromJson: (json) => SnChannel.fromJson(json as Map), - toJson: (pref) => pref.toJson()); + static JsonTypeConverter2> + $convertercontent = const SnChannelConverter(); } class SnLocalChatChannelData extends DataClass implements Insertable { 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 toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); + map['alias'] = Variable(alias); { - map['content'] = Variable( + map['content'] = Variable( $SnLocalChatChannelTable.$convertercontent.toSql(content)); } map['created_at'] = Variable(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(json['id']), + alias: serializer.fromJson(json['alias']), content: $SnLocalChatChannelTable.$convertercontent - .fromJson(serializer.fromJson(json['content'])), + .fromJson(serializer.fromJson>(json['content'])), createdAt: serializer.fromJson(json['createdAt']), ); } @@ -126,22 +144,25 @@ class SnLocalChatChannelData extends DataClass serializer ??= driftRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'content': serializer.toJson( + 'alias': serializer.toJson(alias), + 'content': serializer.toJson>( $SnLocalChatChannelTable.$convertercontent.toJson(content)), 'createdAt': serializer.toJson(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 { final Value id; + final Value alias; final Value content; final Value 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 custom({ Expression? id, - Expression? content, + Expression? alias, + Expression? content, Expression? 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? id, Value? content, Value? createdAt}) { + {Value? id, + Value? alias, + Value? content, + Value? 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(id.value); } + if (alias.present) { + map['alias'] = Variable(alias.value); + } if (content.present) { - map['content'] = Variable( + map['content'] = Variable( $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 - content = GeneratedColumn('content', aliasedName, false, - type: DriftSqlType.blob, requiredDuringInsert: true) + late final GeneratedColumnWithTypeConverter content = + GeneratedColumn('content', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) .withConverter( $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 - $convertercontent = TypeConverter.jsonb( - fromJson: (json) => - SnChatMessage.fromJson(json as Map), - toJson: (pref) => pref.toJson()); + static JsonTypeConverter2> + $convertercontent = const SnMessageConverter(); } class SnLocalChatMessageData extends DataClass @@ -345,7 +379,7 @@ class SnLocalChatMessageData extends DataClass map['id'] = Variable(id); map['channel_id'] = Variable(channelId); { - map['content'] = Variable( + map['content'] = Variable( $SnLocalChatMessageTable.$convertercontent.toSql(content)); } map['created_at'] = Variable(createdAt); @@ -368,7 +402,7 @@ class SnLocalChatMessageData extends DataClass id: serializer.fromJson(json['id']), channelId: serializer.fromJson(json['channelId']), content: $SnLocalChatMessageTable.$convertercontent - .fromJson(serializer.fromJson(json['content'])), + .fromJson(serializer.fromJson>(json['content'])), createdAt: serializer.fromJson(json['createdAt']), ); } @@ -378,7 +412,7 @@ class SnLocalChatMessageData extends DataClass return { 'id': serializer.toJson(id), 'channelId': serializer.toJson(channelId), - 'content': serializer.toJson( + 'content': serializer.toJson>( $SnLocalChatMessageTable.$convertercontent.toJson(content)), 'createdAt': serializer.toJson(createdAt), }; @@ -449,7 +483,7 @@ class SnLocalChatMessageCompanion static Insertable custom({ Expression? id, Expression? channelId, - Expression? content, + Expression? content, Expression? createdAt, }) { return RawValuesInsertable({ @@ -483,7 +517,7 @@ class SnLocalChatMessageCompanion map['channel_id'] = Variable(channelId.value); } if (content.present) { - map['content'] = Variable( + map['content'] = Variable( $SnLocalChatMessageTable.$convertercontent.toSql(content.value)); } if (createdAt.present) { @@ -522,12 +556,14 @@ abstract class _$AppDatabase extends GeneratedDatabase { typedef $$SnLocalChatChannelTableCreateCompanionBuilder = SnLocalChatChannelCompanion Function({ Value id, + required String alias, required SnChannel content, Value createdAt, }); typedef $$SnLocalChatChannelTableUpdateCompanionBuilder = SnLocalChatChannelCompanion Function({ Value id, + Value alias, Value content, Value createdAt, }); @@ -544,7 +580,10 @@ class $$SnLocalChatChannelTableFilterComposer ColumnFilters get id => $composableBuilder( column: $table.id, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters get content => + ColumnFilters get alias => $composableBuilder( + column: $table.alias, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters get content => $composableBuilder( column: $table.content, builder: (column) => ColumnWithTypeConverterFilters(column)); @@ -565,7 +604,10 @@ class $$SnLocalChatChannelTableOrderingComposer ColumnOrderings get id => $composableBuilder( column: $table.id, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get content => $composableBuilder( + ColumnOrderings get alias => $composableBuilder( + column: $table.alias, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get content => $composableBuilder( column: $table.content, builder: (column) => ColumnOrderings(column)); ColumnOrderings get createdAt => $composableBuilder( @@ -584,7 +626,10 @@ class $$SnLocalChatChannelTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumnWithTypeConverter get content => + GeneratedColumn get alias => + $composableBuilder(column: $table.alias, builder: (column) => column); + + GeneratedColumnWithTypeConverter get content => $composableBuilder(column: $table.content, builder: (column) => column); GeneratedColumn get createdAt => @@ -621,21 +666,25 @@ class $$SnLocalChatChannelTableTableManager extends RootTableManager< $db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), + Value alias = const Value.absent(), Value content = const Value.absent(), Value createdAt = const Value.absent(), }) => SnLocalChatChannelCompanion( id: id, + alias: alias, content: content, createdAt: createdAt, ), createCompanionCallback: ({ Value id = const Value.absent(), + required String alias, required SnChannel content, Value createdAt = const Value.absent(), }) => SnLocalChatChannelCompanion.insert( id: id, + alias: alias, content: content, createdAt: createdAt, ), @@ -692,7 +741,7 @@ class $$SnLocalChatMessageTableFilterComposer ColumnFilters get channelId => $composableBuilder( column: $table.channelId, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters + ColumnWithTypeConverterFilters get content => $composableBuilder( column: $table.content, builder: (column) => ColumnWithTypeConverterFilters(column)); @@ -716,7 +765,7 @@ class $$SnLocalChatMessageTableOrderingComposer ColumnOrderings get channelId => $composableBuilder( column: $table.channelId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get content => $composableBuilder( + ColumnOrderings get content => $composableBuilder( column: $table.content, builder: (column) => ColumnOrderings(column)); ColumnOrderings get createdAt => $composableBuilder( @@ -738,7 +787,7 @@ class $$SnLocalChatMessageTableAnnotationComposer GeneratedColumn get channelId => $composableBuilder(column: $table.channelId, builder: (column) => column); - GeneratedColumnWithTypeConverter get content => + GeneratedColumnWithTypeConverter get content => $composableBuilder(column: $table.content, builder: (column) => column); GeneratedColumn get createdAt => diff --git a/lib/providers/channel.dart b/lib/providers/channel.dart index e934c72..b21a1dd 100644 --- a/lib/providers/channel.dart +++ b/lib/providers/channel.dart @@ -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? get _channelBox => Hive.box(kChatChannelBoxName); + late final DatabaseProvider _dt; ChatChannelProvider(BuildContext context) { _sn = context.read(); _ud = context.read(); + _dt = context.read(); _initializeLocalData(); } @@ -26,10 +30,23 @@ class ChatChannelProvider extends ChangeNotifier { } Future _saveChannelToLocal(Iterable 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> _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 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> fetchChannels() async* { - if (_channelBox != null) yield _channelBox!.values.toList(); + Stream> 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.from( @@ -120,23 +149,23 @@ class ChatChannelProvider extends ChangeNotifier { Future> getLastMessages( Iterable channels, ) async { - final result = List.empty(growable: true); + final result = List>.empty(growable: true); for (final channel in channels) { - final channelBox = await Hive.openBox( - '${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; } } diff --git a/lib/providers/database.dart b/lib/providers/database.dart index 1cea385..0255292 100644 --- a/lib/providers/database.dart +++ b/lib/providers/database.dart @@ -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(); } -} \ No newline at end of file + + Future getDatabaseSize() async { + if (kIsWeb) return 0; + final basepath = await getApplicationSupportDirectory(); + return await File(join(basepath.path, 'solar_network_data.sqlite')) + .length(); + } + + Future 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(); + } +} diff --git a/lib/screens/chat.dart b/lib/screens/chat.dart index b22e752..6af1c5b 100644 --- a/lib/screens/chat.dart +++ b/lib/screens/chat.dart @@ -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 { List? _channels; Map? _lastMessages; - void _refreshChannels() { + void _refreshChannels({bool noRemote = false}) { final ua = context.read(); if (!ua.isAuthorized) { setState(() => _isBusy = false); @@ -43,12 +42,15 @@ class _ChatScreenState extends State { } final chan = context.read(); - 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 { 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 { 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 { 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 { final lastMessage = _lastMessages?[channel.id]; if (channel.type == 1) { - final otherMember = channel.members?.cast().firstWhere( - (ele) => ele?.accountId != ua.user?.id, - orElse: () => null, - ); + final otherMember = + channel.members?.cast().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 { 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 { 'alias': channel.alias, }, ).then((value) { - if (mounted) _refreshChannels(); + if (mounted) _refreshChannels(noRemote: true); }); }, ); @@ -259,7 +277,8 @@ class _ChatScreenState extends State { 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), diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index c583f8c..d56ffb3 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -243,7 +243,7 @@ class _ExploreScreenState extends State 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 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 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 with SingleTickerProvider children: [ Icon(Symbols.workspaces, size: 20, color: Theme.of(context).appBarTheme.foregroundColor), const Gap(8), - Expanded( + Flexible( child: Text( 'postChannelRealm', maxLines: 1, diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 33a8ea0..2493fd4 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -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 { @override Widget build(BuildContext context) { final sn = context.read(); + final dt = context.read(); return AppScaffold( appBar: AppBar( @@ -81,7 +85,11 @@ class _SettingsScreenState extends State { 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 { child: DropdownButton2( isExpanded: true, items: [ - ...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) { + ...EasyLocalization.of(context)! + .supportedLocales + .mapIndexed((idx, ele) { return DropdownMenuItem( value: ele, - child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14), + child: + Text('${ele.languageCode}-${ele.countryCode}') + .fontSize(14), ); }), DropdownMenuItem( 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 { 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 { ), if (!kIsWeb) FutureBuilder( - 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 { 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 { 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( context: context, - builder: (context) => - AlertDialog( - content: SingleChildScrollView( - child: ColorPicker( - pickerColor: pickerColor, - onColorChanged: (color) => pickerColor = color, - enableAlpha: false, - hexInputBar: true, - ), - ), - actions: [ - 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: [ + 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 { ], 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(); - 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 { 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 { 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 { 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 { }, ), ), - 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 { 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( + (item) => DropdownMenuItem( value: item.$2, child: Column( mainAxisSize: MainAxisSize.max, @@ -396,11 +430,12 @@ class _SettingsScreenState extends State { 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 { 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 { leading: const Icon(Symbols.image), trailing: DropdownButtonHideUnderline( child: DropdownButton2( - 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( + (item) => DropdownMenuItem( 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 { 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(),