Compare commits

...

31 Commits

Author SHA1 Message Date
88396647f3 🚀 Launch 2.4.2+78 Feature Drop 2025-03-09 14:04:18 +08:00
335318ae3f Status system 2025-03-09 14:00:35 +08:00
da25fb9c29 🐛 Fix user cache 2025-03-09 13:03:57 +08:00
c1aef89b84 ♻️ Refactor account badge showing 2025-03-09 12:57:53 +08:00
0241c5f804 Check in streak 2025-03-09 12:41:34 +08:00
f6939d7c23 💄 Adjust icon size 2025-03-09 01:31:31 +08:00
d654c162e3 Shuffle post 2025-03-09 00:49:13 +08:00
25550ba197 💄 Changes to the showing of realm post 2025-03-09 00:11:01 +08:00
3defd3a593 💄 Modify explore appbar 2025-03-08 23:51:22 +08:00
d62ed4c375 Adjust explore categorized mode 2025-03-08 22:40:17 +08:00
857f3cc832 Post drafts 2025-03-08 22:32:38 +08:00
e16bc80eea 🐛 Fix attachments 2025-03-08 19:19:06 +08:00
a4f6e8af56 ♻️ New post explore realm design 2025-03-08 18:43:58 +08:00
060a97f5ec ♻️ Refactored explore screen 2025-03-08 18:19:57 +08:00
92f7e92018 🐛 Bug fixes due to post editor changes 2025-03-08 16:04:51 +08:00
5c483bd3b8 ♻️ Move the post editor mode into editor itself 2025-03-08 16:00:10 +08:00
1c510d63fe 🐛 Fix share via image errored 2025-03-06 22:46:02 +08:00
115cb4adc1 💄 Redesigned attachment zoom view 2025-03-06 22:35:06 +08:00
54c098c274 🍱 Update assets
 Optimize loading of web version in some regions
2025-03-05 22:23:42 +08:00
29731728cd 🚀 Launch 2.4.2+76 2025-03-05 00:43:50 +08:00
9e8882c580 Complete profile page 2025-03-05 00:21:25 +08:00
6042e57e7a 🐛 Fix orientation inconsistences 2025-03-05 00:00:11 +08:00
6235e736b9 Sticker cache 2025-03-04 23:56:39 +08:00
e075804782 🐛 Bug fixes on channel member cache 2025-03-04 23:39:55 +08:00
d40a6ca1c4 User channel profile cache 2025-03-04 23:35:28 +08:00
5ac657e526 Attachment local cache 2025-03-04 23:13:43 +08:00
97ddc18b8e 🗃️ Add expired to cache
 Add sticker cache
2025-03-04 22:56:43 +08:00
b835c8edea 💄 Optimize badges list screen 2025-03-04 22:33:56 +08:00
288c0399f9 User cache 2025-03-04 22:30:17 +08:00
1478933cf1 🐛 Fix editing message mock issue 2025-03-04 21:59:18 +08:00
93c6fa6e53 🗃️ Add more cache ability to local database 2025-03-04 21:49:24 +08:00
65 changed files with 8420 additions and 1199 deletions

BIN
assets/fonts/Nunito-Bold.ttf Executable file

Binary file not shown.

BIN
assets/fonts/Nunito-Italic.ttf Executable file

Binary file not shown.

BIN
assets/fonts/Nunito-Regular.ttf Executable file

Binary file not shown.

View File

@ -153,6 +153,11 @@
"publisherRunBy": "Run by {}", "publisherRunBy": "Run by {}",
"fieldPublisherBelongToRealm": "Belongs to", "fieldPublisherBelongToRealm": "Belongs to",
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm", "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
"writePost": "Compose",
"postTypeStory": "Story",
"postTypeArticle": "Article",
"postTypeQuestion": "Question",
"postTypeVideo": "Video",
"writePostTypeStory": "Post a story", "writePostTypeStory": "Post a story",
"writePostTypeArticle": "Write an article", "writePostTypeArticle": "Write an article",
"writePostTypeQuestion": "Ask a question", "writePostTypeQuestion": "Ask a question",
@ -763,5 +768,28 @@
"decrypting": "Decrypting……", "decrypting": "Decrypting……",
"decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online", "decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online",
"messageUnablePreview": "Unable preview", "messageUnablePreview": "Unable preview",
"messageUnablePreviewEncrypted": "Unable preview encrypted message" "messageUnablePreviewEncrypted": "Unable preview encrypted message",
"postViewInGlobalDescription": "Do not view the post in the specific realm.",
"postDraftSaved": "The draft has been saved.",
"postDraftBox": "Draft Box",
"postShuffle": "Read Randomly",
"checkInStreak": {
"zero": "No streak",
"one": "{} day streak",
"other": "{} days streak"
},
"accountChangeStatus": "Change Status",
"accountStatusSilent": "Do not Disturb",
"accountStatusSilentDesc": "The notification will stop popping up",
"accountStatusInvisible": "Invisible",
"accountStatusInvisibleDesc": "Will show as offline, but all features still remain normal",
"accountCustomStatus": "Custom Status",
"accountCustomStatusDescription": "Customize your status.",
"accountClearStatus": "Clear Status",
"accountClearStatusDescription": "Clear your status, and let server decide which status you are for you.",
"fieldAccountStatusLabel": "Status Text",
"fieldAccountStatusClearAt": "Clear At",
"accountStatusNegative": "Negative",
"accountStatusNeutral": "Neutral",
"accountStatusPositive": "Positive"
} }

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所属领域", "fieldPublisherBelongToRealm": "所属领域",
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域", "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
"writePost": "撰写",
"postTypeStory": "动态",
"postTypeArticle": "文章",
"postTypeQuestion": "问题",
"postTypeVideo": "视频",
"writePostTypeStory": "发动态", "writePostTypeStory": "发动态",
"writePostTypeArticle": "写文章", "writePostTypeArticle": "写文章",
"writePostTypeQuestion": "提问题", "writePostTypeQuestion": "提问题",
@ -761,5 +766,28 @@
"decrypting": "解密中……", "decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线", "decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线",
"messageUnablePreview": "无法预览消息", "messageUnablePreview": "无法预览消息",
"messageUnablePreviewEncrypted": "无法预览加密消息" "messageUnablePreviewEncrypted": "无法预览加密消息",
"postViewInGlobalDescription": "不查看特定领域的帖子。",
"postDraftSaved": "已保存为草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "随便看看",
"checkInStreak": {
"zero": "无连击",
"one": "连续签到 {} 天",
"other": "连续签到 {} 天"
},
"accountChangeStatus": "修改状态",
"accountStatusSilent": "请勿打扰",
"accountStatusSilentDesc": "将会暂停所有通知推送",
"accountStatusInvisible": "隐身",
"accountStatusInvisibleDesc": "将会在他人界面显示离线,但不影响功能使用",
"accountCustomStatus": "自定义状态",
"accountCustomStatusDescription": "客制化你的状态。",
"accountClearStatus": "清除状态",
"accountClearStatusDescription": "清除你的状态,并让服务器决定你的状态。",
"fieldAccountStatusLabel": "状态文字",
"fieldAccountStatusClearAt": "清除时间",
"accountStatusNegative": "负面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
} }

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域", "fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題", "writePostTypeQuestion": "提問題",
@ -761,5 +766,28 @@
"decrypting": "解密中……", "decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息", "messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息" "messageUnablePreviewEncrypted": "無法預覽加密消息",
"postViewInGlobalDescription": "不查看特定領域的帖子。",
"postDraftSaved": "已保存為草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "隨便看看",
"checkInStreak": {
"zero": "無連擊",
"one": "連續簽到 {} 天",
"other": "連續簽到 {} 天"
},
"accountChangeStatus": "修改狀態",
"accountStatusSilent": "請勿打擾",
"accountStatusSilentDesc": "將會暫停所有通知推送",
"accountStatusInvisible": "隱身",
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
"accountCustomStatus": "自定義狀態",
"accountCustomStatusDescription": "客製化你的狀態。",
"accountClearStatus": "清除狀態",
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
"fieldAccountStatusLabel": "狀態文字",
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
} }

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域", "fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題", "writePostTypeQuestion": "提問題",
@ -761,5 +766,28 @@
"decrypting": "解密中……", "decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息", "messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息" "messageUnablePreviewEncrypted": "無法預覽加密消息",
"postViewInGlobalDescription": "不查看特定領域的帖子。",
"postDraftSaved": "已保存為草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "隨便看看",
"checkInStreak": {
"zero": "無連擊",
"one": "連續簽到 {} 天",
"other": "連續簽到 {} 天"
},
"accountChangeStatus": "修改狀態",
"accountStatusSilent": "請勿打擾",
"accountStatusSilentDesc": "將會暫停所有通知推送",
"accountStatusInvisible": "隱身",
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
"accountCustomStatus": "自定義狀態",
"accountCustomStatusDescription": "客製化你的狀態。",
"accountClearStatus": "清除狀態",
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
"fieldAccountStatusLabel": "狀態文字",
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
} }

File diff suppressed because one or more lines are too long

View File

@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/database/database.dart'; import 'package:surface/database/database.dart';
import 'package:surface/logger.dart'; import 'package:surface/logger.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/database.dart'; import 'package:surface/providers/database.dart';
import 'package:surface/providers/keypair.dart'; import 'package:surface/providers/keypair.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
@ -26,6 +27,7 @@ class ChatMessageController extends ChangeNotifier {
late final WebSocketProvider _ws; late final WebSocketProvider _ws;
late final SnAttachmentProvider _attach; late final SnAttachmentProvider _attach;
late final DatabaseProvider _dt; late final DatabaseProvider _dt;
late final ChatChannelProvider _ct;
late final KeyPairProvider _kp; late final KeyPairProvider _kp;
StreamSubscription? _wsSubscription; StreamSubscription? _wsSubscription;
@ -35,6 +37,7 @@ class ChatMessageController extends ChangeNotifier {
_ud = context.read<UserDirectoryProvider>(); _ud = context.read<UserDirectoryProvider>();
_ws = context.read<WebSocketProvider>(); _ws = context.read<WebSocketProvider>();
_attach = context.read<SnAttachmentProvider>(); _attach = context.read<SnAttachmentProvider>();
_ct = context.read<ChatChannelProvider>();
_dt = context.read<DatabaseProvider>(); _dt = context.read<DatabaseProvider>();
_kp = context.read<KeyPairProvider>(); _kp = context.read<KeyPairProvider>();
} }
@ -65,10 +68,7 @@ class ChatMessageController extends ChangeNotifier {
channel = chan; channel = chan;
// Fetch channel profile // Fetch channel profile
final resp = await _sn.client.get( profile = await _ct.getChannelProfile(channel!);
'/cgi/im/channels/${chan.keyPath}/me',
);
profile = SnChannelMember.fromJson(resp.data);
_wsSubscription = _ws.pk.stream.listen((event) { _wsSubscription = _ws.pk.stream.listen((event) {
switch (event.method) { switch (event.method) {
@ -287,23 +287,26 @@ class ChatMessageController extends ChangeNotifier {
}; };
// Mock the message locally // Mock the message locally
final createdAt = DateTime.now(); // Do not mock the editing message
final message = SnChatMessage( if (editingMessage == null) {
id: 0, final createdAt = DateTime.now();
createdAt: createdAt, final message = SnChatMessage(
updatedAt: createdAt, id: 0,
deletedAt: null, createdAt: createdAt,
uuid: nonce, updatedAt: createdAt,
body: body, deletedAt: null,
type: type, uuid: nonce,
channel: channel!, body: body,
channelId: channel!.id, type: type,
sender: profile!, channel: channel!,
senderId: profile!.id, channelId: channel!.id,
quoteEventId: quoteId, sender: profile!,
relatedEventId: relatedId, senderId: profile!.id,
); quoteEventId: quoteId,
_addUnconfirmedMessage(message); relatedEventId: relatedId,
);
_addUnconfirmedMessage(message);
}
// Send to server // Send to server
try { try {

View File

@ -71,7 +71,8 @@ class PostWriteMedia {
} }
} }
PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file}); PostWriteMedia.fromBytes(this.raw, this.name, this.type,
{this.attachment, this.file});
bool get isEmpty => attachment == null && file == null && raw == null; bool get isEmpty => attachment == null && file == null && raw == null;
@ -105,7 +106,8 @@ class PostWriteMedia {
}) { }) {
if (attachment != null) { if (attachment != null) {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); final ImageProvider provider =
UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
if (width != null && height != null && !kIsWeb) { if (width != null && height != null && !kIsWeb) {
return ResizeImage( return ResizeImage(
provider, provider,
@ -116,7 +118,8 @@ class PostWriteMedia {
} }
return provider; return provider;
} else if (file != null) { } else if (file != null) {
final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path)); final ImageProvider provider =
kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
if (width != null && height != null) { if (width != null && height != null) {
return ResizeImage( return ResizeImage(
provider, provider,
@ -159,11 +162,14 @@ class PostWriteController extends ChangeNotifier {
final TextEditingController aliasController = TextEditingController(); final TextEditingController aliasController = TextEditingController();
final TextEditingController rewardController = TextEditingController(); final TextEditingController rewardController = TextEditingController();
ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration( ContentInsertionConfiguration get contentInsertionConfiguration =>
ContentInsertionConfiguration(
onContentInserted: (KeyboardInsertedContent content) { onContentInserted: (KeyboardInsertedContent content) {
if (content.hasData) { if (content.hasData) {
addAttachments( addAttachments([
[PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]); PostWriteMedia.fromBytes(content.data!,
'attachmentInsertedImage'.tr(), SnMediaType.image)
]);
} }
}, },
); );
@ -193,7 +199,8 @@ class PostWriteController extends ChangeNotifier {
String get description => descriptionController.text; String get description => descriptionController.text;
bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null); bool get isRelatedNull =>
![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
bool isLoading = false, isBusy = false; bool isLoading = false, isBusy = false;
double? progress; double? progress;
@ -201,6 +208,7 @@ class PostWriteController extends ChangeNotifier {
SnRealm? realm; SnRealm? realm;
SnPublisher? publisher; SnPublisher? publisher;
SnPost? editingPost, repostingPost, replyingPost; SnPost? editingPost, repostingPost, replyingPost;
bool editingDraft = false;
int visibility = 0; int visibility = 0;
List<int> visibleUsers = List.empty(); List<int> visibleUsers = List.empty();
@ -237,14 +245,20 @@ class PostWriteController extends ChangeNotifier {
publishedAt = post.publishedAt; publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil; publishedUntil = post.publishedUntil;
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true); visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true); invisibleUsers =
List.from(post.invisibleUsersList ?? [], growable: true);
visibility = post.visibility; visibility = post.visibility;
tags = List.from(post.tags.map((ele) => ele.alias), growable: true); tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
categories = List.from(post.categories.map((ele) => ele.alias), growable: true); categories =
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); List.from(post.categories.map((ele) => ele.alias), growable: true);
attachments.addAll(
post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
poll = post.preload?.poll; poll = post.preload?.poll;
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { editingDraft = post.isDraft;
if (post.preload?.thumbnail != null &&
(post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
thumbnail = PostWriteMedia(post.preload!.thumbnail); thumbnail = PostWriteMedia(post.preload!.thumbnail);
} }
if (post.preload?.realm != null) { if (post.preload?.realm != null) {
@ -272,7 +286,8 @@ class PostWriteController extends ChangeNotifier {
} }
} }
Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media, Future<SnAttachment> _uploadAttachment(
BuildContext context, PostWriteMedia media,
{bool isCompressed = false}) async { {bool isCompressed = false}) async {
final attach = context.read<SnAttachmentProvider>(); final attach = context.read<SnAttachmentProvider>();
@ -281,7 +296,9 @@ class PostWriteController extends ChangeNotifier {
media.name, media.name,
'interactive', 'interactive',
null, null,
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null, mimetype: media.raw != null && media.type == SnMediaType.image
? 'image/png'
: null,
); );
var item = await attach.chunkedUploadParts( var item = await attach.chunkedUploadParts(
@ -297,9 +314,11 @@ class PostWriteController extends ChangeNotifier {
if (media.type == SnMediaType.video && !isCompressed && context.mounted) { if (media.type == SnMediaType.video && !isCompressed && context.mounted) {
try { try {
final compressedAttachment = await _tryCompressVideoCopy(context, media); final compressedAttachment =
await _tryCompressVideoCopy(context, media);
if (compressedAttachment != null) { if (compressedAttachment != null) {
item = await attach.updateOne(item, compressedId: compressedAttachment.id); item = await attach.updateOne(item,
compressedId: compressedAttachment.id);
} }
} catch (err) { } catch (err) {
if (context.mounted) context.showErrorDialog(err); if (context.mounted) context.showErrorDialog(err);
@ -309,8 +328,10 @@ class PostWriteController extends ChangeNotifier {
return item; return item;
} }
Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async { Future<SnAttachment?> _tryCompressVideoCopy(
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null; BuildContext context, PostWriteMedia media) async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS))
return null;
if (media.type != SnMediaType.video) return null; if (media.type != SnMediaType.video) return null;
if (media.file == null) return null; if (media.file == null) return null;
if (VideoCompress.isCompressing) return null; if (VideoCompress.isCompressing) return null;
@ -334,7 +355,8 @@ class PostWriteController extends ChangeNotifier {
if (!context.mounted) return null; if (!context.mounted) return null;
final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!)); final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!));
final compressedAttachment = await _uploadAttachment(context, compressedMedia, isCompressed: true); final compressedAttachment =
await _uploadAttachment(context, compressedMedia, isCompressed: true);
return compressedAttachment; return compressedAttachment;
} }
@ -370,18 +392,25 @@ class PostWriteController extends ChangeNotifier {
'content': contentController.text, 'content': contentController.text,
if (aliasController.text.isNotEmpty) 'alias': aliasController.text, if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text, if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
if (rewardController.text.isNotEmpty) 'reward': rewardController.text, if (rewardController.text.isNotEmpty) 'reward': rewardController.text,
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(), if (thumbnail != null && thumbnail!.attachment != null)
'attachments': 'thumbnail': thumbnail!.attachment!.toJson(),
attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true), 'attachments': attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.toJson())
.toList(growable: true),
'tags': tags.map((ele) => {'alias': ele}).toList(growable: true), 'tags': tags.map((ele) => {'alias': ele}).toList(growable: true),
'categories': categories.map((ele) => {'alias': ele}).toList(growable: true), 'categories':
categories.map((ele) => {'alias': ele}).toList(growable: true),
'visibility': visibility, 'visibility': visibility,
'visible_users_list': visibleUsers, 'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers, 'invisible_users_list': invisibleUsers,
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(), if (publishedAt != null)
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null)
'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.toJson(), if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
if (repostingPost != null) 'repost_to': repostingPost!.toJson(), if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
if (poll != null) 'poll': poll!.toJson(), if (poll != null) 'poll': poll!.toJson(),
@ -391,6 +420,12 @@ class PostWriteController extends ChangeNotifier {
}); });
} }
bool get isNotEmpty =>
title.isNotEmpty ||
description.isNotEmpty ||
contentController.text.isNotEmpty ||
attachments.isNotEmpty;
bool temporaryRestored = false; bool temporaryRestored = false;
void _temporaryLoad() { void _temporaryLoad() {
@ -403,18 +438,24 @@ class PostWriteController extends ChangeNotifier {
titleController.text = data['title'] ?? ''; titleController.text = data['title'] ?? '';
descriptionController.text = data['description'] ?? ''; descriptionController.text = data['description'] ?? '';
rewardController.text = data['reward']?.toString() ?? ''; rewardController.text = data['reward']?.toString() ?? '';
if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail'])); if (data['thumbnail'] != null)
attachments thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
.addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>()); attachments.addAll(data['attachments']
.map((ele) => PostWriteMedia(SnAttachment.fromJson(ele)))
.cast<PostWriteMedia>());
tags = List.from(data['tags'].map((ele) => ele['alias'])); tags = List.from(data['tags'].map((ele) => ele['alias']));
categories = List.from(data['categories'].map((ele) => ele['alias'])); categories = List.from(data['categories'].map((ele) => ele['alias']));
visibility = data['visibility']; visibility = data['visibility'];
visibleUsers = List.from(data['visible_users_list'] ?? []); visibleUsers = List.from(data['visible_users_list'] ?? []);
invisibleUsers = List.from(data['invisible_users_list'] ?? []); invisibleUsers = List.from(data['invisible_users_list'] ?? []);
if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal(); if (data['published_at'] != null)
if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal(); publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null; if (data['published_until'] != null)
repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null; publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
replyingPost =
data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
repostingPost =
data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null; poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null;
realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null; realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null;
temporaryRestored = true; temporaryRestored = true;
@ -436,7 +477,10 @@ class PostWriteController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> sendPost(BuildContext context) async { Future<void> sendPost(
BuildContext context, {
bool saveAsDraft = false,
}) async {
if (isBusy || publisher == null) return; if (isBusy || publisher == null) return;
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
@ -463,7 +507,9 @@ class PostWriteController extends ChangeNotifier {
media.name, media.name,
'interactive', 'interactive',
null, null,
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null, mimetype: media.raw != null && media.type == SnMediaType.image
? 'image/png'
: null,
); );
var item = await attach.chunkedUploadParts( var item = await attach.chunkedUploadParts(
@ -472,16 +518,20 @@ class PostWriteController extends ChangeNotifier {
place.$2, place.$2,
onProgress: (value) { onProgress: (value) {
// Calculate overall progress for attachments // Calculate overall progress for attachments
progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value); progress = math.max(
((i + value) / attachments.length) * kAttachmentProgressWeight,
value);
notifyListeners(); notifyListeners();
}, },
); );
try { try {
if (context.mounted) { if (context.mounted) {
final compressedAttachment = await _tryCompressVideoCopy(context, media); final compressedAttachment =
await _tryCompressVideoCopy(context, media);
if (compressedAttachment != null) { if (compressedAttachment != null) {
item = await attach.updateOne(item, compressedId: compressedAttachment.id); item = await attach.updateOne(item,
compressedId: compressedAttachment.id);
} }
} }
} catch (err) { } catch (err) {
@ -508,7 +558,7 @@ class PostWriteController extends ChangeNotifier {
// Posting the content // Posting the content
try { try {
final baseProgressVal = progress!; final baseProgressVal = progress!;
await sn.client.request( final resp = await sn.client.request(
[ [
'/cgi/co/$mode', '/cgi/co/$mode',
if (editingPost != null) '${editingPost!.id}', if (editingPost != null) '${editingPost!.id}',
@ -518,36 +568,56 @@ class PostWriteController extends ChangeNotifier {
'content': contentController.text, 'content': contentController.text,
if (aliasController.text.isNotEmpty) 'alias': aliasController.text, if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text, if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, if (descriptionController.text.isNotEmpty)
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid, 'description': descriptionController.text,
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), if (thumbnail != null && thumbnail!.attachment != null)
'thumbnail': thumbnail!.attachment!.rid,
'attachments': attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.rid)
.toList(),
'tags': tags.map((ele) => {'alias': ele}).toList(), 'tags': tags.map((ele) => {'alias': ele}).toList(),
'categories': categories.map((ele) => {'alias': ele}).toList(), 'categories': categories.map((ele) => {'alias': ele}).toList(),
'visibility': visibility, 'visibility': visibility,
'visible_users_list': visibleUsers, 'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers, 'invisible_users_list': invisibleUsers,
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(), if (publishedAt != null)
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null)
'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.id, if (replyingPost != null) 'reply_to': replyingPost!.id,
if (repostingPost != null) 'repost_to': repostingPost!.id, if (repostingPost != null) 'repost_to': repostingPost!.id,
if (reward != null) 'reward': reward, if (reward != null) 'reward': reward,
if (videoAttachment != null) 'video': videoAttachment!.rid, if (videoAttachment != null) 'video': videoAttachment!.rid,
if (poll != null) 'poll': poll!.id, if (poll != null) 'poll': poll!.id,
if (realm != null) 'realm': realm!.id, if (realm != null) 'realm': realm!.id,
'is_draft': saveAsDraft,
}, },
onSendProgress: (count, total) { onSendProgress: (count, total) {
progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2); progress =
baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
notifyListeners(); notifyListeners();
}, },
onReceiveProgress: (count, total) { onReceiveProgress: (count, total) {
progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2); progress = baseProgressVal +
(kPostingProgressWeight / 2) +
(count / total) * (kPostingProgressWeight / 2);
notifyListeners(); notifyListeners();
}, },
options: Options( options: Options(
method: editingPost != null ? 'PUT' : 'POST', method: editingPost != null ? 'PUT' : 'POST',
), ),
); );
reset(); if (saveAsDraft) {
if (!context.mounted) return;
editingDraft = true;
final out = SnPost.fromJson(resp.data);
final pt = context.read<SnPostContentProvider>();
editingPost = await pt.completePostData(out);
notifyListeners();
} else {
reset();
}
} catch (err) { } catch (err) {
if (!context.mounted) return; if (!context.mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -683,7 +753,8 @@ class PostWriteController extends ChangeNotifier {
repostingPost = null; repostingPost = null;
mode = kTitleMap.keys.first; mode = kTitleMap.keys.first;
temporaryRestored = false; temporaryRestored = false;
SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey)); SharedPreferences.getInstance()
.then((prefs) => prefs.remove(kTemporaryStorageKey));
notifyListeners(); notifyListeners();
} }

42
lib/database/account.dart Normal file
View File

@ -0,0 +1,42 @@
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

@ -0,0 +1,47 @@
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,6 +28,7 @@ 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()();
@ -63,12 +64,54 @@ 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,19 +1,33 @@
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: [SnLocalChatChannel, SnLocalChatMessage, SnLocalKeyPair]) @DriftDatabase(tables: [
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 => 2; int get schemaVersion => 3;
static QueryExecutor _openConnection() { static QueryExecutor _openConnection() {
return driftDatabase( return driftDatabase(
@ -33,6 +47,8 @@ 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,8 +140,281 @@ 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) {
@ -150,6 +423,11 @@ 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');
} }
@ -158,8 +436,10 @@ 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,
)); ));

74
lib/database/sticker.dart Normal file
View File

@ -0,0 +1,74 @@
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,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;
@ -491,6 +495,11 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context); cfg.calcDrawerSize(context);
}); });
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
cfg.calcDrawerSize(context);
}
});
return SizeChangedLayoutNotifier( return SizeChangedLayoutNotifier(
child: widget.child, child: widget.child,
); );

View File

@ -8,6 +8,7 @@ import 'package:surface/providers/database.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart'; import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart'; import 'package:surface/types/chat.dart';
class ChatChannelProvider extends ChangeNotifier { class ChatChannelProvider extends ChangeNotifier {
@ -15,12 +16,14 @@ class ChatChannelProvider extends ChangeNotifier {
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final UserDirectoryProvider _ud; late final UserDirectoryProvider _ud;
late final UserProvider _ua;
late final DatabaseProvider _dt; late final DatabaseProvider _dt;
late final SnRealmProvider _rels; late final SnRealmProvider _rels;
ChatChannelProvider(BuildContext context) { ChatChannelProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
_ud = context.read<UserDirectoryProvider>(); _ud = context.read<UserDirectoryProvider>();
_ua = context.read<UserProvider>();
_dt = context.read<DatabaseProvider>(); _dt = context.read<DatabaseProvider>();
_rels = context.read<SnRealmProvider>(); _rels = context.read<SnRealmProvider>();
} }
@ -149,4 +152,60 @@ class ChatChannelProvider extends ChangeNotifier {
await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet()); await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet());
return out; return out;
} }
Future<void> _saveMemberToLocal(Iterable<SnChannelMember> members) async {
final queries = members.map((ele) {
return _dt.db.snLocalChannelMember.insertOne(
SnLocalChannelMemberCompanion.insert(
id: Value(ele.id),
channelId: ele.channelId,
accountId: ele.accountId,
content: ele,
cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
),
onConflict: DoUpdate(
(_) => SnLocalChannelMemberCompanion.custom(
content: Constant(jsonEncode(ele.toJson())),
cacheExpiredAt:
Constant(DateTime.now().add(const Duration(days: 7))),
),
),
);
});
await Future.wait(queries);
}
Future<void> removeLocalChannel(SnChannel channel) async {
await _dt.db.transaction(() async {
await (_dt.db.snLocalChannelMember.delete()
..where((e) => e.channelId.equals(channel.id)))
.go();
await (_dt.db.snLocalChatChannel.delete()
..where((e) => e.id.equals(channel.id)))
.go();
await (_dt.db.snLocalChatMessage.delete()
..where((e) => e.channelId.equals(channel.id)))
.go();
});
}
Future<void> updateChannelProfile(SnChannelMember member) {
return _saveMemberToLocal([member]);
}
Future<SnChannelMember> getChannelProfile(SnChannel channel) async {
if (_ua.user == null) throw Exception('User not logged in');
final local = await (_dt.db.snLocalChannelMember.select()
..where((e) => e.channelId.equals(channel.id))
..where((e) => e.accountId.equals(_ua.user!.id)))
.getSingleOrNull();
if (local != null) {
return local.content;
}
final resp = await _sn.client.get('/cgi/im/channels/${channel.keyPath}/me');
final out = SnChannelMember.fromJson(resp.data);
_saveMemberToLocal([out]);
return out;
}
} }

View File

@ -60,16 +60,24 @@ class SnPostContentProvider {
out[i] = out[i].copyWith( out[i] = out[i].copyWith(
preload: SnPostPreload( preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull, thumbnail: attachments
attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(), .where((ele) => ele?.rid == out[i].body['thumbnail'])
video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull, .firstOrNull,
attachments: attachments
.where((ele) =>
out[i].body['attachments']?.contains(ele?.rid) ?? false)
.toList(),
video: attachments
.where((ele) => ele?.rid == out[i].body['video'])
.firstOrNull,
poll: poll, poll: poll,
realm: realm, realm: realm,
), ),
); );
} }
uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); uids.addAll(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
await _ud.listAccount(uids); await _ud.listAccount(uids);
return out; return out;
@ -107,15 +115,23 @@ class SnPostContentProvider {
out = out.copyWith( out = out.copyWith(
preload: SnPostPreload( preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull, thumbnail: attachments
attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(), .where((ele) => ele?.rid == out.body['thumbnail'])
video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull, .firstOrNull,
attachments: attachments
.where(
(ele) => out.body['attachments']?.contains(ele?.rid) ?? false)
.toList(),
video: attachments
.where((ele) => ele?.rid == out.body['video'])
.firstOrNull,
poll: poll, poll: poll,
realm: realm, realm: realm,
), ),
); );
uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); uids.addAll(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
await _ud.listAccount(uids); await _ud.listAccount(uids);
return out; return out;
@ -138,17 +154,25 @@ class SnPostContentProvider {
Iterable<String>? tags, Iterable<String>? tags,
String? realm, String? realm,
String? channel, String? channel,
bool isDraft = false,
bool isShuffle = false,
}) async { }) async {
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { final resp = await _sn.client.get(
'take': take, isShuffle
'offset': offset, ? '/cgi/co/recommendations/shuffle'
if (type != null) 'type': type, : '/cgi/co/posts${isDraft ? '/drafts' : ''}',
if (author != null) 'author': author, queryParameters: {
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), 'take': take,
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','), 'offset': offset,
if (realm != null) 'realm': realm, if (type != null) 'type': type,
if (channel != null) 'channel': channel, if (author != null) 'author': author,
}); if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
if (categories?.isNotEmpty ?? false)
'categories': categories!.join(','),
if (realm != null) 'realm': realm,
if (channel != null) 'channel': channel,
},
);
final List<SnPost> out = await _preloadRelatedDataInBatch( final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
); );
@ -161,7 +185,8 @@ class SnPostContentProvider {
int take = 10, int take = 10,
int offset = 0, int offset = 0,
}) async { }) async {
final resp = await _sn.client.get('/cgi/co/posts/$parentId/replies', queryParameters: { final resp = await _sn.client
.get('/cgi/co/posts/$parentId/replies', queryParameters: {
'take': take, 'take': take,
'offset': offset, 'offset': offset,
}); });
@ -200,4 +225,9 @@ class SnPostContentProvider {
); );
return out; return out;
} }
Future<SnPost> completePostData(SnPost post) async {
final out = await _preloadRelatedDataSingle(post);
return out;
}
} }

View File

@ -1,11 +1,14 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:cross_file/cross_file.dart'; import 'package:cross_file/cross_file.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/attachment.dart'; import 'package:surface/types/attachment.dart';
@ -13,10 +16,12 @@ const kConcurrentUploadChunks = 5;
class SnAttachmentProvider { class SnAttachmentProvider {
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final DatabaseProvider _dt;
final Map<String, SnAttachment> _cache = {}; final Map<String, SnAttachment> _cache = {};
SnAttachmentProvider(BuildContext context) { SnAttachmentProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
_dt = context.read<DatabaseProvider>();
} }
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) { void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
@ -28,21 +33,33 @@ class SnAttachmentProvider {
} }
Future<SnAttachment> getOne(String rid, {noCache = false}) async { Future<SnAttachment> getOne(String rid, {noCache = false}) async {
// In-memory cache
if (!noCache && _cache.containsKey(rid)) { if (!noCache && _cache.containsKey(rid)) {
return _cache[rid]!; return _cache[rid]!;
} }
// On-disk cache
final dbResp = await (_dt.db.snLocalAttachment.select()
..where((e) => e.rid.equals(rid))
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
.getSingleOrNull();
if (dbResp != null) {
_cache[rid] = dbResp.content;
return dbResp.content;
}
// Remote server
final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta'); final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
final out = SnAttachment.fromJson(resp.data); final out = SnAttachment.fromJson(resp.data);
if (out.isAnalyzed) { if (out.isAnalyzed) {
_cache[rid] = out; _cache[rid] = out;
} }
_saveToLocal([out]);
return out; return out;
} }
Future<List<SnAttachment?>> getMultiple(List<String> rids, Future<List<SnAttachment?>> getMultiple(List<String> rids,
{noCache = false}) async { {bool noCache = false}) async {
// In-memory cache
final result = List<SnAttachment?>.filled(rids.length, null); final result = List<SnAttachment?>.filled(rids.length, null);
final Map<String, int> randomMapping = {}; final Map<String, int> randomMapping = {};
for (int i = 0; i < rids.length; i++) { for (int i = 0; i < rids.length; i++) {
@ -53,29 +70,44 @@ class SnAttachmentProvider {
result[i] = _cache[rid]!; result[i] = _cache[rid]!;
} }
} }
final pendingFetch = randomMapping.keys; var pendingFetch = randomMapping.keys;
// On-disk cache
if (pendingFetch.isNotEmpty) { if (pendingFetch.isEmpty) return result;
final resp = await _sn.client.get( if (!noCache) {
'/cgi/uc/attachments', final dbResp = await (_dt.db.snLocalAttachment.select()
queryParameters: { ..where((e) => e.rid.isIn(pendingFetch))
'take': pendingFetch.length, ..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
'id': pendingFetch.join(','), .get();
}, for (final item in dbResp) {
); if (item.content.isAnalyzed) {
final List<SnAttachment?> out = resp.data['data'] _cache[item.rid] = item.content;
.map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e))
.cast<SnAttachment?>()
.toList();
for (final item in out) {
if (item == null) continue;
if (item.isAnalyzed) {
_cache[item.rid] = item;
} }
result[randomMapping[item.rid]!] = item; result[randomMapping[item.rid]!] = item.content;
randomMapping.remove(item.rid);
} }
pendingFetch = randomMapping.keys;
} }
// Remote server
if (pendingFetch.isEmpty) return result;
final resp = await _sn.client.get(
'/cgi/uc/attachments',
queryParameters: {
'take': pendingFetch.length,
'id': pendingFetch.join(','),
},
);
final List<SnAttachment?> out = resp.data['data']
.map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e))
.cast<SnAttachment?>()
.toList();
for (final item in out) {
if (item == null) continue;
if (item.isAnalyzed) {
_cache[item.rid] = item;
}
result[randomMapping[item.rid]!] = item;
}
_saveToLocal(out.where((ele) => ele != null).cast());
return result; return result;
} }
@ -274,6 +306,31 @@ class SnAttachmentProvider {
'metadata': metadata ?? item.usermeta, 'metadata': metadata ?? item.usermeta,
'is_indexable': isIndexable ?? item.isIndexable, 'is_indexable': isIndexable ?? item.isIndexable,
}); });
return SnAttachment.fromJson(resp.data); final out = SnAttachment.fromJson(resp.data);
_saveToLocal([out]);
return out;
}
Future<void> _saveToLocal(Iterable<SnAttachment> out) async {
for (final ele in out) {
if (!ele.isAnalyzed || ele.destination == 0) continue;
await _dt.db.snLocalAttachment.insertOne(
SnLocalAttachmentCompanion.insert(
id: Value(ele.id),
rid: ele.rid,
uuid: ele.uuid,
content: ele,
accountId: ele.accountId,
cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
),
onConflict: DoUpdate(
(_) => SnLocalAttachmentCompanion.custom(
content: Constant(jsonEncode(ele.toJson())),
cacheExpiredAt:
Constant(DateTime.now().add(const Duration(days: 7))),
),
),
);
}
} }
} }

View File

@ -1,11 +1,17 @@
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/logger.dart'; import 'package:surface/logger.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/attachment.dart'; import 'package:surface/types/attachment.dart';
class SnStickerProvider { class SnStickerProvider {
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final DatabaseProvider _dt;
final Map<String, SnSticker?> _cache = {}; final Map<String, SnSticker?> _cache = {};
final Map<int, List<SnSticker>> stickersByPack = {}; final Map<int, List<SnSticker>> stickersByPack = {};
@ -15,6 +21,7 @@ class SnStickerProvider {
SnStickerProvider(BuildContext context) { SnStickerProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
_dt = context.read<DatabaseProvider>();
} }
bool hasNotSticker(String alias) { bool hasNotSticker(String alias) {
@ -31,22 +38,32 @@ class SnStickerProvider {
} }
} }
void putSticker(Iterable<SnSticker> sticker) { void putSticker(Iterable<SnSticker> stickers) {
for (final ele in sticker) { for (final ele in stickers) {
_cacheSticker(ele); _cacheSticker(ele);
} }
_saveStickerToLocal(stickers);
_saveStickerPackToLocal(stickers.map((ele) => ele.pack).toSet());
} }
Future<SnSticker?> lookupSticker(String alias) async { Future<SnSticker?> lookupSticker(String alias) async {
// In-memory cache
if (_cache.containsKey(alias)) { if (_cache.containsKey(alias)) {
return _cache[alias]; return _cache[alias];
} }
// On-disk cache
final localStickers = await (_dt.db.snLocalSticker.select()
..where((e) => e.fullAlias.equals(alias)))
.getSingleOrNull();
if (localStickers != null) {
_cache[alias] = localStickers.content;
return localStickers.content;
}
// Remote server
try { try {
final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias'); final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
final sticker = SnSticker.fromJson(resp.data); final sticker = SnSticker.fromJson(resp.data);
_cacheSticker(sticker); putSticker([sticker]);
return sticker; return sticker;
} catch (err) { } catch (err) {
_cache[alias] = null; _cache[alias] = null;
@ -57,6 +74,18 @@ class SnStickerProvider {
} }
Future<void> listSticker() async { Future<void> listSticker() async {
final localPacks = await _dt.db.snLocalStickerPack.select().get();
final localStickers = await _dt.db.snLocalSticker.select().get();
final local = localStickers.map((ele) {
return ele.content.copyWith(
pack: localPacks
.firstWhere((pk) => pk.content.id == ele.content.packId)
.content,
);
});
for (final sticker in local) {
_cacheSticker(sticker);
}
try { try {
final resp = await _sn.client.get('/cgi/uc/stickers'); final resp = await _sn.client.get('/cgi/uc/stickers');
final data = resp.data; final data = resp.data;
@ -69,4 +98,35 @@ class SnStickerProvider {
rethrow; rethrow;
} }
} }
Future<void> _saveStickerToLocal(Iterable<SnSticker> stickers) async {
await _dt.db.snLocalSticker.insertAll(
stickers.map(
(ele) => SnLocalStickerCompanion.insert(
id: Value(ele.id),
alias: ele.alias,
fullAlias: '${ele.pack.prefix}${ele.alias}',
content: ele,
createdAt: Value(ele.createdAt),
),
),
onConflict: DoNothing(),
);
}
Future<void> _saveStickerPackToLocal(Iterable<SnStickerPack> packs) async {
final queries = packs
.map(
(ele) => _dt.db.snLocalStickerPack.insertOne(
SnLocalStickerPackCompanion.insert(
id: Value(ele.id),
content: ele,
createdAt: Value(ele.createdAt),
),
onConflict: DoUpdate((_) => SnLocalStickerPackCompanion.custom(
content: Constant(jsonEncode(ele.toJson()))))),
)
.toList();
await Future.wait(queries);
}
} }

View File

@ -1,19 +1,44 @@
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 = {};
DateTime? _cacheExpiredAt;
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;
}
_cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
return out.length;
}
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async { Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
// In-memory cache
if (_cacheExpiredAt != null && _cacheExpiredAt!.isAfter(DateTime.now())) {
_cache.clear();
_cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
} else {
_cacheExpiredAt ??= DateTime.now().add(const Duration(hours: 1));
}
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 +52,25 @@ 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
_saveToLocal(out.where((ele) => ele != null).cast());
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 +87,29 @@ 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(
@ -61,16 +117,42 @@ 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,
cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
),
onConflict: DoUpdate(
(_) => SnLocalAccountCompanion.custom(
name: Constant(ele.name),
content: Constant(jsonEncode(ele.toJson())),
cacheExpiredAt:
Constant(DateTime.now().add(const Duration(hours: 1))),
),
),
);
}).toList();
await Future.wait(queries);
}
} }

View File

@ -28,7 +28,9 @@ import 'package:surface/screens/news/news_detail.dart';
import 'package:surface/screens/news/news_list.dart'; import 'package:surface/screens/news/news_list.dart';
import 'package:surface/screens/notification.dart'; import 'package:surface/screens/notification.dart';
import 'package:surface/screens/post/post_detail.dart'; import 'package:surface/screens/post/post_detail.dart';
import 'package:surface/screens/post/post_draft.dart';
import 'package:surface/screens/post/post_editor.dart'; import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/screens/post/post_shuffle.dart';
import 'package:surface/screens/post/publisher_page.dart'; import 'package:surface/screens/post/publisher_page.dart';
import 'package:surface/screens/post/post_search.dart'; import 'package:surface/screens/post/post_search.dart';
import 'package:surface/screens/realm.dart'; import 'package:surface/screens/realm.dart';
@ -66,10 +68,15 @@ final _appRoutes = [
builder: (context, state) => const ExploreScreen(), builder: (context, state) => const ExploreScreen(),
routes: [ routes: [
GoRoute( GoRoute(
path: '/write/:mode', path: '/draft',
name: 'postDraftBox',
builder: (context, state) => const PostDraftBox(),
),
GoRoute(
path: '/write',
name: 'postEditor', name: 'postEditor',
builder: (context, state) => PostEditorScreen( builder: (context, state) => PostEditorScreen(
mode: state.pathParameters['mode']!, mode: state.uri.queryParameters['mode'],
postEditId: int.tryParse( postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '', state.uri.queryParameters['editing'] ?? '',
), ),
@ -82,6 +89,11 @@ final _appRoutes = [
extraProps: state.extra as PostEditorExtra?, extraProps: state.extra as PostEditorExtra?,
), ),
), ),
GoRoute(
path: '/shuffle',
name: 'postShuffle',
builder: (context, state) => const PostShuffleScreen(),
),
GoRoute( GoRoute(
path: '/search', path: '/search',
name: 'postSearch', name: 'postSearch',

View File

@ -11,7 +11,9 @@ import 'package:surface/providers/database.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart'; import 'package:surface/providers/websocket.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/account_status.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
@ -112,7 +114,14 @@ class _AuthorizedAccountScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AccountImage(content: ua.user!.avatar, radius: 28), Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountImage(content: ua.user!.avatar, radius: 28),
_AccountStatusWidget(account: ua.user!),
],
),
const Gap(8), const Gap(8),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.baseline, crossAxisAlignment: CrossAxisAlignment.baseline,
@ -290,3 +299,81 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
); );
} }
} }
class _AccountStatusWidget extends StatefulWidget {
final SnAccount account;
const _AccountStatusWidget({required this.account});
@override
State<_AccountStatusWidget> createState() => _AccountStatusWidgetState();
}
class _AccountStatusWidgetState extends State<_AccountStatusWidget> {
SnAccountStatusInfo? _status;
Future<void> _fetchStatus() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp =
await sn.client.get('/cgi/id/users/${widget.account.name}/status');
setState(() {
_status = SnAccountStatusInfo.fromJson(resp.data);
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() {});
}
}
@override
void initState() {
super.initState();
_fetchStatus();
}
@override
Widget build(BuildContext context) {
return InkWell(
child: Row(
children: [
Text(
_status != null
? (_status!.status?.label.isNotEmpty ?? false)
? _status!.status!.label
: _status!.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(),
),
const Gap(4),
Icon(
(_status?.isDisturbable ?? true)
? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (_status?.isOnline ?? false) ? 1 : 0,
size: 16,
color: (_status?.isOnline ?? false)
? (_status?.isDisturbable ?? true)
? Colors.green
: Colors.red
: Colors.grey,
).padding(all: 4),
],
),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => AccountStatusActionPopup(
currentStatus: _status,
),
).then((value) {
if (value == true && mounted) {
_fetchStatus();
}
});
},
);
}
}

View File

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

View File

@ -18,9 +18,11 @@ import 'package:surface/types/account.dart';
import 'package:surface/types/check_in.dart'; import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import 'package:surface/theme.dart'; import 'package:surface/theme.dart';
import 'package:url_launcher/url_launcher_string.dart';
final Map<String, (String, IconData, Color)> kBadgesMeta = { final Map<String, (String, IconData, Color)> kBadgesMeta = {
'company.staff': ( 'company.staff': (
@ -69,7 +71,8 @@ class UserScreen extends StatefulWidget {
State<UserScreen> createState() => _UserScreenState(); State<UserScreen> createState() => _UserScreenState();
} }
class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin { class _UserScreenState extends State<UserScreen>
with SingleTickerProviderStateMixin {
late final ScrollController _scrollController = ScrollController(); late final ScrollController _scrollController = ScrollController();
SnAccount? _account; SnAccount? _account;
@ -95,7 +98,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
Future<void> _getCheckInRecords() async { Future<void> _getCheckInRecords() async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14'); final resp =
await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
setState(() { setState(() {
_records = List.from( _records = List.from(
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [], resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
@ -128,7 +132,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
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?user=${widget.name}'); final resp =
await sn.client.get('/cgi/co/publishers?user=${widget.name}');
_publishers = List<SnPublisher>.from( _publishers = List<SnPublisher>.from(
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
); );
@ -174,7 +179,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
'related': _account!.name, 'related': _account!.name,
}); });
if (!mounted) return; if (!mounted) return;
context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}'])); context.showSnackbar(
'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -190,9 +196,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
try { try {
final rel = context.read<SnRelationshipProvider>(); final rel = context.read<SnRelationshipProvider>();
await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {}); await rel.updateRelationship(
_account!.id, 1, _accountRelationship?.permNodes ?? {});
if (!mounted) return; if (!mounted) return;
context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}'])); context.showSnackbar(
'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -218,12 +226,14 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
double _appBarBlur = 0.0; double _appBarBlur = 0.0;
late final _appBarWidth = MediaQuery.of(context).size.width; late final _appBarWidth = MediaQuery.of(context).size.width;
late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble(); late final _appBarHeight =
(_appBarWidth * kBannerAspectRatio).roundToDouble();
void _updateAppBarBlur() { void _updateAppBarBlur() {
if (_scrollController.offset > _appBarHeight) return; if (_scrollController.offset > _appBarHeight) return;
setState(() { setState(() {
_appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0); _appBarBlur =
(_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
}); });
} }
@ -291,18 +301,20 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
text: TextSpan(children: [ text: TextSpan(children: [
TextSpan( TextSpan(
text: _account!.nick, text: _account!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith( style:
color: Colors.white, Theme.of(context).textTheme.titleLarge!.copyWith(
shadows: labelShadows, color: Colors.white,
), shadows: labelShadows,
),
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: '@${_account!.name}', text: '@${_account!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith( style:
color: Colors.white, Theme.of(context).textTheme.bodySmall!.copyWith(
shadows: labelShadows, color: Colors.white,
), shadows: labelShadows,
),
), ),
]), ]),
), ),
@ -311,14 +323,21 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
? Stack( ? Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
UniversalImage( if (_account!.banner.isNotEmpty)
sn.getAttachmentUrl(_account!.banner), UniversalImage(
fit: BoxFit.cover, sn.getAttachmentUrl(_account!.banner),
height: imageHeight, fit: BoxFit.cover,
width: _appBarWidth, height: imageHeight,
cacheHeight: imageHeight, width: _appBarWidth,
cacheWidth: _appBarWidth, cacheHeight: imageHeight,
), cacheWidth: _appBarWidth,
)
else
Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh,
),
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
@ -370,7 +389,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
PopupMenuButton( PopupMenuButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
style: ButtonStyle( style: ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4), visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
), ),
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem( PopupMenuItem(
@ -420,27 +440,41 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
), ),
], ],
).padding(right: 8), ).padding(right: 8),
const Gap(12), if (_account!.profile!.description.isNotEmpty)
Text(_account!.profile!.description).padding(horizontal: 8), const Gap(12)
else
const Gap(8),
if (_account!.profile!.description.isNotEmpty)
Text(_account!.profile!.description).padding(horizontal: 8),
const Gap(4), const Gap(4),
Card( Card(
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Symbols.circle, (_status?.isDisturbable ?? true)
fill: 1, ? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (_status?.isOnline ?? false) ? 1 : 0,
size: 16, size: 16,
color: (_status?.isOnline ?? false) ? Colors.green : Colors.grey, color: (_status?.isOnline ?? false)
? (_status?.isDisturbable ?? true)
? Colors.green
: Colors.red
: Colors.grey,
).padding(all: 4), ).padding(all: 4),
const Gap(8), const Gap(8),
Text( Text(
_status != null _status != null
? _status!.isOnline ? (_status!.status?.label.isNotEmpty ?? false)
? 'accountStatusOnline'.tr() ? _status!.status!.label
: 'accountStatusOffline'.tr() : _status!.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(), : 'loading'.tr(),
), ),
if (_status != null && !_status!.isOnline && _status!.lastSeenAt != null) if (_status != null &&
!_status!.isOnline &&
_status!.lastSeenAt != null)
Text( Text(
'accountStatusLastSeen'.tr(args: [ 'accountStatusLastSeen'.tr(args: [
_status!.lastSeenAt != null _status!.lastSeenAt != null
@ -457,31 +491,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
Wrap( Wrap(
children: _account!.badges children: _account!.badges
.map( .map(
(ele) => Tooltip( (ele) => AccountBadge(badge: ele),
richMessage: TextSpan(
children: [
TextSpan(
text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr(),
),
if (ele.metadata['title'] != null)
TextSpan(
text: '\n${ele.metadata['title']}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: '\n'),
TextSpan(
text: DateFormat.yMEd().format(ele.createdAt),
),
],
),
child: Icon(
kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
color: ele.metadata['color'] != null
? HexColor.fromHex(ele.metadata['color']!)
: kBadgesMeta[ele.type]?.$3,
fill: 1,
),
),
) )
.toList(), .toList(),
).padding(horizontal: 8), ).padding(horizontal: 8),
@ -493,7 +503,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
children: [ children: [
const Icon(Symbols.calendar_add_on), const Icon(Symbols.calendar_add_on),
const Gap(8), const Gap(8),
Text('publisherJoinedAt').tr(args: [DateFormat('y/M/d').format(_account!.createdAt)]), Text('publisherJoinedAt').tr(args: [
DateFormat('y/M/d').format(_account!.createdAt)
]),
], ],
), ),
Row( Row(
@ -510,6 +522,44 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
]), ]),
], ],
), ),
if (_account!.profile!.gender.isNotEmpty ||
_account!.profile!.pronouns.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.wc),
const Gap(8),
Text(
_account!.profile!.gender.isNotEmpty
? _account!.profile!.gender
: 'unknown'.tr(),
),
Text(' · ').padding(horizontal: 4),
Text(
_account!.profile!.pronouns.isNotEmpty
? _account!.profile!.pronouns
: 'unknown'.tr(),
),
],
),
if (_account!.profile!.timeZone.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.schedule),
const Gap(8),
Text(_account!.profile!.timeZone),
],
),
if (_account!.profile!.location.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.location_on),
const Gap(8),
Text(_account!.profile!.location),
],
),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@ -526,17 +576,24 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
children: [ children: [
const Icon(Symbols.star), const Icon(Symbols.star),
const Gap(8), const Gap(8),
Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'), Text(
'Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
const Gap(8), const Gap(8),
Text(calcLevelUpProgressLevel(_account?.profile?.experience ?? 0)).fontSize(11).opacity(0.5), Text(calcLevelUpProgressLevel(
_account?.profile?.experience ?? 0))
.fontSize(11)
.opacity(0.5),
const Gap(8), const Gap(8),
Container( Container(
width: double.infinity, width: double.infinity,
constraints: const BoxConstraints(maxWidth: 160), constraints: const BoxConstraints(maxWidth: 160),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: calcLevelUpProgress(_account?.profile?.experience ?? 0), value: calcLevelUpProgress(
_account?.profile?.experience ?? 0),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
backgroundColor: Theme.of(context).colorScheme.surfaceContainer, backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainer,
).alignment(Alignment.centerLeft), ).alignment(Alignment.centerLeft),
), ),
], ],
@ -546,6 +603,26 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
], ],
).padding(all: 16), ).padding(all: 16),
), ),
if (_account?.profile?.links.isNotEmpty ?? false)
SliverToBoxAdapter(child: const Divider()),
if (_account?.profile?.links.isNotEmpty ?? false)
SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _account!.profile!.links.entries.map((ele) {
return ListTile(
leading: const Icon(Symbols.link),
title: Text(ele.key),
subtitle: Text(ele.value),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
launchUrlString(ele.value);
},
);
}).toList(),
),
),
SliverToBoxAdapter(child: const Divider()), SliverToBoxAdapter(child: const Divider()),
const SliverGap(12), const SliverGap(12),
SliverToBoxAdapter( SliverToBoxAdapter(
@ -556,7 +633,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
return Text( return Text(
'accountCheckInNoRecords', 'accountCheckInNoRecords',
textAlign: TextAlign.center, textAlign: TextAlign.center,
).tr().fontWeight(FontWeight.bold).center().padding(horizontal: 20, vertical: 8); )
.tr()
.fontWeight(FontWeight.bold)
.center()
.padding(horizontal: 20, vertical: 8);
} }
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
@ -573,47 +654,55 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
const SliverGap(12), const SliverGap(12),
SliverToBoxAdapter(child: const Divider()), SliverToBoxAdapter(child: const Divider()),
const SliverGap(12), const SliverGap(12),
SliverToBoxAdapter( if (_account?.badges.isNotEmpty ?? false)
child: Column( SliverToBoxAdapter(
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4), children: [
SizedBox( Text('accountBadge')
height: 80, .bold()
width: double.infinity, .fontSize(17)
child: ListView( .tr()
padding: EdgeInsets.symmetric(horizontal: 8), .padding(horizontal: 20, bottom: 4),
scrollDirection: Axis.horizontal, SizedBox(
children: [ height: 80,
for (final badge in _account?.badges ?? []) width: double.infinity,
SizedBox( child: ListView(
width: 280, padding: EdgeInsets.symmetric(horizontal: 8),
child: Card( scrollDirection: Axis.horizontal,
child: ListTile( children: [
leading: Icon( for (final badge in _account?.badges ?? [])
kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark, SizedBox(
color: badge.metadata['color'] != null width: 280,
? HexColor.fromHex(badge.metadata['color']!) child: Card(
: kBadgesMeta[badge.type]?.$3, child: ListTile(
fill: 1, leading: Icon(
kBadgesMeta[badge.type]?.$2 ??
Symbols.question_mark,
color: badge.metadata['color'] != null
? HexColor.fromHex(
badge.metadata['color']!)
: kBadgesMeta[badge.type]?.$3,
fill: 1,
),
title: Text(
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
).tr(),
subtitle: badge.metadata['title'] != null
? Text(badge.metadata['title'])
: Text(
DateFormat('y/M/d')
.format(badge.createdAt),
),
), ),
title: Text(
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
).tr(),
subtitle: badge.metadata['title'] != null
? Text(badge.metadata['title'])
: Text(
DateFormat('y/M/d').format(badge.createdAt),
),
), ),
), ),
), ],
], ),
), ),
), ],
], ),
), ),
),
const SliverGap(8), const SliverGap(8),
SliverToBoxAdapter(child: const Divider()), SliverToBoxAdapter(child: const Divider()),
SliverList.builder( SliverList.builder(
@ -699,7 +788,8 @@ class CheckInRecordChart extends StatelessWidget {
), ),
) )
.toList(), .toList(),
getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh, getTooltipColor: (_) =>
Theme.of(context).colorScheme.surfaceContainerHigh,
), ),
), ),
titlesData: FlTitlesData( titlesData: FlTitlesData(

View File

@ -204,7 +204,6 @@ class _ChatScreenState extends State<ChatScreen> {
Theme.of(context).floatingActionButtonTheme.foregroundColor, Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor, Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
), ),
closeButtonBuilder: DefaultFloatingActionButtonBuilder( closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28), child: const Icon(Symbols.close, size: 28),
@ -213,7 +212,6 @@ class _ChatScreenState extends State<ChatScreen> {
Theme.of(context).floatingActionButtonTheme.foregroundColor, Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor, Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
), ),
children: [ children: [
Row( Row(
@ -336,7 +334,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 +352,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 +397,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

@ -57,11 +57,10 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final sn = context.read<SnNetworkProvider>(); final ct = context.read<ChatChannelProvider>();
final resp = final resp = await ct.getChannelProfile(_channel!);
await sn.client.get('/cgi/im/channels/${_channel!.keyPath}/me'); _profile = resp;
_profile = SnChannelMember.fromJson(resp.data); _notifyLevel = resp.notify;
_notifyLevel = _profile!.notify;
if (!mounted) return; if (!mounted) return;
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
await ud.getAccount(_profile!.accountId); await ud.getAccount(_profile!.accountId);
@ -103,10 +102,12 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
if (!mounted) return; if (!mounted) return;
try { try {
final ct = context.read<ChatChannelProvider>();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.delete( await sn.client.delete(
'/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.alias}/me', '/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.alias}/me',
); );
await ct.removeLocalChannel(_channel!);
if (!mounted) return; if (!mounted) return;
Navigator.pop(context, false); Navigator.pop(context, false);
} catch (err) { } catch (err) {
@ -130,12 +131,15 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
setState(() => _isUpdatingNotifyLevel = true); setState(() => _isUpdatingNotifyLevel = true);
try { try {
final ct = context.read<ChatChannelProvider>();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.put( final resp = await sn.client.put(
'/cgi/im/channels/${_channel!.keyPath}/members/me/notify', '/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
data: {'notify_level': value}, data: {'notify_level': value},
); );
_profile = SnChannelMember.fromJson(resp.data);
_notifyLevel = value; _notifyLevel = value;
await ct.updateChannelProfile(_profile!);
if (!mounted) return; if (!mounted) return;
context.showSnackbar('channelNotifyLevelApplied'.tr()); context.showSnackbar('channelNotifyLevelApplied'.tr());
} catch (err) { } catch (err) {
@ -289,15 +293,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),
@ -408,11 +411,14 @@ class _ChannelProfileDetailDialogState
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final ct = context.read<ChatChannelProvider>();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.put( final resp = await sn.client.put(
'/cgi/im/channels/${widget.channel.keyPath}/members/me', '/cgi/im/channels/${widget.channel.keyPath}/members/me',
data: {'nick': _nickController.text}, data: {'nick': _nickController.text},
); );
final out = SnChannelMember.fromJson(resp.data);
await ct.updateChannelProfile(out);
if (!mounted) return; if (!mounted) return;
Navigator.pop(context, true); Navigator.pop(context, true);
} catch (err) { } catch (err) {
@ -575,11 +581,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

@ -1,4 +1,3 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
@ -19,6 +18,9 @@ import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
const kPostChannels = ['Global', 'Friends', 'Following'];
const kPostChannelIcons = [Symbols.globe, Symbols.group, Symbols.subscriptions];
const Map<String, IconData> kCategoryIcons = { const Map<String, IconData> kCategoryIcons = {
'technology': Symbols.tools_wrench, 'technology': Symbols.tools_wrench,
'gaming': Symbols.gamepad, 'gaming': Symbols.gamepad,
@ -39,17 +41,17 @@ class ExploreScreen extends StatefulWidget {
State<ExploreScreen> createState() => _ExploreScreenState(); State<ExploreScreen> createState() => _ExploreScreenState();
} }
// You know what? I'm not going to make this a global variable.
// Cuz the global key make the selected category not update to child widget when the category is changed.
SnPostCategory? _selectedCategory;
class _ExploreScreenState extends State<ExploreScreen> class _ExploreScreenState extends State<ExploreScreen>
with SingleTickerProviderStateMixin { with TickerProviderStateMixin {
late final TabController _tabController = late TabController _tabController = TabController(
TabController(length: 4, vsync: this); length: kPostChannels.length,
vsync: this,
);
final _fabKey = GlobalKey<ExpandableFabState>(); final _fabKey = GlobalKey<ExpandableFabState>();
final _listKeys = List.generate(4, (_) => GlobalKey<_PostListWidgetState>()); final _listKey = GlobalKey<_PostListWidgetState>();
bool _showCategories = false;
final List<SnPostCategory> _categories = List.empty(growable: true); final List<SnPostCategory> _categories = List.empty(growable: true);
@ -69,14 +71,68 @@ class _ExploreScreenState extends State<ExploreScreen>
} }
} }
void _clearFilter() { final List<SnRealm> _realms = List.empty(growable: true);
_selectedCategory = null;
Future<void> _fetchRealms() async {
try {
final rels = context.read<SnRealmProvider>();
final out = await rels.listAvailableRealms();
setState(() {
_realms.addAll(out);
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
rethrow;
}
}
void _toggleShowCategories() {
_showCategories = !_showCategories;
if (_showCategories) {
_tabController = TabController(length: _categories.length, vsync: this);
_listKey.currentState?.setCategory(_categories[_tabController.index]);
_listKey.currentState?.refreshPosts();
} else {
_tabController = TabController(length: kPostChannels.length, vsync: this);
_listKey.currentState?.setCategory(null);
_listKey.currentState?.refreshPosts();
}
_tabListen();
setState(() {});
}
void _tabListen() {
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
if (_showCategories) {
_listKey.currentState?.setCategory(_categories[_tabController.index]);
_listKey.currentState?.refreshPosts();
return;
}
switch (_tabController.index) {
case 0:
case 3:
_listKey.currentState?.setChannel(null);
break;
case 1:
_listKey.currentState?.setChannel('friends');
break;
case 2:
_listKey.currentState?.setChannel('following');
break;
}
_listKey.currentState?.refreshPosts();
}
});
} }
@override @override
void initState() { void initState() {
_fetchCategories();
super.initState(); super.initState();
_tabListen();
_fetchCategories();
_fetchRealms();
} }
@override @override
@ -86,7 +142,7 @@ class _ExploreScreenState extends State<ExploreScreen>
} }
Future<void> refreshPosts() async { Future<void> refreshPosts() async {
await _listKeys[_tabController.index].currentState?.refreshPosts(); await _listKey.currentState?.refreshPosts();
} }
@override @override
@ -111,7 +167,6 @@ class _ExploreScreenState extends State<ExploreScreen>
Theme.of(context).floatingActionButtonTheme.foregroundColor, Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor, Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
), ),
closeButtonBuilder: DefaultFloatingActionButtonBuilder( closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28), child: const Icon(Symbols.close, size: 28),
@ -120,90 +175,39 @@ class _ExploreScreenState extends State<ExploreScreen>
Theme.of(context).floatingActionButtonTheme.foregroundColor, Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor, Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
), ),
children: [ children: [
Row( Row(
children: [ children: [
Text('writePostTypeStory').tr(), Text('writePost').tr(),
const Gap(20), const Gap(20),
FloatingActionButton( FloatingActionButton(
heroTag: null, heroTag: null,
tooltip: 'writePostTypeStory'.tr(), tooltip: 'writePost'.tr(),
onPressed: () { onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: { GoRouter.of(context).pushNamed('postEditor').then((value) {
'mode': 'stories',
}).then((value) {
if (value == true) { if (value == true) {
refreshPosts(); refreshPosts();
} }
}); });
_fabKey.currentState!.toggle(); _fabKey.currentState!.toggle();
}, },
child: const Icon(Symbols.post_rounded), child: const Icon(Symbols.edit),
), ),
], ],
), ),
Row( Row(
children: [ children: [
Text('writePostTypeArticle').tr(), Text('postDraftBox').tr(),
const Gap(20), const Gap(20),
FloatingActionButton( FloatingActionButton(
heroTag: null, heroTag: null,
tooltip: 'writePostTypeArticle'.tr(), tooltip: 'postDraftBox'.tr(),
onPressed: () { onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: { GoRouter.of(context).pushNamed('postDraftBox');
'mode': 'articles',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle(); _fabKey.currentState!.toggle();
}, },
child: const Icon(Symbols.news), child: const Icon(Symbols.box_edit),
),
],
),
Row(
children: [
Text('writePostTypeQuestion').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeQuestion'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'questions',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.question_answer),
),
],
),
Row(
children: [
Text('writePostTypeVideo').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeVideo'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'videos',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.video_call),
), ),
], ],
), ),
@ -216,25 +220,74 @@ class _ExploreScreenState extends State<ExploreScreen>
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar( sliver: SliverAppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenExplore').tr(), titleSpacing: 0,
title: Row(
children: [
IconButton(
icon: const Icon(Symbols.shuffle),
onPressed: () {
GoRouter.of(context).pushNamed('postShuffle');
},
),
Expanded(
child: Center(
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
icon: _listKey.currentState?.realm != null
? AccountImage(
content: _listKey.currentState!.realm!.avatar,
radius: 14,
)
: Image.asset(
'assets/icon/icon-dark.png',
width: 32,
height: 32,
color: Theme.of(context)
.appBarTheme
.foregroundColor,
),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => _PostListRealmPopup(
realms: _realms,
onUpdate: (realm) {
_listKey.currentState?.setRealm(realm);
_listKey.currentState?.refreshPosts();
Future.delayed(
const Duration(milliseconds: 100), () {
if (mounted) {
setState(() {});
}
});
},
),
);
},
),
),
),
],
),
floating: true, floating: true,
snap: true, snap: true,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.category), icon: const Icon(Symbols.category),
style: _showCategories
? ButtonStyle(
foregroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.primary,
),
backgroundColor: MaterialStateProperty.all(
Theme.of(context).colorScheme.secondaryContainer,
),
)
: null,
onPressed: () { onPressed: () {
showModalBottomSheet( _toggleShowCategories();
context: context,
builder: (context) => _PostCategoryPickerPopup(
categories: _categories,
selected: _selectedCategory,
),
).then((value) {
if (value != null && context.mounted) {
_selectedCategory = value == false ? null : value;
refreshPosts();
}
});
}, },
), ),
IconButton( IconButton(
@ -246,122 +299,79 @@ class _ExploreScreenState extends State<ExploreScreen>
const Gap(8), const Gap(8),
], ],
bottom: TabBar( bottom: TabBar(
isScrollable: _showCategories,
controller: _tabController, controller: _tabController,
tabs: [ tabs: _showCategories
Tab( ? [
child: Row( for (final category in _categories)
mainAxisSize: MainAxisSize.min, Tab(
crossAxisAlignment: CrossAxisAlignment.center, child: Row(
children: [ mainAxisSize: MainAxisSize.min,
Icon(Symbols.globe, crossAxisAlignment: CrossAxisAlignment.center,
size: 20, children: [
color: Theme.of(context) Icon(
.appBarTheme kCategoryIcons[category.alias] ??
.foregroundColor), Symbols.question_mark,
const Gap(8), color: Theme.of(context)
Flexible( .appBarTheme
child: Text( .foregroundColor!,
'postChannelGlobal', ),
maxLines: 1, const Gap(8),
).tr().textColor( Flexible(
Theme.of(context).appBarTheme.foregroundColor), child: Text(
), 'postCategory${category.alias.capitalize()}'
.trExists()
? 'postCategory${category.alias.capitalize()}'
.tr()
: category.name,
maxLines: 1,
).textColor(
Theme.of(context)
.appBarTheme
.foregroundColor!,
),
),
],
),
),
]
: [
for (final channel in kPostChannels)
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
kPostChannelIcons[
kPostChannels.indexOf(channel)],
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor,
),
const Gap(8),
Flexible(
child: Text(
'postChannel$channel',
maxLines: 1,
).tr().textColor(
Theme.of(context)
.appBarTheme
.foregroundColor,
),
),
],
),
),
], ],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.group,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelFriends',
maxLines: 1,
textAlign: TextAlign.center,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.subscriptions,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelFollowing',
maxLines: 1,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.workspaces,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelRealm',
maxLines: 1,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
],
), ),
), ),
), ),
]; ];
}, },
body: TabBarView( body: _PostListWidget(
controller: _tabController, key: _listKey,
children: [
_PostListWidget(
key: _listKeys[0],
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[1],
channel: 'friends',
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[2],
channel: 'following',
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[3],
withRealm: true,
onClearFilter: _clearFilter,
),
],
), ),
), ),
); );
@ -369,15 +379,7 @@ class _ExploreScreenState extends State<ExploreScreen>
} }
class _PostListWidget extends StatefulWidget { class _PostListWidget extends StatefulWidget {
final String? channel; const _PostListWidget({super.key});
final bool withRealm;
final Function onClearFilter;
const _PostListWidget(
{super.key,
this.channel,
this.withRealm = false,
required this.onClearFilter});
@override @override
State<_PostListWidget> createState() => _PostListWidgetState(); State<_PostListWidget> createState() => _PostListWidgetState();
@ -386,25 +388,13 @@ class _PostListWidget extends StatefulWidget {
class _PostListWidgetState extends State<_PostListWidget> { class _PostListWidgetState extends State<_PostListWidget> {
bool _isBusy = false; bool _isBusy = false;
final List<SnPost> _posts = List.empty(growable: true); SnRealm? get realm => _selectedRealm;
final List<SnRealm> _realms = List.empty(growable: true);
SnRealm? _selectedRealm;
int? _postCount;
Future<void> _fetchRealms() async { final List<SnPost> _posts = List.empty(growable: true);
try { SnRealm? _selectedRealm;
final rels = context.read<SnRealmProvider>(); String? _selectedChannel;
final out = await rels.listAvailableRealms(); SnPostCategory? _selectedCategory;
setState(() { int? _postCount;
_realms.addAll(out);
_selectedRealm = out.firstOrNull;
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
rethrow;
}
}
Future<void> _fetchPosts() async { Future<void> _fetchPosts() async {
if (_postCount != null && _posts.length >= _postCount!) return; if (_postCount != null && _posts.length >= _postCount!) return;
@ -416,7 +406,7 @@ class _PostListWidgetState extends State<_PostListWidget> {
take: 10, take: 10,
offset: _posts.length, offset: _posts.length,
categories: _selectedCategory != null ? [_selectedCategory!.alias] : null, categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
channel: widget.channel, channel: _selectedChannel,
realm: _selectedRealm?.alias, realm: _selectedRealm?.alias,
); );
final out = result.$1; final out = result.$1;
@ -429,6 +419,21 @@ class _PostListWidgetState extends State<_PostListWidget> {
if (mounted) setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
} }
void setChannel(String? channel) {
_selectedChannel = channel;
setState(() {});
}
void setRealm(SnRealm? realm) {
_selectedRealm = realm;
setState(() {});
}
void setCategory(SnPostCategory? category) {
_selectedCategory = category;
setState(() {});
}
Future<void> refreshPosts() { Future<void> refreshPosts() {
_postCount = null; _postCount = null;
_posts.clear(); _posts.clear();
@ -438,13 +443,7 @@ class _PostListWidgetState extends State<_PostListWidget> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.withRealm) { _fetchPosts();
_fetchRealms().then((_) {
_fetchPosts();
});
} else {
_fetchPosts();
}
} }
@override @override
@ -467,52 +466,13 @@ class _PostListWidgetState extends State<_PostListWidget> {
IconButton( IconButton(
icon: const Icon(Symbols.clear), icon: const Icon(Symbols.clear),
onPressed: () { onPressed: () {
widget.onClearFilter.call(); setState(() => _selectedCategory = null);
refreshPosts(); refreshPosts();
}, },
), ),
], ],
padding: const EdgeInsets.only(left: 20, right: 4), padding: const EdgeInsets.only(left: 20, right: 4),
), ),
if (widget.withRealm)
DropdownButtonHideUnderline(
child: DropdownButton2<SnRealm>(
isExpanded: true,
items: _realms
.map(
(ele) => DropdownMenuItem<SnRealm>(
value: ele,
child: Row(
children: [
AccountImage(
content: ele.avatar,
fallbackWidget: const Icon(Symbols.group, size: 16),
radius: 14,
),
const Gap(8),
Text(
ele.name,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
)
.toList(),
value: _selectedRealm,
onChanged: (SnRealm? value) {
setState(() => _selectedRealm = value);
refreshPosts();
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(left: 4, right: 12),
),
menuItemStyleData: const MenuItemStyleData(
height: 48,
),
),
),
if (widget.withRealm) const Divider(height: 1),
Expanded( Expanded(
child: MediaQuery.removePadding( child: MediaQuery.removePadding(
context: context, context: context,
@ -521,6 +481,7 @@ class _PostListWidgetState extends State<_PostListWidget> {
displacement: 40 + MediaQuery.of(context).padding.top, displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () => refreshPosts(), onRefresh: () => refreshPosts(),
child: InfiniteList( child: InfiniteList(
padding: EdgeInsets.only(top: 8),
itemCount: _posts.length, itemCount: _posts.length,
isLoading: _isBusy, isLoading: _isBusy,
centerLoading: true, centerLoading: true,
@ -542,18 +503,21 @@ class _PostListWidgetState extends State<_PostListWidget> {
separatorBuilder: (_, __) => const Gap(8), separatorBuilder: (_, __) => const Gap(8),
), ),
), ),
).padding(top: 8), ),
), ),
], ],
); );
} }
} }
class _PostCategoryPickerPopup extends StatelessWidget { class _PostListRealmPopup extends StatelessWidget {
final List<SnPostCategory> categories; final List<SnRealm>? realms;
final SnPostCategory? selected; final Function(SnRealm?) onUpdate;
const _PostCategoryPickerPopup({required this.categories, this.selected}); const _PostListRealmPopup({
required this.realms,
required this.onUpdate,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -563,62 +527,38 @@ class _PostCategoryPickerPopup extends StatelessWidget {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Symbols.category, size: 24), const Icon(Symbols.face, size: 24),
const Gap(16), const Gap(16),
Text('postCategory') Text('accountRealms', style: Theme.of(context).textTheme.titleLarge)
.tr() .tr(),
.textStyle(Theme.of(context).textTheme.titleLarge!),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
ListTile( ListTile(
leading: const Icon(Symbols.clear), leading: const Icon(Symbols.close),
title: Text('postFilterReset').tr(), title: Text('postInGlobal').tr(),
subtitle: Text('postFilterResetDescription').tr(), subtitle: Text('postViewInGlobalDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 20), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () { onTap: () {
Navigator.pop(context, false); onUpdate.call(null);
Navigator.pop(context);
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: GridView.count( child: ListView.builder(
crossAxisCount: 4, itemCount: realms?.length ?? 0,
shrinkWrap: true, itemBuilder: (context, idx) {
physics: const NeverScrollableScrollPhysics(), final realm = realms![idx];
childAspectRatio: 1, return ListTile(
children: categories title: Text(realm.name),
.map( subtitle: Text('@${realm.alias}'),
(ele) => InkWell( leading: AccountImage(content: realm.avatar, radius: 18),
onTap: () { onTap: () {
_selectedCategory = ele; onUpdate.call(realm);
Navigator.pop(context, ele); Navigator.pop(context);
}, },
child: Column( );
crossAxisAlignment: CrossAxisAlignment.center, },
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
kCategoryIcons[ele.alias] ?? Symbols.question_mark,
color: selected == ele
? Theme.of(context).colorScheme.primary
: null,
),
const Gap(4),
Text(
'postCategory${ele.alias.capitalize()}'.trExists()
? 'postCategory${ele.alias.capitalize()}'.tr()
: ele.name,
)
.textStyle(Theme.of(context).textTheme.titleMedium!)
.textColor(selected == ele
? Theme.of(context).colorScheme.primary
: null),
],
),
),
)
.toList(),
), ),
), ),
], ],

View File

@ -546,11 +546,26 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
'+${_todayRecord!.resultExperience} EXP', '+${_todayRecord!.resultExperience} EXP',
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
if (_todayRecord!.resultCoin >= 0) if (_todayRecord!.resultCoin > 0)
Text( Text(
'+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}', '+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
) ),
if (_todayRecord!.currentStreak > 0)
Row(
children: [
const Icon(
Symbols.local_fire_department,
size: 14,
).padding(bottom: 2),
const Gap(4),
Text(
'checkInStreak'
.plural(_todayRecord!.currentStreak),
style: Theme.of(context).textTheme.bodySmall,
),
],
).padding(top: 4),
], ],
), ),
), ),

View File

@ -0,0 +1,88 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostDraftBox extends StatefulWidget {
const PostDraftBox({super.key});
@override
State<PostDraftBox> createState() => _PostDraftBoxState();
}
class _PostDraftBoxState extends State<PostDraftBox> {
bool _isBusy = false;
final List<SnPost> _posts = List.empty(growable: true);
int? _totalCount;
Future<void> _fetchPosts() async {
setState(() => _isBusy = true);
try {
final pt = context.read<SnPostContentProvider>();
final resp = await pt.listPosts(
take: 10,
offset: _posts.length,
isDraft: true,
);
final out = resp.$1;
_totalCount = resp.$2;
if (!mounted) return;
_posts.addAll(out);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text('postDraftBox').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_posts.clear();
return _fetchPosts();
},
child: InfiniteList(
padding: EdgeInsets.only(top: 8),
hasReachedMax:
_totalCount != null && _posts.length >= _totalCount!,
itemCount: _posts.length,
onFetchData: () => _fetchPosts(),
itemBuilder: (context, idx) {
final ele = _posts[idx];
return OpenablePostItem(
data: ele,
onChanged: (data) {
_posts[idx] = data;
},
onDeleted: () {
_posts.clear();
_fetchPosts();
},
);
},
separatorBuilder: (_, __) => const Gap(8),
),
),
),
],
),
);
}
}

View File

@ -18,6 +18,7 @@ import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart'; import 'package:surface/types/realm.dart';
@ -36,7 +37,8 @@ import 'package:provider/provider.dart';
import 'package:surface/widgets/post/post_poll_editor.dart'; import 'package:surface/widgets/post/post_poll_editor.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../../providers/sn_realm.dart'; const kPostTypes = ['Story', 'Article', 'Question', 'Video'];
const kPostTypeAliases = ['stories', 'articles', 'questions', 'videos'];
class PostEditorExtra { class PostEditorExtra {
final String? text; final String? text;
@ -53,7 +55,7 @@ class PostEditorExtra {
} }
class PostEditorScreen extends StatefulWidget { class PostEditorScreen extends StatefulWidget {
final String mode; final String? mode;
final int? postEditId; final int? postEditId;
final int? postReplyId; final int? postReplyId;
final int? postRepostId; final int? postRepostId;
@ -72,7 +74,10 @@ class PostEditorScreen extends StatefulWidget {
State<PostEditorScreen> createState() => _PostEditorScreenState(); State<PostEditorScreen> createState() => _PostEditorScreenState();
} }
class _PostEditorScreenState extends State<PostEditorScreen> { class _PostEditorScreenState extends State<PostEditorScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController =
TabController(length: 4, vsync: this);
late final PostWriteController _writeController = PostWriteController( late final PostWriteController _writeController = PostWriteController(
doLoadFromTemporary: widget.postEditId == null, doLoadFromTemporary: widget.postEditId == null,
); );
@ -133,6 +138,15 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
], ],
scope: HotKeyScope.inapp, scope: HotKeyScope.inapp,
); );
final HotKey _saveDraftHotKey = HotKey(
key: PhysicalKeyboardKey.keyS,
modifiers: [
(!kIsWeb && Platform.isMacOS)
? HotKeyModifier.meta
: HotKeyModifier.control
],
scope: HotKeyScope.inapp,
);
void _registerHotKey() { void _registerHotKey() {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
@ -148,6 +162,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
]); ]);
setState(() {}); setState(() {});
}); });
hotKeyManager.register(_saveDraftHotKey, keyDownHandler: (_) async {
if (mounted) {
_writeController.sendPost(context);
}
});
} }
void _showPublisherPopup() { void _showPublisherPopup() {
@ -209,9 +228,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
@override @override
void dispose() { void dispose() {
_tabController.dispose();
_writeController.dispose(); _writeController.dispose();
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
hotKeyManager.unregister(_pasteHotKey); hotKeyManager.unregister(_pasteHotKey);
hotKeyManager.unregister(_saveDraftHotKey);
} }
super.dispose(); super.dispose();
} }
@ -220,14 +241,16 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_registerHotKey(); _registerHotKey();
if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
context.showErrorDialog('Unknown post type');
Navigator.pop(context);
} else {
_writeController.setMode(widget.mode);
}
_fetchRealms(); _fetchRealms();
_fetchPublishers(); _fetchPublishers();
if (widget.mode != null) {
_writeController.setMode(widget.mode!);
}
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
_writeController.setMode(kPostTypeAliases[_tabController.index]);
}
});
_writeController.fetchRelatedPost( _writeController.fetchRelatedPost(
context, context,
editing: widget.postEditId, editing: widget.postEditId,
@ -255,38 +278,55 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
title: RichText( title: Text(
textAlign: TextAlign.center, _writeController.title.isNotEmpty
text: TextSpan(children: [ ? _writeController.title
TextSpan( : 'untitled'.tr(),
text: _writeController.title.isNotEmpty
? _writeController.title
: 'untitled'.tr(),
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: PostWriteController.kTitleMap[widget.mode]!.tr(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
]),
maxLines: 2,
), ),
actions: [ actions: [
IconButton(
icon: _writeController.editingDraft
? const Icon(Icons.save)
: const Icon(Symbols.save_as),
onPressed: () {
_writeController.sendPost(context, saveAsDraft: true).then(
(_) {
if (!context.mounted) return;
context.showSnackbar('postDraftSaved'.tr());
HapticFeedback.mediumImpact();
},
);
},
),
IconButton( IconButton(
icon: const Icon(Symbols.tune), icon: const Icon(Symbols.tune),
onPressed: _writeController.isBusy ? null : _updateMeta, onPressed: _writeController.isBusy ? null : _updateMeta,
), ),
const Gap(8), const Gap(8),
], ],
bottom: _writeController.isNotEmpty || widget.mode != null
? null
: TabBar(
controller: _tabController,
tabs: [
for (final type in kPostTypes)
Tab(
child: Text(
'postType$type'.tr(),
style: TextStyle(
color: Theme.of(context)
.appBarTheme
.foregroundColor!,
),
),
),
],
),
), ),
body: Column( body: Column(
children: [ children: [
if (_writeController.editingPost != null) if (_writeController.editingPost != null &&
!_writeController.editingDraft)
Container( Container(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: 4, bottom: 4, left: 20, right: 20), top: 4, bottom: 4, left: 20, right: 20),
@ -374,7 +414,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
children: [ children: [
SingleChildScrollView( SingleChildScrollView(
padding: EdgeInsets.only(bottom: 160), padding: EdgeInsets.only(bottom: 160),
child: StyledWidget(switch (_writeController.mode) { child: switch (_writeController.mode) {
'stories' => _PostStoryEditor( 'stories' => _PostStoryEditor(
controller: _writeController, controller: _writeController,
onTapPublisher: _showPublisherPopup, onTapPublisher: _showPublisherPopup,
@ -396,8 +436,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
onTapRealm: _showRealmPopup, onTapRealm: _showRealmPopup,
), ),
_ => const Placeholder(), _ => const Placeholder(),
}) },
.padding(top: 8),
), ),
if (_writeController.attachments.isNotEmpty || if (_writeController.attachments.isNotEmpty ||
_writeController.thumbnail != null) _writeController.thumbnail != null)
@ -720,7 +759,7 @@ class _PostStoryEditor extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640), constraints: const BoxConstraints(maxWidth: 640),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -969,7 +1008,7 @@ class _PostQuestionEditor extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640), constraints: const BoxConstraints(maxWidth: 640),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -1053,7 +1092,7 @@ class _PostQuestionEditor extends StatelessWidget {
), ),
), ),
], ],
).padding(top: 8), ),
); );
} }
} }
@ -1154,7 +1193,7 @@ class _PostVideoEditor extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640), constraints: const BoxConstraints(maxWidth: 640),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -0,0 +1,132 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_card_swiper/flutter_card_swiper.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
class PostShuffleScreen extends StatefulWidget {
const PostShuffleScreen({super.key});
@override
State<PostShuffleScreen> createState() => _PostShuffleScreenState();
}
class _PostShuffleScreenState extends State<PostShuffleScreen> {
late final CardSwiperController _cardController = CardSwiperController();
bool _isBusy = false;
final List<SnPost> _posts = List.empty(growable: true);
Future<void> _fetchPosts() async {
_posts.clear();
setState(() => _isBusy = true);
try {
final pt = context.read<SnPostContentProvider>();
final result = await pt.listPosts(
take: 10,
offset: _posts.length,
isShuffle: true,
);
_posts.addAll(result.$1);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchPosts();
}
@override
void dispose() {
super.dispose();
_cardController.dispose();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text('postShuffle').tr(),
),
body: Stack(
children: [
Column(
children: [
if (_isBusy || _posts.isEmpty)
const Expanded(
child: Center(
child: CircularProgressIndicator(),
),
)
else
Expanded(
child: CardSwiper(
controller: _cardController,
isLoop: false,
padding: EdgeInsets.zero,
cardsCount: _posts.length,
cardBuilder: (context, idx, _, __) {
final ele = _posts[idx];
return SingleChildScrollView(
child: Center(
child: OpenablePostItem(
key: ValueKey(ele),
data: ele,
maxWidth: 640,
onChanged: (ele) {
_posts[idx] = ele;
setState(() {});
},
onDeleted: () {
_fetchPosts();
},
).padding(
all: 24,
bottom:
MediaQuery.of(context).padding.bottom + 16 + 50,
),
),
);
},
onEnd: () {
_fetchPosts();
},
),
),
],
),
if (!_isBusy && _posts.isNotEmpty)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filled(
icon: const Icon(Symbols.next_plan),
color: Theme.of(context).colorScheme.onPrimary,
onPressed: () {
_cardController.swipe(CardSwiperDirection.right);
},
),
],
),
),
],
),
);
}
}

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,26 +51,35 @@ 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(),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postEditor', 'postEditor',
pathParameters: { queryParameters: {
'mode': 'stories', 'mode': 'stories',
}, },
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

@ -284,7 +284,10 @@ class _StickerPackAddPopupState extends State<_StickerPackAddPopup> {
); );
if (!mounted) return; if (!mounted) return;
context.showSnackbar('stickersAdded'.tr()); context.showSnackbar('stickersAdded'.tr());
if (_pack?.stickers != null) stickers.putSticker(_pack!.stickers!); if (_pack?.stickers != null) {
stickers.putSticker(
_pack!.stickers!.map((ele) => ele.copyWith(pack: _pack!)));
}
Navigator.pop(context, true); Navigator.pop(context, true);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;

View File

@ -50,16 +50,17 @@ Future<ThemeData> createAppTheme(
useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true); useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true);
final inUseFonts = (customFonts ?? prefs.getString(kAppCustomFonts)) final inUseFonts = (customFonts ?? prefs.getString(kAppCustomFonts))
?.split(',') ?.split(',')
.map((ele) => ele.trim()) .map((ele) => ele.trim())
.toList(); .toList() ??
['Nunito'];
return ThemeData( return ThemeData(
useMaterial3: useM3, useMaterial3: useM3,
colorScheme: colorScheme, colorScheme: colorScheme,
brightness: brightness, brightness: brightness,
fontFamily: inUseFonts?.firstOrNull, fontFamily: inUseFonts.firstOrNull,
fontFamilyFallback: inUseFonts?.sublist(1), fontFamilyFallback: inUseFonts.sublist(1),
iconTheme: IconThemeData( iconTheme: IconThemeData(
fill: 0, fill: 0,
weight: 400, weight: 400,

View File

@ -119,13 +119,33 @@ abstract class SnAccountStatusInfo with _$SnAccountStatusInfo {
required bool isDisturbable, required bool isDisturbable,
required bool isOnline, required bool isOnline,
required DateTime? lastSeenAt, required DateTime? lastSeenAt,
required dynamic status, required SnAccountStatus? status,
}) = _SnAccountStatusInfo; }) = _SnAccountStatusInfo;
factory SnAccountStatusInfo.fromJson(Map<String, Object?> json) => factory SnAccountStatusInfo.fromJson(Map<String, Object?> json) =>
_$SnAccountStatusInfoFromJson(json); _$SnAccountStatusInfoFromJson(json);
} }
@freezed
abstract class SnAccountStatus with _$SnAccountStatus {
const factory SnAccountStatus({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String type,
required String label,
required int attitude,
required bool isNoDisturb,
required bool isInvisible,
required DateTime? clearAt,
required int accountId,
}) = _SnAccountStatus;
factory SnAccountStatus.fromJson(Map<String, Object?> json) =>
_$SnAccountStatusFromJson(json);
}
@freezed @freezed
abstract class SnAbuseReport with _$SnAbuseReport { abstract class SnAbuseReport with _$SnAbuseReport {
const factory SnAbuseReport({ const factory SnAbuseReport({

View File

@ -2139,7 +2139,7 @@ mixin _$SnAccountStatusInfo {
bool get isDisturbable; bool get isDisturbable;
bool get isOnline; bool get isOnline;
DateTime? get lastSeenAt; DateTime? get lastSeenAt;
dynamic get status; SnAccountStatus? get status;
/// Create a copy of SnAccountStatusInfo /// Create a copy of SnAccountStatusInfo
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -2163,13 +2163,13 @@ mixin _$SnAccountStatusInfo {
other.isOnline == isOnline) && other.isOnline == isOnline) &&
(identical(other.lastSeenAt, lastSeenAt) || (identical(other.lastSeenAt, lastSeenAt) ||
other.lastSeenAt == lastSeenAt) && other.lastSeenAt == lastSeenAt) &&
const DeepCollectionEquality().equals(other.status, status)); (identical(other.status, status) || other.status == status));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType, isDisturbable, isOnline, int get hashCode =>
lastSeenAt, const DeepCollectionEquality().hash(status)); Object.hash(runtimeType, isDisturbable, isOnline, lastSeenAt, status);
@override @override
String toString() { String toString() {
@ -2187,7 +2187,9 @@ abstract mixin class $SnAccountStatusInfoCopyWith<$Res> {
{bool isDisturbable, {bool isDisturbable,
bool isOnline, bool isOnline,
DateTime? lastSeenAt, DateTime? lastSeenAt,
dynamic status}); SnAccountStatus? status});
$SnAccountStatusCopyWith<$Res>? get status;
} }
/// @nodoc /// @nodoc
@ -2224,9 +2226,23 @@ class _$SnAccountStatusInfoCopyWithImpl<$Res>
status: freezed == status status: freezed == status
? _self.status ? _self.status
: status // ignore: cast_nullable_to_non_nullable : status // ignore: cast_nullable_to_non_nullable
as dynamic, as SnAccountStatus?,
)); ));
} }
/// Create a copy of SnAccountStatusInfo
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountStatusCopyWith<$Res>? get status {
if (_self.status == null) {
return null;
}
return $SnAccountStatusCopyWith<$Res>(_self.status!, (value) {
return _then(_self.copyWith(status: value));
});
}
} }
/// @nodoc /// @nodoc
@ -2247,7 +2263,7 @@ class _SnAccountStatusInfo implements SnAccountStatusInfo {
@override @override
final DateTime? lastSeenAt; final DateTime? lastSeenAt;
@override @override
final dynamic status; final SnAccountStatus? status;
/// Create a copy of SnAccountStatusInfo /// Create a copy of SnAccountStatusInfo
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -2276,13 +2292,13 @@ class _SnAccountStatusInfo implements SnAccountStatusInfo {
other.isOnline == isOnline) && other.isOnline == isOnline) &&
(identical(other.lastSeenAt, lastSeenAt) || (identical(other.lastSeenAt, lastSeenAt) ||
other.lastSeenAt == lastSeenAt) && other.lastSeenAt == lastSeenAt) &&
const DeepCollectionEquality().equals(other.status, status)); (identical(other.status, status) || other.status == status));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType, isDisturbable, isOnline, int get hashCode =>
lastSeenAt, const DeepCollectionEquality().hash(status)); Object.hash(runtimeType, isDisturbable, isOnline, lastSeenAt, status);
@override @override
String toString() { String toString() {
@ -2302,7 +2318,10 @@ abstract mixin class _$SnAccountStatusInfoCopyWith<$Res>
{bool isDisturbable, {bool isDisturbable,
bool isOnline, bool isOnline,
DateTime? lastSeenAt, DateTime? lastSeenAt,
dynamic status}); SnAccountStatus? status});
@override
$SnAccountStatusCopyWith<$Res>? get status;
} }
/// @nodoc /// @nodoc
@ -2339,7 +2358,386 @@ class __$SnAccountStatusInfoCopyWithImpl<$Res>
status: freezed == status status: freezed == status
? _self.status ? _self.status
: status // ignore: cast_nullable_to_non_nullable : status // ignore: cast_nullable_to_non_nullable
as dynamic, as SnAccountStatus?,
));
}
/// Create a copy of SnAccountStatusInfo
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountStatusCopyWith<$Res>? get status {
if (_self.status == null) {
return null;
}
return $SnAccountStatusCopyWith<$Res>(_self.status!, (value) {
return _then(_self.copyWith(status: value));
});
}
}
/// @nodoc
mixin _$SnAccountStatus {
int get id;
DateTime get createdAt;
DateTime get updatedAt;
DateTime? get deletedAt;
String get type;
String get label;
int get attitude;
bool get isNoDisturb;
bool get isInvisible;
DateTime? get clearAt;
int get accountId;
/// Create a copy of SnAccountStatus
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnAccountStatusCopyWith<SnAccountStatus> get copyWith =>
_$SnAccountStatusCopyWithImpl<SnAccountStatus>(
this as SnAccountStatus, _$identity);
/// Serializes this SnAccountStatus to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is SnAccountStatus &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.type, type) || other.type == type) &&
(identical(other.label, label) || other.label == label) &&
(identical(other.attitude, attitude) ||
other.attitude == attitude) &&
(identical(other.isNoDisturb, isNoDisturb) ||
other.isNoDisturb == isNoDisturb) &&
(identical(other.isInvisible, isInvisible) ||
other.isInvisible == isInvisible) &&
(identical(other.clearAt, clearAt) || other.clearAt == clearAt) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
type,
label,
attitude,
isNoDisturb,
isInvisible,
clearAt,
accountId);
@override
String toString() {
return 'SnAccountStatus(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, label: $label, attitude: $attitude, isNoDisturb: $isNoDisturb, isInvisible: $isInvisible, clearAt: $clearAt, accountId: $accountId)';
}
}
/// @nodoc
abstract mixin class $SnAccountStatusCopyWith<$Res> {
factory $SnAccountStatusCopyWith(
SnAccountStatus value, $Res Function(SnAccountStatus) _then) =
_$SnAccountStatusCopyWithImpl;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String type,
String label,
int attitude,
bool isNoDisturb,
bool isInvisible,
DateTime? clearAt,
int accountId});
}
/// @nodoc
class _$SnAccountStatusCopyWithImpl<$Res>
implements $SnAccountStatusCopyWith<$Res> {
_$SnAccountStatusCopyWithImpl(this._self, this._then);
final SnAccountStatus _self;
final $Res Function(SnAccountStatus) _then;
/// Create a copy of SnAccountStatus
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? type = null,
Object? label = null,
Object? attitude = null,
Object? isNoDisturb = null,
Object? isInvisible = null,
Object? clearAt = freezed,
Object? accountId = null,
}) {
return _then(_self.copyWith(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as String,
label: null == label
? _self.label
: label // ignore: cast_nullable_to_non_nullable
as String,
attitude: null == attitude
? _self.attitude
: attitude // ignore: cast_nullable_to_non_nullable
as int,
isNoDisturb: null == isNoDisturb
? _self.isNoDisturb
: isNoDisturb // ignore: cast_nullable_to_non_nullable
as bool,
isInvisible: null == isInvisible
? _self.isInvisible
: isInvisible // ignore: cast_nullable_to_non_nullable
as bool,
clearAt: freezed == clearAt
? _self.clearAt
: clearAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
@JsonSerializable()
class _SnAccountStatus implements SnAccountStatus {
const _SnAccountStatus(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.type,
required this.label,
required this.attitude,
required this.isNoDisturb,
required this.isInvisible,
required this.clearAt,
required this.accountId});
factory _SnAccountStatus.fromJson(Map<String, dynamic> json) =>
_$SnAccountStatusFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String type;
@override
final String label;
@override
final int attitude;
@override
final bool isNoDisturb;
@override
final bool isInvisible;
@override
final DateTime? clearAt;
@override
final int accountId;
/// Create a copy of SnAccountStatus
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnAccountStatusCopyWith<_SnAccountStatus> get copyWith =>
__$SnAccountStatusCopyWithImpl<_SnAccountStatus>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnAccountStatusToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _SnAccountStatus &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.type, type) || other.type == type) &&
(identical(other.label, label) || other.label == label) &&
(identical(other.attitude, attitude) ||
other.attitude == attitude) &&
(identical(other.isNoDisturb, isNoDisturb) ||
other.isNoDisturb == isNoDisturb) &&
(identical(other.isInvisible, isInvisible) ||
other.isInvisible == isInvisible) &&
(identical(other.clearAt, clearAt) || other.clearAt == clearAt) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
type,
label,
attitude,
isNoDisturb,
isInvisible,
clearAt,
accountId);
@override
String toString() {
return 'SnAccountStatus(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, label: $label, attitude: $attitude, isNoDisturb: $isNoDisturb, isInvisible: $isInvisible, clearAt: $clearAt, accountId: $accountId)';
}
}
/// @nodoc
abstract mixin class _$SnAccountStatusCopyWith<$Res>
implements $SnAccountStatusCopyWith<$Res> {
factory _$SnAccountStatusCopyWith(
_SnAccountStatus value, $Res Function(_SnAccountStatus) _then) =
__$SnAccountStatusCopyWithImpl;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String type,
String label,
int attitude,
bool isNoDisturb,
bool isInvisible,
DateTime? clearAt,
int accountId});
}
/// @nodoc
class __$SnAccountStatusCopyWithImpl<$Res>
implements _$SnAccountStatusCopyWith<$Res> {
__$SnAccountStatusCopyWithImpl(this._self, this._then);
final _SnAccountStatus _self;
final $Res Function(_SnAccountStatus) _then;
/// Create a copy of SnAccountStatus
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? type = null,
Object? label = null,
Object? attitude = null,
Object? isNoDisturb = null,
Object? isInvisible = null,
Object? clearAt = freezed,
Object? accountId = null,
}) {
return _then(_SnAccountStatus(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as String,
label: null == label
? _self.label
: label // ignore: cast_nullable_to_non_nullable
as String,
attitude: null == attitude
? _self.attitude
: attitude // ignore: cast_nullable_to_non_nullable
as int,
isNoDisturb: null == isNoDisturb
? _self.isNoDisturb
: isNoDisturb // ignore: cast_nullable_to_non_nullable
as bool,
isInvisible: null == isInvisible
? _self.isInvisible
: isInvisible // ignore: cast_nullable_to_non_nullable
as bool,
clearAt: freezed == clearAt
? _self.clearAt
: clearAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
)); ));
} }
} }

View File

@ -210,7 +210,9 @@ _SnAccountStatusInfo _$SnAccountStatusInfoFromJson(Map<String, dynamic> json) =>
lastSeenAt: json['last_seen_at'] == null lastSeenAt: json['last_seen_at'] == null
? null ? null
: DateTime.parse(json['last_seen_at'] as String), : DateTime.parse(json['last_seen_at'] as String),
status: json['status'], status: json['status'] == null
? null
: SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$SnAccountStatusInfoToJson( Map<String, dynamic> _$SnAccountStatusInfoToJson(
@ -219,7 +221,41 @@ Map<String, dynamic> _$SnAccountStatusInfoToJson(
'is_disturbable': instance.isDisturbable, 'is_disturbable': instance.isDisturbable,
'is_online': instance.isOnline, 'is_online': instance.isOnline,
'last_seen_at': instance.lastSeenAt?.toIso8601String(), 'last_seen_at': instance.lastSeenAt?.toIso8601String(),
'status': instance.status, 'status': instance.status?.toJson(),
};
_SnAccountStatus _$SnAccountStatusFromJson(Map<String, dynamic> json) =>
_SnAccountStatus(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
type: json['type'] as String,
label: json['label'] as String,
attitude: (json['attitude'] as num).toInt(),
isNoDisturb: json['is_no_disturb'] as bool,
isInvisible: json['is_invisible'] as bool,
clearAt: json['clear_at'] == null
? null
: DateTime.parse(json['clear_at'] as String),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$SnAccountStatusToJson(_SnAccountStatus instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'type': instance.type,
'label': instance.label,
'attitude': instance.attitude,
'is_no_disturb': instance.isNoDisturb,
'is_invisible': instance.isInvisible,
'clear_at': instance.clearAt?.toIso8601String(),
'account_id': instance.accountId,
}; };
_SnAbuseReport _$SnAbuseReportFromJson(Map<String, dynamic> json) => _SnAbuseReport _$SnAbuseReportFromJson(Map<String, dynamic> json) =>

View File

@ -25,11 +25,13 @@ abstract class SnCheckInRecord with _$SnCheckInRecord {
required int resultTier, required int resultTier,
required int resultExperience, required int resultExperience,
required double resultCoin, required double resultCoin,
@Default(0) int currentStreak,
required List<int> resultModifiers, required List<int> resultModifiers,
required int accountId, required int accountId,
}) = _SnCheckInRecord; }) = _SnCheckInRecord;
factory SnCheckInRecord.fromJson(Map<String, dynamic> json) => _$SnCheckInRecordFromJson(json); factory SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
_$SnCheckInRecordFromJson(json);
String get symbol => kCheckInResultTierSymbols[resultTier]; String get symbol => kCheckInResultTierSymbols[resultTier];
} }

View File

@ -22,6 +22,7 @@ mixin _$SnCheckInRecord {
int get resultTier; int get resultTier;
int get resultExperience; int get resultExperience;
double get resultCoin; double get resultCoin;
int get currentStreak;
List<int> get resultModifiers; List<int> get resultModifiers;
int get accountId; int get accountId;
@ -54,6 +55,8 @@ mixin _$SnCheckInRecord {
other.resultExperience == resultExperience) && other.resultExperience == resultExperience) &&
(identical(other.resultCoin, resultCoin) || (identical(other.resultCoin, resultCoin) ||
other.resultCoin == resultCoin) && other.resultCoin == resultCoin) &&
(identical(other.currentStreak, currentStreak) ||
other.currentStreak == currentStreak) &&
const DeepCollectionEquality() const DeepCollectionEquality()
.equals(other.resultModifiers, resultModifiers) && .equals(other.resultModifiers, resultModifiers) &&
(identical(other.accountId, accountId) || (identical(other.accountId, accountId) ||
@ -71,12 +74,13 @@ mixin _$SnCheckInRecord {
resultTier, resultTier,
resultExperience, resultExperience,
resultCoin, resultCoin,
currentStreak,
const DeepCollectionEquality().hash(resultModifiers), const DeepCollectionEquality().hash(resultModifiers),
accountId); accountId);
@override @override
String toString() { String toString() {
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)'; return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, currentStreak: $currentStreak, resultModifiers: $resultModifiers, accountId: $accountId)';
} }
} }
@ -94,6 +98,7 @@ abstract mixin class $SnCheckInRecordCopyWith<$Res> {
int resultTier, int resultTier,
int resultExperience, int resultExperience,
double resultCoin, double resultCoin,
int currentStreak,
List<int> resultModifiers, List<int> resultModifiers,
int accountId}); int accountId});
} }
@ -118,6 +123,7 @@ class _$SnCheckInRecordCopyWithImpl<$Res>
Object? resultTier = null, Object? resultTier = null,
Object? resultExperience = null, Object? resultExperience = null,
Object? resultCoin = null, Object? resultCoin = null,
Object? currentStreak = null,
Object? resultModifiers = null, Object? resultModifiers = null,
Object? accountId = null, Object? accountId = null,
}) { }) {
@ -150,6 +156,10 @@ class _$SnCheckInRecordCopyWithImpl<$Res>
? _self.resultCoin ? _self.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable : resultCoin // ignore: cast_nullable_to_non_nullable
as double, as double,
currentStreak: null == currentStreak
? _self.currentStreak
: currentStreak // ignore: cast_nullable_to_non_nullable
as int,
resultModifiers: null == resultModifiers resultModifiers: null == resultModifiers
? _self.resultModifiers ? _self.resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable : resultModifiers // ignore: cast_nullable_to_non_nullable
@ -173,6 +183,7 @@ class _SnCheckInRecord extends SnCheckInRecord {
required this.resultTier, required this.resultTier,
required this.resultExperience, required this.resultExperience,
required this.resultCoin, required this.resultCoin,
this.currentStreak = 0,
required final List<int> resultModifiers, required final List<int> resultModifiers,
required this.accountId}) required this.accountId})
: _resultModifiers = resultModifiers, : _resultModifiers = resultModifiers,
@ -194,6 +205,9 @@ class _SnCheckInRecord extends SnCheckInRecord {
final int resultExperience; final int resultExperience;
@override @override
final double resultCoin; final double resultCoin;
@override
@JsonKey()
final int currentStreak;
final List<int> _resultModifiers; final List<int> _resultModifiers;
@override @override
List<int> get resultModifiers { List<int> get resultModifiers {
@ -238,6 +252,8 @@ class _SnCheckInRecord extends SnCheckInRecord {
other.resultExperience == resultExperience) && other.resultExperience == resultExperience) &&
(identical(other.resultCoin, resultCoin) || (identical(other.resultCoin, resultCoin) ||
other.resultCoin == resultCoin) && other.resultCoin == resultCoin) &&
(identical(other.currentStreak, currentStreak) ||
other.currentStreak == currentStreak) &&
const DeepCollectionEquality() const DeepCollectionEquality()
.equals(other._resultModifiers, _resultModifiers) && .equals(other._resultModifiers, _resultModifiers) &&
(identical(other.accountId, accountId) || (identical(other.accountId, accountId) ||
@ -255,12 +271,13 @@ class _SnCheckInRecord extends SnCheckInRecord {
resultTier, resultTier,
resultExperience, resultExperience,
resultCoin, resultCoin,
currentStreak,
const DeepCollectionEquality().hash(_resultModifiers), const DeepCollectionEquality().hash(_resultModifiers),
accountId); accountId);
@override @override
String toString() { String toString() {
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)'; return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, currentStreak: $currentStreak, resultModifiers: $resultModifiers, accountId: $accountId)';
} }
} }
@ -280,6 +297,7 @@ abstract mixin class _$SnCheckInRecordCopyWith<$Res>
int resultTier, int resultTier,
int resultExperience, int resultExperience,
double resultCoin, double resultCoin,
int currentStreak,
List<int> resultModifiers, List<int> resultModifiers,
int accountId}); int accountId});
} }
@ -304,6 +322,7 @@ class __$SnCheckInRecordCopyWithImpl<$Res>
Object? resultTier = null, Object? resultTier = null,
Object? resultExperience = null, Object? resultExperience = null,
Object? resultCoin = null, Object? resultCoin = null,
Object? currentStreak = null,
Object? resultModifiers = null, Object? resultModifiers = null,
Object? accountId = null, Object? accountId = null,
}) { }) {
@ -336,6 +355,10 @@ class __$SnCheckInRecordCopyWithImpl<$Res>
? _self.resultCoin ? _self.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable : resultCoin // ignore: cast_nullable_to_non_nullable
as double, as double,
currentStreak: null == currentStreak
? _self.currentStreak
: currentStreak // ignore: cast_nullable_to_non_nullable
as int,
resultModifiers: null == resultModifiers resultModifiers: null == resultModifiers
? _self._resultModifiers ? _self._resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable : resultModifiers // ignore: cast_nullable_to_non_nullable

View File

@ -17,6 +17,7 @@ _SnCheckInRecord _$SnCheckInRecordFromJson(Map<String, dynamic> json) =>
resultTier: (json['result_tier'] as num).toInt(), resultTier: (json['result_tier'] as num).toInt(),
resultExperience: (json['result_experience'] as num).toInt(), resultExperience: (json['result_experience'] as num).toInt(),
resultCoin: (json['result_coin'] as num).toDouble(), resultCoin: (json['result_coin'] as num).toDouble(),
currentStreak: (json['current_streak'] as num?)?.toInt() ?? 0,
resultModifiers: (json['result_modifiers'] as List<dynamic>) resultModifiers: (json['result_modifiers'] as List<dynamic>)
.map((e) => (e as num).toInt()) .map((e) => (e as num).toInt())
.toList(), .toList(),
@ -32,6 +33,7 @@ Map<String, dynamic> _$SnCheckInRecordToJson(_SnCheckInRecord instance) =>
'result_tier': instance.resultTier, 'result_tier': instance.resultTier,
'result_experience': instance.resultExperience, 'result_experience': instance.resultExperience,
'result_coin': instance.resultCoin, 'result_coin': instance.resultCoin,
'current_streak': instance.currentStreak,
'result_modifiers': instance.resultModifiers, 'result_modifiers': instance.resultModifiers,
'account_id': instance.accountId, 'account_id': instance.accountId,
}; };

View File

@ -13,6 +13,7 @@ class AccountImage extends StatelessWidget {
final double? borderRadius; final double? borderRadius;
final Widget? fallbackWidget; final Widget? fallbackWidget;
final Widget? badge; final Widget? badge;
final Offset? badgeOffset;
const AccountImage({ const AccountImage({
super.key, super.key,
@ -23,6 +24,7 @@ class AccountImage extends StatelessWidget {
this.borderRadius, this.borderRadius,
this.fallbackWidget, this.fallbackWidget,
this.badge, this.badge,
this.badgeOffset,
}); });
@override @override
@ -40,7 +42,8 @@ class AccountImage extends StatelessWidget {
borderRadius: BorderRadius.circular(borderRadius ?? radius ?? 20), borderRadius: BorderRadius.circular(borderRadius ?? radius ?? 20),
child: (content?.isEmpty ?? true) child: (content?.isEmpty ?? true)
? Container( ? Container(
color: backgroundColor ?? Theme.of(context).colorScheme.primaryContainer, color: backgroundColor ??
Theme.of(context).colorScheme.primaryContainer,
child: (fallbackWidget ?? child: (fallbackWidget ??
Icon( Icon(
Symbols.account_circle, Symbols.account_circle,
@ -58,8 +61,8 @@ class AccountImage extends StatelessWidget {
), ),
if (badge != null) if (badge != null)
Positioned( Positioned(
right: -4, right: badgeOffset?.dx ?? -4,
bottom: -2, bottom: badgeOffset?.dy ?? -2,
child: badge!, child: badge!,
), ),
], ],

View File

@ -8,9 +8,9 @@ import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/experience.dart'; import 'package:surface/providers/experience.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/types/account.dart'; import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
class AccountPopoverCard extends StatelessWidget { class AccountPopoverCard extends StatelessWidget {
@ -72,37 +72,21 @@ class AccountPopoverCard extends StatelessWidget {
const Gap(8) const Gap(8)
], ],
).padding(horizontal: 16), ).padding(horizontal: 16),
if (data.badges.isNotEmpty) const Gap(12),
if (data.badges.isNotEmpty) if (data.badges.isNotEmpty)
Wrap( Wrap(
spacing: 4, spacing: 4,
children: data.badges children: data.badges
.map( .map(
(ele) => Tooltip( (ele) => AccountBadge(badge: ele),
richMessage: TextSpan(
children: [
TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
if (ele.metadata['title'] != null)
TextSpan(
text: '\n${ele.metadata['title']}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: '\n'),
TextSpan(
text: DateFormat.yMEd().format(ele.createdAt),
),
],
),
child: Icon(
kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
color: kBadgesMeta[ele.type]?.$3,
fill: 1,
),
),
) )
.toList(), .toList(),
).padding(horizontal: 24), ).padding(horizontal: 24, bottom: 12, top: 12),
const Gap(8), if (data.profile?.description.isNotEmpty ?? false)
Text(
data.profile?.description ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
).padding(horizontal: 26, bottom: 8),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@ -110,7 +94,9 @@ class AccountPopoverCard extends StatelessWidget {
const Gap(8), const Gap(8),
Text('Lv${getLevelFromExp(data.profile?.experience ?? 0)}'), Text('Lv${getLevelFromExp(data.profile?.experience ?? 0)}'),
const Gap(8), const Gap(8),
Text(calcLevelUpProgressLevel(data.profile?.experience ?? 0)).fontSize(11).opacity(0.5), Text(calcLevelUpProgressLevel(data.profile?.experience ?? 0))
.fontSize(11)
.opacity(0.5),
const Gap(8), const Gap(8),
Container( Container(
width: double.infinity, width: double.infinity,
@ -126,25 +112,36 @@ class AccountPopoverCard extends StatelessWidget {
FutureBuilder( FutureBuilder(
future: sn.client.get('/cgi/id/users/${data.name}/status'), future: sn.client.get('/cgi/id/users/${data.name}/status'),
builder: (context, snapshot) { builder: (context, snapshot) {
final SnAccountStatusInfo? status = final SnAccountStatusInfo? status = snapshot.hasData
snapshot.hasData ? SnAccountStatusInfo.fromJson(snapshot.data!.data) : null; ? SnAccountStatusInfo.fromJson(snapshot.data!.data)
: null;
return Row( return Row(
children: [ children: [
Icon( Icon(
Symbols.circle, (status?.isDisturbable ?? true)
fill: 1, ? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (status?.isOnline ?? false) ? 1 : 0,
size: 16, size: 16,
color: (status?.isOnline ?? false) ? Colors.green : Colors.grey, color: (status?.isOnline ?? false)
? (status?.isDisturbable ?? true)
? Colors.green
: Colors.red
: Colors.grey,
).padding(all: 4), ).padding(all: 4),
const Gap(8), const Gap(8),
Text( Text(
status != null status != null
? status.isOnline ? (status.status?.label.isNotEmpty ?? false)
? 'accountStatusOnline'.tr() ? status.status!.label
: 'accountStatusOffline'.tr() : status.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(), : 'loading'.tr(),
), ),
if (status != null && !status.isOnline && status.lastSeenAt != null) if (status != null &&
!status.isOnline &&
status.lastSeenAt != null)
Text( Text(
'accountStatusLastSeen'.tr(args: [ 'accountStatusLastSeen'.tr(args: [
status.lastSeenAt != null status.lastSeenAt != null

View File

@ -0,0 +1,391 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
final Map<String, (Widget, String, String?)> kPresetStatus = {
'online': (
const Icon(Symbols.circle, color: Colors.green, fill: 1),
'accountStatusOnline'.tr(),
null,
),
'silent': (
const Icon(Symbols.do_not_disturb_on, color: Colors.red),
'accountStatusSilent'.tr(),
'accountStatusSilentDesc'.tr(),
),
'invisible': (
const Icon(Symbols.circle, color: Colors.grey),
'accountStatusInvisible'.tr(),
'accountStatusInvisibleDesc'.tr(),
),
};
class AccountStatusActionPopup extends StatefulWidget {
final SnAccountStatusInfo? currentStatus;
const AccountStatusActionPopup({super.key, this.currentStatus});
@override
State<AccountStatusActionPopup> createState() =>
_AccountStatusActionPopupState();
}
class _AccountStatusActionPopupState extends State<AccountStatusActionPopup> {
bool _isBusy = false;
Future<void> setStatus(
String type,
String? label,
int attitude, {
bool isUpdate = false,
bool isSilent = false,
bool isInvisible = false,
DateTime? clearAt,
}) async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final payload = {
'type': type,
'label': label,
'attitude': attitude,
'is_no_disturb': isSilent,
'is_invisible': isInvisible,
'clear_at': clearAt?.toUtc().toIso8601String()
};
try {
await sn.client.request(
'/cgi/id/users/me/status',
data: payload,
options: Options(method: isUpdate ? 'PUT' : 'POST'),
);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _clearStatus() async {
if (_isBusy) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/id/users/me/status');
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.mood, size: 24),
const Gap(16),
Text('accountChangeStatus',
style: Theme.of(context).textTheme.titleLarge)
.tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
LoadingIndicator(isActive: _isBusy),
SizedBox(
height: 48,
child: ListView(
padding: EdgeInsets.symmetric(horizontal: 18),
scrollDirection: Axis.horizontal,
children: kPresetStatus.entries
.map(
(x) => StyledWidget(ActionChip(
avatar: x.value.$1,
label: Text(x.value.$2),
tooltip: x.value.$3,
onPressed: _isBusy
? null
: () {
setStatus(
x.key,
x.value.$2,
0,
isInvisible: x.key == 'invisible',
isSilent: x.key == 'silent',
);
},
)).padding(right: 6),
)
.toList(),
),
),
const Gap(16),
const Divider(thickness: 0.3, height: 0.3),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: widget.currentStatus != null
? const Icon(Icons.edit)
: const Icon(Icons.add),
title: Text('accountCustomStatus').tr(),
subtitle: Text('accountCustomStatusDescription').tr(),
onTap: _isBusy
? null
: () async {
final val = await showDialog(
context: context,
builder: (context) => _AccountStatusEditorDialog(
currentStatus: widget.currentStatus,
),
);
if (val == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
if (widget.currentStatus != null)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.clear),
title: Text('accountClearStatus').tr(),
subtitle: Text('accountClearStatusDescription').tr(),
onTap: _isBusy
? null
: () {
_clearStatus();
},
),
],
);
}
}
class _AccountStatusEditorDialog extends StatefulWidget {
final SnAccountStatusInfo? currentStatus;
const _AccountStatusEditorDialog({this.currentStatus});
@override
State<_AccountStatusEditorDialog> createState() =>
_AccountStatusEditorDialogState();
}
class _AccountStatusEditorDialogState
extends State<_AccountStatusEditorDialog> {
bool _isBusy = false;
final TextEditingController _labelController = TextEditingController();
final TextEditingController _clearAtController = TextEditingController();
int _attitude = 0;
bool _isSilent = false;
bool _isInvisible = false;
DateTime? _clearAt;
Future<void> _selectClearAt() async {
final DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: _clearAt?.toLocal() ?? DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (pickedDate == null) return;
if (!mounted) return;
final TimeOfDay? pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (pickedTime == null) return;
if (!mounted) return;
final picked = pickedDate.copyWith(
hour: pickedTime.hour,
minute: pickedTime.minute,
);
setState(() {
_clearAt = picked;
_clearAtController.text = DateFormat('y/M/d HH:mm').format(_clearAt!);
});
}
Future<void> _applyStatus() async {
if (_isBusy) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.request(
'/cgi/id/users/me/status',
data: {
'type': 'custom',
'label': _labelController.text,
'attitude': _attitude,
'is_no_disturb': _isSilent,
'is_invisible': _isInvisible,
'clear_at': _clearAt?.toUtc().toIso8601String(),
},
options: Options(
method: widget.currentStatus?.status != null ? 'PUT' : 'POST',
),
);
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
void _syncWidget() {
if (widget.currentStatus?.status != null) {
_clearAt = widget.currentStatus!.status!.clearAt;
if (_clearAt != null) {
_clearAtController.text = DateFormat('y/M/d HH:mm').format(_clearAt!);
}
_labelController.text = widget.currentStatus!.status!.label;
_attitude = widget.currentStatus!.status!.attitude;
_isInvisible = widget.currentStatus!.status!.isInvisible;
_isSilent = widget.currentStatus!.status!.isNoDisturb;
}
}
@override
void initState() {
_syncWidget();
super.initState();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('accountCustomStatus').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
LoadingIndicator(isActive: _isBusy),
TextField(
controller: _labelController,
decoration: InputDecoration(
isDense: true,
prefixIcon: const Icon(Icons.label),
border: const OutlineInputBorder(),
labelText: 'fieldAccountStatusLabel'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(8),
TextField(
controller: _clearAtController,
readOnly: true,
decoration: InputDecoration(
isDense: true,
prefixIcon: const Icon(Icons.event_busy),
border: const OutlineInputBorder(),
labelText: 'fieldAccountStatusClearAt'.tr(),
),
onTap: () => _selectClearAt(),
),
const Gap(8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: 6,
runSpacing: 0,
children: [
ChoiceChip(
avatar: Icon(
Symbols.radio_button_unchecked,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
selected: _attitude == 2,
label: Text('accountStatusNegative'.tr()),
onSelected: (val) {
if (val) setState(() => _attitude = 2);
},
),
ChoiceChip(
avatar: Icon(
Symbols.contrast,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
selected: _attitude == 0,
label: Text('accountStatusNeutral'.tr()),
onSelected: (val) {
if (val) setState(() => _attitude = 0);
},
),
ChoiceChip(
avatar: Icon(
Symbols.circle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
selected: _attitude == 1,
label: Text('accountStatusPositive'.tr()),
onSelected: (val) {
if (val) setState(() => _attitude = 1);
},
),
],
),
),
const Gap(4),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: 6,
runSpacing: 0,
children: [
ChoiceChip(
selected: _isSilent,
label: Text('accountStatusSilent').tr(),
onSelected: (val) {
setState(() => _isSilent = val);
},
),
ChoiceChip(
selected: _isInvisible,
label: Text('accountStatusInvisible').tr(),
onSelected: (val) {
setState(() => _isInvisible = val);
},
),
],
),
),
],
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
onPressed: _isBusy ? null : () => Navigator.pop(context),
child: Text('dialogCancel').tr(),
),
TextButton(
onPressed: _isBusy ? null : () => _applyStatus(),
child: Text('dialogConfirm').tr(),
),
],
);
}
}

View File

@ -0,0 +1,50 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:surface/screens/account/profile_page.dart' show kBadgesMeta;
import 'package:surface/types/account.dart';
class AccountBadge extends StatelessWidget {
final SnAccountBadge badge;
final double radius;
final EdgeInsets? padding;
const AccountBadge({
super.key,
required this.badge,
this.radius = 20,
this.padding,
});
@override
Widget build(BuildContext context) {
return Tooltip(
richMessage: TextSpan(
children: [
TextSpan(text: kBadgesMeta[badge.type]?.$1.tr() ?? 'unknown'.tr()),
if (badge.metadata['title'] != null)
TextSpan(
text: '\n${badge.metadata['title']}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: '\n'),
TextSpan(
text: DateFormat.yMEd().format(badge.createdAt),
),
],
),
child: Container(
padding: padding ?? EdgeInsets.all(3),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
color: kBadgesMeta[badge.type]?.$3,
),
child: Icon(
kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
color: Colors.white,
fill: 1,
size: radius - 4,
),
),
);
}
}

View File

@ -22,12 +22,14 @@ class AttachmentItem extends StatelessWidget {
final SnAttachment? data; final SnAttachment? data;
final String? heroTag; final String? heroTag;
final BoxFit fit; final BoxFit fit;
final FilterQuality? filterQuality;
const AttachmentItem({ const AttachmentItem({
super.key, super.key,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
required this.data, required this.data,
required this.heroTag, required this.heroTag,
this.filterQuality,
}); });
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
@ -47,6 +49,7 @@ class AttachmentItem extends StatelessWidget {
sn.getAttachmentUrl(data!.rid), sn.getAttachmentUrl(data!.rid),
key: Key('attachment-${data!.rid}-$tag'), key: Key('attachment-${data!.rid}-$tag'),
fit: fit, fit: fit,
filterQuality: filterQuality,
), ),
); );
case 'video': case 'video':
@ -83,13 +86,16 @@ class _AttachmentItemSensitiveBlur extends StatefulWidget {
final Widget child; final Widget child;
final bool isCompact; final bool isCompact;
const _AttachmentItemSensitiveBlur({required this.child, this.isCompact = false}); const _AttachmentItemSensitiveBlur(
{required this.child, this.isCompact = false});
@override @override
State<_AttachmentItemSensitiveBlur> createState() => _AttachmentItemSensitiveBlurState(); State<_AttachmentItemSensitiveBlur> createState() =>
_AttachmentItemSensitiveBlurState();
} }
class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBlur> { class _AttachmentItemSensitiveBlurState
extends State<_AttachmentItemSensitiveBlur> {
bool _doesShow = false; bool _doesShow = false;
@override @override
@ -124,10 +130,15 @@ class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBl
Text( Text(
'sensitiveContentDescription', 'sensitiveContentDescription',
textAlign: TextAlign.center, textAlign: TextAlign.center,
).tr().fontSize(14).textColor(Colors.white.withOpacity(0.8)), )
.tr()
.fontSize(14)
.textColor(Colors.white.withOpacity(0.8)),
if (!widget.isCompact) const Gap(16), if (!widget.isCompact) const Gap(16),
InkWell( InkWell(
child: Text('sensitiveContentReveal').tr().textColor(Colors.white), child: Text('sensitiveContentReveal')
.tr()
.textColor(Colors.white),
onTap: () { onTap: () {
setState(() => _doesShow = !_doesShow); setState(() => _doesShow = !_doesShow);
}, },
@ -137,7 +148,9 @@ class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBl
).center(), ).center(),
), ),
), ),
).opacity(_doesShow ? 0 : 1, animate: true).animate(const Duration(milliseconds: 300), Curves.easeInOut), )
.opacity(_doesShow ? 0 : 1, animate: true)
.animate(const Duration(milliseconds: 300), Curves.easeInOut),
if (_doesShow) if (_doesShow)
Positioned( Positioned(
top: 0, top: 0,
@ -174,10 +187,12 @@ class _AttachmentItemContentVideo extends StatefulWidget {
}); });
@override @override
State<_AttachmentItemContentVideo> createState() => _AttachmentItemContentVideoState(); State<_AttachmentItemContentVideo> createState() =>
_AttachmentItemContentVideoState();
} }
class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo> { class _AttachmentItemContentVideoState
extends State<_AttachmentItemContentVideo> {
bool _showContent = false; bool _showContent = false;
bool _showOriginal = false; bool _showOriginal = false;
@ -188,7 +203,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
setState(() => _showContent = true); setState(() => _showContent = true);
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final url = _showOriginal ? sn.getAttachmentUrl(widget.data.rid) : sn.getAttachmentUrl(widget.data.compressed!.rid); final url = _showOriginal
? sn.getAttachmentUrl(widget.data.rid)
: sn.getAttachmentUrl(widget.data.compressed!.rid);
_videoPlayer = Player(); _videoPlayer = Player();
_videoController = VideoController(_videoPlayer!); _videoController = VideoController(_videoPlayer!);
_videoPlayer!.open(Media(url), play: !widget.isAutoload); _videoPlayer!.open(Media(url), play: !widget.isAutoload);
@ -201,7 +218,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
_videoPlayer?.open( _videoPlayer?.open(
Media( Media(
_showOriginal ? sn.getAttachmentUrl(widget.data.rid) : sn.getAttachmentUrl(widget.data.compressed!.rid), _showOriginal
? sn.getAttachmentUrl(widget.data.rid)
: sn.getAttachmentUrl(widget.data.compressed!.rid),
), ),
play: true, play: true,
); );
@ -283,7 +302,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
), ),
Text( Text(
Duration( Duration(
milliseconds: (widget.data.data['duration'] ?? 0).toInt() * 1000, milliseconds:
(widget.data.data['duration'] ?? 0).toInt() *
1000,
).toString(), ).toString(),
style: GoogleFonts.robotoMono( style: GoogleFonts.robotoMono(
fontSize: 12, fontSize: 12,
@ -346,7 +367,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
MaterialDesktopCustomButton( MaterialDesktopCustomButton(
iconSize: 24, iconSize: 24,
onPressed: _toggleOriginal, onPressed: _toggleOriginal,
icon: _showOriginal ? const Icon(Symbols.high_quality, size: 24) : const Icon(Symbols.sd, size: 24), icon: _showOriginal
? const Icon(Symbols.high_quality, size: 24)
: const Icon(Symbols.sd, size: 24),
), ),
], ],
), ),
@ -354,8 +377,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
child: Video( child: Video(
controller: _videoController!, controller: _videoController!,
aspectRatio: ratio, aspectRatio: ratio,
controls: controls: !kIsWeb && (Platform.isAndroid || Platform.isIOS)
!kIsWeb && (Platform.isAndroid || Platform.isIOS) ? MaterialVideoControls : MaterialDesktopVideoControls, ? MaterialVideoControls
: MaterialDesktopVideoControls,
), ),
), ),
); );
@ -378,10 +402,12 @@ class _AttachmentItemContentAudio extends StatefulWidget {
}); });
@override @override
State<_AttachmentItemContentAudio> createState() => _AttachmentItemContentAudioState(); State<_AttachmentItemContentAudio> createState() =>
_AttachmentItemContentAudioState();
} }
class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio> { class _AttachmentItemContentAudioState
extends State<_AttachmentItemContentAudio> {
bool _showContent = false; bool _showContent = false;
double? _draggingValue; double? _draggingValue;
@ -552,8 +578,12 @@ class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio
overlayShape: SliderComponentShape.noOverlay, overlayShape: SliderComponentShape.noOverlay,
), ),
child: Slider( child: Slider(
secondaryTrackValue: _bufferedPosition.inMilliseconds.abs().toDouble(), secondaryTrackValue: _bufferedPosition
value: _draggingValue?.abs() ?? _position.inMilliseconds.toDouble().abs(), .inMilliseconds
.abs()
.toDouble(),
value: _draggingValue?.abs() ??
_position.inMilliseconds.toDouble().abs(),
min: 0, min: 0,
max: math max: math
.max( .max(
@ -593,7 +623,9 @@ class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio
), ),
const Gap(16), const Gap(16),
IconButton.filled( IconButton.filled(
icon: _isPlaying ? const Icon(Symbols.pause) : const Icon(Symbols.play_arrow), icon: _isPlaying
? const Icon(Symbols.pause)
: const Icon(Symbols.play_arrow),
onPressed: () { onPressed: () {
_audioPlayer!.playOrPause(); _audioPlayer!.playOrPause();
}, },

View File

@ -21,6 +21,7 @@ class AttachmentList extends StatefulWidget {
final double? minWidth; final double? minWidth;
final double? maxWidth; final double? maxWidth;
final EdgeInsets? padding; final EdgeInsets? padding;
final FilterQuality? filterQuality;
const AttachmentList({ const AttachmentList({
super.key, super.key,
@ -33,23 +34,27 @@ class AttachmentList extends StatefulWidget {
this.minWidth, this.minWidth,
this.maxWidth, this.maxWidth,
this.padding, this.padding,
this.filterQuality,
}); });
static const BorderRadius kDefaultRadius = BorderRadius.all(Radius.circular(8)); static const BorderRadius kDefaultRadius =
BorderRadius.all(Radius.circular(8));
@override @override
State<AttachmentList> createState() => _AttachmentListState(); State<AttachmentList> createState() => _AttachmentListState();
} }
class _AttachmentListState extends State<AttachmentList> { class _AttachmentListState extends State<AttachmentList> {
late final List<String> heroTags = List.generate(widget.data.length, (_) => const Uuid().v4()); late final List<String> heroTags =
List.generate(widget.data.length, (_) => const Uuid().v4());
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, layoutConstraints) { builder: (context, layoutConstraints) {
final borderSide = final borderSide = widget.bordered
widget.bordered ? BorderSide(width: 1, color: Theme.of(context).dividerColor) : BorderSide.none; ? BorderSide(width: 1, color: Theme.of(context).dividerColor)
: BorderSide.none;
final backgroundColor = Theme.of(context).colorScheme.surfaceContainer; final backgroundColor = Theme.of(context).colorScheme.surfaceContainer;
final constraints = BoxConstraints( final constraints = BoxConstraints(
minWidth: widget.minWidth ?? 80, minWidth: widget.minWidth ?? 80,
@ -58,13 +63,13 @@ class _AttachmentListState extends State<AttachmentList> {
if (widget.data.isEmpty) return const SizedBox.shrink(); if (widget.data.isEmpty) return const SizedBox.shrink();
if (widget.data.length == 1) { if (widget.data.length == 1) {
final singleAspectRatio = final singleAspectRatio = widget.data[0]?.data['ratio']?.toDouble() ??
widget.data[0]?.data['ratio']?.toDouble() ??
switch (widget.data[0]?.mimetype.split('/').firstOrNull) { switch (widget.data[0]?.mimetype.split('/').firstOrNull) {
'audio' => 16 / 9, 'audio' => 16 / 9,
'video' => 16 / 9, 'video' => 16 / 9,
_ => 1, _ => 1,
}.toDouble(); }
.toDouble();
return Container( return Container(
padding: widget.padding ?? EdgeInsets.zero, padding: widget.padding ?? EdgeInsets.zero,
@ -80,12 +85,18 @@ class _AttachmentListState extends State<AttachmentList> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: AttachmentList.kDefaultRadius, borderRadius: AttachmentList.kDefaultRadius,
child: AttachmentItem(data: widget.data[0], heroTag: heroTags[0], fit: widget.fit), child: AttachmentItem(
data: widget.data[0],
heroTag: heroTags[0],
fit: widget.fit,
filterQuality: widget.filterQuality,
),
), ),
), ),
), ),
onTap: () { onTap: () {
if (widget.data.firstOrNull?.mediaType != SnMediaType.image) return; if (widget.data.firstOrNull?.mediaType != SnMediaType.image)
return;
context.pushTransparentRoute( context.pushTransparentRoute(
AttachmentZoomView( AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(), data: widget.data.where((ele) => ele != null).cast(),
@ -100,8 +111,10 @@ class _AttachmentListState extends State<AttachmentList> {
); );
} }
final fullOfImage = final fullOfImage = widget.data
widget.data.where((ele) => ele?.mediaType == SnMediaType.image).length == widget.data.length; .where((ele) => ele?.mediaType == SnMediaType.image)
.length ==
widget.data.length;
if (widget.gridded && fullOfImage) { if (widget.gridded && fullOfImage) {
return Container( return Container(
@ -117,29 +130,38 @@ class _AttachmentListState extends State<AttachmentList> {
crossAxisCount: math.min(widget.data.length, 2), crossAxisCount: math.min(widget.data.length, 2),
crossAxisSpacing: 4, crossAxisSpacing: 4,
mainAxisSpacing: 4, mainAxisSpacing: 4,
children: children: widget.data
widget.data .mapIndexed(
.mapIndexed( (idx, ele) => GestureDetector(
(idx, ele) => GestureDetector( child: Container(
child: Container( constraints: constraints,
constraints: constraints, child: AttachmentItem(
child: AttachmentItem(data: ele, heroTag: heroTags[idx], fit: BoxFit.cover), data: ele,
), heroTag: heroTags[idx],
onTap: () { fit: BoxFit.cover,
if (widget.data[idx]!.mediaType != SnMediaType.image) return; filterQuality: widget.filterQuality,
context.pushTransparentRoute(
AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(),
initialIndex: idx,
heroTags: heroTags,
),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
},
), ),
) ),
.toList(), onTap: () {
if (widget.data[idx]!.mediaType !=
SnMediaType.image) {
return;
}
context.pushTransparentRoute(
AttachmentZoomView(
data: widget.data
.where((ele) => ele != null)
.cast(),
initialIndex: idx,
heroTags: heroTags,
),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
},
),
)
.toList(),
), ),
), ),
); );
@ -156,22 +178,26 @@ class _AttachmentListState extends State<AttachmentList> {
child: ClipRRect( child: ClipRRect(
borderRadius: AttachmentList.kDefaultRadius, borderRadius: AttachmentList.kDefaultRadius,
child: Column( child: Column(
children: children: widget.data
widget.data .mapIndexed(
.mapIndexed( (idx, ele) => GestureDetector(
(idx, ele) => GestureDetector( child: AspectRatio(
child: AspectRatio( aspectRatio: ele?.data['ratio']?.toDouble() ?? 1,
aspectRatio: ele?.data['ratio']?.toDouble() ?? 1, child: Container(
child: Container( constraints: constraints,
constraints: constraints, child: AttachmentItem(
child: AttachmentItem(data: ele, heroTag: heroTags[idx], fit: BoxFit.cover), data: ele,
), heroTag: heroTags[idx],
fit: BoxFit.cover,
filterQuality: widget.filterQuality,
), ),
), ),
) ),
.expand((ele) => [ele, const Divider(height: 1)]) ),
.toList() )
..removeLast(), .expand((ele) => [ele, const Divider(height: 1)])
.toList()
..removeLast(),
), ),
), ),
); );
@ -179,6 +205,7 @@ class _AttachmentListState extends State<AttachmentList> {
return Container( return Container(
constraints: BoxConstraints(maxHeight: constraints.maxHeight), constraints: BoxConstraints(maxHeight: constraints.maxHeight),
width: double.infinity,
child: AspectRatio( child: AspectRatio(
aspectRatio: widget.data[0]?.data['ratio']?.toDouble() ?? 1, aspectRatio: widget.data[0]?.data['ratio']?.toDouble() ?? 1,
child: ScrollConfiguration( child: ScrollConfiguration(
@ -189,16 +216,22 @@ class _AttachmentListState extends State<AttachmentList> {
itemCount: widget.data.length, itemCount: widget.data.length,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return Container( return Container(
constraints: constraints.copyWith(maxWidth: widget.maxWidth), constraints:
constraints.copyWith(maxWidth: widget.maxWidth),
child: AspectRatio( child: AspectRatio(
aspectRatio: (widget.data[idx]?.data['ratio'] ?? 1).toDouble(), aspectRatio:
(widget.data[idx]?.data['ratio'] ?? 1).toDouble(),
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
if (widget.data[idx]?.mediaType != SnMediaType.image) return; if (widget.data[idx]?.mediaType != SnMediaType.image)
return;
context.pushTransparentRoute( context.pushTransparentRoute(
AttachmentZoomView( AttachmentZoomView(
data: data: widget.data
widget.data.where((ele) => ele != null && ele.mediaType == SnMediaType.image).cast(), .where((ele) =>
ele != null &&
ele.mediaType == SnMediaType.image)
.cast(),
initialIndex: idx, initialIndex: idx,
heroTags: heroTags, heroTags: heroTags,
), ),
@ -212,18 +245,25 @@ class _AttachmentListState extends State<AttachmentList> {
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
border: Border(top: borderSide, bottom: borderSide), border:
Border(top: borderSide, bottom: borderSide),
borderRadius: AttachmentList.kDefaultRadius, borderRadius: AttachmentList.kDefaultRadius,
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: AttachmentList.kDefaultRadius, borderRadius: AttachmentList.kDefaultRadius,
child: AttachmentItem(data: widget.data[idx], heroTag: heroTags[idx]), child: AttachmentItem(
data: widget.data[idx],
heroTag: heroTags[idx],
filterQuality: widget.filterQuality,
),
), ),
), ),
Positioned( Positioned(
right: 8, right: 8,
bottom: 8, bottom: 8,
child: Chip(label: Text('${idx + 1}/${widget.data.length}')), child: Chip(
label:
Text('${idx + 1}/${widget.data.length}')),
), ),
], ],
), ),
@ -245,5 +285,6 @@ class _AttachmentListState extends State<AttachmentList> {
class _AttachmentListScrollBehavior extends MaterialScrollBehavior { class _AttachmentListScrollBehavior extends MaterialScrollBehavior {
@override @override
Set<PointerDeviceKind> get dragDevices => {PointerDeviceKind.touch, PointerDeviceKind.mouse}; Set<PointerDeviceKind> get dragDevices =>
{PointerDeviceKind.touch, PointerDeviceKind.mouse};
} }

View File

@ -1,5 +1,4 @@
import 'dart:io'; import 'dart:io';
import 'dart:math' show max;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dismissible_page/dismissible_page.dart'; import 'package:dismissible_page/dismissible_page.dart';
@ -42,16 +41,20 @@ 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;
int _page = 0;
void _updatePage() { void _updatePage() {
setState(() { setState(() {
if (_isCompletedDownload) { if (_isCompletedDownload) {
setState(() => _isCompletedDownload = false); setState(() => _isCompletedDownload = false);
} }
_page = _pageController.page?.round() ?? 0;
}); });
} }
@ -107,7 +110,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 +136,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 +156,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.down
: DismissiblePageDismissDirection.none,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
isFullScreen: true, isFullScreen: true,
child: GestureDetector( child: GestureDetector(
@ -165,10 +173,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 +192,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,39 +213,22 @@ 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(
top: max(MediaQuery.of(context).padding.top, 8),
left: 14,
child: IgnorePointer(
ignoring: !_showOverlay,
child: IconButton(
constraints: const BoxConstraints(),
icon: const Icon(Icons.close),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
Theme.of(context).colorScheme.surface.withOpacity(0.5),
),
),
onPressed: () {
Navigator.of(context).pop();
},
)
.opacity(_showOverlay ? 1 : 0, animate: true)
.animate(const Duration(milliseconds: 300), Curves.easeInOut),
),
),
Align( Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: IgnorePointer( child: IgnorePointer(
child: Container( child: Container(
height: 300, height: 200,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.bottomCenter, begin: Alignment.bottomCenter,
@ -255,161 +251,155 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: Builder(builder: (context) { child: Builder(builder: (context) {
final ud = context.read<UserDirectoryProvider>(); return Row(
final item = widget.data.elementAt(
widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0,
);
final account = ud.getAccountFromCache(item.accountId);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (item.accountId > 0) IconButton(
Row( iconSize: 18,
children: [ constraints: const BoxConstraints(),
IgnorePointer( icon: const Icon(Icons.close),
child: AccountImage( style: ButtonStyle(
content: account?.avatar, backgroundColor: MaterialStateProperty.all(
radius: 19, Theme.of(context)
), .colorScheme
), .surface
const Gap(8), .withOpacity(0.5),
Expanded(
child: IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'attachmentUploadBy'.tr(),
style: Theme.of(context).textTheme.bodySmall,
),
Text(
account?.nick ?? 'unknown'.tr(),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
if (widget.data.length > 1)
IgnorePointer(
child: Text(
'${(_pageController.page?.round() ?? 0) + 1}/${widget.data.length}',
style: GoogleFonts.robotoMono(fontSize: 13),
).padding(right: 8),
),
InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
onTap: _isDownloading
? null
: () =>
_saveToAlbum(widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0),
child: Container(
padding: const EdgeInsets.all(6),
child: !_isDownloading
? !_isCompletedDownload
? const Icon(Symbols.save_alt)
: const Icon(Symbols.download_done)
: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: _progressOfDownload,
strokeWidth: 3,
),
),
),
),
],
),
const Gap(4),
IgnorePointer(
child: Text(
item.alt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
), ),
), ),
onPressed: () {
Navigator.of(context).pop();
},
), ),
const Gap(2), IconButton(
IgnorePointer( iconSize: 20,
child: Wrap( constraints: const BoxConstraints(),
spacing: 6, padding: EdgeInsets.zero,
children: [ visualDensity: VisualDensity.compact,
if (item.metadata['exif'] == null) icon: const Icon(Symbols.hide).padding(all: 6),
Text( onPressed: () {
'#${item.rid}', setState(() => _showOverlay = false);
style: metaTextStyle, }),
), Expanded(
if (item.metadata['exif']?['Model'] != null) child: IgnorePointer(
Text( child: Builder(builder: (context) {
'attachmentShotOn'.tr(args: [ final item = widget.data.elementAt(_page);
item.metadata['exif']?['Model'], final doShowCameraInfo =
]), item.metadata['exif']?['Model'] != null;
style: metaTextStyle, final exif = item.metadata['exif'];
).padding(right: 2), return Column(
if (item.metadata['exif']?['Megapixels'] != null && children: [
item.metadata['exif']?['Model'] != null) if (widget.data.length > 1)
Text( Text(
'${item.metadata['exif']?['Megapixels']}MP', '${_page + 1}/${widget.data.length}',
style: metaTextStyle, style:
) GoogleFonts.robotoMono(fontSize: 13),
else ).padding(right: 8),
Text( if (doShowCameraInfo)
item.size.formatBytes(), Text(
style: metaTextStyle, 'attachmentShotOn'
), .tr(args: [exif?['Model']]),
if (item.metadata['width'] != null && item.metadata['height'] != null) style: metaTextStyle,
Text( textAlign: TextAlign.center,
'${item.metadata['width']}x${item.metadata['height']}', ),
style: metaTextStyle, if (doShowCameraInfo)
), Row(
], spacing: 4,
mainAxisSize: MainAxisSize.min,
children: [
if (exif?['Megapixels'] != null)
Text(
'${exif?['Megapixels']}MP',
style: metaTextStyle,
),
if (exif?['ISO'] != null)
Text(
'ISO${exif['ISO']}',
style: metaTextStyle,
),
if (exif?['FNumber'] != null)
Text(
'f/${exif['FNumber']}',
style: metaTextStyle,
),
],
)
],
);
}),
), ),
), ),
const Gap(4), IconButton(
InkWell( constraints: const BoxConstraints(),
onTap: () { padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: Container(
padding: const EdgeInsets.all(6),
child: !_isDownloading
? !_isCompletedDownload
? const Icon(Symbols.save_alt)
: const Icon(Symbols.download_done)
: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
value: _progressOfDownload,
strokeWidth: 3,
),
),
),
onPressed:
_isDownloading ? null : () => _saveToAlbum(_page),
),
IconButton(
iconSize: 18,
constraints: const BoxConstraints(),
icon: const Icon(Icons.info_outline),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
Theme.of(context)
.colorScheme
.surface
.withOpacity(0.5),
),
),
onPressed: () {
_showDetail = true; _showDetail = true;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => _AttachmentZoomDetailPopup( builder: (context) => _AttachmentZoomDetailPopup(
data: widget.data data: widget.data.elementAt(_page),
.elementAt(widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0),
), ),
).then((_) { ).then((_) {
_showDetail = false; _showDetail = false;
}); });
}, },
child: Text(
'viewDetailedAttachment'.tr(),
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),
), ),
], ],
), ),
), ),
onTap: () { onTap: () {
if (_showOverlay) {
Navigator.pop(context);
return;
}
setState(() => _showOverlay = !_showOverlay); setState(() => _showOverlay = !_showOverlay);
}, },
onVerticalDragUpdate: (details) { onVerticalDragUpdate: (details) {
if (_showDetail) return; if (_showDetail || !_dismissable) return;
if (details.delta.dy <= -20) { if (details.delta.dy <= -20) {
_showDetail = true; _showDetail = true;
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(_page),
), ),
).then((_) { ).then((_) {
_showDetail = false; _showDetail = false;
@ -429,7 +419,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 +437,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 +453,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 +465,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 +492,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 +509,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

@ -12,10 +12,10 @@ import 'package:surface/providers/config.dart';
import 'package:surface/providers/keypair.dart'; import 'package:surface/providers/keypair.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/types/chat.dart'; import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/account_popover.dart'; import 'package:surface/widgets/account/account_popover.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/attachment/attachment_list.dart'; import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/context_menu.dart'; import 'package:surface/widgets/context_menu.dart';
import 'package:surface/widgets/link_preview.dart'; import 'package:surface/widgets/link_preview.dart';
@ -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;
@ -109,18 +109,10 @@ class ChatMessage extends StatelessWidget {
child: AccountImage( child: AccountImage(
content: user?.avatar, content: user?.avatar,
badge: (user?.badges.isNotEmpty ?? false) badge: (user?.badges.isNotEmpty ?? false)
? Icon( ? AccountBadge(
kBadgesMeta[user!.badges.first.type]?.$2 ?? badge: user!.badges.first,
Symbols.question_mark, radius: 16,
color: kBadgesMeta[user.badges.first.type]?.$3, padding: EdgeInsets.all(2),
fill: 1,
size: 18,
shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 5.0,
color: Color.fromARGB(200, 0, 0, 0)),
],
) )
: null, : null,
), ),
@ -214,7 +206,8 @@ class ChatMessage extends StatelessWidget {
data.type == 'messages.new' && data.type == 'messages.new' &&
(data.body['text']?.isNotEmpty ?? false) && (data.body['text']?.isNotEmpty ?? false) &&
(cfg.prefs.getBool(kAppExpandChatLink) ?? true)) (cfg.prefs.getBool(kAppExpandChatLink) ?? true))
LinkPreviewWidget(text: data.body['text']!).padding(left: 48), LinkPreviewWidget(text: data.body['text']!)
.padding(left: isCompact ? 0 : 48),
if (data.preload?.attachments?.isNotEmpty ?? false) if (data.preload?.attachments?.isNotEmpty ?? false)
AttachmentList( AttachmentList(
data: data.preload!.attachments!, data: data.preload!.attachments!,

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

@ -140,28 +140,14 @@ class MarkdownTextContent extends StatelessWidget {
future: st.lookupSticker(alias), future: st.lookupSticker(alias),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return GestureDetector( return UniversalImage(
child: UniversalImage( sn.getAttachmentUrl(snapshot.data!.attachment.rid),
sn.getAttachmentUrl( fit: BoxFit.contain,
snapshot.data!.attachment.rid), width: size,
fit: BoxFit.contain, height: size,
width: size, cacheHeight: size,
height: size, cacheWidth: size,
cacheHeight: size, );
cacheWidth: size,
),
onTap: () {
if (snapshot.data == null) return;
context.pushTransparentRoute(
AttachmentZoomView(
data: [snapshot.data!.attachment],
initialIndex: 0,
heroTags: [const Uuid().v4()],
),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
});
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },

View File

@ -29,6 +29,7 @@ import 'package:surface/types/attachment.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/types/reaction.dart'; import 'package:surface/types/reaction.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/attachment/attachment_item.dart'; import 'package:surface/widgets/attachment/attachment_item.dart';
import 'package:surface/widgets/attachment/attachment_list.dart'; import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
@ -43,8 +44,6 @@ import 'package:surface/widgets/post/publisher_popover.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
import '../../screens/account/profile_page.dart' show kBadgesMeta;
class OpenablePostItem extends StatelessWidget { class OpenablePostItem extends StatelessWidget {
final SnPost data; final SnPost data;
final bool showReactions; final bool showReactions;
@ -95,9 +94,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,15 +138,16 @@ 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);
} }
} }
void _doShareViaPicture(BuildContext context) async { void _doShareViaPicture(BuildContext context) async {
final box = context.findRenderObject() as RenderBox?; final box = context.findRenderObject() as RenderBox?;
context.showSnackbar('postSharingViaPicture'.tr());
final controller = ScreenshotController(); final controller = ScreenshotController();
final capturedImage = await controller.captureFromLongWidget( final capturedImage = await controller.captureFromLongWidget(
@ -157,8 +158,9 @@ class PostItem extends StatelessWidget {
child: Material( child: Material(
child: MultiProvider( child: MultiProvider(
providers: [ providers: [
// Create a copy of environments
Provider<SnNetworkProvider>(create: (_) => context.read()), Provider<SnNetworkProvider>(create: (_) => context.read()),
ChangeNotifierProvider<ConfigProvider>(create: (_) => context.read()), Provider<UserDirectoryProvider>(create: (_) => context.read()),
], ],
child: ResponsiveBreakpoints.builder( child: ResponsiveBreakpoints.builder(
breakpoints: ResponsiveBreakpoints.of(context).breakpoints, breakpoints: ResponsiveBreakpoints.of(context).breakpoints,
@ -186,7 +188,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 +203,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: [
@ -217,10 +222,11 @@ class PostItem extends StatelessWidget {
onShareImage: () => _doShareViaPicture(context), onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer, onSelectAnswer: onSelectAnswer,
onDeleted: () { onDeleted: () {
if (onDeleted != null) {} onDeleted?.call();
}, },
).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(
@ -265,10 +271,11 @@ class PostItem extends StatelessWidget {
onShareImage: () => _doShareViaPicture(context), onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer, onSelectAnswer: onSelectAnswer,
onDeleted: () { onDeleted: () {
if (onDeleted != null) {} onDeleted?.call();
}, },
).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 +318,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 +339,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>();
@ -349,12 +362,16 @@ class PostItem extends StatelessWidget {
onShareImage: () => _doShareViaPicture(context), onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer, onSelectAnswer: onSelectAnswer,
onDeleted: () { onDeleted: () {
if (onDeleted != null) onDeleted!(); onDeleted?.call();
}, },
).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 +385,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 +398,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 +413,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 +484,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,16 +500,20 @@ 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,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
)).padding(horizontal: 16, bottom: 8), )).padding(horizontal: 16, bottom: 8),
Column( Column(
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),
), ),
@ -842,7 +886,7 @@ class _PostContentHeader extends StatelessWidget {
'publisherId': data.publisherId, 'publisherId': data.publisherId,
}); });
if (!context.mounted) return; if (!context.mounted) return;
context.showSnackbar('postDeleted'.tr(args: ['#${data.id}'])); onDeleted.call();
} catch (err) { } catch (err) {
if (!context.mounted) return; if (!context.mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -871,31 +915,46 @@ 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: [
GestureDetector( GestureDetector(
child: AccountImage( child: data.preload?.realm == null
content: data.publisher.avatar, ? AccountImage(
radius: isCompact ? 12 : 20, content: data.publisher.avatar,
borderRadius: data.publisher.type == 1 ? (isCompact ? 4 : 8) : 20, radius: isCompact ? 12 : 20,
badge: (user?.badges.isNotEmpty ?? false) borderRadius:
? Icon( data.publisher.type == 1 ? (isCompact ? 4 : 8) : 20,
kBadgesMeta[user!.badges.first.type]?.$2 ?? Symbols.question_mark, badge: (user?.badges.isNotEmpty ?? false)
color: kBadgesMeta[user.badges.first.type]?.$3, ? AccountBadge(
fill: 1, badge: user!.badges.first,
size: 18, radius: 16,
shadows: [ padding: EdgeInsets.all(2),
Shadow( )
offset: Offset(1, 1), : null,
blurRadius: 5.0, )
color: Color.fromARGB(200, 0, 0, 0), : AccountImage(
content: data.preload!.realm!.avatar,
radius: isCompact ? 12 : 20,
borderRadius: isCompact ? 4 : 8,
badgeOffset: Offset(-6, -4),
badge: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.surface,
width: 2,
), ),
], borderRadius: BorderRadius.circular(10),
) ),
: null, child: AccountImage(
), content: data.publisher.avatar,
radius: 10,
),
),
),
onTap: () { onTap: () {
showPopover( showPopover(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
@ -926,8 +985,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),
@ -938,15 +999,27 @@ class _PostContentHeader extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(data.publisher.nick).bold(), Row(
children: [
Text(data.publisher.nick).bold(),
if (data.preload?.realm != null)
const Icon(Symbols.arrow_right, size: 16)
.padding(horizontal: 2)
.opacity(0.5),
if (data.preload?.realm != null)
Text(data.preload!.realm!.name),
],
),
Row( Row(
children: [ children: [
Text('@${data.publisher.name}').fontSize(13), Text('@${data.publisher.name}').fontSize(13),
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),
@ -986,8 +1059,10 @@ class _PostContentHeader extends StatelessWidget {
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postEditor', 'postEditor',
pathParameters: {'mode': data.typePlural}, queryParameters: {
queryParameters: {'editing': data.id.toString()}, 'editing': data.id.toString(),
'mode': data.typePlural,
},
); );
}, },
), ),
@ -1014,8 +1089,10 @@ class _PostContentHeader extends StatelessWidget {
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postEditor', 'postEditor',
pathParameters: {'mode': 'stories'}, queryParameters: {
queryParameters: {'replying': data.id.toString()}, 'replying': data.id.toString(),
'mode': data.typePlural,
},
); );
}, },
), ),
@ -1030,8 +1107,10 @@ class _PostContentHeader extends StatelessWidget {
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postEditor', 'postEditor',
pathParameters: {'mode': 'stories'}, queryParameters: {
queryParameters: {'reposting': data.id.toString()}, 'reposting': data.id.toString(),
'mode': 'stories',
},
); );
}, },
), ),
@ -1129,7 +1208,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 +1258,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 +1414,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 +1455,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 +1464,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 +1497,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 +1519,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 +1667,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 +1691,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 +1718,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 +1764,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

@ -25,7 +25,8 @@ class PostMiniEditor extends StatefulWidget {
} }
class _PostMiniEditorState extends State<PostMiniEditor> { class _PostMiniEditorState extends State<PostMiniEditor> {
final PostWriteController _writeController = PostWriteController(doLoadFromTemporary: false); final PostWriteController _writeController =
PostWriteController(doLoadFromTemporary: false);
bool _isFetching = false; bool _isFetching = false;
@ -44,8 +45,9 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
); );
final beforeId = config.prefs.getInt('int_last_publisher_id'); final beforeId = config.prefs.getInt('int_last_publisher_id');
_writeController _writeController.setPublisher(
.setPublisher(_publishers?.where((ele) => ele.id == beforeId).firstOrNull ?? _publishers?.firstOrNull); _publishers?.where((ele) => ele.id == beforeId).firstOrNull ??
_publishers?.firstOrNull);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -99,11 +101,17 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
Expanded( Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!), Text(item.nick).textStyle(
Theme.of(context)
.textTheme
.bodyMedium!),
Text('@${item.name}') Text('@${item.name}')
.textStyle(Theme.of(context).textTheme.bodySmall!) .textStyle(Theme.of(context)
.textTheme
.bodySmall!)
.fontSize(12), .fontSize(12),
], ],
), ),
@ -120,7 +128,8 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
CircleAvatar( CircleAvatar(
radius: 16, radius: 16,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.onSurface, foregroundColor:
Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
), ),
const Gap(8), const Gap(8),
@ -129,7 +138,8 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!), Text('publishersNew').tr().textStyle(
Theme.of(context).textTheme.bodyMedium!),
], ],
), ),
), ),
@ -140,7 +150,9 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
value: _writeController.publisher, value: _writeController.publisher,
onChanged: (SnPublisher? value) { onChanged: (SnPublisher? value) {
if (value == null) { if (value == null) {
GoRouter.of(context).pushNamed('accountPublisherNew').then((value) { GoRouter.of(context)
.pushNamed('accountPublisherNew')
.then((value) {
if (value == true) { if (value == true) {
_publishers = null; _publishers = null;
_fetchPublishers(); _fetchPublishers();
@ -176,7 +188,8 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
), ),
border: InputBorder.none, border: InputBorder.none,
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
), ),
const Gap(8), const Gap(8),
@ -185,7 +198,8 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
TweenAnimationBuilder<double>( TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _writeController.progress), tween: Tween(begin: 0, end: _writeController.progress),
duration: Duration(milliseconds: 300), duration: Duration(milliseconds: 300),
builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2), builder: (context, value, _) =>
LinearProgressIndicator(value: value, minHeight: 2),
) )
else if (_writeController.isBusy) else if (_writeController.isBusy)
const LinearProgressIndicator(value: null, minHeight: 2), const LinearProgressIndicator(value: null, minHeight: 2),
@ -200,15 +214,17 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
onPressed: () { onPressed: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postEditor', 'postEditor',
pathParameters: {'mode': 'stories'},
queryParameters: { queryParameters: {
if (widget.postReplyId != null) 'replying': widget.postReplyId.toString(), if (widget.postReplyId != null)
'replying': widget.postReplyId.toString(),
'mode': 'stories',
}, },
); );
}, },
), ),
TextButton.icon( TextButton.icon(
onPressed: (_writeController.isBusy || _writeController.publisher == null) onPressed: (_writeController.isBusy ||
_writeController.publisher == null)
? null ? null
: () { : () {
_writeController.sendPost(context).then((_) { _writeController.sendPost(context).then((_) {

View File

@ -9,10 +9,9 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import '../../screens/account/profile_page.dart' show kBadgesMeta;
class PublisherPopoverCard extends StatelessWidget { class PublisherPopoverCard extends StatelessWidget {
final SnPublisher data; final SnPublisher data;
@ -23,7 +22,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,
@ -76,37 +75,22 @@ class PublisherPopoverCard extends StatelessWidget {
const Gap(8) const Gap(8)
], ],
).padding(horizontal: 16), ).padding(horizontal: 16),
if (user != null && user.badges.isNotEmpty) const Gap(16),
if (user != null && user.badges.isNotEmpty) if (user != null && user.badges.isNotEmpty)
Wrap( Wrap(
spacing: 4, spacing: 4,
children: user.badges children: user.badges
.map( .map(
(ele) => Tooltip( (ele) => AccountBadge(badge: ele),
richMessage: TextSpan(
children: [
TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
if (ele.metadata['title'] != null)
TextSpan(
text: '\n${ele.metadata['title']}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: '\n'),
TextSpan(
text: DateFormat.yMEd().format(ele.createdAt),
),
],
),
child: Icon(
kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
color: kBadgesMeta[ele.type]?.$3,
fill: 1,
),
),
) )
.toList(), .toList(),
).padding(horizontal: 24), ).padding(horizontal: 24, top: 16),
const Gap(16), const Gap(16),
if (data.description.isNotEmpty)
Text(
data.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).padding(horizontal: 26, bottom: 20),
Row( Row(
children: [ children: [
Expanded( Expanded(
@ -146,7 +130,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()),
], ],
), ),

View File

@ -34,11 +34,14 @@ class UniversalImage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final double? resizeHeight = cacheHeight != null ? (cacheHeight! * devicePixelRatio) : null; final double? resizeHeight =
final double? resizeWidth = cacheWidth != null ? (cacheWidth! * devicePixelRatio) : null; cacheHeight != null ? (cacheHeight! * devicePixelRatio) : null;
final double? resizeWidth =
cacheWidth != null ? (cacheWidth! * devicePixelRatio) : null;
return Image( return Image(
filterQuality: filterQuality ?? context.read<ConfigProvider>().imageQuality, filterQuality:
filterQuality ?? context.read<ConfigProvider>().imageQuality,
image: kIsWeb image: kIsWeb
? UniversalImage.provider(url) ? UniversalImage.provider(url)
: ResizeImage( : ResizeImage(
@ -52,7 +55,8 @@ class UniversalImage extends StatelessWidget {
fit: fit, fit: fit,
loadingBuilder: noProgressIndicator loadingBuilder: noProgressIndicator
? null ? null
: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { : (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child; if (loadingProgress == null) return child;
return Container( return Container(
constraints: BoxConstraints(maxHeight: 80), constraints: BoxConstraints(maxHeight: 80),
@ -61,12 +65,15 @@ class UniversalImage extends StatelessWidget {
tween: Tween( tween: Tween(
begin: 0, begin: 0,
end: loadingProgress.expectedTotalBytes != null end: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! ? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: 0, : 0,
), ),
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator( builder: (context, value, _) => CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null ? value.toDouble() : null, value: loadingProgress.expectedTotalBytes != null
? value.toDouble()
: null,
), ),
), ),
), ),
@ -114,6 +121,7 @@ class AutoResizeUniversalImage extends StatelessWidget {
final BoxFit? fit; final BoxFit? fit;
final bool noProgressIndicator; final bool noProgressIndicator;
final bool noErrorWidget; final bool noErrorWidget;
final FilterQuality? filterQuality;
const AutoResizeUniversalImage( const AutoResizeUniversalImage(
this.url, { this.url, {
@ -123,6 +131,7 @@ class AutoResizeUniversalImage extends StatelessWidget {
this.fit, this.fit,
this.noProgressIndicator = false, this.noProgressIndicator = false,
this.noErrorWidget = false, this.noErrorWidget = false,
this.filterQuality,
}); });
@override @override
@ -137,6 +146,7 @@ class AutoResizeUniversalImage extends StatelessWidget {
noErrorWidget: noErrorWidget, noErrorWidget: noErrorWidget,
cacheHeight: constraints.maxHeight, cacheHeight: constraints.maxHeight,
cacheWidth: constraints.maxWidth, cacheWidth: constraints.maxWidth,
filterQuality: filterQuality,
); );
}); });
} }

View File

@ -173,10 +173,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: built_value name: built_value
sha256: "8b158ab94ec6913e480dc3f752418348b5ae099eb75868b5f4775f0572999c61" sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.9.4" version: "8.9.5"
cached_network_image: cached_network_image:
dependency: "direct main" dependency: "direct main"
description: description:
@ -301,10 +301,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: croppy name: croppy
sha256: bf99b00023df0d7d047e04d27d496d87cbefd968f578d0bd30f342ff75570a12 sha256: "99f4fbb4a4b44d2712e8dcd61c57c1acac151bd53cab11de3babec80407ed266"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.5"
cross_file: cross_file:
dependency: "direct main" dependency: "direct main"
description: description:
@ -381,10 +381,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: device_info_plus name: device_info_plus
sha256: "610739247975c2d0de43482afa13ec1018f63c9fddf97ef3d8dc895faa3b4543" sha256: "306b78788d1bb569edb7c55d622953c2414ca12445b41c9117963e03afc5c513"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.3.2" version: "11.3.3"
device_info_plus_platform_interface: device_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -429,18 +429,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: drift name: drift
sha256: "97d5832657d49f26e7a8e07de397ddc63790b039372878d5117af816d0fdb5cb" sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.25.1" version: "2.26.0"
drift_dev: drift_dev:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: drift_dev name: drift_dev
sha256: f1db88482dbb016b9bbddddf746d5d0a6938b156ff20e07320052981f97388cc sha256: "0d3f8b33b76cf1c6a82ee34d9511c40957549c4674b8f1688609e6d6c7306588"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.25.2" version: "2.26.0"
drift_flutter: drift_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@ -710,6 +710,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.1" version: "3.4.1"
flutter_card_swiper:
dependency: "direct main"
description:
name: flutter_card_swiper
sha256: "1eacbfab31b572223042e03409726553aec431abe48af48c8d591d376d070d3d"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
flutter_colorpicker: flutter_colorpicker:
dependency: "direct main" dependency: "direct main"
description: description:
@ -831,10 +839,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_map name: flutter_map
sha256: bbf145e8220531f2f727608c431871c7457f3b134e513543913afd00fdc1cd47 sha256: f7d0379477274f323c3f3bc12d369a2b42eb86d1e7bd2970ae1ea3cff782449a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.0" version: "8.1.1"
flutter_markdown: flutter_markdown:
dependency: "direct main" dependency: "direct main"
description: description:
@ -871,10 +879,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_plugin_android_lifecycle name: flutter_plugin_android_lifecycle
sha256: "1c2b787f99bdca1f3718543f81d38aa1b124817dfeb9fb196201bea85b6134bf" sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.26" version: "2.0.27"
flutter_shaders: flutter_shaders:
dependency: transitive dependency: transitive
description: description:
@ -937,10 +945,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: freezed name: freezed
sha256: a3d6429368603a591ca7c1795799a247998fb213ded509070c2c59708b25df31 sha256: a6274c34d61b3d68082f2b0e9a641a3ec197e525d269f35b82f62d5b2c6d9f75
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.3"
freezed_annotation: freezed_annotation:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1153,10 +1161,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: icons_launcher name: icons_launcher
sha256: a7c83fbc837dc6f81944ef35c3756f533bb2aba32fcca5cbcdb2dbcd877d5ae9 sha256: "2949eef3d336028d89133f69ef221d877e09deed04ebd8e738ab4a427850a7a2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.1"
image: image:
dependency: transitive dependency: transitive
description: description:
@ -1177,10 +1185,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker_android name: image_picker_android
sha256: "82652a75e3dd667a91187769a6a2cc81bd8c111bbead698d8e938d2b63e5e89a" sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.12+21" version: "0.8.12+22"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@ -1409,10 +1417,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: material_symbols_icons name: material_symbols_icons
sha256: "1403944c2a68dbdf934fca2b2115a372e70a4a185c136ab71a9d16e2108d0b14" sha256: ca30ccbd97763353bde6bb1076aa4f4d17a40db0804384da77df142102aa225d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2805.1" version: "4.2808.0"
media_kit: media_kit:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1601,10 +1609,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.15" version: "2.2.16"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
@ -1785,10 +1793,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: pub_semver name: pub_semver
sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.2.0"
pubspec_parse: pubspec_parse:
dependency: transitive dependency: transitive
description: description:
@ -1953,10 +1961,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: a768fc8ede5f0c8e6150476e14f38e2417c0864ca36bb4582be8e21925a03c22 sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.6" version: "2.4.8"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
@ -2118,10 +2126,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqlite3 name: sqlite3
sha256: "32b632dda27d664f85520093ed6f735ae5c49b5b75345afb8b19411bc59bb53d" sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.7.4" version: "2.7.5"
sqlite3_flutter_libs: sqlite3_flutter_libs:
dependency: transitive dependency: transitive
description: description:
@ -2326,10 +2334,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.14" version: "6.3.15"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2.3.2+75 version: 2.4.2+78
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@ -138,6 +138,7 @@ dependencies:
flutter_map: ^8.1.0 flutter_map: ^8.1.0
geolocator: ^13.0.2 geolocator: ^13.0.2
fast_rsa: ^3.8.0 fast_rsa: ^3.8.0
flutter_card_swiper: ^7.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -187,18 +188,14 @@ flutter:
# "family" key with the font family name, and a "fonts" key with a # "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For # list giving the asset and other descriptors for the font. For
# example: # example:
# fonts: fonts:
# - family: Schyler - family: Nunito
# fonts: fonts:
# - asset: fonts/Schyler-Regular.ttf - asset: assets/fonts/Nunito-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf - asset: assets/fonts/Nunito-Bold.ttf
# style: italic weight: 700
# - family: Trajan Pro - asset: assets/fonts/Nunito-Italic.ttf
# fonts: style: italic
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies, # For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package # see https://flutter.dev/to/font-from-package

View File

@ -5,6 +5,7 @@ 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
@ -14,10 +15,12 @@ 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]; static const versions = const [1, 2, 3];
} }

File diff suppressed because it is too large Load Diff

View File

@ -127,9 +127,29 @@
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt=""> <img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
</picture> </picture>
<script>
{{flutter_js}}
{{flutter_build_config}}
<script src="flutter_bootstrap.js" async=""></script> const searchParams = new URLSearchParams(window.location.search);
const renderer = searchParams.get('renderer');
let cdn = searchParams.get('cdn');
if (cdn) {
localStorage.setItem('sn-web-canvaskit-cdn', cdn);
} else {
const storagedCdn = localStorage.getItem('sn-web-canvaskit-cdn');
cdn = storagedCdn ?? 'com';
}
_flutter.loader.load({
config: {
renderer: renderer ?? 'canvaskit',
canvasKitVariant: 'full',
canvasKitBaseUrl: `https://www.gstatic.${cdn}/flutter-canvaskit/f73bfc4522dd0bc87bbcdb4bb3088082755c5e87`,
},
});
</script>
</body> </body>
</html> </html>