Compare commits

..

7 Commits

Author SHA1 Message Date
e2ecb573a2 🚀 Launch 2.3.2+70 2025-02-18 00:52:07 +08:00
8cb5dff498 🌐 Update Traditional Chinese localization files 2025-02-18 00:43:57 +08:00
a5629975ed Content insert support 2025-02-18 00:43:12 +08:00
972b304969 Flag posts 2025-02-18 00:38:32 +08:00
e8ded55055 🐛 Fix some listing non offset bugs
 Optimize user listing speed
2025-02-18 00:07:48 +08:00
04875eb164 Support notifications with multiple attachments 2025-02-18 00:07:20 +08:00
54a59aa470 💄 Recommendation post indicator 2025-02-17 18:53:46 +08:00
19 changed files with 362 additions and 83 deletions

View File

@ -333,6 +333,7 @@
"addAttachmentFromRandomId": "Link via RID", "addAttachmentFromRandomId": "Link via RID",
"attachmentDetailInfo": "Attachment details", "attachmentDetailInfo": "Attachment details",
"attachmentPastedImage": "Pasted Image", "attachmentPastedImage": "Pasted Image",
"attachmentInsertedImage": "Inserted Image",
"attachmentInsertLink": "Insert Link", "attachmentInsertLink": "Insert Link",
"attachmentSetAsPostThumbnail": "Set as post thumbnail", "attachmentSetAsPostThumbnail": "Set as post thumbnail",
"attachmentUnsetAsPostThumbnail": "Unset as post thumbnail", "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
@ -650,5 +651,19 @@
"realmIsCommunity": "Community Realm", "realmIsCommunity": "Community Realm",
"realmIsCommunityDescription": "Community realm will be displayed on the discover page.", "realmIsCommunityDescription": "Community realm will be displayed on the discover page.",
"realmLeave": "Leave Realm", "realmLeave": "Leave Realm",
"realmLeaveDescription": "Leave the current realm and delete the realm's identity." "realmLeaveDescription": "Leave the current realm and delete the realm's identity.",
"checkInResultTier1": "Worst",
"checkInResultTier2": "Worse",
"checkInResultTier3": "Normal",
"checkInResultTier4": "Better",
"checkInResultTier5": "Best",
"flagPostAction": "Flag the Post",
"flagPost": "Flag objectionable content",
"flagPostDescription": "If flagged users takes 50% or more of the views, the post will be collapsed. You cannot revoke the action.",
"flaggedPost": "Post has been flagged.",
"postViews": {
"zero": "No views",
"one": "{} view",
"other": "{} views"
}
} }

View File

@ -331,6 +331,7 @@
"addAttachmentFromRandomId": "通过访问 ID 链接", "addAttachmentFromRandomId": "通过访问 ID 链接",
"attachmentDetailInfo": "附件详细信息", "attachmentDetailInfo": "附件详细信息",
"attachmentPastedImage": "粘贴的图片", "attachmentPastedImage": "粘贴的图片",
"attachmentInsertedImage": "插入的图片",
"attachmentInsertLink": "插入连接", "attachmentInsertLink": "插入连接",
"attachmentSetAsPostThumbnail": "设置为帖子缩略图", "attachmentSetAsPostThumbnail": "设置为帖子缩略图",
"attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图", "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
@ -649,5 +650,19 @@
"realmIsCommunity": "社区领域", "realmIsCommunity": "社区领域",
"realmIsCommunityDescription": "社区领域会显示在发现页面上。", "realmIsCommunityDescription": "社区领域会显示在发现页面上。",
"realmLeave": "离开领域", "realmLeave": "离开领域",
"realmLeaveDescription": "离开当前领域,并且删除领域中的身份。" "realmLeaveDescription": "离开当前领域,并且删除领域中的身份。",
"checkInResultTier1": "大凶",
"checkInResultTier2": "凶",
"checkInResultTier3": "中平",
"checkInResultTier4": "吉",
"checkInResultTier5": "大吉",
"flagPostAction": "吹哨",
"flagPost": "吹哨不良内容",
"flagPostDescription": "吹哨不良内容,如果吹哨用户占浏览量的 50% 或以上,则帖子会被折叠。吹哨后不可撤销。",
"flaggedPost": "哨子已经吹响。",
"postViews": {
"zero": "{} 次浏览",
"one": "{} 次浏览",
"other": "{} 次浏览"
}
} }

View File

@ -331,6 +331,7 @@
"addAttachmentFromRandomId": "通過訪問 ID 鏈接", "addAttachmentFromRandomId": "通過訪問 ID 鏈接",
"attachmentDetailInfo": "附件詳細信息", "attachmentDetailInfo": "附件詳細信息",
"attachmentPastedImage": "粘貼的圖片", "attachmentPastedImage": "粘貼的圖片",
"attachmentInsertedImage": "插入的圖片",
"attachmentInsertLink": "插入連接", "attachmentInsertLink": "插入連接",
"attachmentSetAsPostThumbnail": "設置為帖子縮略圖", "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
"attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖", "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
@ -624,5 +625,44 @@
"realmCommunityHint": "該領域是一個社區領域,你可以自由加入。", "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
"realmCommunityPublicChannelsHint": "該領域包含的公共頻道", "realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
"realmJoined": "已加入領域 {}。", "realmJoined": "已加入領域 {}。",
"join": "加入" "join": "加入",
"pollEditorNew": "新投票",
"pollEditorEdit": "編輯投票",
"pollEditorDelete": "刪除投票",
"pollEditorDeleteDescription": "你確定要刪除這個投票嗎?該操作不可撤銷。",
"pollEditorUnlink": "解除鏈接",
"pollOptionAdd": "添加選項",
"pollOptionName": "選項名稱",
"pollLinkExisting": "鏈接現有投票",
"pollAnswered": "答案已經反饋。",
"pollVotes": {
"one": "{} 票",
"other": "{} 票"
},
"publisherDelete": "刪除發佈者 {}",
"publisherDeleteDescription": "你確定要刪除這個發佈者嗎?該操作不可撤銷。",
"channelIsPublic": "公開頻道",
"channelIsPublicDescription": "該頻道是公開的,任何人都可以加入。",
"channelIsCommunity": "社區頻道",
"channelIsCommunityDescription": "目前來説,社區頻道還沒有什麼特別之處。",
"realmIsPublic": "公開領域",
"realmIsPublicDescription": "該領域是公開的,任何人都可以加入。",
"realmIsCommunity": "社區領域",
"realmIsCommunityDescription": "社區領域會顯示在發現頁面上。",
"realmLeave": "離開領域",
"realmLeaveDescription": "離開當前領域,並且刪除領域中的身份。",
"checkInResultTier1": "大凶",
"checkInResultTier2": "兇",
"checkInResultTier3": "中平",
"checkInResultTier4": "吉",
"checkInResultTier5": "大吉",
"flagPostAction": "吹哨",
"flagPost": "吹哨不良內容",
"flagPostDescription": "吹哨不良內容,如果吹哨用户佔瀏覽量的 50% 或以上,則帖子會被摺疊。吹哨後不可撤銷。",
"flaggedPost": "哨子已經吹響。",
"postViews": {
"zero": "{} 次瀏覽",
"one": "{} 次瀏覽",
"other": "{} 次瀏覽"
}
} }

View File

@ -331,6 +331,7 @@
"addAttachmentFromRandomId": "通過訪問 ID 鏈接", "addAttachmentFromRandomId": "通過訪問 ID 鏈接",
"attachmentDetailInfo": "附件詳細信息", "attachmentDetailInfo": "附件詳細信息",
"attachmentPastedImage": "粘貼的圖片", "attachmentPastedImage": "粘貼的圖片",
"attachmentInsertedImage": "插入的圖片",
"attachmentInsertLink": "插入連接", "attachmentInsertLink": "插入連接",
"attachmentSetAsPostThumbnail": "設置為帖子縮略圖", "attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
"attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖", "attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
@ -624,5 +625,44 @@
"realmCommunityHint": "該領域是一個社區領域,你可以自由加入。", "realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
"realmCommunityPublicChannelsHint": "該領域包含的公共頻道", "realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
"realmJoined": "已加入領域 {}。", "realmJoined": "已加入領域 {}。",
"join": "加入" "join": "加入",
"pollEditorNew": "新投票",
"pollEditorEdit": "編輯投票",
"pollEditorDelete": "刪除投票",
"pollEditorDeleteDescription": "你確定要刪除這個投票嗎?該操作不可撤銷。",
"pollEditorUnlink": "解除鏈接",
"pollOptionAdd": "添加選項",
"pollOptionName": "選項名稱",
"pollLinkExisting": "鏈接現有投票",
"pollAnswered": "答案已經反饋。",
"pollVotes": {
"one": "{} 票",
"other": "{} 票"
},
"publisherDelete": "刪除發佈者 {}",
"publisherDeleteDescription": "你確定要刪除這個發佈者嗎?該操作不可撤銷。",
"channelIsPublic": "公開頻道",
"channelIsPublicDescription": "該頻道是公開的,任何人都可以加入。",
"channelIsCommunity": "社區頻道",
"channelIsCommunityDescription": "目前來說,社區頻道還沒有什麼特別之處。",
"realmIsPublic": "公開領域",
"realmIsPublicDescription": "該領域是公開的,任何人都可以加入。",
"realmIsCommunity": "社區領域",
"realmIsCommunityDescription": "社區領域會顯示在發現頁面上。",
"realmLeave": "離開領域",
"realmLeaveDescription": "離開當前領域,並且刪除領域中的身份。",
"checkInResultTier1": "大凶",
"checkInResultTier2": "兇",
"checkInResultTier3": "中平",
"checkInResultTier4": "吉",
"checkInResultTier5": "大吉",
"flagPostAction": "吹哨",
"flagPost": "吹哨不良內容",
"flagPostDescription": "吹哨不良內容,如果吹哨用戶佔瀏覽量的 50% 或以上,則帖子會被摺疊。吹哨後不可撤銷。",
"flaggedPost": "哨子已經吹響。",
"postViews": {
"zero": "{} 次瀏覽",
"one": "{} 次瀏覽",
"other": "{} 次瀏覽"
}
} }

View File

@ -123,48 +123,59 @@ class NotificationService: UNNotificationServiceExtension {
} }
if let imageIdentifier = metadata["image"] as? String { if let imageIdentifier = metadata["image"] as? String {
attachMedia(to: content, withIdentifier: imageIdentifier, fileType: UTType.jpeg, doScaleDown: true) attachMedia(to: content, withIdentifier: [imageIdentifier], fileType: UTType.jpeg, doScaleDown: true)
} else if let avatarIdentifier = metadata["avatar"] as? String { } else if let avatarIdentifier = metadata["avatar"] as? String {
attachMedia(to: content, withIdentifier: avatarIdentifier, fileType: UTType.jpeg, doScaleDown: true) attachMedia(to: content, withIdentifier: [avatarIdentifier], fileType: UTType.jpeg, doScaleDown: true)
} else if let imagesIdentifier = metadata["images"] as? Array<String> {
attachMedia(to: content, withIdentifier: imagesIdentifier, fileType: UTType.jpeg, doScaleDown: true)
} else { } else {
contentHandler?(content) contentHandler?(content)
} }
} }
private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: String, fileType type: UTType?, doScaleDown scaleDown: Bool = false) { private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: Array<String>, fileType type: UTType?, doScaleDown scaleDown: Bool = false) {
let attachmentUrl = getAttachmentUrl(for: identifier) let attachmentUrls = identifier.compactMap { element in
return getAttachmentUrl(for: element)
guard let remoteUrl = URL(string: attachmentUrl) else { }
print("Invalid URL for attachment: \(attachmentUrl)")
guard !attachmentUrls.isEmpty else {
print("Invalid URLs for attachments: \(attachmentUrls)")
return return
} }
let targetSize = 800 let targetSize = 800
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit) let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [ for attachmentUrl in attachmentUrls {
.processor(scaleProcessor) guard let remoteUrl = URL(string: attachmentUrl) else {
] : nil) { [weak self] result in print("Invalid URL for attachment: \(attachmentUrl)")
guard let self = self else { return } continue // Skip this URL and move to the next one
}
switch result {
case .success(let retrievalResult): KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [
// The image is either retrieved from cache or downloaded .processor(scaleProcessor)
let tempDirectory = FileManager.default.temporaryDirectory ] : nil) { [weak self] result in
let cachedFileUrl = tempDirectory.appendingPathComponent(identifier) guard let self = self else { return }
do { switch result {
// Write the image data to a temporary file for UNNotificationAttachment case .success(let retrievalResult):
try retrievalResult.image.pngData()?.write(to: cachedFileUrl) // The image is either retrieved from cache or downloaded
self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: identifier) let tempDirectory = FileManager.default.temporaryDirectory
} catch { let cachedFileUrl = tempDirectory.appendingPathComponent(UUID().uuidString) // Unique identifier for each file
print("Failed to write media to temporary file: \(error.localizedDescription)")
do {
// Write the image data to a temporary file for UNNotificationAttachment
try retrievalResult.image.pngData()?.write(to: cachedFileUrl)
self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: attachmentUrl)
} catch {
print("Failed to write media to temporary file: \(error.localizedDescription)")
self.contentHandler?(content)
}
case .failure(let error):
print("Failed to retrieve image: \(error.localizedDescription)")
self.contentHandler?(content) self.contentHandler?(content)
} }
case .failure(let error):
print("Failed to retrieve image: \(error.localizedDescription)")
self.contentHandler?(content)
} }
} }
} }

View File

@ -158,6 +158,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(
onContentInserted: (KeyboardInsertedContent content) {
if (content.hasData) {
addAttachments([PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]);
}
},
);
bool _temporarySaveActive = false; bool _temporarySaveActive = false;
PostWriteController({bool doLoadFromTemporary = true}) { PostWriteController({bool doLoadFromTemporary = true}) {

View File

@ -45,8 +45,8 @@ class ConfigProvider extends ChangeNotifier {
bool newDrawerIsCollapsed = false; bool newDrawerIsCollapsed = false;
bool newDrawerIsExpanded = false; bool newDrawerIsExpanded = false;
if (withMediaQuery) { if (withMediaQuery) {
newDrawerIsCollapsed = MediaQuery.of(context).size.width < 450; newDrawerIsCollapsed = MediaQuery.of(context).size.width < 600;
newDrawerIsExpanded = MediaQuery.of(context).size.width >= 451; newDrawerIsExpanded = MediaQuery.of(context).size.width >= 601;
} else { } else {
final rpb = ResponsiveBreakpoints.of(context); final rpb = ResponsiveBreakpoints.of(context);
newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE); newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE);

View File

@ -14,9 +14,32 @@ class UserDirectoryProvider {
final Map<int, SnAccount> _cache = {}; final Map<int, SnAccount> _cache = {};
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async { Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
final out = await Future.wait( final out = List<SnAccount?>.generate(id.length, (e) => null);
id.map((e) => getAccount(e)), final plannedQuery = <int>{};
); for (var idx = 0; idx < out.length; idx++) {
var item = id.elementAt(idx);
if (item is String && _idCache.containsKey(item)) {
item = _idCache[item];
}
if (_cache.containsKey(item)) {
out[idx] = _cache[item];
} else {
plannedQuery.add(item);
}
}
final resp = await _sn.client.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});
final respDecoded = resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList();
var sideIdx = 0;
for (var idx = 0; idx < out.length; idx++) {
if (out[idx] != null) continue;
if (respDecoded.length <= sideIdx) {
break;
}
out[idx] = respDecoded[sideIdx];
_cache[respDecoded[sideIdx].id] = out[idx]!;
_idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id;
sideIdx++;
}
return out; return out;
} }

View File

@ -474,7 +474,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/im/channels/${widget.channel.keyPath}/members', queryParameters: { final resp = await sn.client.get('/cgi/im/channels/${widget.channel.keyPath}/members', queryParameters: {
'take': 10, 'take': 10,
'offset': 0, 'offset': _members.length,
}); });
final out = List<SnChannelMember>.from( final out = List<SnChannelMember>.from(
resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [], resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [],

View File

@ -663,10 +663,24 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
} }
} }
int _currentPage = 0;
final PageController _pageController = PageController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_fetchRecommendationPosts(); _fetchRecommendationPosts();
_pageController.addListener(() {
setState(() {
_currentPage = _pageController.page?.round() ?? 0;
});
});
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
} }
@override @override
@ -684,17 +698,24 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Icon(Symbols.star), Row(
const Gap(8), children: [
Text( const Icon(Symbols.star),
'postRecommendation', const Gap(8),
style: Theme.of(context).textTheme.titleLarge, Text(
).tr() 'postRecommendation',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
],
),
Text('${_currentPage + 1}/${_posts?.length ?? 0}', style: GoogleFonts.robotoMono())
], ],
).padding(horizontal: 18, top: 12, bottom: 8), ).padding(horizontal: 18, top: 12, bottom: 8),
Expanded( Expanded(
child: PageView.builder( child: PageView.builder(
controller: _pageController,
scrollBehavior: ScrollConfiguration.of(context).copyWith(dragDevices: { scrollBehavior: ScrollConfiguration.of(context).copyWith(dragDevices: {
PointerDeviceKind.mouse, PointerDeviceKind.mouse,
PointerDeviceKind.touch, PointerDeviceKind.touch,

View File

@ -3,6 +3,7 @@ import 'dart:math' as math;
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:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
@ -59,10 +60,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
final resp = await sn.client.get('/cgi/id/notifications?take=10'); final resp = await sn.client.get('/cgi/id/notifications?take=10');
_totalCount = resp.data['count']; _totalCount = resp.data['count'];
_notifications.addAll( _notifications.addAll(
resp.data['data'] resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? [],
?.map((e) => SnNotification.fromJson(e))
.cast<SnNotification>() ??
[],
); );
nty.updateTray(); nty.updateTray();
} catch (err) { } catch (err) {
@ -188,8 +186,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
_fetchNotifications(); _fetchNotifications();
}, },
isLoading: _isBusy, isLoading: _isBusy,
hasReachedMax: _totalCount != null && hasReachedMax: _totalCount != null && _notifications.length >= _totalCount!,
_notifications.length >= _totalCount!,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
final nty = _notifications[idx]; final nty = _notifications[idx];
return Row( return Row(
@ -221,29 +218,36 @@ class _NotificationScreenState extends State<NotificationScreen> {
isAutoWarp: true, isAutoWarp: true,
), ),
), ),
if ([ if (['interactive.reply', 'interactive.feedback', 'interactive.subscription']
'interactive.feedback', .contains(nty.topic) &&
'interactive.subscription'
].contains(nty.topic) &&
nty.metadata['related_post'] != null) nty.metadata['related_post'] != null)
StyledWidget(Container( GestureDetector(
decoration: BoxDecoration( child: Container(
borderRadius: const BorderRadius.all( decoration: BoxDecoration(
Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all( border: Border.all(
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
width: 1, width: 1,
),
),
child: PostItem(
data: SnPost.fromJson(
nty.metadata['related_post']!,
),
showComments: false,
showReactions: false,
showMenu: false,
), ),
), ),
child: PostItem( onTap: () {
data: SnPost.fromJson( GoRouter.of(context).pushNamed(
nty.metadata['related_post']!, 'postDetail',
), pathParameters: {
showComments: false, 'slug': nty.metadata['related_post']!['id'].toString(),
showReactions: false, },
showMenu: false, );
), },
)).padding(top: 8), ).padding(top: 8),
const Gap(8), const Gap(8),
Row( Row(
children: [ children: [
@ -268,10 +272,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
IconButton( IconButton(
icon: const Icon(Symbols.check), icon: const Icon(Symbols.check),
padding: EdgeInsets.all(0), padding: EdgeInsets.all(0),
visualDensity: visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
const VisualDensity(horizontal: -4, vertical: -4), onPressed: _isSubmitting ? null : () => _markOneAsRead(nty),
onPressed:
_isSubmitting ? null : () => _markOneAsRead(nty),
), ),
], ],
).padding(horizontal: 16); ).padding(horizontal: 16);

View File

@ -602,6 +602,7 @@ class _PostStoryEditor extends StatelessWidget {
border: InputBorder.none, border: InputBorder.none,
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration: controller.contentInsertionConfiguration,
), ),
], ],
), ),
@ -665,6 +666,7 @@ class _PostArticleEditor extends StatelessWidget {
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration: controller.contentInsertionConfiguration,
).padding(horizontal: 16), ).padding(horizontal: 16),
const Gap(4), const Gap(4),
]; ];
@ -692,6 +694,7 @@ class _PostArticleEditor extends StatelessWidget {
border: InputBorder.none, border: InputBorder.none,
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration: controller.contentInsertionConfiguration,
), ),
), ),
const Gap(8), const Gap(8),
@ -726,6 +729,7 @@ class _PostArticleEditor extends StatelessWidget {
border: InputBorder.none, border: InputBorder.none,
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration: controller.contentInsertionConfiguration,
), ),
), ),
], ],
@ -797,6 +801,7 @@ class _PostQuestionEditor extends StatelessWidget {
border: InputBorder.none, border: InputBorder.none,
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration: controller.contentInsertionConfiguration,
), ),
], ],
), ),

View File

@ -189,7 +189,7 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
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('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: {
'take': 10, 'take': 10,
'offset': 0, 'offset': _members.length,
}); });
final out = List<SnRealmMember>.from( final out = List<SnRealmMember>.from(

View File

@ -1,9 +1,17 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'check_in.freezed.dart'; part 'check_in.freezed.dart';
part 'check_in.g.dart'; part 'check_in.g.dart';
const List<String> kCheckInResultTierSymbols = ['Bad', 'Poor', 'Medium', 'Good', 'Great']; final List<String> kCheckInResultTierSymbols = [
'checkInResultTier1',
'checkInResultTier2',
'checkInResultTier3',
'checkInResultTier4',
'checkInResultTier5'
].map((e) => e.tr()).toList();
@freezed @freezed
class SnCheckInRecord with _$SnCheckInRecord { class SnCheckInRecord with _$SnCheckInRecord {
@ -21,8 +29,7 @@ class SnCheckInRecord with _$SnCheckInRecord {
required int accountId, required int accountId,
}) = _SnCheckInRecord; }) = _SnCheckInRecord;
factory SnCheckInRecord.fromJson(Map<String, dynamic> json) => factory SnCheckInRecord.fromJson(Map<String, dynamic> json) => _$SnCheckInRecordFromJson(json);
_$SnCheckInRecordFromJson(json);
String get symbol => kCheckInResultTierSymbols[resultTier]; String get symbol => kCheckInResultTierSymbols[resultTier];
} }

View File

@ -37,6 +37,8 @@ class SnPost with _$SnPost {
required DateTime? publishedUntil, required DateTime? publishedUntil,
required int totalUpvote, required int totalUpvote,
required int totalDownvote, required int totalDownvote,
@Default(0) int totalViews,
@Default(0) int totalAggregatedViews,
required int publisherId, required int publisherId,
required int? pollId, required int? pollId,
required SnPublisher publisher, required SnPublisher publisher,

View File

@ -47,6 +47,8 @@ mixin _$SnPost {
DateTime? get publishedUntil => throw _privateConstructorUsedError; DateTime? get publishedUntil => throw _privateConstructorUsedError;
int get totalUpvote => throw _privateConstructorUsedError; int get totalUpvote => throw _privateConstructorUsedError;
int get totalDownvote => throw _privateConstructorUsedError; int get totalDownvote => throw _privateConstructorUsedError;
int get totalViews => throw _privateConstructorUsedError;
int get totalAggregatedViews => throw _privateConstructorUsedError;
int get publisherId => throw _privateConstructorUsedError; int get publisherId => throw _privateConstructorUsedError;
int? get pollId => throw _privateConstructorUsedError; int? get pollId => throw _privateConstructorUsedError;
SnPublisher get publisher => throw _privateConstructorUsedError; SnPublisher get publisher => throw _privateConstructorUsedError;
@ -95,6 +97,8 @@ abstract class $SnPostCopyWith<$Res> {
DateTime? publishedUntil, DateTime? publishedUntil,
int totalUpvote, int totalUpvote,
int totalDownvote, int totalDownvote,
int totalViews,
int totalAggregatedViews,
int publisherId, int publisherId,
int? pollId, int? pollId,
SnPublisher publisher, SnPublisher publisher,
@ -150,6 +154,8 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
Object? publishedUntil = freezed, Object? publishedUntil = freezed,
Object? totalUpvote = null, Object? totalUpvote = null,
Object? totalDownvote = null, Object? totalDownvote = null,
Object? totalViews = null,
Object? totalAggregatedViews = null,
Object? publisherId = null, Object? publisherId = null,
Object? pollId = freezed, Object? pollId = freezed,
Object? publisher = null, Object? publisher = null,
@ -265,6 +271,14 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
? _value.totalDownvote ? _value.totalDownvote
: totalDownvote // ignore: cast_nullable_to_non_nullable : totalDownvote // ignore: cast_nullable_to_non_nullable
as int, as int,
totalViews: null == totalViews
? _value.totalViews
: totalViews // ignore: cast_nullable_to_non_nullable
as int,
totalAggregatedViews: null == totalAggregatedViews
? _value.totalAggregatedViews
: totalAggregatedViews // ignore: cast_nullable_to_non_nullable
as int,
publisherId: null == publisherId publisherId: null == publisherId
? _value.publisherId ? _value.publisherId
: publisherId // ignore: cast_nullable_to_non_nullable : publisherId // ignore: cast_nullable_to_non_nullable
@ -386,6 +400,8 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
DateTime? publishedUntil, DateTime? publishedUntil,
int totalUpvote, int totalUpvote,
int totalDownvote, int totalDownvote,
int totalViews,
int totalAggregatedViews,
int publisherId, int publisherId,
int? pollId, int? pollId,
SnPublisher publisher, SnPublisher publisher,
@ -444,6 +460,8 @@ class __$$SnPostImplCopyWithImpl<$Res>
Object? publishedUntil = freezed, Object? publishedUntil = freezed,
Object? totalUpvote = null, Object? totalUpvote = null,
Object? totalDownvote = null, Object? totalDownvote = null,
Object? totalViews = null,
Object? totalAggregatedViews = null,
Object? publisherId = null, Object? publisherId = null,
Object? pollId = freezed, Object? pollId = freezed,
Object? publisher = null, Object? publisher = null,
@ -559,6 +577,14 @@ class __$$SnPostImplCopyWithImpl<$Res>
? _value.totalDownvote ? _value.totalDownvote
: totalDownvote // ignore: cast_nullable_to_non_nullable : totalDownvote // ignore: cast_nullable_to_non_nullable
as int, as int,
totalViews: null == totalViews
? _value.totalViews
: totalViews // ignore: cast_nullable_to_non_nullable
as int,
totalAggregatedViews: null == totalAggregatedViews
? _value.totalAggregatedViews
: totalAggregatedViews // ignore: cast_nullable_to_non_nullable
as int,
publisherId: null == publisherId publisherId: null == publisherId
? _value.publisherId ? _value.publisherId
: publisherId // ignore: cast_nullable_to_non_nullable : publisherId // ignore: cast_nullable_to_non_nullable
@ -614,6 +640,8 @@ class _$SnPostImpl extends _SnPost {
required this.publishedUntil, required this.publishedUntil,
required this.totalUpvote, required this.totalUpvote,
required this.totalDownvote, required this.totalDownvote,
this.totalViews = 0,
this.totalAggregatedViews = 0,
required this.publisherId, required this.publisherId,
required this.pollId, required this.pollId,
required this.publisher, required this.publisher,
@ -731,6 +759,12 @@ class _$SnPostImpl extends _SnPost {
@override @override
final int totalDownvote; final int totalDownvote;
@override @override
@JsonKey()
final int totalViews;
@override
@JsonKey()
final int totalAggregatedViews;
@override
final int publisherId; final int publisherId;
@override @override
final int? pollId; final int? pollId;
@ -743,7 +777,7 @@ class _$SnPostImpl extends _SnPost {
@override @override
String toString() { String toString() {
return 'SnPost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, body: $body, language: $language, alias: $alias, aliasPrefix: $aliasPrefix, tags: $tags, categories: $categories, replies: $replies, replyId: $replyId, repostId: $repostId, replyTo: $replyTo, repostTo: $repostTo, visibleUsersList: $visibleUsersList, invisibleUsersList: $invisibleUsersList, visibility: $visibility, editedAt: $editedAt, pinnedAt: $pinnedAt, lockedAt: $lockedAt, isDraft: $isDraft, publishedAt: $publishedAt, publishedUntil: $publishedUntil, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, publisherId: $publisherId, pollId: $pollId, publisher: $publisher, metric: $metric, preload: $preload)'; return 'SnPost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, body: $body, language: $language, alias: $alias, aliasPrefix: $aliasPrefix, tags: $tags, categories: $categories, replies: $replies, replyId: $replyId, repostId: $repostId, replyTo: $replyTo, repostTo: $repostTo, visibleUsersList: $visibleUsersList, invisibleUsersList: $invisibleUsersList, visibility: $visibility, editedAt: $editedAt, pinnedAt: $pinnedAt, lockedAt: $lockedAt, isDraft: $isDraft, publishedAt: $publishedAt, publishedUntil: $publishedUntil, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, totalViews: $totalViews, totalAggregatedViews: $totalAggregatedViews, publisherId: $publisherId, pollId: $pollId, publisher: $publisher, metric: $metric, preload: $preload)';
} }
@override @override
@ -796,6 +830,10 @@ class _$SnPostImpl extends _SnPost {
other.totalUpvote == totalUpvote) && other.totalUpvote == totalUpvote) &&
(identical(other.totalDownvote, totalDownvote) || (identical(other.totalDownvote, totalDownvote) ||
other.totalDownvote == totalDownvote) && other.totalDownvote == totalDownvote) &&
(identical(other.totalViews, totalViews) ||
other.totalViews == totalViews) &&
(identical(other.totalAggregatedViews, totalAggregatedViews) ||
other.totalAggregatedViews == totalAggregatedViews) &&
(identical(other.publisherId, publisherId) || (identical(other.publisherId, publisherId) ||
other.publisherId == publisherId) && other.publisherId == publisherId) &&
(identical(other.pollId, pollId) || other.pollId == pollId) && (identical(other.pollId, pollId) || other.pollId == pollId) &&
@ -836,6 +874,8 @@ class _$SnPostImpl extends _SnPost {
publishedUntil, publishedUntil,
totalUpvote, totalUpvote,
totalDownvote, totalDownvote,
totalViews,
totalAggregatedViews,
publisherId, publisherId,
pollId, pollId,
publisher, publisher,
@ -888,6 +928,8 @@ abstract class _SnPost extends SnPost {
required final DateTime? publishedUntil, required final DateTime? publishedUntil,
required final int totalUpvote, required final int totalUpvote,
required final int totalDownvote, required final int totalDownvote,
final int totalViews,
final int totalAggregatedViews,
required final int publisherId, required final int publisherId,
required final int? pollId, required final int? pollId,
required final SnPublisher publisher, required final SnPublisher publisher,
@ -952,6 +994,10 @@ abstract class _SnPost extends SnPost {
@override @override
int get totalDownvote; int get totalDownvote;
@override @override
int get totalViews;
@override
int get totalAggregatedViews;
@override
int get publisherId; int get publisherId;
@override @override
int? get pollId; int? get pollId;

View File

@ -62,6 +62,9 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
: DateTime.parse(json['published_until'] as String), : DateTime.parse(json['published_until'] as String),
totalUpvote: (json['total_upvote'] as num).toInt(), totalUpvote: (json['total_upvote'] as num).toInt(),
totalDownvote: (json['total_downvote'] as num).toInt(), totalDownvote: (json['total_downvote'] as num).toInt(),
totalViews: (json['total_views'] as num?)?.toInt() ?? 0,
totalAggregatedViews:
(json['total_aggregated_views'] as num?)?.toInt() ?? 0,
publisherId: (json['publisher_id'] as num).toInt(), publisherId: (json['publisher_id'] as num).toInt(),
pollId: (json['poll_id'] as num?)?.toInt(), pollId: (json['poll_id'] as num?)?.toInt(),
publisher: publisher:
@ -101,6 +104,8 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'published_until': instance.publishedUntil?.toIso8601String(), 'published_until': instance.publishedUntil?.toIso8601String(),
'total_upvote': instance.totalUpvote, 'total_upvote': instance.totalUpvote,
'total_downvote': instance.totalDownvote, 'total_downvote': instance.totalDownvote,
'total_views': instance.totalViews,
'total_aggregated_views': instance.totalAggregatedViews,
'publisher_id': instance.publisherId, 'publisher_id': instance.publisherId,
'poll_id': instance.pollId, 'poll_id': instance.pollId,
'publisher': instance.publisher.toJson(), 'publisher': instance.publisher.toJson(),

View File

@ -684,6 +684,15 @@ class _PostBottomAction extends StatelessWidget {
); );
}, },
), ),
InkWell(
child: Row(
children: [
Icon(Symbols.play_circle, size: 20, color: iconColor),
const Gap(8),
Text('postViews').plural(data.totalViews),
],
),
),
], ],
), ),
InkWell( InkWell(
@ -829,7 +838,6 @@ class _PostContentHeader extends StatelessWidget {
await sn.client.delete('/cgi/co/posts/${data.id}', queryParameters: { await sn.client.delete('/cgi/co/posts/${data.id}', queryParameters: {
'publisherId': data.publisherId, 'publisherId': data.publisherId,
}); });
if (!context.mounted) return; if (!context.mounted) return;
context.showSnackbar('postDeleted'.tr(args: ['#${data.id}'])); context.showSnackbar('postDeleted'.tr(args: ['#${data.id}']));
} catch (err) { } catch (err) {
@ -838,6 +846,25 @@ class _PostContentHeader extends StatelessWidget {
} }
} }
Future<void> _flagPost(BuildContext context) async {
final confirm = await context.showConfirmDialog(
'flagPost'.tr(),
'flagPostDescription'.tr(),
);
if (!confirm) return;
if (!context.mounted) return;
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/co/posts/${data.id}/flag');
if (!context.mounted) return;
context.showSnackbar('postFlagged'.tr());
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
@ -1029,6 +1056,18 @@ class _PostContentHeader extends StatelessWidget {
children: [ children: [
const Icon(Symbols.flag), const Icon(Symbols.flag),
const Gap(16), const Gap(16),
Text('flagPostAction').tr(),
],
),
onTap: () {
_flagPost(context);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.report),
const Gap(16),
Text('report').tr(), Text('report').tr(),
], ],
), ),

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+69 version: 2.3.2+70
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4