Compare commits

..

11 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
31 changed files with 1700 additions and 271 deletions

View File

@ -769,5 +769,27 @@
"decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online",
"messageUnablePreview": "Unable preview",
"messageUnablePreviewEncrypted": "Unable preview encrypted message",
"postViewInGlobalDescription": "Do not view the post in the specific realm."
"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

@ -767,5 +767,27 @@
"decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线",
"messageUnablePreview": "无法预览消息",
"messageUnablePreviewEncrypted": "无法预览加密消息",
"postViewInGlobalDescription": "不查看特定领域的帖子。"
"postViewInGlobalDescription": "不查看特定领域的帖子。",
"postDraftSaved": "已保存为草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "随便看看",
"checkInStreak": {
"zero": "无连击",
"one": "连续签到 {} 天",
"other": "连续签到 {} 天"
},
"accountChangeStatus": "修改状态",
"accountStatusSilent": "请勿打扰",
"accountStatusSilentDesc": "将会暂停所有通知推送",
"accountStatusInvisible": "隐身",
"accountStatusInvisibleDesc": "将会在他人界面显示离线,但不影响功能使用",
"accountCustomStatus": "自定义状态",
"accountCustomStatusDescription": "客制化你的状态。",
"accountClearStatus": "清除状态",
"accountClearStatusDescription": "清除你的状态,并让服务器决定你的状态。",
"fieldAccountStatusLabel": "状态文字",
"fieldAccountStatusClearAt": "清除时间",
"accountStatusNegative": "负面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
}

View File

@ -767,5 +767,27 @@
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息",
"postViewInGlobalDescription": "不查看特定領域的帖子。"
"postViewInGlobalDescription": "不查看特定領域的帖子。",
"postDraftSaved": "已保存為草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "隨便看看",
"checkInStreak": {
"zero": "無連擊",
"one": "連續簽到 {} 天",
"other": "連續簽到 {} 天"
},
"accountChangeStatus": "修改狀態",
"accountStatusSilent": "請勿打擾",
"accountStatusSilentDesc": "將會暫停所有通知推送",
"accountStatusInvisible": "隱身",
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
"accountCustomStatus": "自定義狀態",
"accountCustomStatusDescription": "客製化你的狀態。",
"accountClearStatus": "清除狀態",
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
"fieldAccountStatusLabel": "狀態文字",
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
}

View File

@ -767,5 +767,27 @@
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息",
"postViewInGlobalDescription": "不查看特定領域的帖子。"
"postViewInGlobalDescription": "不查看特定領域的帖子。",
"postDraftSaved": "已保存為草稿。",
"postDraftBox": "草稿箱",
"postShuffle": "隨便看看",
"checkInStreak": {
"zero": "無連擊",
"one": "連續簽到 {} 天",
"other": "連續簽到 {} 天"
},
"accountChangeStatus": "修改狀態",
"accountStatusSilent": "請勿打擾",
"accountStatusSilentDesc": "將會暫停所有通知推送",
"accountStatusInvisible": "隱身",
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
"accountCustomStatus": "自定義狀態",
"accountCustomStatusDescription": "客製化你的狀態。",
"accountClearStatus": "清除狀態",
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
"fieldAccountStatusLabel": "狀態文字",
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
}

View File

@ -208,6 +208,7 @@ class PostWriteController extends ChangeNotifier {
SnRealm? realm;
SnPublisher? publisher;
SnPost? editingPost, repostingPost, replyingPost;
bool editingDraft = false;
int visibility = 0;
List<int> visibleUsers = List.empty();
@ -254,6 +255,8 @@ class PostWriteController extends ChangeNotifier {
post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
poll = post.preload?.poll;
editingDraft = post.isDraft;
if (post.preload?.thumbnail != null &&
(post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
thumbnail = PostWriteMedia(post.preload!.thumbnail);
@ -474,7 +477,10 @@ class PostWriteController extends ChangeNotifier {
notifyListeners();
}
Future<void> sendPost(BuildContext context) async {
Future<void> sendPost(
BuildContext context, {
bool saveAsDraft = false,
}) async {
if (isBusy || publisher == null) return;
final sn = context.read<SnNetworkProvider>();
@ -552,7 +558,7 @@ class PostWriteController extends ChangeNotifier {
// Posting the content
try {
final baseProgressVal = progress!;
await sn.client.request(
final resp = await sn.client.request(
[
'/cgi/co/$mode',
if (editingPost != null) '${editingPost!.id}',
@ -585,6 +591,7 @@ class PostWriteController extends ChangeNotifier {
if (videoAttachment != null) 'video': videoAttachment!.rid,
if (poll != null) 'poll': poll!.id,
if (realm != null) 'realm': realm!.id,
'is_draft': saveAsDraft,
},
onSendProgress: (count, total) {
progress =
@ -601,7 +608,16 @@ class PostWriteController extends ChangeNotifier {
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) {
if (!context.mounted) return;
context.showErrorDialog(err);

View File

@ -23,8 +23,6 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel
late final GeneratedColumn<String> alias = GeneratedColumn<String>(
'alias', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnChannel, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -60,7 +58,6 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel
} else if (isInserting) {
context.missing(_aliasMeta);
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -295,8 +292,6 @@ class $SnLocalChatMessageTable extends SnLocalChatMessage
late final GeneratedColumn<int> senderId = GeneratedColumn<int>(
'sender_id', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnChatMessage, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -338,7 +333,6 @@ class $SnLocalChatMessageTable extends SnLocalChatMessage
context.handle(_senderIdMeta,
senderId.isAcceptableOrUnknown(data['sender_id']!, _senderIdMeta));
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -604,8 +598,6 @@ class $SnLocalChannelMemberTable extends SnLocalChannelMember
late final GeneratedColumn<int> accountId = GeneratedColumn<int>(
'account_id', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnChannelMember, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -655,7 +647,6 @@ class $SnLocalChannelMemberTable extends SnLocalChannelMember
} else if (isInserting) {
context.missing(_accountIdMeta);
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -1265,8 +1256,6 @@ class $SnLocalAccountTable extends SnLocalAccount
late final GeneratedColumn<String> name = GeneratedColumn<String>(
'name', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnAccount, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -1308,7 +1297,6 @@ class $SnLocalAccountTable extends SnLocalAccount
} else if (isInserting) {
context.missing(_nameMeta);
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -1582,8 +1570,6 @@ class $SnLocalAttachmentTable extends SnLocalAttachment
type: DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'));
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnAttachment, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -1639,7 +1625,6 @@ class $SnLocalAttachmentTable extends SnLocalAttachment
} else if (isInserting) {
context.missing(_uuidMeta);
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('account_id')) {
context.handle(_accountIdMeta,
accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta));
@ -1968,8 +1953,6 @@ class $SnLocalStickerTable extends SnLocalSticker
late final GeneratedColumn<String> fullAlias = GeneratedColumn<String>(
'full_alias', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnSticker, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -2011,7 +1994,6 @@ class $SnLocalStickerTable extends SnLocalSticker
} else if (isInserting) {
context.missing(_fullAliasMeta);
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -2261,8 +2243,6 @@ class $SnLocalStickerPackTable extends SnLocalStickerPack
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
late final GeneratedColumnWithTypeConverter<SnStickerPack, String> content =
GeneratedColumn<String>('content', aliasedName, false,
@ -2293,7 +2273,6 @@ class $SnLocalStickerPackTable extends SnLocalStickerPack
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));

View File

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

View File

@ -19,6 +19,7 @@ class UserDirectoryProvider {
final Map<String, int> _idCache = {};
final Map<int, SnAccount> _cache = {};
DateTime? _cacheExpiredAt;
Future<int> loadAccountCache({int max = 100}) async {
final out = await (_dt.db.snLocalAccount.select()..limit(max)).get();
@ -26,11 +27,18 @@ class UserDirectoryProvider {
_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 {
// 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 plannedQuery = <int>{};
for (var idx = 0; idx < out.length; idx++) {
@ -62,6 +70,7 @@ class UserDirectoryProvider {
plannedQuery.remove(dbResp[idx].id);
}
// Remote server
_saveToLocal(out.where((ele) => ele != null).cast());
if (plannedQuery.isEmpty) return out;
final resp = await _sn.client
.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});

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/notification.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_shuffle.dart';
import 'package:surface/screens/post/publisher_page.dart';
import 'package:surface/screens/post/post_search.dart';
import 'package:surface/screens/realm.dart';
@ -65,6 +67,11 @@ final _appRoutes = [
name: 'explore',
builder: (context, state) => const ExploreScreen(),
routes: [
GoRoute(
path: '/draft',
name: 'postDraftBox',
builder: (context, state) => const PostDraftBox(),
),
GoRoute(
path: '/write',
name: 'postEditor',
@ -82,6 +89,11 @@ final _appRoutes = [
extraProps: state.extra as PostEditorExtra?,
),
),
GoRoute(
path: '/shuffle',
name: 'postShuffle',
builder: (context, state) => const PostShuffleScreen(),
),
GoRoute(
path: '/search',
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/userinfo.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_status.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
@ -112,7 +114,14 @@ class _AuthorizedAccountScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
Row(
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

@ -18,6 +18,7 @@ import 'package:surface/types/account.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.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/universal_image.dart';
import 'package:surface/theme.dart';
@ -450,19 +451,25 @@ class _UserScreenState extends State<UserScreen>
child: Row(
children: [
Icon(
Symbols.circle,
fill: 1,
(_status?.isDisturbable ?? true)
? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (_status?.isOnline ?? false) ? 1 : 0,
size: 16,
color: (_status?.isOnline ?? false)
? Colors.green
? (_status?.isDisturbable ?? true)
? Colors.green
: Colors.red
: Colors.grey,
).padding(all: 4),
const Gap(8),
Text(
_status != null
? _status!.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
? (_status!.status?.label.isNotEmpty ?? false)
? _status!.status!.label
: _status!.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(),
),
if (_status != null &&
@ -484,34 +491,7 @@ class _UserScreenState extends State<UserScreen>
Wrap(
children: _account!.badges
.map(
(ele) => Tooltip(
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,
),
),
(ele) => AccountBadge(badge: ele),
)
.toList(),
).padding(horizontal: 8),

View File

@ -91,8 +91,12 @@ class _ExploreScreenState extends State<ExploreScreen>
_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(() {});
@ -192,6 +196,21 @@ class _ExploreScreenState extends State<ExploreScreen>
),
],
),
Row(
children: [
Text('postDraftBox').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'postDraftBox'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postDraftBox');
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.box_edit),
),
],
),
],
),
body: NestedScrollView(
@ -205,37 +224,49 @@ class _ExploreScreenState extends State<ExploreScreen>
title: Row(
children: [
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
icon: _listKey.currentState?.realm != null
? AccountImage(
content: _listKey.currentState!.realm!.avatar,
radius: 14,
)
: const Icon(Symbols.group),
icon: const Icon(Symbols.shuffle),
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(() {});
}
});
},
),
);
GoRouter.of(context).pushNamed('postShuffle');
},
),
Expanded(
child: Center(
child: Text('screenExplore').tr(),
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(() {});
}
});
},
),
);
},
),
),
),
],
@ -279,11 +310,12 @@ class _ExploreScreenState extends State<ExploreScreen>
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
kCategoryIcons[category.alias] ??
Symbols.question_mark,
color: Theme.of(context)
.appBarTheme
.foregroundColor!),
kCategoryIcons[category.alias] ??
Symbols.question_mark,
color: Theme.of(context)
.appBarTheme
.foregroundColor!,
),
const Gap(8),
Flexible(
child: Text(
@ -293,9 +325,11 @@ class _ExploreScreenState extends State<ExploreScreen>
.tr()
: category.name,
maxLines: 1,
).textColor(Theme.of(context)
.appBarTheme
.foregroundColor!),
).textColor(
Theme.of(context)
.appBarTheme
.foregroundColor!,
),
),
],
),
@ -321,9 +355,11 @@ class _ExploreScreenState extends State<ExploreScreen>
child: Text(
'postChannel$channel',
maxLines: 1,
).tr().textColor(Theme.of(context)
.appBarTheme
.foregroundColor),
).tr().textColor(
Theme.of(context)
.appBarTheme
.foregroundColor,
),
),
],
),

View File

@ -546,11 +546,26 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
'+${_todayRecord!.resultExperience} EXP',
style: Theme.of(context).textTheme.bodyLarge,
),
if (_todayRecord!.resultCoin >= 0)
if (_todayRecord!.resultCoin > 0)
Text(
'+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
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

@ -138,6 +138,15 @@ class _PostEditorScreenState extends State<PostEditorScreen>
],
scope: HotKeyScope.inapp,
);
final HotKey _saveDraftHotKey = HotKey(
key: PhysicalKeyboardKey.keyS,
modifiers: [
(!kIsWeb && Platform.isMacOS)
? HotKeyModifier.meta
: HotKeyModifier.control
],
scope: HotKeyScope.inapp,
);
void _registerHotKey() {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
@ -153,6 +162,11 @@ class _PostEditorScreenState extends State<PostEditorScreen>
]);
setState(() {});
});
hotKeyManager.register(_saveDraftHotKey, keyDownHandler: (_) async {
if (mounted) {
_writeController.sendPost(context);
}
});
}
void _showPublisherPopup() {
@ -218,6 +232,7 @@ class _PostEditorScreenState extends State<PostEditorScreen>
_writeController.dispose();
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
hotKeyManager.unregister(_pasteHotKey);
hotKeyManager.unregister(_saveDraftHotKey);
}
super.dispose();
}
@ -269,6 +284,20 @@ class _PostEditorScreenState extends State<PostEditorScreen>
: 'untitled'.tr(),
),
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(
icon: const Icon(Symbols.tune),
onPressed: _writeController.isBusy ? null : _updateMeta,
@ -296,7 +325,8 @@ class _PostEditorScreenState extends State<PostEditorScreen>
),
body: Column(
children: [
if (_writeController.editingPost != null)
if (_writeController.editingPost != null &&
!_writeController.editingDraft)
Container(
padding: const EdgeInsets.only(
top: 4, bottom: 4, left: 20, right: 20),

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

@ -119,13 +119,33 @@ abstract class SnAccountStatusInfo with _$SnAccountStatusInfo {
required bool isDisturbable,
required bool isOnline,
required DateTime? lastSeenAt,
required dynamic status,
required SnAccountStatus? status,
}) = _SnAccountStatusInfo;
factory SnAccountStatusInfo.fromJson(Map<String, Object?> 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
abstract class SnAbuseReport with _$SnAbuseReport {
const factory SnAbuseReport({

View File

@ -2139,7 +2139,7 @@ mixin _$SnAccountStatusInfo {
bool get isDisturbable;
bool get isOnline;
DateTime? get lastSeenAt;
dynamic get status;
SnAccountStatus? get status;
/// Create a copy of SnAccountStatusInfo
/// with the given fields replaced by the non-null parameter values.
@ -2163,13 +2163,13 @@ mixin _$SnAccountStatusInfo {
other.isOnline == isOnline) &&
(identical(other.lastSeenAt, lastSeenAt) ||
other.lastSeenAt == lastSeenAt) &&
const DeepCollectionEquality().equals(other.status, status));
(identical(other.status, status) || other.status == status));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, isDisturbable, isOnline,
lastSeenAt, const DeepCollectionEquality().hash(status));
int get hashCode =>
Object.hash(runtimeType, isDisturbable, isOnline, lastSeenAt, status);
@override
String toString() {
@ -2187,7 +2187,9 @@ abstract mixin class $SnAccountStatusInfoCopyWith<$Res> {
{bool isDisturbable,
bool isOnline,
DateTime? lastSeenAt,
dynamic status});
SnAccountStatus? status});
$SnAccountStatusCopyWith<$Res>? get status;
}
/// @nodoc
@ -2224,9 +2226,23 @@ class _$SnAccountStatusInfoCopyWithImpl<$Res>
status: freezed == status
? _self.status
: 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
@ -2247,7 +2263,7 @@ class _SnAccountStatusInfo implements SnAccountStatusInfo {
@override
final DateTime? lastSeenAt;
@override
final dynamic status;
final SnAccountStatus? status;
/// Create a copy of SnAccountStatusInfo
/// with the given fields replaced by the non-null parameter values.
@ -2276,13 +2292,13 @@ class _SnAccountStatusInfo implements SnAccountStatusInfo {
other.isOnline == isOnline) &&
(identical(other.lastSeenAt, lastSeenAt) ||
other.lastSeenAt == lastSeenAt) &&
const DeepCollectionEquality().equals(other.status, status));
(identical(other.status, status) || other.status == status));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, isDisturbable, isOnline,
lastSeenAt, const DeepCollectionEquality().hash(status));
int get hashCode =>
Object.hash(runtimeType, isDisturbable, isOnline, lastSeenAt, status);
@override
String toString() {
@ -2302,7 +2318,10 @@ abstract mixin class _$SnAccountStatusInfoCopyWith<$Res>
{bool isDisturbable,
bool isOnline,
DateTime? lastSeenAt,
dynamic status});
SnAccountStatus? status});
@override
$SnAccountStatusCopyWith<$Res>? get status;
}
/// @nodoc
@ -2339,7 +2358,386 @@ class __$SnAccountStatusInfoCopyWithImpl<$Res>
status: freezed == status
? _self.status
: 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
? null
: 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(
@ -219,7 +221,41 @@ Map<String, dynamic> _$SnAccountStatusInfoToJson(
'is_disturbable': instance.isDisturbable,
'is_online': instance.isOnline,
'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) =>

View File

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

View File

@ -22,6 +22,7 @@ mixin _$SnCheckInRecord {
int get resultTier;
int get resultExperience;
double get resultCoin;
int get currentStreak;
List<int> get resultModifiers;
int get accountId;
@ -54,6 +55,8 @@ mixin _$SnCheckInRecord {
other.resultExperience == resultExperience) &&
(identical(other.resultCoin, resultCoin) ||
other.resultCoin == resultCoin) &&
(identical(other.currentStreak, currentStreak) ||
other.currentStreak == currentStreak) &&
const DeepCollectionEquality()
.equals(other.resultModifiers, resultModifiers) &&
(identical(other.accountId, accountId) ||
@ -71,12 +74,13 @@ mixin _$SnCheckInRecord {
resultTier,
resultExperience,
resultCoin,
currentStreak,
const DeepCollectionEquality().hash(resultModifiers),
accountId);
@override
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 resultExperience,
double resultCoin,
int currentStreak,
List<int> resultModifiers,
int accountId});
}
@ -118,6 +123,7 @@ class _$SnCheckInRecordCopyWithImpl<$Res>
Object? resultTier = null,
Object? resultExperience = null,
Object? resultCoin = null,
Object? currentStreak = null,
Object? resultModifiers = null,
Object? accountId = null,
}) {
@ -150,6 +156,10 @@ class _$SnCheckInRecordCopyWithImpl<$Res>
? _self.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable
as double,
currentStreak: null == currentStreak
? _self.currentStreak
: currentStreak // ignore: cast_nullable_to_non_nullable
as int,
resultModifiers: null == resultModifiers
? _self.resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable
@ -173,6 +183,7 @@ class _SnCheckInRecord extends SnCheckInRecord {
required this.resultTier,
required this.resultExperience,
required this.resultCoin,
this.currentStreak = 0,
required final List<int> resultModifiers,
required this.accountId})
: _resultModifiers = resultModifiers,
@ -194,6 +205,9 @@ class _SnCheckInRecord extends SnCheckInRecord {
final int resultExperience;
@override
final double resultCoin;
@override
@JsonKey()
final int currentStreak;
final List<int> _resultModifiers;
@override
List<int> get resultModifiers {
@ -238,6 +252,8 @@ class _SnCheckInRecord extends SnCheckInRecord {
other.resultExperience == resultExperience) &&
(identical(other.resultCoin, resultCoin) ||
other.resultCoin == resultCoin) &&
(identical(other.currentStreak, currentStreak) ||
other.currentStreak == currentStreak) &&
const DeepCollectionEquality()
.equals(other._resultModifiers, _resultModifiers) &&
(identical(other.accountId, accountId) ||
@ -255,12 +271,13 @@ class _SnCheckInRecord extends SnCheckInRecord {
resultTier,
resultExperience,
resultCoin,
currentStreak,
const DeepCollectionEquality().hash(_resultModifiers),
accountId);
@override
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 resultExperience,
double resultCoin,
int currentStreak,
List<int> resultModifiers,
int accountId});
}
@ -304,6 +322,7 @@ class __$SnCheckInRecordCopyWithImpl<$Res>
Object? resultTier = null,
Object? resultExperience = null,
Object? resultCoin = null,
Object? currentStreak = null,
Object? resultModifiers = null,
Object? accountId = null,
}) {
@ -336,6 +355,10 @@ class __$SnCheckInRecordCopyWithImpl<$Res>
? _self.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable
as double,
currentStreak: null == currentStreak
? _self.currentStreak
: currentStreak // ignore: cast_nullable_to_non_nullable
as int,
resultModifiers: null == resultModifiers
? _self._resultModifiers
: 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(),
resultExperience: (json['result_experience'] as num).toInt(),
resultCoin: (json['result_coin'] as num).toDouble(),
currentStreak: (json['current_streak'] as num?)?.toInt() ?? 0,
resultModifiers: (json['result_modifiers'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
@ -32,6 +33,7 @@ Map<String, dynamic> _$SnCheckInRecordToJson(_SnCheckInRecord instance) =>
'result_tier': instance.resultTier,
'result_experience': instance.resultExperience,
'result_coin': instance.resultCoin,
'current_streak': instance.currentStreak,
'result_modifiers': instance.resultModifiers,
'account_id': instance.accountId,
};

View File

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

View File

@ -8,9 +8,9 @@ import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/experience.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/widgets/account/account_image.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/universal_image.dart';
class AccountPopoverCard extends StatelessWidget {
@ -72,37 +72,21 @@ class AccountPopoverCard extends StatelessWidget {
const Gap(8)
],
).padding(horizontal: 16),
if (data.badges.isNotEmpty) const Gap(12),
if (data.badges.isNotEmpty)
Wrap(
spacing: 4,
children: data.badges
.map(
(ele) => Tooltip(
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,
),
),
(ele) => AccountBadge(badge: ele),
)
.toList(),
).padding(horizontal: 24),
const Gap(8),
).padding(horizontal: 24, bottom: 12, top: 12),
if (data.profile?.description.isNotEmpty ?? false)
Text(
data.profile?.description ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
).padding(horizontal: 26, bottom: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@ -110,7 +94,9 @@ class AccountPopoverCard extends StatelessWidget {
const Gap(8),
Text('Lv${getLevelFromExp(data.profile?.experience ?? 0)}'),
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),
Container(
width: double.infinity,
@ -126,25 +112,36 @@ class AccountPopoverCard extends StatelessWidget {
FutureBuilder(
future: sn.client.get('/cgi/id/users/${data.name}/status'),
builder: (context, snapshot) {
final SnAccountStatusInfo? status =
snapshot.hasData ? SnAccountStatusInfo.fromJson(snapshot.data!.data) : null;
final SnAccountStatusInfo? status = snapshot.hasData
? SnAccountStatusInfo.fromJson(snapshot.data!.data)
: null;
return Row(
children: [
Icon(
Symbols.circle,
fill: 1,
(status?.isDisturbable ?? true)
? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (status?.isOnline ?? false) ? 1 : 0,
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),
const Gap(8),
Text(
status != null
? status.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
? (status.status?.label.isNotEmpty ?? false)
? status.status!.label
: status.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(),
),
if (status != null && !status.isOnline && status.lastSeenAt != null)
if (status != null &&
!status.isOnline &&
status.lastSeenAt != null)
Text(
'accountStatusLastSeen'.tr(args: [
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

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

View File

@ -29,6 +29,7 @@ import 'package:surface/types/attachment.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/reaction.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_list.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:xml/xml.dart';
import '../../screens/account/profile_page.dart' show kBadgesMeta;
class OpenablePostItem extends StatelessWidget {
final SnPost data;
final bool showReactions;
@ -223,7 +222,7 @@ class PostItem extends StatelessWidget {
onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer,
onDeleted: () {
if (onDeleted != null) {}
onDeleted?.call();
},
).padding(bottom: 8),
if (data.preload?.video != null)
@ -272,7 +271,7 @@ class PostItem extends StatelessWidget {
onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer,
onDeleted: () {
if (onDeleted != null) {}
onDeleted?.call();
},
).padding(horizontal: 12, top: 8, bottom: 8),
if (data.preload?.video != null)
@ -363,7 +362,7 @@ class PostItem extends StatelessWidget {
onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer,
onDeleted: () {
if (onDeleted != null) onDeleted!();
onDeleted?.call();
},
).padding(horizontal: 12, vertical: 8),
if (data.preload?.video != null)
@ -887,7 +886,7 @@ class _PostContentHeader extends StatelessWidget {
'publisherId': data.publisherId,
});
if (!context.mounted) return;
context.showSnackbar('postDeleted'.tr(args: ['#${data.id}']));
onDeleted.call();
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
@ -923,27 +922,39 @@ class _PostContentHeader extends StatelessWidget {
return Row(
children: [
GestureDetector(
child: AccountImage(
content: data.publisher.avatar,
radius: isCompact ? 12 : 20,
borderRadius: data.publisher.type == 1 ? (isCompact ? 4 : 8) : 20,
badge: (user?.badges.isNotEmpty ?? false)
? Icon(
kBadgesMeta[user!.badges.first.type]?.$2 ??
Symbols.question_mark,
color: kBadgesMeta[user.badges.first.type]?.$3,
fill: 1,
size: 18,
shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 5.0,
color: Color.fromARGB(200, 0, 0, 0),
child: data.preload?.realm == null
? AccountImage(
content: data.publisher.avatar,
radius: isCompact ? 12 : 20,
borderRadius:
data.publisher.type == 1 ? (isCompact ? 4 : 8) : 20,
badge: (user?.badges.isNotEmpty ?? false)
? AccountBadge(
badge: user!.badges.first,
radius: 16,
padding: EdgeInsets.all(2),
)
: null,
)
: 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,
),
],
)
: null,
),
borderRadius: BorderRadius.circular(10),
),
child: AccountImage(
content: data.publisher.avatar,
radius: 10,
),
),
),
onTap: () {
showPopover(
backgroundColor: Theme.of(context).colorScheme.surface,
@ -988,7 +999,17 @@ class _PostContentHeader extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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(
children: [
Text('@${data.publisher.name}').fontSize(13),

View File

@ -9,10 +9,9 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/universal_image.dart';
import '../../screens/account/profile_page.dart' show kBadgesMeta;
class PublisherPopoverCard extends StatelessWidget {
final SnPublisher data;
@ -76,39 +75,22 @@ class PublisherPopoverCard extends StatelessWidget {
const Gap(8)
],
).padding(horizontal: 16),
if (user != null && user.badges.isNotEmpty) const Gap(16),
if (user != null && user.badges.isNotEmpty)
Wrap(
spacing: 4,
children: user.badges
.map(
(ele) => Tooltip(
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,
),
),
(ele) => AccountBadge(badge: ele),
)
.toList(),
).padding(horizontal: 24),
).padding(horizontal: 24, top: 16),
const Gap(16),
if (data.description.isNotEmpty)
Text(
data.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).padding(horizontal: 26, bottom: 20),
Row(
children: [
Expanded(

View File

@ -173,10 +173,10 @@ packages:
dependency: transitive
description:
name: built_value
sha256: "8b158ab94ec6913e480dc3f752418348b5ae099eb75868b5f4775f0572999c61"
sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4
url: "https://pub.dev"
source: hosted
version: "8.9.4"
version: "8.9.5"
cached_network_image:
dependency: "direct main"
description:
@ -301,10 +301,10 @@ packages:
dependency: "direct main"
description:
name: croppy
sha256: bf99b00023df0d7d047e04d27d496d87cbefd968f578d0bd30f342ff75570a12
sha256: "99f4fbb4a4b44d2712e8dcd61c57c1acac151bd53cab11de3babec80407ed266"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
version: "1.3.5"
cross_file:
dependency: "direct main"
description:
@ -429,18 +429,18 @@ packages:
dependency: "direct main"
description:
name: drift
sha256: "97d5832657d49f26e7a8e07de397ddc63790b039372878d5117af816d0fdb5cb"
sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5"
url: "https://pub.dev"
source: hosted
version: "2.25.1"
version: "2.26.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: f1db88482dbb016b9bbddddf746d5d0a6938b156ff20e07320052981f97388cc
sha256: "0d3f8b33b76cf1c6a82ee34d9511c40957549c4674b8f1688609e6d6c7306588"
url: "https://pub.dev"
source: hosted
version: "2.25.2"
version: "2.26.0"
drift_flutter:
dependency: "direct main"
description:
@ -710,6 +710,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@ -831,10 +839,10 @@ packages:
dependency: "direct main"
description:
name: flutter_map
sha256: bbf145e8220531f2f727608c431871c7457f3b134e513543913afd00fdc1cd47
sha256: f7d0379477274f323c3f3bc12d369a2b42eb86d1e7bd2970ae1ea3cff782449a
url: "https://pub.dev"
source: hosted
version: "8.1.0"
version: "8.1.1"
flutter_markdown:
dependency: "direct main"
description:
@ -1177,10 +1185,10 @@ packages:
dependency: "direct main"
description:
name: image_picker_android
sha256: "82652a75e3dd667a91187769a6a2cc81bd8c111bbead698d8e938d2b63e5e89a"
sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9"
url: "https://pub.dev"
source: hosted
version: "0.8.12+21"
version: "0.8.12+22"
image_picker_for_web:
dependency: transitive
description:
@ -1409,10 +1417,10 @@ packages:
dependency: "direct main"
description:
name: material_symbols_icons
sha256: "1403944c2a68dbdf934fca2b2115a372e70a4a185c136ab71a9d16e2108d0b14"
sha256: ca30ccbd97763353bde6bb1076aa4f4d17a40db0804384da77df142102aa225d
url: "https://pub.dev"
source: hosted
version: "4.2805.1"
version: "4.2808.0"
media_kit:
dependency: "direct main"
description:
@ -1601,10 +1609,10 @@ packages:
dependency: transitive
description:
name: path_provider_android
sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2"
sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12"
url: "https://pub.dev"
source: hosted
version: "2.2.15"
version: "2.2.16"
path_provider_foundation:
dependency: transitive
description:
@ -1785,10 +1793,10 @@ packages:
dependency: transitive
description:
name: pub_semver
sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd"
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
@ -1953,10 +1961,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: a768fc8ede5f0c8e6150476e14f38e2417c0864ca36bb4582be8e21925a03c22
sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad"
url: "https://pub.dev"
source: hosted
version: "2.4.6"
version: "2.4.8"
shared_preferences_foundation:
dependency: transitive
description:
@ -2118,10 +2126,10 @@ packages:
dependency: transitive
description:
name: sqlite3
sha256: "32b632dda27d664f85520093ed6f735ae5c49b5b75345afb8b19411bc59bb53d"
sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e"
url: "https://pub.dev"
source: hosted
version: "2.7.4"
version: "2.7.5"
sqlite3_flutter_libs:
dependency: transitive
description:
@ -2326,10 +2334,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193"
sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4"
url: "https://pub.dev"
source: hosted
version: "6.3.14"
version: "6.3.15"
url_launcher_ios:
dependency: transitive
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
# 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.
version: 2.4.2+77
version: 2.4.2+78
environment:
sdk: ^3.5.4
@ -138,6 +138,7 @@ dependencies:
flutter_map: ^8.1.0
geolocator: ^13.0.2
fast_rsa: ^3.8.0
flutter_card_swiper: ^7.0.2
dev_dependencies:
flutter_test: