♻️ Refactor to replace Hive with Sqlite

This commit is contained in:
LittleSheep 2025-02-22 20:43:24 +08:00
parent 338c22a606
commit 3c0e4046a4
12 changed files with 530 additions and 215 deletions

View File

@ -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."
}

View File

@ -681,5 +681,9 @@
"postChannelRealm": "领域",
"postFilterReset": "重置过滤器",
"postFilterResetDescription": "清除过滤器并显示所有帖子。",
"postFilterWithCategory": "查看{}区中的帖子"
"postFilterWithCategory": "查看{}区中的帖子",
"databaseSize": "数据库大小",
"databaseDelete": "删除数据库",
"databaseDeleteDescription": "删除本地数据库,内容将从服务器重新获取。",
"databaseDeleted": "本地数据库已被删除。"
}

View File

@ -681,5 +681,9 @@
"postChannelRealm": "領域",
"postFilterReset": "重置過濾器",
"postFilterResetDescription": "清除過濾器並顯示所有帖子。",
"postFilterWithCategory": "查看{}區中的帖子"
"postFilterWithCategory": "查看{}區中的帖子",
"databaseSize": "數據庫大小",
"databaseDelete": "刪除數據庫",
"databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。",
"databaseDeleted": "本地數據庫已被刪除。"
}

View File

@ -681,5 +681,9 @@
"postChannelRealm": "領域",
"postFilterReset": "重置過濾器",
"postFilterResetDescription": "清除過濾器並顯示所有帖子。",
"postFilterWithCategory": "查看{}區中的帖子"
"postFilterWithCategory": "查看{}區中的帖子",
"databaseSize": "數據庫大小",
"databaseDelete": "刪除數據庫",
"databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。",
"databaseDeleted": "本地數據庫已被刪除。"
}

View File

@ -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();
}

View File

@ -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)();
}
}

View File

@ -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 =>

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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),

View File

@ -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,

View File

@ -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(),