Compare commits

..

No commits in common. "97ddc18b8ef759332d5104921deaeb6c25c9b9e1" and "ce6e9c185aeb7151930234828236a2bec2ca9fe0" have entirely different histories.

25 changed files with 193 additions and 5622 deletions

File diff suppressed because one or more lines are too long

View File

@ -287,26 +287,23 @@ class ChatMessageController extends ChangeNotifier {
}; };
// Mock the message locally // Mock the message locally
// Do not mock the editing message final createdAt = DateTime.now();
if (editingMessage == null) { final message = SnChatMessage(
final createdAt = DateTime.now(); id: 0,
final message = SnChatMessage( createdAt: createdAt,
id: 0, updatedAt: createdAt,
createdAt: createdAt, deletedAt: null,
updatedAt: createdAt, uuid: nonce,
deletedAt: null, body: body,
uuid: nonce, type: type,
body: body, channel: channel!,
type: type, channelId: channel!.id,
channel: channel!, sender: profile!,
channelId: channel!.id, senderId: profile!.id,
sender: profile!, quoteEventId: quoteId,
senderId: profile!.id, relatedEventId: relatedId,
quoteEventId: quoteId, );
relatedEventId: relatedId, _addUnconfirmedMessage(message);
);
_addUnconfirmedMessage(message);
}
// Send to server // Send to server
try { try {

View File

@ -1,42 +0,0 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:surface/types/account.dart';
class SnAccountConverter extends TypeConverter<SnAccount, String>
with JsonTypeConverter2<SnAccount, String, Map<String, Object?>> {
const SnAccountConverter();
@override
SnAccount fromSql(String fromDb) {
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
}
@override
String toSql(SnAccount value) {
return jsonEncode(toJson(value));
}
@override
SnAccount fromJson(Map<String, Object?> json) {
return SnAccount.fromJson(json);
}
@override
Map<String, Object?> toJson(SnAccount value) {
return value.toJson();
}
}
@TableIndex(name: 'idx_account_name', columns: {#name})
class SnLocalAccount extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
TextColumn get content => text().map(const SnAccountConverter())();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get cacheExpiredAt => dateTime()();
}

View File

@ -1,47 +0,0 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:surface/types/attachment.dart';
class SnAttachmentConverter extends TypeConverter<SnAttachment, String>
with JsonTypeConverter2<SnAttachment, String, Map<String, Object?>> {
const SnAttachmentConverter();
@override
SnAttachment fromSql(String fromDb) {
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
}
@override
String toSql(SnAttachment value) {
return jsonEncode(toJson(value));
}
@override
SnAttachment fromJson(Map<String, Object?> json) {
return SnAttachment.fromJson(json);
}
@override
Map<String, Object?> toJson(SnAttachment value) {
return value.toJson();
}
}
@TableIndex(name: 'idx_attachment_rid', columns: {#rid})
@TableIndex(name: 'idx_attachment_account', columns: {#accountId})
class SnLocalAttachment extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get rid => text().unique()();
TextColumn get uuid => text().unique()();
TextColumn get content => text().map(const SnAttachmentConverter())();
IntColumn get accountId => integer()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get cacheExpiredAt => dateTime()();
}

View File

@ -28,7 +28,6 @@ class SnChannelConverter extends TypeConverter<SnChannel, String>
} }
} }
@TableIndex(name: 'idx_channel_alias', columns: {#alias})
class SnLocalChatChannel extends Table { class SnLocalChatChannel extends Table {
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
@ -64,54 +63,12 @@ class SnMessageConverter extends TypeConverter<SnChatMessage, String>
} }
} }
@TableIndex(name: 'idx_chat_channel', columns: {#channelId})
class SnLocalChatMessage extends Table { class SnLocalChatMessage extends Table {
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
IntColumn get channelId => integer()(); IntColumn get channelId => integer()();
IntColumn get senderId => integer().nullable()();
TextColumn get content => text().map(const SnMessageConverter())(); TextColumn get content => text().map(const SnMessageConverter())();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
} }
class SnChannelMemberConverter extends TypeConverter<SnChannelMember, String>
with JsonTypeConverter2<SnChannelMember, String, Map<String, Object?>> {
const SnChannelMemberConverter();
@override
SnChannelMember fromSql(String fromDb) {
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
}
@override
String toSql(SnChannelMember value) {
return jsonEncode(toJson(value));
}
@override
SnChannelMember fromJson(Map<String, Object?> json) {
return SnChannelMember.fromJson(json);
}
@override
Map<String, Object?> toJson(SnChannelMember value) {
return value.toJson();
}
}
class SnLocalChannelMember extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get channelId => integer()();
IntColumn get accountId => integer()();
TextColumn get content => text().map(SnChannelMemberConverter())();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get cacheExpiredAt => dateTime()();
}

View File

@ -1,33 +1,19 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'; import 'package:drift_flutter/drift_flutter.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:surface/database/account.dart';
import 'package:surface/database/attachment.dart';
import 'package:surface/database/chat.dart'; import 'package:surface/database/chat.dart';
import 'package:surface/database/database.steps.dart'; import 'package:surface/database/database.steps.dart';
import 'package:surface/database/keypair.dart'; import 'package:surface/database/keypair.dart';
import 'package:surface/database/sticker.dart';
import 'package:surface/types/chat.dart'; import 'package:surface/types/chat.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/account.dart';
part 'database.g.dart'; part 'database.g.dart';
@DriftDatabase(tables: [ @DriftDatabase(tables: [SnLocalChatChannel, SnLocalChatMessage, SnLocalKeyPair])
SnLocalChatChannel,
SnLocalChatMessage,
SnLocalChannelMember,
SnLocalKeyPair,
SnLocalAccount,
SnLocalAttachment,
SnLocalSticker,
SnLocalStickerPack,
])
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection()); AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection());
@override @override
int get schemaVersion => 3; int get schemaVersion => 2;
static QueryExecutor _openConnection() { static QueryExecutor _openConnection() {
return driftDatabase( return driftDatabase(
@ -47,8 +33,6 @@ class AppDatabase extends _$AppDatabase {
return MigrationStrategy( return MigrationStrategy(
onUpgrade: stepByStep(from1To2: (m, schema) async { onUpgrade: stepByStep(from1To2: (m, schema) async {
// Nothing else to do here // Nothing else to do here
}, from2To3: (m, schema) async {
// Nothing else to do here, too
}), }),
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -140,281 +140,8 @@ i1.GeneratedColumn<bool> _column_9(String aliasedName) =>
defaultConstraints: i1.GeneratedColumn.constraintIsAlways( defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_active" IN (0, 1))'), 'CHECK ("is_active" IN (0, 1))'),
defaultValue: const CustomExpression('0')); defaultValue: const CustomExpression('0'));
final class Schema3 extends i0.VersionedSchema {
Schema3({required super.database}) : super(version: 3);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
snLocalChatChannel,
snLocalChatMessage,
snLocalChannelMember,
snLocalKeyPair,
snLocalAccount,
snLocalAttachment,
snLocalSticker,
snLocalStickerPack,
idxChannelAlias,
idxChatChannel,
idxAccountName,
idxAttachmentRid,
idxAttachmentAccount,
];
late final Shape0 snLocalChatChannel = Shape0(
source: i0.VersionedTable(
entityName: 'sn_local_chat_channel',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape3 snLocalChatMessage = Shape3(
source: i0.VersionedTable(
entityName: 'sn_local_chat_message',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_10,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape4 snLocalChannelMember = Shape4(
source: i0.VersionedTable(
entityName: 'sn_local_channel_member',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_6,
_column_2,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape2 snLocalKeyPair = Shape2(
source: i0.VersionedTable(
entityName: 'sn_local_key_pair',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
],
attachedDatabase: database,
),
alias: null);
late final Shape5 snLocalAccount = Shape5(
source: i0.VersionedTable(
entityName: 'sn_local_account',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_12,
_column_2,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape6 snLocalAttachment = Shape6(
source: i0.VersionedTable(
entityName: 'sn_local_attachment',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_13,
_column_14,
_column_2,
_column_6,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 snLocalSticker = Shape7(
source: i0.VersionedTable(
entityName: 'sn_local_sticker',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_15,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape8 snLocalStickerPack = Shape8(
source: i0.VersionedTable(
entityName: 'sn_local_sticker_pack',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
final i1.Index idxChannelAlias = i1.Index('idx_channel_alias',
'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
final i1.Index idxChatChannel = i1.Index('idx_chat_channel',
'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)');
final i1.Index idxAccountName = i1.Index('idx_account_name',
'CREATE INDEX idx_account_name ON sn_local_account (name)');
final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid',
'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account',
'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
}
class Shape3 extends i0.VersionedTable {
Shape3({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get channelId =>
columnsByName['channel_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get senderId =>
columnsByName['sender_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<int> _column_10(String aliasedName) =>
i1.GeneratedColumn<int>('sender_id', aliasedName, true,
type: i1.DriftSqlType.int);
class Shape4 extends i0.VersionedTable {
Shape4({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get channelId =>
columnsByName['channel_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get accountId =>
columnsByName['account_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<DateTime> _column_11(String aliasedName) =>
i1.GeneratedColumn<DateTime>('cache_expired_at', aliasedName, false,
type: i1.DriftSqlType.dateTime);
class Shape5 extends i0.VersionedTable {
Shape5({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_12(String aliasedName) =>
i1.GeneratedColumn<String>('name', aliasedName, false,
type: i1.DriftSqlType.string);
class Shape6 extends i0.VersionedTable {
Shape6({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get rid =>
columnsByName['rid']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get uuid =>
columnsByName['uuid']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get accountId =>
columnsByName['account_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_13(String aliasedName) =>
i1.GeneratedColumn<String>('rid', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
i1.GeneratedColumn<String> _column_14(String aliasedName) =>
i1.GeneratedColumn<String>('uuid', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
class Shape7 extends i0.VersionedTable {
Shape7({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get alias =>
columnsByName['alias']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get fullAlias =>
columnsByName['full_alias']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_15(String aliasedName) =>
i1.GeneratedColumn<String>('full_alias', aliasedName, false,
type: i1.DriftSqlType.string);
class Shape8 extends i0.VersionedTable {
Shape8({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
}
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@ -423,11 +150,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from1To2(migrator, schema); await from1To2(migrator, schema);
return 2; return 2;
case 2:
final schema = Schema3(database: database);
final migrator = i1.Migrator(database, schema);
await from2To3(migrator, schema);
return 3;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@ -436,10 +158,8 @@ i0.MigrationStepWithVersion migrationSteps({
i1.OnUpgrade stepByStep({ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
}) => }) =>
i0.VersionedSchema.stepByStepHelper( i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
from2To3: from2To3,
)); ));

View File

@ -1,74 +0,0 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:surface/types/attachment.dart';
class SnStickerConverter extends TypeConverter<SnSticker, String>
with JsonTypeConverter2<SnSticker, String, Map<String, Object?>> {
const SnStickerConverter();
@override
SnSticker fromSql(String fromDb) {
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
}
@override
String toSql(SnSticker value) {
return jsonEncode(toJson(value));
}
@override
SnSticker fromJson(Map<String, Object?> json) {
return SnSticker.fromJson(json);
}
@override
Map<String, Object?> toJson(SnSticker value) {
return value.toJson();
}
}
class SnLocalSticker extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get alias => text()();
TextColumn get fullAlias => text()();
TextColumn get content => text().map(const SnStickerConverter())();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
class SnStickerPackConverter extends TypeConverter<SnStickerPack, String>
with JsonTypeConverter2<SnStickerPack, String, Map<String, Object?>> {
const SnStickerPackConverter();
@override
SnStickerPack fromSql(String fromDb) {
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
}
@override
String toSql(SnStickerPack value) {
return jsonEncode(toJson(value));
}
@override
SnStickerPack fromJson(Map<String, Object?> json) {
return SnStickerPack.fromJson(json);
}
@override
Map<String, Object?> toJson(SnStickerPack value) {
return value.toJson();
}
}
class SnLocalStickerPack extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get content => text().map(const SnStickerPackConverter())();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

View File

@ -314,10 +314,6 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
if (!mounted) return; if (!mounted) return;
final sticker = context.read<SnStickerProvider>(); final sticker = context.read<SnStickerProvider>();
await sticker.listSticker(); await sticker.listSticker();
if (!mounted) return;
final ud = context.read<UserDirectoryProvider>();
final userCacheSize = await ud.loadAccountCache();
logging.info('[Users] Loaded local user cache, size: $userCacheSize');
logging.info('[Bootstrap] Everything initialized!'); logging.info('[Bootstrap] Everything initialized!');
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;

View File

@ -1,36 +1,19 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/database/database.dart';
import 'package:surface/providers/database.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart'; import 'package:surface/types/account.dart';
class UserDirectoryProvider { class UserDirectoryProvider {
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final DatabaseProvider _dt;
UserDirectoryProvider(BuildContext context) { UserDirectoryProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
_dt = context.read<DatabaseProvider>();
} }
final Map<String, int> _idCache = {}; final Map<String, int> _idCache = {};
final Map<int, SnAccount> _cache = {}; final Map<int, SnAccount> _cache = {};
Future<int> loadAccountCache({int max = 100}) async {
final out = await (_dt.db.snLocalAccount.select()..limit(max)).get();
for (final ele in out) {
_cache[ele.id] = ele.content;
_idCache[ele.name] = ele.id;
}
return out.length;
}
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async { Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
// In-memory cache
final out = List<SnAccount?>.generate(id.length, (e) => null); final out = List<SnAccount?>.generate(id.length, (e) => null);
final plannedQuery = <int>{}; final plannedQuery = <int>{};
for (var idx = 0; idx < out.length; idx++) { for (var idx = 0; idx < out.length; idx++) {
@ -44,24 +27,6 @@ class UserDirectoryProvider {
plannedQuery.add(item); plannedQuery.add(item);
} }
} }
// On-disk cache
if (plannedQuery.isEmpty) return out;
final dbResp = await (_dt.db.snLocalAccount.select()
..where((e) => e.id.isIn(plannedQuery))
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))
..limit(plannedQuery.length))
.get();
for (var idx = 0; idx < out.length; idx++) {
if (out[idx] != null) continue;
if (dbResp.length <= idx) {
break;
}
out[idx] = dbResp[idx].content;
_cache[dbResp[idx].id] = dbResp[idx].content;
_idCache[dbResp[idx].name] = dbResp[idx].id;
plannedQuery.remove(dbResp[idx].id);
}
// Remote server
if (plannedQuery.isEmpty) return out; if (plannedQuery.isEmpty) return out;
final resp = await _sn.client final resp = await _sn.client
.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')}); .get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});
@ -78,29 +43,17 @@ class UserDirectoryProvider {
_idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id; _idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id;
sideIdx++; sideIdx++;
} }
if (respDecoded.isNotEmpty) _saveToLocal(respDecoded);
return out; return out;
} }
Future<SnAccount?> getAccount(dynamic id) async { Future<SnAccount?> getAccount(dynamic id) async {
// In-memory cache
if (id is String && _idCache.containsKey(id)) { if (id is String && _idCache.containsKey(id)) {
id = _idCache[id]; id = _idCache[id];
} }
if (_cache.containsKey(id)) { if (_cache.containsKey(id)) {
return _cache[id]; return _cache[id];
} }
// On-disk cache
final dbResp = await (_dt.db.snLocalAccount.select()
..where((e) => e.id.equals(id))
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
.getSingleOrNull();
if (dbResp != null) {
_cache[dbResp.id] = dbResp.content;
_idCache[dbResp.name] = dbResp.id;
return dbResp.content;
}
// Remote server
try { try {
final resp = await _sn.client.get('/cgi/id/users/$id'); final resp = await _sn.client.get('/cgi/id/users/$id');
final account = SnAccount.fromJson( final account = SnAccount.fromJson(
@ -108,40 +61,16 @@ class UserDirectoryProvider {
); );
_cache[account.id] = account; _cache[account.id] = account;
if (id is String) _idCache[id] = account.id; if (id is String) _idCache[id] = account.id;
_saveToLocal([account]);
return account; return account;
} catch (err) { } catch (err) {
return null; return null;
} }
} }
SnAccount? getFromCache(dynamic id) { SnAccount? getAccountFromCache(dynamic id) {
if (id is String && _idCache.containsKey(id)) { if (id is String && _idCache.containsKey(id)) {
id = _idCache[id]; id = _idCache[id];
} }
return _cache[id]; return _cache[id];
} }
Future<void> _saveToLocal(Iterable<SnAccount> out) async {
// For better on conflict resolution
// And consider the method usually called with usually small amount of data
// Use for to insert each record instead of bulk insert
List<Future<int>> queries = out.map((ele) {
return _dt.db.snLocalAccount.insertOne(
SnLocalAccountCompanion.insert(
id: Value(ele.id),
name: ele.name,
content: ele,
cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
),
onConflict: DoUpdate(
(_) => SnLocalAccountCompanion.custom(
name: Constant(ele.name),
content: Constant(jsonEncode(ele.toJson())),
),
),
);
}).toList();
await Future.wait(queries);
}
} }

View File

@ -50,8 +50,7 @@ class _AccountBadgesScreenState extends State<AccountBadgesScreen> {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/badges/${badge.id}/active'); await sn.client.post('/cgi/id/badges/${badge.id}/active');
if (!mounted) return; if (!mounted) return;
context.showSnackbar('badgeActivated' context.showSnackbar('badgeActivated'.tr(args: [(kBadgesMeta[badge.type]?.$1 ?? 'unknown').tr()]));
.tr(args: [(kBadgesMeta[badge.type]?.$1 ?? 'unknown').tr()]));
await _fetchBadges(); await _fetchBadges();
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
@ -91,12 +90,7 @@ class _AccountBadgesScreenState extends State<AccountBadgesScreen> {
title: Text( title: Text(
kBadgesMeta[badge.type]?.$1 ?? 'unknown', kBadgesMeta[badge.type]?.$1 ?? 'unknown',
).tr(), ).tr(),
contentPadding: const EdgeInsets.only( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
left: 24,
right: 16,
top: 4,
bottom: 4,
),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View File

@ -336,7 +336,7 @@ class _ChatChannelEntry extends StatelessWidget {
: null; : null;
final title = otherMember != null final title = otherMember != null
? ud.getFromCache(otherMember.accountId)?.nick ?? channel.name ? ud.getAccountFromCache(otherMember.accountId)?.nick ?? channel.name
: channel.name; : channel.name;
return ListTile( return ListTile(
@ -354,9 +354,10 @@ class _ChatChannelEntry extends StatelessWidget {
? Row( ? Row(
children: [ children: [
Badge( Badge(
label: Text( label: Text(ud
ud.getFromCache(lastMessage!.sender.accountId)?.nick ?? .getAccountFromCache(lastMessage!.sender.accountId)
'unknown'.tr()), ?.nick ??
'unknown'.tr()),
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary, textColor: Theme.of(context).colorScheme.onPrimary,
), ),
@ -399,7 +400,7 @@ class _ChatChannelEntry extends StatelessWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage( leading: AccountImage(
content: otherMember != null content: otherMember != null
? ud.getFromCache(otherMember.accountId)?.avatar ? ud.getAccountFromCache(otherMember.accountId)?.avatar
: channel.realm?.avatar, : channel.realm?.avatar,
fallbackWidget: const Icon(Symbols.chat, size: 20), fallbackWidget: const Icon(Symbols.chat, size: 20),
), ),

View File

@ -289,14 +289,15 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
), ),
ListTile( ListTile(
leading: AccountImage( leading: AccountImage(
content: ud.getFromCache(_profile!.accountId)?.avatar, content:
ud.getAccountFromCache(_profile!.accountId)?.avatar,
radius: 18, radius: 18,
), ),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
title: Text('channelEditProfile').tr(), title: Text('channelEditProfile').tr(),
subtitle: Text( subtitle: Text(
(_profile?.nick?.isEmpty ?? true) (_profile?.nick?.isEmpty ?? true)
? ud.getFromCache(_profile!.accountId)!.nick ? ud.getAccountFromCache(_profile!.accountId)!.nick
: _profile!.nick!, : _profile!.nick!,
), ),
contentPadding: const EdgeInsets.only(left: 20, right: 20), contentPadding: const EdgeInsets.only(left: 20, right: 20),
@ -574,10 +575,11 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
return ListTile( return ListTile(
contentPadding: const EdgeInsets.only(right: 24, left: 16), contentPadding: const EdgeInsets.only(right: 24, left: 16),
leading: AccountImage( leading: AccountImage(
content: ud.getFromCache(member.accountId)?.avatar, content: ud.getAccountFromCache(member.accountId)?.avatar,
), ),
title: Text( title: Text(
ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(), ud.getAccountFromCache(member.accountId)?.name ??
'unknown'.tr(),
), ),
subtitle: Text(member.nick ?? 'unknown'.tr()), subtitle: Text(member.nick ?? 'unknown'.tr()),
trailing: SizedBox( trailing: SizedBox(

View File

@ -277,7 +277,8 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
_channel?.type == 1 _channel?.type == 1
? ud.getFromCache(_otherMember?.accountId)?.nick ?? _channel!.name ? ud.getAccountFromCache(_otherMember?.accountId)?.nick ??
_channel!.name
: _channel?.name ?? 'loading'.tr(), : _channel?.name ?? 'loading'.tr(),
), ),
actions: [ actions: [

View File

@ -51,8 +51,7 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
Future<void> _fetchPublishers() async { Future<void> _fetchPublishers() async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = final resp = await sn.client.get('/cgi/co/publishers?realm=${widget.alias}');
await sn.client.get('/cgi/co/publishers?realm=${widget.alias}');
_publishers = List<SnPublisher>.from( _publishers = List<SnPublisher>.from(
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
); );
@ -69,8 +68,7 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
Future<void> _fetchChannels() async { Future<void> _fetchChannels() async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = final resp = await sn.client.get('/cgi/im/channels/${widget.alias}/public');
await sn.client.get('/cgi/im/channels/${widget.alias}/public');
_channels = List<SnChannel>.from( _channels = List<SnChannel>.from(
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(), resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
); );
@ -100,32 +98,15 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[ return <Widget>[
SliverOverlapAbsorber( SliverOverlapAbsorber(
handle: handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar( sliver: SliverAppBar(
title: Text(_realm?.name ?? 'loading'.tr()), title: Text(_realm?.name ?? 'loading'.tr()),
bottom: TabBar( bottom: TabBar(
tabs: [ tabs: [
Tab( Tab(icon: Icon(Symbols.home, color: Theme.of(context).appBarTheme.foregroundColor)),
icon: Icon(Symbols.home, Tab(icon: Icon(Symbols.explore, color: Theme.of(context).appBarTheme.foregroundColor)),
color: Theme.of(context) Tab(icon: Icon(Symbols.group, color: Theme.of(context).appBarTheme.foregroundColor)),
.appBarTheme Tab(icon: Icon(Symbols.settings, color: Theme.of(context).appBarTheme.foregroundColor)),
.foregroundColor)),
Tab(
icon: Icon(Symbols.explore,
color: Theme.of(context)
.appBarTheme
.foregroundColor)),
Tab(
icon: Icon(Symbols.group,
color: Theme.of(context)
.appBarTheme
.foregroundColor)),
Tab(
icon: Icon(Symbols.settings,
color: Theme.of(context)
.appBarTheme
.foregroundColor)),
], ],
), ),
), ),
@ -134,8 +115,7 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
}, },
body: TabBarView( body: TabBarView(
children: [ children: [
_RealmDetailHomeWidget( _RealmDetailHomeWidget(realm: _realm, publishers: _publishers, channels: _channels),
realm: _realm, publishers: _publishers, channels: _channels),
_RealmPostListWidget(realm: _realm), _RealmPostListWidget(realm: _realm),
_RealmMemberListWidget(realm: _realm), _RealmMemberListWidget(realm: _realm),
_RealmSettingsWidget( _RealmSettingsWidget(
@ -157,8 +137,7 @@ class _RealmDetailHomeWidget extends StatelessWidget {
final List<SnPublisher>? publishers; final List<SnPublisher>? publishers;
final List<SnChannel>? channels; final List<SnChannel>? channels;
const _RealmDetailHomeWidget( const _RealmDetailHomeWidget({required this.realm, this.publishers, this.channels});
{required this.realm, this.publishers, this.channels});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -189,8 +168,7 @@ class _RealmDetailHomeWidget extends StatelessWidget {
child: Container( child: Container(
width: double.infinity, width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh, color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Text('realmCommunityPublishersHint'.tr(), child: Text('realmCommunityPublishersHint'.tr(), style: Theme.of(context).textTheme.bodyMedium)
style: Theme.of(context).textTheme.bodyMedium)
.padding(horizontal: 24, vertical: 8), .padding(horizontal: 24, vertical: 8),
), ),
), ),
@ -221,8 +199,7 @@ class _RealmDetailHomeWidget extends StatelessWidget {
child: Container( child: Container(
width: double.infinity, width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh, color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Text('realmCommunityPublicChannelsHint'.tr(), child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium)
style: Theme.of(context).textTheme.bodyMedium)
.padding(horizontal: 24, vertical: 8), .padding(horizontal: 24, vertical: 8),
), ),
), ),
@ -346,12 +323,10 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
try { try {
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get( final resp = await sn.client.get('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: {
'/cgi/id/realms/${widget.realm!.alias}/members', 'take': 10,
queryParameters: { 'offset': _members.length,
'take': 10, });
'offset': _members.length,
});
final out = List<SnRealmMember>.from( final out = List<SnRealmMember>.from(
resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [], resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [],
@ -457,14 +432,14 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
return ListTile( return ListTile(
contentPadding: const EdgeInsets.only(right: 24, left: 16), contentPadding: const EdgeInsets.only(right: 24, left: 16),
leading: AccountImage( leading: AccountImage(
content: ud.getFromCache(member.accountId)?.avatar, content: ud.getAccountFromCache(member.accountId)?.avatar,
fallbackWidget: const Icon(Symbols.group, size: 24), fallbackWidget: const Icon(Symbols.group, size: 24),
), ),
title: Text( title: Text(
ud.getFromCache(member.accountId)?.nick ?? 'unknown'.tr(), ud.getAccountFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
), ),
subtitle: Text( subtitle: Text(
ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(), ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
), ),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Symbols.person_remove), icon: const Icon(Symbols.person_remove),

View File

@ -51,10 +51,8 @@ class _AppSharingListenerState extends State<AppSharingListener> {
child: Column( child: Column(
children: [ children: [
ListTile( ListTile(
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 24),
const EdgeInsets.symmetric(horizontal: 24), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
leading: Icon(Icons.post_add), leading: Icon(Icons.post_add),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
title: Text('shareIntentPostStory').tr(), title: Text('shareIntentPostStory').tr(),
@ -66,20 +64,13 @@ class _AppSharingListenerState extends State<AppSharingListener> {
}, },
extra: PostEditorExtra( extra: PostEditorExtra(
text: value text: value
.where((e) => [ .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
SharedMediaType.text,
SharedMediaType.url
].contains(e.type))
.map((e) => e.path) .map((e) => e.path)
.join('\n'), .join('\n'),
attachments: value attachments: value
.where((e) => [ .where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image]
SharedMediaType.video, .contains(e.type))
SharedMediaType.file, .map((e) => PostWriteMedia.fromFile(XFile(e.path)))
SharedMediaType.image
].contains(e.type))
.map((e) =>
PostWriteMedia.fromFile(XFile(e.path)))
.toList(), .toList(),
), ),
); );
@ -87,18 +78,15 @@ class _AppSharingListenerState extends State<AppSharingListener> {
}, },
), ),
ListTile( ListTile(
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 24),
const EdgeInsets.symmetric(horizontal: 24), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
leading: Icon(Icons.chat_outlined), leading: Icon(Icons.chat_outlined),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
title: Text('shareIntentSendChannel').tr(), title: Text('shareIntentSendChannel').tr(),
onTap: () { onTap: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => builder: (context) => _ShareIntentChannelSelect(value: value),
_ShareIntentChannelSelect(value: value),
).then((val) { ).then((val) {
if (!context.mounted) return; if (!context.mounted) return;
if (val == true) Navigator.pop(context); if (val == true) Navigator.pop(context);
@ -122,8 +110,7 @@ class _AppSharingListenerState extends State<AppSharingListener> {
} }
void _initialize() async { void _initialize() async {
_shareIntentSubscription = _shareIntentSubscription = ReceiveSharingIntent.instance.getMediaStream().listen((value) {
ReceiveSharingIntent.instance.getMediaStream().listen((value) {
if (value.isEmpty) return; if (value.isEmpty) return;
if (mounted) { if (mounted) {
_gotoPost(value); _gotoPost(value);
@ -170,8 +157,7 @@ class _ShareIntentChannelSelect extends StatefulWidget {
const _ShareIntentChannelSelect({required this.value}); const _ShareIntentChannelSelect({required this.value});
@override @override
State<_ShareIntentChannelSelect> createState() => State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState();
_ShareIntentChannelSelectState();
} }
class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
@ -192,11 +178,8 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
final lastMessages = await chan.getLastMessages(channels); final lastMessages = await chan.getLastMessages(channels);
_lastMessages = {for (final val in lastMessages) val.channelId: val}; _lastMessages = {for (final val in lastMessages) val.channelId: val};
channels.sort((a, b) { channels.sort((a, b) {
if (_lastMessages!.containsKey(a.id) && if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
_lastMessages!.containsKey(b.id)) { return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
return _lastMessages![b.id]!
.createdAt
.compareTo(_lastMessages![a.id]!.createdAt);
} }
if (_lastMessages!.containsKey(a.id)) return -1; if (_lastMessages!.containsKey(a.id)) return -1;
if (_lastMessages!.containsKey(b.id)) return 1; if (_lastMessages!.containsKey(b.id)) return 1;
@ -249,9 +232,7 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
children: [ children: [
const Icon(Symbols.chat, size: 24), const Icon(Symbols.chat, size: 24),
const Gap(16), const Gap(16),
Text('shareIntentSendChannel', Text('shareIntentSendChannel', style: Theme.of(context).textTheme.titleLarge).tr(),
style: Theme.of(context).textTheme.titleLarge)
.tr(),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
LoadingIndicator(isActive: _isBusy), LoadingIndicator(isActive: _isBusy),
@ -268,34 +249,29 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
final lastMessage = _lastMessages?[channel.id]; final lastMessage = _lastMessages?[channel.id];
if (channel.type == 1) { if (channel.type == 1) {
final otherMember = final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
channel.members?.cast<SnChannelMember?>().firstWhere( (ele) => ele?.accountId != ua.user?.id,
(ele) => ele?.accountId != ua.user?.id, orElse: () => null,
orElse: () => null, );
);
return ListTile( return ListTile(
title: Text( title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
ud.getFromCache(otherMember?.accountId)?.nick ??
channel.name),
subtitle: lastMessage != null subtitle: lastMessage != null
? Text( ? Text(
'${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
) )
: Text( : Text(
'channelDirectMessageDescription'.tr(args: [ 'channelDirectMessageDescription'.tr(args: [
'@${ud.getFromCache(otherMember?.accountId)?.name}', '@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
]), ]),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 16),
const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage( leading: AccountImage(
content: content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
ud.getFromCache(otherMember?.accountId)?.avatar,
), ),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
@ -315,7 +291,7 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
title: Text(channel.name), title: Text(channel.name),
subtitle: lastMessage != null subtitle: lastMessage != null
? Text( ? Text(
'${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
) )
@ -340,20 +316,13 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
}, },
extra: ChatRoomScreenExtra( extra: ChatRoomScreenExtra(
initialText: widget.value initialText: widget.value
.where((e) => [ .where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
SharedMediaType.text,
SharedMediaType.url
].contains(e.type))
.map((e) => e.path) .map((e) => e.path)
.join('\n'), .join('\n'),
initialAttachments: widget.value initialAttachments: widget.value
.where((e) => [ .where((e) =>
SharedMediaType.video, [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
SharedMediaType.file, .map((e) => PostWriteMedia.fromFile(XFile(e.path)))
SharedMediaType.image
].contains(e.type))
.map(
(e) => PostWriteMedia.fromFile(XFile(e.path)))
.toList(), .toList(),
), ),
) )

View File

@ -42,8 +42,7 @@ class AttachmentZoomView extends StatefulWidget {
} }
class _AttachmentZoomViewState extends State<AttachmentZoomView> { class _AttachmentZoomViewState extends State<AttachmentZoomView> {
late final PageController _pageController = late final PageController _pageController = PageController(initialPage: widget.initialIndex ?? 0);
PageController(initialPage: widget.initialIndex ?? 0);
bool _showOverlay = true; bool _showOverlay = true;
bool _dismissable = true; bool _dismissable = true;
@ -108,9 +107,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
if (!mounted) return; if (!mounted) return;
context.showSnackbar( context.showSnackbar(
(!kIsWeb && (Platform.isIOS || Platform.isAndroid)) (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) ? 'attachmentSaved'.tr() : 'attachmentSavedDesktop'.tr(),
? 'attachmentSaved'.tr()
: 'attachmentSavedDesktop'.tr(),
action: (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) action: (!kIsWeb && (Platform.isIOS || Platform.isAndroid))
? SnackBarAction( ? SnackBarAction(
label: 'openInAlbum'.tr(), label: 'openInAlbum'.tr(),
@ -134,8 +131,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
super.dispose(); super.dispose();
} }
Color get _unFocusColor => Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
bool _showDetail = false; bool _showDetail = false;
@ -154,9 +150,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
onDismissed: () { onDismissed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
direction: _dismissable direction: _dismissable ? DismissiblePageDismissDirection.multi : DismissiblePageDismissDirection.none,
? DismissiblePageDismissDirection.multi
: DismissiblePageDismissDirection.none,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
isFullScreen: true, isFullScreen: true,
child: GestureDetector( child: GestureDetector(
@ -171,13 +165,10 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
return Hero( return Hero(
tag: 'attachment-${widget.data.first.rid}-$heroTag', tag: 'attachment-${widget.data.first.rid}-$heroTag',
child: PhotoView( child: PhotoView(
key: Key( key: Key('attachment-detail-${widget.data.first.rid}-$heroTag'),
'attachment-detail-${widget.data.first.rid}-$heroTag'), backgroundDecoration: BoxDecoration(color: Colors.transparent),
backgroundDecoration:
BoxDecoration(color: Colors.transparent),
scaleStateChangedCallback: (scaleState) { scaleStateChangedCallback: (scaleState) {
setState(() => _dismissable = setState(() => _dismissable = scaleState == PhotoViewScaleState.initial);
scaleState == PhotoViewScaleState.initial);
}, },
imageProvider: UniversalImage.provider( imageProvider: UniversalImage.provider(
sn.getAttachmentUrl(widget.data.first.rid), sn.getAttachmentUrl(widget.data.first.rid),
@ -190,12 +181,10 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
pageController: _pageController, pageController: _pageController,
enableRotation: true, enableRotation: true,
scaleStateChangedCallback: (scaleState) { scaleStateChangedCallback: (scaleState) {
setState(() => _dismissable = setState(() => _dismissable = scaleState == PhotoViewScaleState.initial);
scaleState == PhotoViewScaleState.initial);
}, },
builder: (context, idx) { builder: (context, idx) {
final heroTag = final heroTag = widget.heroTags?.elementAt(idx) ?? uuid.v4();
widget.heroTags?.elementAt(idx) ?? uuid.v4();
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
imageProvider: UniversalImage.provider( imageProvider: UniversalImage.provider(
sn.getAttachmentUrl(widget.data.elementAt(idx).rid), sn.getAttachmentUrl(widget.data.elementAt(idx).rid),
@ -211,15 +200,11 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
width: 20.0, width: 20.0,
height: 20.0, height: 20.0,
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: event == null value: event == null ? 0 : event.cumulativeBytesLoaded / (event.expectedTotalBytes ?? 1),
? 0
: event.cumulativeBytesLoaded /
(event.expectedTotalBytes ?? 1),
), ),
), ),
), ),
backgroundDecoration: backgroundDecoration: BoxDecoration(color: Colors.transparent),
BoxDecoration(color: Colors.transparent),
); );
}), }),
Positioned( Positioned(
@ -238,8 +223,9 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
).opacity(_showOverlay ? 1 : 0, animate: true).animate( )
const Duration(milliseconds: 300), Curves.easeInOut), .opacity(_showOverlay ? 1 : 0, animate: true)
.animate(const Duration(milliseconds: 300), Curves.easeInOut),
), ),
), ),
Align( Align(
@ -271,11 +257,9 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
child: Builder(builder: (context) { child: Builder(builder: (context) {
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
final item = widget.data.elementAt( final item = widget.data.elementAt(
widget.data.length > 1 widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0,
? _pageController.page?.round() ?? 0
: 0,
); );
final account = ud.getFromCache(item.accountId); final account = ud.getAccountFromCache(item.accountId);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -293,20 +277,15 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
Expanded( Expanded(
child: IgnorePointer( child: IgnorePointer(
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'attachmentUploadBy'.tr(), 'attachmentUploadBy'.tr(),
style: Theme.of(context) style: Theme.of(context).textTheme.bodySmall,
.textTheme
.bodySmall,
), ),
Text( Text(
account?.nick ?? 'unknown'.tr(), account?.nick ?? 'unknown'.tr(),
style: Theme.of(context) style: Theme.of(context).textTheme.bodyMedium,
.textTheme
.bodyMedium,
), ),
], ],
), ),
@ -320,13 +299,11 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
).padding(right: 8), ).padding(right: 8),
), ),
InkWell( InkWell(
borderRadius: borderRadius: const BorderRadius.all(Radius.circular(16)),
const BorderRadius.all(Radius.circular(16)),
onTap: _isDownloading onTap: _isDownloading
? null ? null
: () => _saveToAlbum(widget.data.length > 1 : () =>
? _pageController.page?.round() ?? 0 _saveToAlbum(widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0),
: 0),
child: Container( child: Container(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
child: !_isDownloading child: !_isDownloading
@ -374,8 +351,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
]), ]),
style: metaTextStyle, style: metaTextStyle,
).padding(right: 2), ).padding(right: 2),
if (item.metadata['exif']?['Megapixels'] != if (item.metadata['exif']?['Megapixels'] != null &&
null &&
item.metadata['exif']?['Model'] != null) item.metadata['exif']?['Model'] != null)
Text( Text(
'${item.metadata['exif']?['Megapixels']}MP', '${item.metadata['exif']?['Megapixels']}MP',
@ -386,8 +362,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
item.size.formatBytes(), item.size.formatBytes(),
style: metaTextStyle, style: metaTextStyle,
), ),
if (item.metadata['width'] != null && if (item.metadata['width'] != null && item.metadata['height'] != null)
item.metadata['height'] != null)
Text( Text(
'${item.metadata['width']}x${item.metadata['height']}', '${item.metadata['width']}x${item.metadata['height']}',
style: metaTextStyle, style: metaTextStyle,
@ -402,10 +377,8 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => _AttachmentZoomDetailPopup( builder: (context) => _AttachmentZoomDetailPopup(
data: widget.data.elementAt( data: widget.data
widget.data.length > 1 .elementAt(widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0),
? _pageController.page?.round() ?? 0
: 0),
), ),
).then((_) { ).then((_) {
_showDetail = false; _showDetail = false;
@ -413,15 +386,15 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
}, },
child: Text( child: Text(
'viewDetailedAttachment'.tr(), 'viewDetailedAttachment'.tr(),
style: metaTextStyle.copyWith( style: metaTextStyle.copyWith(decoration: TextDecoration.underline),
decoration: TextDecoration.underline),
), ),
), ),
], ],
); );
}), }),
).opacity(_showOverlay ? 1 : 0, animate: true).animate( )
const Duration(milliseconds: 300), Curves.easeInOut), .opacity(_showOverlay ? 1 : 0, animate: true)
.animate(const Duration(milliseconds: 300), Curves.easeInOut),
), ),
], ],
), ),
@ -436,9 +409,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => _AttachmentZoomDetailPopup( builder: (context) => _AttachmentZoomDetailPopup(
data: widget.data.elementAt(widget.data.length > 1 data: widget.data.elementAt(widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0),
? _pageController.page?.round() ?? 0
: 0),
), ),
).then((_) { ).then((_) {
_showDetail = false; _showDetail = false;
@ -458,7 +429,7 @@ class _AttachmentZoomDetailPopup extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
final account = ud.getFromCache(data.accountId); final account = ud.getAccountFromCache(data.accountId);
const tableGap = TableRow( const tableGap = TableRow(
children: [ children: [
@ -476,9 +447,7 @@ class _AttachmentZoomDetailPopup extends StatelessWidget {
children: [ children: [
const Icon(Symbols.info, size: 24), const Icon(Symbols.info, size: 24),
const Gap(16), const Gap(16),
Text('attachmentDetailInfo') Text('attachmentDetailInfo').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
.tr()
.textStyle(Theme.of(context).textTheme.titleLarge!),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
Expanded( Expanded(
@ -492,8 +461,7 @@ class _AttachmentZoomDetailPopup extends StatelessWidget {
TableRow( TableRow(
children: [ children: [
TableCell( TableCell(
child: child: Text('attachmentUploadBy').tr().padding(right: 16),
Text('attachmentUploadBy').tr().padding(right: 16),
), ),
TableCell( TableCell(
child: Row( child: Row(
@ -504,13 +472,9 @@ class _AttachmentZoomDetailPopup extends StatelessWidget {
radius: 8, radius: 8,
), ),
const Gap(8), const Gap(8),
Text(data.accountId > 0 Text(data.accountId > 0 ? account?.nick ?? 'unknown'.tr() : 'unknown'.tr()),
? account?.nick ?? 'unknown'.tr()
: 'unknown'.tr()),
const Gap(8), const Gap(8),
Text('#${data.accountId}', Text('#${data.accountId}', style: GoogleFonts.robotoMono()).opacity(0.75),
style: GoogleFonts.robotoMono())
.opacity(0.75),
], ],
), ),
), ),
@ -531,9 +495,7 @@ class _AttachmentZoomDetailPopup extends StatelessWidget {
children: [ children: [
Text(data.size.formatBytes()), Text(data.size.formatBytes()),
const Gap(12), const Gap(12),
Text('${data.size} Bytes', Text('${data.size} Bytes', style: GoogleFonts.robotoMono()).opacity(0.75),
style: GoogleFonts.robotoMono())
.opacity(0.75),
], ],
)), )),
], ],
@ -548,27 +510,19 @@ class _AttachmentZoomDetailPopup extends StatelessWidget {
TableRow( TableRow(
children: [ children: [
TableCell(child: Text('Hash').padding(right: 16)), TableCell(child: Text('Hash').padding(right: 16)),
TableCell( TableCell(child: Text(data.hash, style: GoogleFonts.robotoMono(fontSize: 11)).opacity(0.9)),
child: Text(data.hash,
style: GoogleFonts.robotoMono(fontSize: 11))
.opacity(0.9)),
], ],
), ),
tableGap, tableGap,
...(data.metadata['exif']?.keys.map((k) => TableRow( ...(data.metadata['exif']?.keys.map((k) => TableRow(
children: [ children: [
TableCell(child: Text(k).padding(right: 16)), TableCell(child: Text(k).padding(right: 16)),
TableCell( TableCell(child: Text(data.metadata['exif'][k].toString())),
child: Text(
data.metadata['exif'][k].toString())),
], ],
)) ?? )) ??
[]), []),
], ],
).padding( ).padding(horizontal: 20, vertical: 8, bottom: MediaQuery.of(context).padding.bottom),
horizontal: 20,
vertical: 8,
bottom: MediaQuery.of(context).padding.bottom),
), ),
), ),
], ],

View File

@ -51,7 +51,7 @@ class ChatMessage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
final user = ud.getFromCache(data.sender.accountId); final user = ud.getAccountFromCache(data.sender.accountId);
final isOwner = ua.isAuthorized && data.sender.accountId == ua.user?.id; final isOwner = ua.isAuthorized && data.sender.accountId == ua.user?.id;

View File

@ -380,7 +380,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
_isEncrypted ? Icon(Symbols.lock, size: 18) : null, _isEncrypted ? Icon(Symbols.lock, size: 18) : null,
hintText: widget.otherMember != null hintText: widget.otherMember != null
? 'fieldChatMessageDirect'.tr(args: [ ? 'fieldChatMessageDirect'.tr(args: [
'@${ud.getFromCache(widget.otherMember?.accountId)?.name}', '@${ud.getAccountFromCache(widget.otherMember?.accountId)?.name}',
]) ])
: 'fieldChatMessage'.tr(args: [ : 'fieldChatMessage'.tr(args: [
widget.controller.channel?.name ?? 'loading'.tr() widget.controller.channel?.name ?? 'loading'.tr()

View File

@ -33,13 +33,11 @@ class ChatTypingIndicator extends StatelessWidget {
const Icon(Symbols.more_horiz, weight: 600, size: 20), const Icon(Symbols.more_horiz, weight: 600, size: 20),
const Gap(8), const Gap(8),
Text( Text(
'messageTyping' 'messageTyping'.plural(controller.typingMembers.length, args: [
.plural(controller.typingMembers.length, args: [
controller.typingMembers controller.typingMembers
.map((ele) => (ele.nick?.isNotEmpty ?? false) .map((ele) => (ele.nick?.isNotEmpty ?? false)
? ele.nick! ? ele.nick!
: ud.getFromCache(ele.accountId)?.name ?? : ud.getAccountFromCache(ele.accountId)?.name ?? 'unknown')
'unknown')
.join(', '), .join(', '),
]), ]),
), ),

View File

@ -95,10 +95,9 @@ class OpenablePostItem extends StatelessWidget {
openColor: Colors.transparent, openColor: Colors.transparent,
openElevation: 0, openElevation: 0,
transitionType: ContainerTransitionType.fade, transitionType: ContainerTransitionType.fade,
closedColor: closedColor: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(
Theme.of(context).colorScheme.surfaceContainerLow.withOpacity( cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1,
cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1, ),
),
closedShape: const RoundedRectangleBorder( closedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)), borderRadius: BorderRadius.all(Radius.circular(16)),
), ),
@ -139,11 +138,9 @@ class PostItem extends StatelessWidget {
final box = context.findRenderObject() as RenderBox?; final box = context.findRenderObject() as RenderBox?;
final url = 'https://solsynth.dev/posts/${data.id}'; final url = 'https://solsynth.dev/posts/${data.id}';
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
Share.shareUri(Uri.parse(url), Share.shareUri(Uri.parse(url), sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
} else { } else {
Share.share(url, Share.share(url, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
} }
} }
@ -161,8 +158,7 @@ class PostItem extends StatelessWidget {
child: MultiProvider( child: MultiProvider(
providers: [ providers: [
Provider<SnNetworkProvider>(create: (_) => context.read()), Provider<SnNetworkProvider>(create: (_) => context.read()),
ChangeNotifierProvider<ConfigProvider>( ChangeNotifierProvider<ConfigProvider>(create: (_) => context.read()),
create: (_) => context.read()),
], ],
child: ResponsiveBreakpoints.builder( child: ResponsiveBreakpoints.builder(
breakpoints: ResponsiveBreakpoints.of(context).breakpoints, breakpoints: ResponsiveBreakpoints.of(context).breakpoints,
@ -190,8 +186,7 @@ class PostItem extends StatelessWidget {
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
); );
} else { } else {
await FileSaver.instance.saveFile( await FileSaver.instance.saveFile(name: 'Solar Network Post #${data.id}.png', file: imageFile);
name: 'Solar Network Post #${data.id}.png', file: imageFile);
} }
await imageFile.delete(); await imageFile.delete();
@ -205,9 +200,7 @@ class PostItem extends StatelessWidget {
final isAuthor = ua.isAuthorized && data.publisher.accountId == ua.user?.id; final isAuthor = ua.isAuthorized && data.publisher.accountId == ua.user?.id;
// Video full view // Video full view
if (showFullPost && if (showFullPost && data.type == 'video' && ResponsiveBreakpoints.of(context).largerThan(TABLET)) {
data.type == 'video' &&
ResponsiveBreakpoints.of(context).largerThan(TABLET)) {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -227,8 +220,7 @@ class PostItem extends StatelessWidget {
if (onDeleted != null) {} if (onDeleted != null) {}
}, },
).padding(bottom: 8), ).padding(bottom: 8),
if (data.preload?.video != null) if (data.preload?.video != null) _PostVideoPlayer(data: data).padding(bottom: 8),
_PostVideoPlayer(data: data).padding(bottom: 8),
_PostHeadline(data: data).padding(horizontal: 4, bottom: 8), _PostHeadline(data: data).padding(horizontal: 4, bottom: 8),
_PostFeaturedComment(data: data), _PostFeaturedComment(data: data),
_PostBottomAction( _PostBottomAction(
@ -276,8 +268,7 @@ class PostItem extends StatelessWidget {
if (onDeleted != null) {} if (onDeleted != null) {}
}, },
).padding(horizontal: 12, top: 8, bottom: 8), ).padding(horizontal: 12, top: 8, bottom: 8),
if (data.preload?.video != null) if (data.preload?.video != null) _PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8),
_PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8),
Container( Container(
width: double.infinity, width: double.infinity,
margin: const EdgeInsets.only(bottom: 4, left: 12, right: 12), margin: const EdgeInsets.only(bottom: 4, left: 12, right: 12),
@ -320,13 +311,8 @@ class PostItem extends StatelessWidget {
], ],
), ),
), ),
Text('postArticle') Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8),
.tr() _PostFeaturedComment(data: data, maxWidth: maxWidth).padding(horizontal: 12),
.fontSize(13)
.opacity(0.75)
.padding(horizontal: 24, bottom: 8),
_PostFeaturedComment(data: data, maxWidth: maxWidth)
.padding(horizontal: 12),
_PostBottomAction( _PostBottomAction(
data: data, data: data,
showComments: showComments, showComments: showComments,
@ -341,8 +327,7 @@ class PostItem extends StatelessWidget {
} }
final displayableAttachments = data.preload?.attachments final displayableAttachments = data.preload?.attachments
?.where((ele) => ?.where((ele) => ele?.mediaType != SnMediaType.image || data.type != 'article')
ele?.mediaType != SnMediaType.image || data.type != 'article')
.toList(); .toList();
final cfg = context.read<ConfigProvider>(); final cfg = context.read<ConfigProvider>();
@ -367,13 +352,9 @@ class PostItem extends StatelessWidget {
if (onDeleted != null) onDeleted!(); if (onDeleted != null) onDeleted!();
}, },
).padding(horizontal: 12, vertical: 8), ).padding(horizontal: 12, vertical: 8),
if (data.preload?.video != null) if (data.preload?.video != null) _PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8),
_PostVideoPlayer(data: data).padding(horizontal: 12, bottom: 8), if (data.type == 'question') _PostQuestionHint(data: data).padding(horizontal: 16, bottom: 8),
if (data.type == 'question') if (data.body['title'] != null || data.body['description'] != null)
_PostQuestionHint(data: data)
.padding(horizontal: 16, bottom: 8),
if (data.body['title'] != null ||
data.body['description'] != null)
_PostHeadline( _PostHeadline(
data: data, data: data,
isEnlarge: data.type == 'article' && showFullPost, isEnlarge: data.type == 'article' && showFullPost,
@ -387,8 +368,7 @@ class PostItem extends StatelessWidget {
if (data.repostTo != null) if (data.repostTo != null)
_PostQuoteContent(child: data.repostTo!).padding( _PostQuoteContent(child: data.repostTo!).padding(
horizontal: 12, horizontal: 12,
bottom: bottom: data.preload?.attachments?.isNotEmpty ?? false ? 12 : 0,
data.preload?.attachments?.isNotEmpty ?? false ? 12 : 0,
), ),
if (data.visibility > 0) if (data.visibility > 0)
_PostVisibilityHint(data: data).padding( _PostVisibilityHint(data: data).padding(
@ -400,9 +380,7 @@ class PostItem extends StatelessWidget {
horizontal: 16, horizontal: 16,
vertical: 4, vertical: 4,
), ),
if (data.tags.isNotEmpty) if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, top: 4, bottom: 6),
_PostTagsList(data: data)
.padding(horizontal: 16, top: 4, bottom: 6),
], ],
), ),
), ),
@ -415,16 +393,12 @@ class PostItem extends StatelessWidget {
fit: showFullPost ? BoxFit.cover : BoxFit.contain, fit: showFullPost ? BoxFit.cover : BoxFit.contain,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
), ),
if (data.preload?.poll != null) if (data.preload?.poll != null) PostPoll(poll: data.preload!.poll!).padding(horizontal: 12, vertical: 4),
PostPoll(poll: data.preload!.poll!) if (data.body['content'] != null && (cfg.prefs.getBool(kAppExpandPostLink) ?? true))
.padding(horizontal: 12, vertical: 4),
if (data.body['content'] != null &&
(cfg.prefs.getBool(kAppExpandPostLink) ?? true))
LinkPreviewWidget( LinkPreviewWidget(
text: data.body['content'], text: data.body['content'],
).padding(horizontal: 4), ).padding(horizontal: 4),
_PostFeaturedComment(data: data, maxWidth: maxWidth) _PostFeaturedComment(data: data, maxWidth: maxWidth).padding(horizontal: 12),
.padding(horizontal: 12),
Container( Container(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
child: Column( child: Column(
@ -486,8 +460,7 @@ class PostShareImageWidget extends StatelessWidget {
showMenu: false, showMenu: false,
isRelativeDate: false, isRelativeDate: false,
).padding(horizontal: 16, bottom: 8), ).padding(horizontal: 16, bottom: 8),
if (data.type == 'question') if (data.type == 'question') _PostQuestionHint(data: data).padding(horizontal: 16, bottom: 8),
_PostQuestionHint(data: data).padding(horizontal: 16, bottom: 8),
_PostHeadline( _PostHeadline(
data: data, data: data,
isEnlarge: data.type == 'article', isEnlarge: data.type == 'article',
@ -502,8 +475,7 @@ class PostShareImageWidget extends StatelessWidget {
child: data.repostTo!, child: data.repostTo!,
isRelativeDate: false, isRelativeDate: false,
).padding(horizontal: 16, bottom: 8), ).padding(horizontal: 16, bottom: 8),
if (data.type != 'article' && if (data.type != 'article' && (data.preload?.attachments?.isNotEmpty ?? false))
(data.preload?.attachments?.isNotEmpty ?? false))
StyledWidget(AttachmentList( StyledWidget(AttachmentList(
data: data.preload!.attachments!, data: data.preload!.attachments!,
columned: true, columned: true,
@ -512,8 +484,7 @@ class PostShareImageWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (data.visibility > 0) _PostVisibilityHint(data: data), if (data.visibility > 0) _PostVisibilityHint(data: data),
if (data.body['content_truncated'] == true) if (data.body['content_truncated'] == true) _PostTruncatedHint(data: data),
_PostTruncatedHint(data: data),
], ],
).padding(horizontal: 16), ).padding(horizontal: 16),
_PostBottomAction( _PostBottomAction(
@ -573,8 +544,7 @@ class PostShareImageWidget extends StatelessWidget {
version: QrVersions.auto, version: QrVersions.auto,
size: 100, size: 100,
gapless: true, gapless: true,
embeddedImage: embeddedImage: AssetImage('assets/icon/icon-light-radius.png'),
AssetImage('assets/icon/icon-light-radius.png'),
embeddedImageStyle: QrEmbeddedImageStyle( embeddedImageStyle: QrEmbeddedImageStyle(
size: Size(28, 28), size: Size(28, 28),
), ),
@ -605,11 +575,9 @@ class _PostQuestionHint extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
Icon(data.body['answer'] == null ? Symbols.help : Symbols.check_circle, Icon(data.body['answer'] == null ? Symbols.help : Symbols.check_circle, size: 20),
size: 20),
const Gap(4), const Gap(4),
if (data.body['answer'] == null && if (data.body['answer'] == null && data.body['reward']?.toDouble() != null)
data.body['reward']?.toDouble() != null)
Text('postQuestionUnansweredWithReward'.tr(args: [ Text('postQuestionUnansweredWithReward'.tr(args: [
'${data.body['reward']}', '${data.body['reward']}',
])).opacity(0.75) ])).opacity(0.75)
@ -645,9 +613,7 @@ class _PostBottomAction extends StatelessWidget {
); );
final String? mostTypicalReaction = data.metric.reactionList.isNotEmpty final String? mostTypicalReaction = data.metric.reactionList.isNotEmpty
? data.metric.reactionList.entries ? data.metric.reactionList.entries.reduce((a, b) => a.value > b.value ? a : b).key
.reduce((a, b) => a.value > b.value ? a : b)
.key
: null; : null;
return Row( return Row(
@ -661,8 +627,7 @@ class _PostBottomAction extends StatelessWidget {
InkWell( InkWell(
child: Row( child: Row(
children: [ children: [
if (mostTypicalReaction == null || if (mostTypicalReaction == null || kTemplateReactions[mostTypicalReaction] == null)
kTemplateReactions[mostTypicalReaction] == null)
Icon(Symbols.add_reaction, size: 20, color: iconColor) Icon(Symbols.add_reaction, size: 20, color: iconColor)
else else
Text( Text(
@ -674,8 +639,7 @@ class _PostBottomAction extends StatelessWidget {
), ),
), ),
const Gap(8), const Gap(8),
if (data.totalUpvote > 0 && if (data.totalUpvote > 0 && data.totalUpvote >= data.totalDownvote)
data.totalUpvote >= data.totalDownvote)
Text('postReactionUpvote').plural( Text('postReactionUpvote').plural(
data.totalUpvote, data.totalUpvote,
) )
@ -694,12 +658,8 @@ class _PostBottomAction extends StatelessWidget {
data: data, data: data,
onChanged: (value, attr, delta) { onChanged: (value, attr, delta) {
onChanged(data.copyWith( onChanged(data.copyWith(
totalUpvote: attr == 1 totalUpvote: attr == 1 ? data.totalUpvote + delta : data.totalUpvote,
? data.totalUpvote + delta totalDownvote: attr == 2 ? data.totalDownvote + delta : data.totalDownvote,
: data.totalUpvote,
totalDownvote: attr == 2
? data.totalDownvote + delta
: data.totalDownvote,
metric: data.metric.copyWith(reactionList: value), metric: data.metric.copyWith(reactionList: value),
)); ));
}, },
@ -806,9 +766,7 @@ class _PostHeadline extends StatelessWidget {
children: [ children: [
Text( Text(
'articleWrittenAt'.tr( 'articleWrittenAt'.tr(
args: [ args: [DateFormat('y/M/d HH:mm').format(data.createdAt.toLocal())],
DateFormat('y/M/d HH:mm').format(data.createdAt.toLocal())
],
), ),
style: TextStyle(fontSize: 13), style: TextStyle(fontSize: 13),
), ),
@ -816,9 +774,7 @@ class _PostHeadline extends StatelessWidget {
if (data.editedAt != null) if (data.editedAt != null)
Text( Text(
'articleEditedAt'.tr( 'articleEditedAt'.tr(
args: [ args: [DateFormat('y/M/d HH:mm').format(data.editedAt!.toLocal())],
DateFormat('y/M/d HH:mm').format(data.editedAt!.toLocal())
],
), ),
style: TextStyle(fontSize: 13), style: TextStyle(fontSize: 13),
), ),
@ -915,9 +871,7 @@ class _PostContentHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
final user = data.publisher.type == 0 final user = data.publisher.type == 0 ? ud.getAccountFromCache(data.publisher.accountId) : null;
? ud.getFromCache(data.publisher.accountId)
: null;
return Row( return Row(
children: [ children: [
@ -928,8 +882,7 @@ class _PostContentHeader extends StatelessWidget {
borderRadius: data.publisher.type == 1 ? (isCompact ? 4 : 8) : 20, borderRadius: data.publisher.type == 1 ? (isCompact ? 4 : 8) : 20,
badge: (user?.badges.isNotEmpty ?? false) badge: (user?.badges.isNotEmpty ?? false)
? Icon( ? Icon(
kBadgesMeta[user!.badges.first.type]?.$2 ?? kBadgesMeta[user!.badges.first.type]?.$2 ?? Symbols.question_mark,
Symbols.question_mark,
color: kBadgesMeta[user.badges.first.type]?.$3, color: kBadgesMeta[user.badges.first.type]?.$3,
fill: 1, fill: 1,
size: 18, size: 18,
@ -973,10 +926,8 @@ class _PostContentHeader extends StatelessWidget {
const Gap(4), const Gap(4),
Text( Text(
isRelativeDate isRelativeDate
? RelativeTime(context).format( ? RelativeTime(context).format((data.publishedAt ?? data.createdAt).toLocal())
(data.publishedAt ?? data.createdAt).toLocal()) : DateFormat('y/M/d HH:mm').format((data.publishedAt ?? data.createdAt).toLocal()),
: DateFormat('y/M/d HH:mm').format(
(data.publishedAt ?? data.createdAt).toLocal()),
).fontSize(13), ).fontSize(13),
], ],
).opacity(0.8), ).opacity(0.8),
@ -994,10 +945,8 @@ class _PostContentHeader extends StatelessWidget {
const Gap(4), const Gap(4),
Text( Text(
isRelativeDate isRelativeDate
? RelativeTime(context).format( ? RelativeTime(context).format((data.publishedAt ?? data.createdAt).toLocal())
(data.publishedAt ?? data.createdAt).toLocal()) : DateFormat('y/M/d HH:mm').format((data.publishedAt ?? data.createdAt).toLocal()),
: DateFormat('y/M/d HH:mm').format(
(data.publishedAt ?? data.createdAt).toLocal()),
).fontSize(13), ).fontSize(13),
], ],
).opacity(0.8), ).opacity(0.8),
@ -1180,8 +1129,7 @@ class _PostContentBody extends StatelessWidget {
if (data.body['content'] == null) return const SizedBox.shrink(); if (data.body['content'] == null) return const SizedBox.shrink();
final content = MarkdownTextContent( final content = MarkdownTextContent(
isAutoWarp: data.type == 'story', isAutoWarp: data.type == 'story',
isEnlargeSticker: isEnlargeSticker: RegExp(r"^:([-\w]+):$").hasMatch(data.body['content'] ?? ''),
RegExp(r"^:([-\w]+):$").hasMatch(data.body['content'] ?? ''),
textScaler: isEnlarge ? TextScaler.linear(1.1) : null, textScaler: isEnlarge ? TextScaler.linear(1.1) : null,
content: data.body['content'], content: data.body['content'],
attachments: data.preload?.attachments, attachments: data.preload?.attachments,
@ -1230,12 +1178,10 @@ class _PostQuoteContent extends StatelessWidget {
onDeleted: () {}, onDeleted: () {},
).padding(bottom: 4), ).padding(bottom: 4),
_PostContentBody(data: child), _PostContentBody(data: child),
if (child.visibility > 0) if (child.visibility > 0) _PostVisibilityHint(data: child).padding(top: 4),
_PostVisibilityHint(data: child).padding(top: 4),
], ],
).padding(horizontal: 16), ).padding(horizontal: 16),
if (child.type != 'article' && if (child.type != 'article' && (child.preload?.attachments?.isNotEmpty ?? false))
(child.preload?.attachments?.isNotEmpty ?? false))
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8), bottomLeft: Radius.circular(8),
@ -1386,9 +1332,7 @@ class _PostTruncatedHint extends StatelessWidget {
const Gap(4), const Gap(4),
Text('postReadEstimate').tr(args: [ Text('postReadEstimate').tr(args: [
'${Duration( '${Duration(
seconds: (data.body['content_length'] as num).toDouble() * seconds: (data.body['content_length'] as num).toDouble() * 60 ~/ kHumanReadSpeed,
60 ~/
kHumanReadSpeed,
).inSeconds}s', ).inSeconds}s',
]), ]),
], ],
@ -1427,8 +1371,7 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
// If this is a answered question, fetch the answer instead // If this is a answered question, fetch the answer instead
if (widget.data.type == 'question' && widget.data.body['answer'] != null) { if (widget.data.type == 'question' && widget.data.body['answer'] != null) {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = final resp = await sn.client.get('/cgi/co/posts/${widget.data.body['answer']}');
await sn.client.get('/cgi/co/posts/${widget.data.body['answer']}');
_isAnswer = true; _isAnswer = true;
setState(() => _featuredComment = SnPost.fromJson(resp.data)); setState(() => _featuredComment = SnPost.fromJson(resp.data));
return; return;
@ -1436,11 +1379,9 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get( final resp = await sn.client.get('/cgi/co/posts/${widget.data.id}/replies/featured', queryParameters: {
'/cgi/co/posts/${widget.data.id}/replies/featured', 'take': 1,
queryParameters: { });
'take': 1,
});
setState(() => _featuredComment = SnPost.fromJson(resp.data[0])); setState(() => _featuredComment = SnPost.fromJson(resp.data[0]));
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
@ -1469,9 +1410,7 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
width: double.infinity, width: double.infinity,
child: Material( child: Material(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
color: _isAnswer color: _isAnswer ? Colors.green.withOpacity(0.5) : Theme.of(context).colorScheme.surfaceContainerHigh,
? Colors.green.withOpacity(0.5)
: Theme.of(context).colorScheme.surfaceContainerHigh,
child: InkWell( child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () { onTap: () {
@ -1491,17 +1430,11 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Gap(2), const Gap(2),
Icon(_isAnswer ? Symbols.task_alt : Symbols.prompt_suggestion, Icon(_isAnswer ? Symbols.task_alt : Symbols.prompt_suggestion, size: 20),
size: 20),
const Gap(10), const Gap(10),
Text( Text(
_isAnswer _isAnswer ? 'postQuestionAnswerTitle' : 'postFeaturedComment',
? 'postQuestionAnswerTitle' style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 15),
: 'postFeaturedComment',
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontSize: 15),
).tr(), ).tr(),
], ],
), ),
@ -1639,8 +1572,7 @@ class _PostGetInsightPopupState extends State<_PostGetInsightPopup> {
} }
RegExp cleanThinkingRegExp = RegExp(r'<think>[\s\S]*?</think>'); RegExp cleanThinkingRegExp = RegExp(r'<think>[\s\S]*?</think>');
setState( setState(() => _response = out.replaceAll(cleanThinkingRegExp, '').trim());
() => _response = out.replaceAll(cleanThinkingRegExp, '').trim());
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -1663,16 +1595,11 @@ class _PostGetInsightPopupState extends State<_PostGetInsightPopup> {
children: [ children: [
const Icon(Symbols.book_4_spark, size: 24), const Icon(Symbols.book_4_spark, size: 24),
const Gap(16), const Gap(16),
Text('postGetInsightTitle', Text('postGetInsightTitle', style: Theme.of(context).textTheme.titleLarge).tr(),
style: Theme.of(context).textTheme.titleLarge)
.tr(),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
const Gap(4), const Gap(4),
Text('postGetInsightDescription', Text('postGetInsightDescription', style: Theme.of(context).textTheme.bodySmall).tr().padding(horizontal: 20),
style: Theme.of(context).textTheme.bodySmall)
.tr()
.padding(horizontal: 20),
const Gap(4), const Gap(4),
if (_response == null) if (_response == null)
Expanded( Expanded(
@ -1690,16 +1617,12 @@ class _PostGetInsightPopupState extends State<_PostGetInsightPopup> {
leading: const Icon(Symbols.info), leading: const Icon(Symbols.info),
title: Text('aiThinkingProcess'.tr()), title: Text('aiThinkingProcess'.tr()),
tilePadding: const EdgeInsets.symmetric(horizontal: 20), tilePadding: const EdgeInsets.symmetric(horizontal: 20),
collapsedBackgroundColor: collapsedBackgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
Theme.of(context).colorScheme.surfaceContainerHigh,
minTileHeight: 32, minTileHeight: 32,
children: [ children: [
SelectableText( SelectableText(
_thinkingProcess!, _thinkingProcess!,
style: Theme.of(context) style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontStyle: FontStyle.italic),
.textTheme
.bodyMedium!
.copyWith(fontStyle: FontStyle.italic),
).padding(horizontal: 20, vertical: 8), ).padding(horizontal: 20, vertical: 8),
], ],
).padding(vertical: 8), ).padding(vertical: 8),
@ -1736,8 +1659,7 @@ class _PostVideoPlayer extends StatelessWidget {
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AttachmentItem( child: AttachmentItem(data: data.preload!.video!, heroTag: 'post-video-${data.id}'),
data: data.preload!.video!, heroTag: 'post-video-${data.id}'),
), ),
), ),
); );

View File

@ -23,7 +23,7 @@ class PublisherPopoverCard extends StatelessWidget {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
final user = data.type == 0 ? ud.getFromCache(data.accountId) : null; final user = data.type == 0 ? ud.getAccountFromCache(data.accountId) : null;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -85,9 +85,7 @@ class PublisherPopoverCard extends StatelessWidget {
(ele) => Tooltip( (ele) => Tooltip(
richMessage: TextSpan( richMessage: TextSpan(
children: [ children: [
TextSpan( TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
text: kBadgesMeta[ele.type]?.$1.tr() ??
'unknown'.tr()),
if (ele.metadata['title'] != null) if (ele.metadata['title'] != null)
TextSpan( TextSpan(
text: '\n${ele.metadata['title']}', text: '\n${ele.metadata['title']}',
@ -148,10 +146,7 @@ class PublisherPopoverCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text('publisherTotalDownvote') Text('publisherTotalDownvote').tr().fontSize(13).opacity(0.75),
.tr()
.fontSize(13)
.opacity(0.75),
Text(data.totalDownvote.toString()), Text(data.totalDownvote.toString()),
], ],
), ),

View File

@ -5,7 +5,6 @@ import 'package:drift/drift.dart';
import 'package:drift/internal/migrations.dart'; import 'package:drift/internal/migrations.dart';
import 'schema_v1.dart' as v1; import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2; import 'schema_v2.dart' as v2;
import 'schema_v3.dart' as v3;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@ -15,12 +14,10 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v1.DatabaseAtV1(db); return v1.DatabaseAtV1(db);
case 2: case 2:
return v2.DatabaseAtV2(db); return v2.DatabaseAtV2(db);
case 3:
return v3.DatabaseAtV3(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [1, 2, 3]; static const versions = const [1, 2];
} }

File diff suppressed because it is too large Load Diff