User cache

This commit is contained in:
LittleSheep 2025-03-04 22:30:17 +08:00
parent 1478933cf1
commit 288c0399f9
13 changed files with 422 additions and 167 deletions

View File

@ -314,6 +314,10 @@ 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,19 +1,36 @@
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++) {
@ -27,6 +44,23 @@ 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))
..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(',')});
@ -43,17 +77,28 @@ 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)))
.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(
@ -61,16 +106,39 @@ 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? getAccountFromCache(dynamic id) { SnAccount? getFromCache(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,
),
onConflict: DoUpdate(
(_) => SnLocalAccountCompanion.custom(
name: Constant(ele.name),
content: Constant(jsonEncode(ele.toJson())),
),
),
);
}).toList();
await Future.wait(queries);
}
} }

View File

@ -336,7 +336,7 @@ class _ChatChannelEntry extends StatelessWidget {
: null; : null;
final title = otherMember != null final title = otherMember != null
? ud.getAccountFromCache(otherMember.accountId)?.nick ?? channel.name ? ud.getFromCache(otherMember.accountId)?.nick ?? channel.name
: channel.name; : channel.name;
return ListTile( return ListTile(
@ -354,10 +354,9 @@ class _ChatChannelEntry extends StatelessWidget {
? Row( ? Row(
children: [ children: [
Badge( Badge(
label: Text(ud label: Text(
.getAccountFromCache(lastMessage!.sender.accountId) ud.getFromCache(lastMessage!.sender.accountId)?.nick ??
?.nick ?? 'unknown'.tr()),
'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,
), ),
@ -400,7 +399,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.getAccountFromCache(otherMember.accountId)?.avatar ? ud.getFromCache(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,15 +289,14 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
), ),
ListTile( ListTile(
leading: AccountImage( leading: AccountImage(
content: content: ud.getFromCache(_profile!.accountId)?.avatar,
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.getAccountFromCache(_profile!.accountId)!.nick ? ud.getFromCache(_profile!.accountId)!.nick
: _profile!.nick!, : _profile!.nick!,
), ),
contentPadding: const EdgeInsets.only(left: 20, right: 20), contentPadding: const EdgeInsets.only(left: 20, right: 20),
@ -575,11 +574,10 @@ 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.getAccountFromCache(member.accountId)?.avatar, content: ud.getFromCache(member.accountId)?.avatar,
), ),
title: Text( title: Text(
ud.getAccountFromCache(member.accountId)?.name ?? ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
'unknown'.tr(),
), ),
subtitle: Text(member.nick ?? 'unknown'.tr()), subtitle: Text(member.nick ?? 'unknown'.tr()),
trailing: SizedBox( trailing: SizedBox(

View File

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

View File

@ -51,7 +51,8 @@ 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 = await sn.client.get('/cgi/co/publishers?realm=${widget.alias}'); final resp =
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)) ?? [],
); );
@ -68,7 +69,8 @@ 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 = await sn.client.get('/cgi/im/channels/${widget.alias}/public'); final resp =
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>(),
); );
@ -98,15 +100,32 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[ return <Widget>[
SliverOverlapAbsorber( SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), handle:
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(icon: Icon(Symbols.home, color: Theme.of(context).appBarTheme.foregroundColor)), Tab(
Tab(icon: Icon(Symbols.explore, color: Theme.of(context).appBarTheme.foregroundColor)), icon: Icon(Symbols.home,
Tab(icon: Icon(Symbols.group, color: Theme.of(context).appBarTheme.foregroundColor)), color: Theme.of(context)
Tab(icon: Icon(Symbols.settings, color: Theme.of(context).appBarTheme.foregroundColor)), .appBarTheme
.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)),
], ],
), ),
), ),
@ -115,7 +134,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
}, },
body: TabBarView( body: TabBarView(
children: [ children: [
_RealmDetailHomeWidget(realm: _realm, publishers: _publishers, channels: _channels), _RealmDetailHomeWidget(
realm: _realm, publishers: _publishers, channels: _channels),
_RealmPostListWidget(realm: _realm), _RealmPostListWidget(realm: _realm),
_RealmMemberListWidget(realm: _realm), _RealmMemberListWidget(realm: _realm),
_RealmSettingsWidget( _RealmSettingsWidget(
@ -137,7 +157,8 @@ class _RealmDetailHomeWidget extends StatelessWidget {
final List<SnPublisher>? publishers; final List<SnPublisher>? publishers;
final List<SnChannel>? channels; final List<SnChannel>? channels;
const _RealmDetailHomeWidget({required this.realm, this.publishers, this.channels}); const _RealmDetailHomeWidget(
{required this.realm, this.publishers, this.channels});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -168,7 +189,8 @@ 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(), style: Theme.of(context).textTheme.bodyMedium) child: Text('realmCommunityPublishersHint'.tr(),
style: Theme.of(context).textTheme.bodyMedium)
.padding(horizontal: 24, vertical: 8), .padding(horizontal: 24, vertical: 8),
), ),
), ),
@ -199,7 +221,8 @@ 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(), style: Theme.of(context).textTheme.bodyMedium) child: Text('realmCommunityPublicChannelsHint'.tr(),
style: Theme.of(context).textTheme.bodyMedium)
.padding(horizontal: 24, vertical: 8), .padding(horizontal: 24, vertical: 8),
), ),
), ),
@ -323,10 +346,12 @@ 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('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: { final resp = await sn.client.get(
'take': 10, '/cgi/id/realms/${widget.realm!.alias}/members',
'offset': _members.length, queryParameters: {
}); '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)) ?? [],
@ -432,14 +457,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.getAccountFromCache(member.accountId)?.avatar, content: ud.getFromCache(member.accountId)?.avatar,
fallbackWidget: const Icon(Symbols.group, size: 24), fallbackWidget: const Icon(Symbols.group, size: 24),
), ),
title: Text( title: Text(
ud.getAccountFromCache(member.accountId)?.nick ?? 'unknown'.tr(), ud.getFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
), ),
subtitle: Text( subtitle: Text(
ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(), ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
), ),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Symbols.person_remove), icon: const Icon(Symbols.person_remove),

View File

@ -51,8 +51,10 @@ class _AppSharingListenerState extends State<AppSharingListener> {
child: Column( child: Column(
children: [ children: [
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding:
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), const EdgeInsets.symmetric(horizontal: 24),
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(),
@ -64,13 +66,20 @@ class _AppSharingListenerState extends State<AppSharingListener> {
}, },
extra: PostEditorExtra( extra: PostEditorExtra(
text: value text: value
.where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type)) .where((e) => [
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) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image] .where((e) => [
.contains(e.type)) SharedMediaType.video,
.map((e) => PostWriteMedia.fromFile(XFile(e.path))) SharedMediaType.file,
SharedMediaType.image
].contains(e.type))
.map((e) =>
PostWriteMedia.fromFile(XFile(e.path)))
.toList(), .toList(),
), ),
); );
@ -78,15 +87,18 @@ class _AppSharingListenerState extends State<AppSharingListener> {
}, },
), ),
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding:
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), const EdgeInsets.symmetric(horizontal: 24),
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) => _ShareIntentChannelSelect(value: value), builder: (context) =>
_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);
@ -110,7 +122,8 @@ class _AppSharingListenerState extends State<AppSharingListener> {
} }
void _initialize() async { void _initialize() async {
_shareIntentSubscription = ReceiveSharingIntent.instance.getMediaStream().listen((value) { _shareIntentSubscription =
ReceiveSharingIntent.instance.getMediaStream().listen((value) {
if (value.isEmpty) return; if (value.isEmpty) return;
if (mounted) { if (mounted) {
_gotoPost(value); _gotoPost(value);
@ -157,7 +170,8 @@ class _ShareIntentChannelSelect extends StatefulWidget {
const _ShareIntentChannelSelect({required this.value}); const _ShareIntentChannelSelect({required this.value});
@override @override
State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState(); State<_ShareIntentChannelSelect> createState() =>
_ShareIntentChannelSelectState();
} }
class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> { class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
@ -178,8 +192,11 @@ 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) && _lastMessages!.containsKey(b.id)) { if (_lastMessages!.containsKey(a.id) &&
return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt); _lastMessages!.containsKey(b.id)) {
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;
@ -232,7 +249,9 @@ 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', style: Theme.of(context).textTheme.titleLarge).tr(), Text('shareIntentSendChannel',
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),
@ -249,29 +268,34 @@ 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 = channel.members?.cast<SnChannelMember?>().firstWhere( final otherMember =
(ele) => ele?.accountId != ua.user?.id, channel.members?.cast<SnChannelMember?>().firstWhere(
orElse: () => null, (ele) => ele?.accountId != ua.user?.id,
); orElse: () => null,
);
return ListTile( return ListTile(
title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name), title: Text(
ud.getFromCache(otherMember?.accountId)?.nick ??
channel.name),
subtitle: lastMessage != null subtitle: lastMessage != null
? Text( ? Text(
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', '${ud.getFromCache(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.getAccountFromCache(otherMember?.accountId)?.name}', '@${ud.getFromCache(otherMember?.accountId)?.name}',
]), ]),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16), contentPadding:
const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage( leading: AccountImage(
content: ud.getAccountFromCache(otherMember?.accountId)?.avatar, content:
ud.getFromCache(otherMember?.accountId)?.avatar,
), ),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
@ -291,7 +315,7 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
title: Text(channel.name), title: Text(channel.name),
subtitle: lastMessage != null subtitle: lastMessage != null
? Text( ? Text(
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', '${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
) )
@ -316,13 +340,20 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
}, },
extra: ChatRoomScreenExtra( extra: ChatRoomScreenExtra(
initialText: widget.value initialText: widget.value
.where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type)) .where((e) => [
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.file, SharedMediaType.image].contains(e.type)) SharedMediaType.video,
.map((e) => PostWriteMedia.fromFile(XFile(e.path))) SharedMediaType.file,
SharedMediaType.image
].contains(e.type))
.map(
(e) => PostWriteMedia.fromFile(XFile(e.path)))
.toList(), .toList(),
), ),
) )

View File

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

View File

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