Compare commits

...

9 Commits

Author SHA1 Message Date
dad869967e 🚀 Launch 2.3.2+64 2025-02-08 15:01:41 +08:00
2d5b3b554e ♻️ Apply new OpenablePostItem to almost everywhere 2025-02-08 13:58:35 +08:00
74882116e3 🐛 Bug fixes on AI Insight 2025-02-08 13:41:39 +08:00
a97c3bce3a Select & Featured Answer 2025-02-08 13:27:53 +08:00
1aa70827dc Create questions & display questions 2025-02-08 01:35:27 +08:00
fe028860e9 💄 Optimize post editors 2025-02-07 22:35:04 +08:00
a2d2ce4d38 🐛 Trying to fix stream already listen 2025-02-07 21:33:39 +08:00
167c11b9eb ♻️ Optimize post editor architecture 2025-02-07 20:19:48 +08:00
8cb3933fcc 🐛 Bug fixes 2025-02-07 18:11:28 +08:00
22 changed files with 661 additions and 318 deletions

View File

@ -154,9 +154,11 @@
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm", "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
"writePostTypeStory": "Post a story", "writePostTypeStory": "Post a story",
"writePostTypeArticle": "Write an article", "writePostTypeArticle": "Write an article",
"writePostTypeQuestion": "Ask a question",
"fieldPostPublisher": "Post publisher", "fieldPostPublisher": "Post publisher",
"fieldPostContent": "What happened?!", "fieldPostContent": "What happened?!",
"fieldPostTitle": "Title", "fieldPostTitle": "Title",
"fieldPostQuestionReward": "Answer Rewards (Source Points)",
"fieldPostDescription": "Description", "fieldPostDescription": "Description",
"fieldPostTags": "Tags", "fieldPostTags": "Tags",
"fieldPostCategories": "Categories", "fieldPostCategories": "Categories",
@ -166,9 +168,9 @@
"postPosted": "Post has been posted.", "postPosted": "Post has been posted.",
"postPublishedAt": "Published At", "postPublishedAt": "Published At",
"postPublishedUntil": "Published Until", "postPublishedUntil": "Published Until",
"postEditingNotice": "You're about to editing a post that posted {}.", "postEditingNotice": "You're about to editing a post that posted by {}.",
"postReplyingNotice": "You're about to reply to a post that posted {}.", "postReplyingNotice": "You're about to reply to a post that posted by {}.",
"postRepostingNotice": "You're about to repost a post that posted {}.", "postRepostingNotice": "You're about to repost a post that posted by {}.",
"postReact": "React", "postReact": "React",
"postReactions": "Reactions of Post", "postReactions": "Reactions of Post",
"postReactionUpvote": { "postReactionUpvote": {
@ -610,5 +612,10 @@
}, },
"aiThinkingProcess": "AI Thinking Process", "aiThinkingProcess": "AI Thinking Process",
"accountSettingsApplied": "Account settings have been applied.", "accountSettingsApplied": "Account settings have been applied.",
"trayMenuExit": "Exit" "trayMenuExit": "Exit",
"postQuestionUnanswered": "Unanswered Question",
"postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}",
"postQuestionAnswered": "Answered Question",
"postQuestionAnswerSelect": "Select as Answer",
"postQuestionAnswerSelected": "Answer has been selected, reward has been applied."
} }

View File

@ -138,9 +138,11 @@
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域", "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
"writePostTypeStory": "发动态", "writePostTypeStory": "发动态",
"writePostTypeArticle": "写文章", "writePostTypeArticle": "写文章",
"writePostTypeQuestion": "提问题",
"fieldPostPublisher": "帖子发布者", "fieldPostPublisher": "帖子发布者",
"fieldPostContent": "发生什么事了?!", "fieldPostContent": "发生什么事了?!",
"fieldPostTitle": "标题", "fieldPostTitle": "标题",
"fieldPostQuestionReward": "回答奖励源点",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "标签", "fieldPostTags": "标签",
"fieldPostCategories": "分类", "fieldPostCategories": "分类",
@ -608,5 +610,11 @@
}, },
"aiThinkingProcess": "AI 思考过程", "aiThinkingProcess": "AI 思考过程",
"accountSettingsApplied": "帐号设置已应用。", "accountSettingsApplied": "帐号设置已应用。",
"trayMenuExit": "退出" "trayMenuExit": "退出",
"postQuestionUnanswered": "未解答的问题",
"postQuestionUnansweredWithReward": "未解答的问题,悬赏源点 {}",
"postQuestionAnswered": "已解答的问题",
"postQuestionAnswerTitle": "精选解答",
"postQuestionAnswerSelect": "选择解答",
"postQuestionAnswerSelected": "解答已选择,奖励已发放。"
} }

View File

@ -138,9 +138,11 @@
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題",
"fieldPostPublisher": "帖子發佈者", "fieldPostPublisher": "帖子發佈者",
"fieldPostContent": "發生什麼事了?!", "fieldPostContent": "發生什麼事了?!",
"fieldPostTitle": "標題", "fieldPostTitle": "標題",
"fieldPostQuestionReward": "回答獎勵源點",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "標籤", "fieldPostTags": "標籤",
"fieldPostCategories": "分類", "fieldPostCategories": "分類",
@ -607,5 +609,12 @@
"other": "{} 源點" "other": "{} 源點"
}, },
"aiThinkingProcess": "AI 思考過程", "aiThinkingProcess": "AI 思考過程",
"accountSettingsApplied": "帳號設置已應用。" "accountSettingsApplied": "帳號設置已應用。",
"trayMenuExit": "退出",
"postQuestionUnanswered": "未解答的問題",
"postQuestionUnansweredWithReward": "未解答的問題,懸賞源點 {}",
"postQuestionAnswered": "已解答的問題",
"postQuestionAnswerTitle": "精選解答",
"postQuestionAnswerSelect": "選擇解答",
"postQuestionAnswerSelected": "解答已選擇,獎勵已發放。"
} }

View File

@ -138,9 +138,11 @@
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題",
"fieldPostPublisher": "帖子發佈者", "fieldPostPublisher": "帖子發佈者",
"fieldPostContent": "發生什麼事了?!", "fieldPostContent": "發生什麼事了?!",
"fieldPostTitle": "標題", "fieldPostTitle": "標題",
"fieldPostQuestionReward": "回答獎勵源點",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "標籤", "fieldPostTags": "標籤",
"fieldPostCategories": "分類", "fieldPostCategories": "分類",
@ -607,5 +609,12 @@
"other": "{} 源點" "other": "{} 源點"
}, },
"aiThinkingProcess": "AI 思考過程", "aiThinkingProcess": "AI 思考過程",
"accountSettingsApplied": "帳號設置已應用。" "accountSettingsApplied": "帳號設置已應用。",
"trayMenuExit": "退出",
"postQuestionUnanswered": "未解答的問題",
"postQuestionUnansweredWithReward": "未解答的問題,懸賞源點 {}",
"postQuestionAnswered": "已解答的問題",
"postQuestionAnswerTitle": "精選解答",
"postQuestionAnswerSelect": "選擇解答",
"postQuestionAnswerSelected": "解答已選擇,獎勵已發放。"
} }

View File

@ -71,7 +71,7 @@ class ChatMessageController extends ChangeNotifier {
resp.data as Map<String, dynamic>, resp.data as Map<String, dynamic>,
); );
_wsSubscription = _ws.stream.stream.listen((event) { _wsSubscription = _ws.pk.stream.listen((event) {
switch (event.method) { switch (event.method) {
case 'events.new': case 'events.new':
if (event.payload?['channel_id'] != channel?.id) break; if (event.payload?['channel_id'] != channel?.id) break;

View File

@ -144,6 +144,7 @@ class PostWriteController extends ChangeNotifier {
static const Map<String, String> kTitleMap = { static const Map<String, String> kTitleMap = {
'stories': 'writePostTypeStory', 'stories': 'writePostTypeStory',
'articles': 'writePostTypeArticle', 'articles': 'writePostTypeArticle',
'questions': 'writePostTypeQuestion',
}; };
static const kAttachmentProgressWeight = 0.9; static const kAttachmentProgressWeight = 0.9;
@ -153,6 +154,7 @@ class PostWriteController extends ChangeNotifier {
final TextEditingController titleController = TextEditingController(); final TextEditingController titleController = TextEditingController();
final TextEditingController descriptionController = TextEditingController(); final TextEditingController descriptionController = TextEditingController();
final TextEditingController aliasController = TextEditingController(); final TextEditingController aliasController = TextEditingController();
final TextEditingController rewardController = TextEditingController();
bool _temporarySaveActive = false; bool _temporarySaveActive = false;
@ -168,6 +170,7 @@ class PostWriteController extends ChangeNotifier {
}); });
contentController.addListener(() { contentController.addListener(() {
_temporaryPlanSave(); _temporaryPlanSave();
notifyListeners();
}); });
if (doLoadFromTemporary) _temporaryLoad(); if (doLoadFromTemporary) _temporaryLoad();
} }
@ -214,6 +217,7 @@ class PostWriteController extends ChangeNotifier {
descriptionController.text = post.body['description'] ?? ''; descriptionController.text = post.body['description'] ?? '';
contentController.text = post.body['content'] ?? ''; contentController.text = post.body['content'] ?? '';
aliasController.text = post.alias ?? ''; aliasController.text = post.alias ?? '';
rewardController.text = post.body['reward']?.toString() ?? '';
publishedAt = post.publishedAt; publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil; publishedUntil = post.publishedUntil;
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true); visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
@ -347,6 +351,7 @@ class PostWriteController extends ChangeNotifier {
if (aliasController.text.isNotEmpty) 'alias': aliasController.text, if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text, if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
if (rewardController.text.isNotEmpty) 'reward': rewardController.text,
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(), if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
'attachments': 'attachments':
attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true), attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true),
@ -375,6 +380,7 @@ class PostWriteController extends ChangeNotifier {
aliasController.text = data['alias'] ?? ''; aliasController.text = data['alias'] ?? '';
titleController.text = data['title'] ?? ''; titleController.text = data['title'] ?? '';
descriptionController.text = data['description'] ?? ''; descriptionController.text = data['description'] ?? '';
rewardController.text = data['reward']?.toString() ?? '';
if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail'])); if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
attachments attachments
.addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>()); .addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
@ -473,6 +479,8 @@ class PostWriteController extends ChangeNotifier {
progress = kAttachmentProgressWeight; progress = kAttachmentProgressWeight;
notifyListeners(); notifyListeners();
final reward = double.tryParse(rewardController.text);
// Posting the content // Posting the content
try { try {
final baseProgressVal = progress!; final baseProgressVal = progress!;
@ -498,6 +506,7 @@ class PostWriteController extends ChangeNotifier {
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(), if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.id, if (replyingPost != null) 'reply_to': replyingPost!.id,
if (repostingPost != null) 'repost_to': repostingPost!.id, if (repostingPost != null) 'repost_to': repostingPost!.id,
if (reward != null) 'reward': reward,
}, },
onSendProgress: (count, total) { onSendProgress: (count, total) {
progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2); progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);

View File

@ -77,7 +77,7 @@ class NotificationProvider extends ChangeNotifier {
List<SnNotification> notifications = List.empty(growable: true); List<SnNotification> notifications = List.empty(growable: true);
void listen() { void listen() {
_ws.stream.stream.listen((event) { _ws.pk.stream.listen((event) {
if (event.method == 'notifications.new') { if (event.method == 'notifications.new') {
final notification = SnNotification.fromJson(event.payload!); final notification = SnNotification.fromJson(event.payload!);
if (showingCount < 0) showingCount = 0; if (showingCount < 0) showingCount = 0;
@ -103,10 +103,10 @@ class NotificationProvider extends ChangeNotifier {
void updateTray() { void updateTray() {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
if (notifications.isEmpty) { if (showingTrayCount == 0) {
trayManager.setTitle(''); trayManager.setTitle('');
} else { } else {
trayManager.setTitle(' ${notifications.length.toString()}'); trayManager.setTitle(' $showingTrayCount');
} }
} }

View File

@ -18,7 +18,8 @@ class WebSocketProvider extends ChangeNotifier {
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
late final UserProvider _ua; late final UserProvider _ua;
StreamController<WebSocketPackage> stream = StreamController.broadcast(); StreamController<WebSocketPackage> pk = StreamController.broadcast();
Stream<dynamic>? _wsStream;
WebSocketProvider(BuildContext context) { WebSocketProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
@ -36,7 +37,7 @@ class WebSocketProvider extends ChangeNotifier {
Completer<void>? _connectCompleter; Completer<void>? _connectCompleter;
Future<void> connect({noRetry = false}) async { Future<void> connect({noRetry = false}) async {
if(_connectCompleter != null) { if (_connectCompleter != null) {
await _connectCompleter!.future; await _connectCompleter!.future;
_connectCompleter = null; _connectCompleter = null;
} }
@ -59,6 +60,7 @@ class WebSocketProvider extends ChangeNotifier {
try { try {
conn = WebSocketChannel.connect(uri); conn = WebSocketChannel.connect(uri);
await conn!.ready; await conn!.ready;
_wsStream = conn!.stream.asBroadcastStream();
listen(); listen();
log('[WebSocket] Connected to server!'); log('[WebSocket] Connected to server!');
isConnected = true; isConnected = true;
@ -73,7 +75,7 @@ class WebSocketProvider extends ChangeNotifier {
log('Retry connecting to websocket in 3 seconds...'); log('Retry connecting to websocket in 3 seconds...');
return Future.delayed( return Future.delayed(
const Duration(seconds: 3), const Duration(seconds: 3),
() => connect(noRetry: true), () => connect(noRetry: true),
); );
} }
} finally { } finally {
@ -93,11 +95,12 @@ class WebSocketProvider extends ChangeNotifier {
} }
void listen() { void listen() {
conn?.stream.listen( if (_wsStream == null) return;
_wsStream!.listen(
(event) { (event) {
final packet = WebSocketPackage.fromJson(jsonDecode(event)); final packet = WebSocketPackage.fromJson(jsonDecode(event));
log('Websocket incoming message: ${packet.method} ${packet.message}'); log('Websocket incoming message: ${packet.method} ${packet.message}');
stream.sink.add(packet); pk.sink.add(packet);
}, },
onDone: () { onDone: () {
isConnected = false; isConnected = false;

View File

@ -155,12 +155,16 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
text: TextSpan(children: [ text: TextSpan(children: [
TextSpan( TextSpan(
text: 'call'.tr(), text: 'call'.tr(),
style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white), style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor,
),
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: call.lastDuration.toString(), text: call.lastDuration.toString(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.white), style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor,
),
), ),
]), ]),
), ),

View File

@ -206,7 +206,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
}); });
final ws = context.read<WebSocketProvider>(); final ws = context.read<WebSocketProvider>();
_wsSubscription = ws.stream.stream.listen((event) { _wsSubscription = ws.pk.stream.listen((event) {
switch (event.method) { switch (event.method) {
case 'calls.new': case 'calls.new':
final payload = SnChatCall.fromJson(event.payload!); final payload = SnChatCall.fromJson(event.payload!);

View File

@ -166,6 +166,27 @@ class _ExploreScreenState extends State<ExploreScreen> {
), ),
], ],
), ),
Row(
children: [
Text('writePostTypeQuestion').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeQuestion'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'questions',
}).then((value) {
if (value == true) {
_refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.question_answer),
),
],
),
], ],
), ),
body: RefreshIndicator( body: RefreshIndicator(
@ -225,34 +246,15 @@ class _ExploreScreenState extends State<ExploreScreen> {
onFetchData: _fetchPosts, onFetchData: _fetchPosts,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return Center( return Center(
child: OpenContainer( child: OpenablePostItem(
closedBuilder: (_, __) => Container( data: _posts[idx],
constraints: const BoxConstraints(maxWidth: 640), maxWidth: 640,
child: PostItem( onChanged: (data) {
data: _posts[idx], setState(() => _posts[idx] = data);
maxWidth: 640, },
onChanged: (data) { onDeleted: () {
setState(() => _posts[idx] = data); _refreshPosts();
}, },
onDeleted: () {
_refreshPosts();
},
),
),
openBuilder: (_, close) => PostDetailScreen(
slug: _posts[idx].id.toString(),
preload: _posts[idx],
onBack: close,
),
openColor: Colors.transparent,
openElevation: 0,
transitionType: ContainerTransitionType.fade,
closedColor: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(
cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1,
),
closedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
), ),
); );
}, },

View File

@ -183,7 +183,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
if (_data != null) if (_data != null)
PostCommentSliverList( PostCommentSliverList(
key: _childListKey, key: _childListKey,
parentPostId: _data!.id, parentPost: _data!,
maxWidth: 640, maxWidth: 640,
), ),
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),

View File

@ -1,33 +1,31 @@
import 'dart:io'; import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:pasteboard/pasteboard.dart'; import 'package:pasteboard/pasteboard.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_media_pending_list.dart'; import 'package:surface/widgets/post/post_media_pending_list.dart';
import 'package:surface/widgets/post/post_meta_editor.dart'; import 'package:surface/widgets/post/post_meta_editor.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../types/attachment.dart';
class PostEditorExtra { class PostEditorExtra {
final String? text; final String? text;
final String? title; final String? title;
@ -124,6 +122,16 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
}); });
} }
void _showPublisherPopup() {
showModalBottomSheet(
context: context,
builder: (context) => _PostPublisherPopup(
controller: _writeController,
publishers: _publishers,
),
);
}
@override @override
void dispose() { void dispose() {
_writeController.dispose(); _writeController.dispose();
@ -197,174 +205,46 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
), ),
body: Column( body: Column(
children: [ children: [
DropdownButtonHideUnderline( if (_writeController.editingPost != null)
child: DropdownButton2<SnPublisher>( Container(
isExpanded: true, padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20, right: 20),
hint: Text( decoration: BoxDecoration(
'fieldPostPublisher', border: Border(
style: TextStyle( bottom: BorderSide(
fontSize: 14, color: Theme.of(context).dividerColor,
color: Theme.of(context).hintColor, width: 1 / MediaQuery.of(context).devicePixelRatio,
),
).tr(),
items: <DropdownMenuItem<SnPublisher>>[
...(_publishers?.map(
(item) => DropdownMenuItem<SnPublisher>(
enabled: _writeController.editingPost == null,
value: item,
child: Row(
children: [
AccountImage(content: item.avatar, radius: 16),
const Gap(8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!),
Text('@${item.name}')
.textStyle(Theme.of(context).textTheme.bodySmall!)
.fontSize(12),
],
),
),
],
),
),
) ??
[]),
DropdownMenuItem<SnPublisher>(
value: null,
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.add),
),
const Gap(8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!),
],
),
),
],
), ),
), ),
],
value: _writeController.publisher,
onChanged: (SnPublisher? value) {
if (value == null) {
GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
if (value == true) {
_publishers = null;
_fetchPublishers();
}
});
} else {
_writeController.setPublisher(value);
final config = context.read<ConfigProvider>();
config.prefs.setInt('int_last_publisher_id', value.id);
}
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(right: 16),
height: 48,
), ),
menuItemStyleData: const MenuItemStyleData( child: Row(
height: 48, crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.edit, size: 16),
const Gap(10),
Text('postEditingNotice').tr(args: ['@${_writeController.editingPost!.publisher.name}']),
],
), ),
), ),
),
const Divider(height: 1),
Expanded( Expanded(
child: Stack( child: Stack(
children: [ children: [
SingleChildScrollView( SingleChildScrollView(
padding: EdgeInsets.only(bottom: 160), padding: EdgeInsets.only(bottom: 160),
child: Column( child: switch (_writeController.mode) {
children: [ 'stories' => _PostStoryEditor(
// Replying Notice controller: _writeController,
if (_writeController.replyingPost != null) onTapPublisher: _showPublisherPopup,
Column(
children: [
ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.reply).padding(left: 4),
title: Text('postReplyingNotice')
.fontSize(15)
.tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.replyingPost!)],
),
const Divider(height: 1),
],
),
// Reposting Notice
if (_writeController.repostingPost != null)
Column(
children: [
ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.forward).padding(left: 4),
title: Text('postRepostingNotice')
.fontSize(15)
.tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
children: <Widget>[
PostItem(
data: _writeController.repostingPost!,
)
],
),
const Divider(height: 1),
],
),
// Editing Notice
if (_writeController.editingPost != null)
Column(
children: [
ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.edit_note).padding(left: 4),
title: Text('postEditingNotice')
.fontSize(15)
.tr(args: ['@${_writeController.editingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.editingPost!)],
),
const Divider(height: 1),
],
),
// Content Input Area
Container(
constraints: const BoxConstraints(maxWidth: 640),
child: TextField(
controller: _writeController.contentController,
maxLines: null,
decoration: InputDecoration(
hintText: 'fieldPostContent'.tr(),
hintStyle: TextStyle(fontSize: 14),
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
),
border: InputBorder.none,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
), ),
] 'articles' => _PostArticleEditor(
.expandIndexed( controller: _writeController,
(idx, ele) => [ onTapPublisher: _showPublisherPopup,
if (idx != 0 || _writeController.isRelatedNull) const Gap(8), ),
ele, 'questions' => _PostQuestionEditor(
], controller: _writeController,
) onTapPublisher: _showPublisherPopup,
.toList(), ),
), _ => const Placeholder(),
},
), ),
if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null) if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null)
Positioned( Positioned(
@ -508,3 +388,302 @@ class _PostEditorActionScrollBehavior extends MaterialScrollBehavior {
PointerDeviceKind.mouse, PointerDeviceKind.mouse,
}; };
} }
class _PostPublisherPopup extends StatelessWidget {
final PostWriteController controller;
final List<SnPublisher>? publishers;
const _PostPublisherPopup({required this.controller, this.publishers});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.face, size: 24),
const Gap(16),
Text('accountPublishers', style: Theme.of(context).textTheme.titleLarge).tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Expanded(
child: ListView.builder(
itemCount: publishers?.length ?? 0,
itemBuilder: (context, idx) {
final publisher = publishers![idx];
return ListTile(
title: Text(publisher.nick),
subtitle: Text('@${publisher.name}'),
leading: AccountImage(content: publisher.avatar, radius: 18),
onTap: () {
controller.setPublisher(publisher);
Navigator.pop(context, true);
},
);
},
),
),
],
);
}
}
class _PostStoryEditor extends StatelessWidget {
final PostWriteController controller;
final Function? onTapPublisher;
const _PostStoryEditor({required this.controller, this.onTapPublisher});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
constraints: const BoxConstraints(maxWidth: 640),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Material(
elevation: 2,
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: GestureDetector(
onTap: () {
onTapPublisher?.call();
},
child: AccountImage(
content: controller.publisher?.avatar,
),
),
),
Expanded(
child: Column(
children: [
const Gap(6),
TextField(
controller: controller.titleController,
decoration: InputDecoration.collapsed(
hintText: 'fieldPostTitle'.tr(),
border: InputBorder.none,
),
style: Theme.of(context).textTheme.titleLarge,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(8),
TextField(
controller: controller.contentController,
maxLines: null,
decoration: InputDecoration(
hintText: 'fieldPostContent'.tr(),
hintStyle: TextStyle(fontSize: 14),
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
),
border: InputBorder.none,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
),
],
).padding(bottom: 8),
);
}
}
class _PostArticleEditor extends StatelessWidget {
final PostWriteController controller;
final Function? onTapPublisher;
const _PostArticleEditor({required this.controller, this.onTapPublisher});
@override
Widget build(BuildContext context) {
final editorWidgets = <Widget>[
Material(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: InkWell(
child: Row(
children: [
AccountImage(content: controller.publisher?.avatar, radius: 20),
const Gap(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(controller.publisher?.nick ?? 'loading'.tr()).bold(),
Text('@${controller.publisher?.name}'),
],
),
),
],
).padding(horizontal: 12, vertical: 8),
onTap: () {
onTapPublisher?.call();
},
),
),
const Gap(16),
TextField(
controller: controller.titleController,
decoration: InputDecoration.collapsed(
hintText: 'fieldPostTitle'.tr(),
border: InputBorder.none,
),
style: Theme.of(context).textTheme.titleLarge,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(8),
TextField(
controller: controller.descriptionController,
decoration: InputDecoration.collapsed(
hintText: 'fieldPostDescription'.tr(),
border: InputBorder.none,
),
maxLines: null,
keyboardType: TextInputType.multiline,
style: Theme.of(context).textTheme.bodyLarge,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(4),
];
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) {
return Container(
constraints: const BoxConstraints(maxWidth: 640 * 2 + 8),
child: Column(
children: [
...editorWidgets,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: controller.contentController,
maxLines: null,
decoration: InputDecoration(
hintText: 'fieldPostContent'.tr(),
hintStyle: TextStyle(fontSize: 14),
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
),
border: InputBorder.none,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Gap(8),
Expanded(
child: MarkdownTextContent(
content: controller.contentController.text,
).padding(horizontal: 24),
),
],
),
],
),
);
}
return Column(
children: [
...editorWidgets,
Container(
padding: const EdgeInsets.only(top: 8),
constraints: const BoxConstraints(maxWidth: 640),
child: TextField(
controller: controller.contentController,
maxLines: null,
decoration: InputDecoration(
hintText: 'fieldPostContent'.tr(),
hintStyle: TextStyle(fontSize: 14),
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
),
border: InputBorder.none,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
],
);
}
}
class _PostQuestionEditor extends StatelessWidget {
final PostWriteController controller;
final Function? onTapPublisher;
const _PostQuestionEditor({required this.controller, this.onTapPublisher});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
constraints: const BoxConstraints(maxWidth: 640),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Material(
elevation: 1,
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: GestureDetector(
onTap: () {
onTapPublisher?.call();
},
child: AccountImage(
content: controller.publisher?.avatar,
),
),
),
Expanded(
child: Column(
children: [
const Gap(6),
TextField(
controller: controller.titleController,
decoration: InputDecoration.collapsed(
hintText: 'fieldPostTitle'.tr(),
border: InputBorder.none,
),
style: Theme.of(context).textTheme.titleLarge,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(8),
TextField(
controller: controller.rewardController,
decoration: InputDecoration(
hintText: 'fieldPostQuestionReward'.tr(),
suffixText: 'walletCurrencyShort'.tr(),
border: InputBorder.none,
isCollapsed: true,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(8),
TextField(
controller: controller.contentController,
maxLines: null,
decoration: InputDecoration(
hintText: 'fieldPostContent'.tr(),
hintStyle: TextStyle(fontSize: 14),
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
),
border: InputBorder.none,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
),
],
).padding(top: 8),
);
}
}

View File

@ -134,7 +134,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
body: Stack( body: Stack(
children: [ children: [
InfiniteList( InfiniteList(
padding: const EdgeInsets.only(top: 100), padding: const EdgeInsets.only(top: 100 + 8),
itemCount: _posts.length, itemCount: _posts.length,
isLoading: _isBusy, isLoading: _isBusy,
hasReachedMax: _postCount != null && _posts.length >= _postCount!, hasReachedMax: _postCount != null && _posts.length >= _postCount!,
@ -142,27 +142,18 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
_fetchPosts(); _fetchPosts();
}, },
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return GestureDetector( return OpenablePostItem(
child: PostItem( data: _posts[idx],
data: _posts[idx], maxWidth: 640,
maxWidth: 640, onChanged: (data) {
onChanged: (data) { setState(() => _posts[idx] = data);
setState(() => _posts[idx] = data); },
}, onDeleted: () {
onDeleted: () { _refreshPosts();
_refreshPosts();
},
),
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',
pathParameters: {'slug': _posts[idx].id.toString()},
extra: _posts[idx],
);
}, },
); );
}, },
separatorBuilder: (context, index) => const Divider(height: 1), separatorBuilder: (_, __) => const Gap(8),
), ),
Positioned( Positioned(
top: 16, top: 16,

View File

@ -287,8 +287,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
Theme( Theme(
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
appBarTheme: Theme.of(context).appBarTheme.copyWith( appBarTheme: Theme.of(context).appBarTheme.copyWith(
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
), ),
child: SliverAppBar( child: SliverAppBar(
expandedHeight: _appBarHeight, expandedHeight: _appBarHeight,
@ -597,25 +597,16 @@ class _PublisherPostList extends StatelessWidget {
hasReachedMax: postCount != null && posts.length >= postCount!, hasReachedMax: postCount != null && posts.length >= postCount!,
onFetchData: fetchPosts, onFetchData: fetchPosts,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return GestureDetector( return OpenablePostItem(
child: PostItem( data: posts[idx],
data: posts[idx], maxWidth: 640,
maxWidth: 640, onChanged: (data) {
onChanged: (data) { onChanged(idx, data);
onChanged(idx, data);
},
onDeleted: onDeleted,
),
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',
pathParameters: {'slug': posts[idx].id.toString()},
extra: posts[idx],
);
}, },
onDeleted: onDeleted,
); );
}, },
separatorBuilder: (context, index) => const Divider(height: 1), separatorBuilder: (_, __) => const Gap(8),
); );
} }
} }

View File

@ -1,5 +1,7 @@
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:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
@ -96,10 +98,14 @@ class _AccountSelectState extends State<AccountSelect> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
widget.title, crossAxisAlignment: CrossAxisAlignment.center,
style: Theme.of(context).textTheme.headlineSmall, children: [
).padding(left: 24, right: 24, top: 16, bottom: 16), const Icon(Symbols.group, size: 24),
const Gap(16),
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Container( Container(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),

View File

@ -336,6 +336,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
: 'fieldChatMessage'.tr(args: [widget.controller.channel?.name ?? 'loading'.tr()]), : 'fieldChatMessage'.tr(args: [widget.controller.channel?.name ?? 'loading'.tr()]),
border: InputBorder.none, border: InputBorder.none,
), ),
textInputAction: TextInputAction.send,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) { onSubmitted: (_) {
if (_isBusy) return; if (_isBusy) return;

View File

@ -8,17 +8,23 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart'; import 'package:surface/providers/post.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_mini_editor.dart'; import 'package:surface/widgets/post/post_mini_editor.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../../providers/sn_network.dart';
class PostCommentSliverList extends StatefulWidget { class PostCommentSliverList extends StatefulWidget {
final int parentPostId; final SnPost parentPost;
final double? maxWidth; final double? maxWidth;
final Function(SnPost)? onSelectAnswer;
const PostCommentSliverList({ const PostCommentSliverList({
super.key, super.key,
required this.parentPostId, required this.parentPost,
this.maxWidth, this.maxWidth,
this.onSelectAnswer,
}); });
@override @override
@ -37,7 +43,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final pt = context.read<SnPostContentProvider>(); final pt = context.read<SnPostContentProvider>();
final result = await pt.listPostReplies(widget.parentPostId); final result = await pt.listPostReplies(widget.parentPost.id);
final List<SnPost> out = result.$1; final List<SnPost> out = result.$1;
if (!mounted) return; if (!mounted) return;
@ -48,6 +54,21 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
if (mounted) setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
} }
Future<void> _selectAnswer(SnPost answer) async {
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put('/cgi/co/questions/${widget.parentPost.id}/answer', data: {
'publisher': answer.publisherId,
'answer_id': answer.id,
});
if (!mounted) return;
await refresh();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
Future<void> refresh() async { Future<void> refresh() async {
_posts.clear(); _posts.clear();
_fetchPosts(); _fetchPosts();
@ -71,6 +92,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
child: PostItem( child: PostItem(
data: _posts[idx], data: _posts[idx],
maxWidth: widget.maxWidth, maxWidth: widget.maxWidth,
onSelectAnswer: widget.parentPost.type == 'question' ? () => _selectAnswer(_posts[idx]) : null,
onChanged: (data) { onChanged: (data) {
setState(() => _posts[idx] = data); setState(() => _posts[idx] = data);
}, },
@ -94,11 +116,12 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
} }
class PostCommentListPopup extends StatefulWidget { class PostCommentListPopup extends StatefulWidget {
final int postId; final SnPost post;
final int commentCount; final int commentCount;
const PostCommentListPopup({ const PostCommentListPopup({
super.key, super.key,
required this.postId, required this.post,
this.commentCount = 0, this.commentCount = 0,
}); });
@ -122,9 +145,7 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
children: [ children: [
const Icon(Symbols.comment, size: 24), const Icon(Symbols.comment, size: 24),
const Gap(16), const Gap(16),
Text('postCommentsDetailed') Text('postCommentsDetailed').plural(widget.commentCount).textStyle(Theme.of(context).textTheme.titleLarge!),
.plural(widget.commentCount)
.textStyle(Theme.of(context).textTheme.titleLarge!),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
Expanded( Expanded(
@ -143,7 +164,7 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
), ),
), ),
child: PostMiniEditor( child: PostMiniEditor(
postReplyId: widget.postId, postReplyId: widget.post.id,
onPost: () { onPost: () {
_childListKey.currentState!.refresh(); _childListKey.currentState!.refresh();
}, },
@ -151,8 +172,8 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
), ),
), ),
PostCommentSliverList( PostCommentSliverList(
parentPost: widget.post,
key: _childListKey, key: _childListKey,
parentPostId: widget.postId,
), ),
], ],
), ),

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:animations/animations.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:file_saver/file_saver.dart'; import 'package:file_saver/file_saver.dart';
@ -22,6 +23,7 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/screens/post/post_detail.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/types/reaction.dart'; import 'package:surface/types/reaction.dart';
@ -38,6 +40,65 @@ import 'package:surface/widgets/post/publisher_popover.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
class OpenablePostItem extends StatelessWidget {
final SnPost data;
final bool showReactions;
final bool showComments;
final bool showMenu;
final bool showFullPost;
final double? maxWidth;
final Function(SnPost data)? onChanged;
final Function()? onDeleted;
final Function()? onSelectAnswer;
const OpenablePostItem({
super.key,
required this.data,
this.showReactions = true,
this.showComments = true,
this.showMenu = true,
this.showFullPost = false,
this.maxWidth,
this.onChanged,
this.onDeleted,
this.onSelectAnswer,
});
@override
Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>();
return OpenContainer(
closedBuilder: (_, __) => Container(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
child: PostItem(
data: data,
maxWidth: maxWidth,
showComments: showComments,
showFullPost: showFullPost,
onChanged: onChanged,
onDeleted: onDeleted,
onSelectAnswer: onSelectAnswer,
),
),
openBuilder: (_, close) => PostDetailScreen(
slug: data.id.toString(),
preload: data,
onBack: close,
),
openColor: Colors.transparent,
openElevation: 0,
transitionType: ContainerTransitionType.fade,
closedColor: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(
cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1,
),
closedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
);
}
}
class PostItem extends StatelessWidget { class PostItem extends StatelessWidget {
final SnPost data; final SnPost data;
final bool showReactions; final bool showReactions;
@ -47,6 +108,7 @@ class PostItem extends StatelessWidget {
final double? maxWidth; final double? maxWidth;
final Function(SnPost data)? onChanged; final Function(SnPost data)? onChanged;
final Function()? onDeleted; final Function()? onDeleted;
final Function()? onSelectAnswer;
const PostItem({ const PostItem({
super.key, super.key,
@ -58,6 +120,7 @@ class PostItem extends StatelessWidget {
this.maxWidth, this.maxWidth,
this.onChanged, this.onChanged,
this.onDeleted, this.onDeleted,
this.onSelectAnswer,
}); });
void _onChanged(SnPost data) { void _onChanged(SnPost data) {
@ -142,6 +205,7 @@ class PostItem extends StatelessWidget {
isRelativeDate: !showFullPost, isRelativeDate: !showFullPost,
onShare: () => _doShare(context), onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context), onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer,
onDeleted: () { onDeleted: () {
if (onDeleted != null) {} if (onDeleted != null) {}
}, },
@ -224,10 +288,12 @@ class PostItem extends StatelessWidget {
showMenu: showMenu, showMenu: showMenu,
onShare: () => _doShare(context), onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context), onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer,
onDeleted: () { onDeleted: () {
if (onDeleted != null) onDeleted!(); if (onDeleted != null) onDeleted!();
}, },
).padding(horizontal: 12, vertical: 8), ).padding(horizontal: 12, vertical: 8),
if (data.type == 'question') _PostQuestionHint(data: data).padding(horizontal: 16, bottom: 8),
if (data.body['title'] != null || data.body['description'] != null) if (data.body['title'] != null || data.body['description'] != null)
_PostHeadline( _PostHeadline(
data: data, data: data,
@ -333,6 +399,7 @@ class PostShareImageWidget extends StatelessWidget {
showMenu: false, showMenu: false,
isRelativeDate: false, isRelativeDate: false,
).padding(horizontal: 16, bottom: 8), ).padding(horizontal: 16, bottom: 8),
if (data.type == 'question') _PostQuestionHint(data: data).padding(horizontal: 16, bottom: 8),
_PostHeadline( _PostHeadline(
data: data, data: data,
isEnlarge: data.type == 'article', isEnlarge: data.type == 'article',
@ -438,6 +505,30 @@ class PostShareImageWidget extends StatelessWidget {
} }
} }
class _PostQuestionHint extends StatelessWidget {
final SnPost data;
const _PostQuestionHint({required this.data});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(data.body['answer'] == null ? Symbols.help : Symbols.check_circle, size: 20),
const Gap(4),
if (data.body['answer'] == null && data.body['reward']?.toDouble() != null)
Text('postQuestionUnansweredWithReward'.tr(args: [
'${data.body['reward']}',
])).opacity(0.75)
else if (data.body['answer'] == null)
Text('postQuestionUnanswered'.tr()).opacity(0.75)
else
Text('postQuestionAnswered'.tr()).opacity(0.75),
],
).opacity(0.75);
}
}
class _PostBottomAction extends StatelessWidget { class _PostBottomAction extends StatelessWidget {
final SnPost data; final SnPost data;
final bool showComments; final bool showComments;
@ -529,7 +620,7 @@ class _PostBottomAction extends StatelessWidget {
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
builder: (context) => PostCommentListPopup( builder: (context) => PostCommentListPopup(
postId: data.id, post: data,
commentCount: data.metric.replyCount, commentCount: data.metric.replyCount,
), ),
); );
@ -652,6 +743,7 @@ class _PostContentHeader extends StatelessWidget {
final bool showMenu; final bool showMenu;
final Function onDeleted; final Function onDeleted;
final Function() onShare, onShareImage; final Function() onShare, onShareImage;
final Function()? onSelectAnswer;
const _PostContentHeader({ const _PostContentHeader({
required this.data, required this.data,
@ -662,6 +754,7 @@ class _PostContentHeader extends StatelessWidget {
required this.onDeleted, required this.onDeleted,
required this.onShare, required this.onShare,
required this.onShareImage, required this.onShareImage,
this.onSelectAnswer,
}); });
Future<void> _deletePost(BuildContext context) async { Future<void> _deletePost(BuildContext context) async {
@ -760,6 +853,20 @@ class _PostContentHeader extends StatelessWidget {
visualDensity: VisualDensity(horizontal: -4, vertical: -4), visualDensity: VisualDensity(horizontal: -4, vertical: -4),
), ),
itemBuilder: (BuildContext context) => <PopupMenuEntry>[ itemBuilder: (BuildContext context) => <PopupMenuEntry>[
if (isAuthor && onSelectAnswer != null)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.check_circle),
const Gap(16),
Text('postQuestionAnswerSelect').tr(),
],
),
onTap: () {
onSelectAnswer?.call();
},
),
if (isAuthor && onSelectAnswer != null) PopupMenuDivider(),
if (isAuthor) if (isAuthor)
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
@ -833,7 +940,7 @@ class _PostContentHeader extends StatelessWidget {
onTap: () { onTap: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => _PostGetInsightSheet(postId: data.id), builder: (context) => _PostGetInsightPopup(postId: data.id),
); );
}, },
), ),
@ -1139,8 +1246,18 @@ class _PostFeaturedComment extends StatefulWidget {
class _PostFeaturedCommentState extends State<_PostFeaturedComment> { class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
SnPost? _featuredComment; SnPost? _featuredComment;
bool _isAnswer = false;
Future<void> _fetchComments() async { Future<void> _fetchComments() async {
// If this is a answered question, fetch the answer instead
if (widget.data.type == 'question' && widget.data.body['answer'] != null) {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/posts/${widget.data.body['answer']}');
_isAnswer = true;
setState(() => _featuredComment = SnPost.fromJson(resp.data));
return;
}
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/posts/${widget.data.id}/replies/featured', queryParameters: { final resp = await sn.client.get('/cgi/co/posts/${widget.data.id}/replies/featured', queryParameters: {
@ -1166,13 +1283,15 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
if (widget.data.metric.replyCount == 0) return const SizedBox.shrink(); if (widget.data.metric.replyCount == 0) return const SizedBox.shrink();
if (_featuredComment == null) return const SizedBox.shrink(); if (_featuredComment == null) return const SizedBox.shrink();
final sn = context.read<SnNetworkProvider>();
return AnimateWidgetExtensions(Container( return AnimateWidgetExtensions(Container(
constraints: BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity), constraints: BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity),
margin: const EdgeInsets.only(top: 8), margin: const EdgeInsets.only(top: 8),
width: double.infinity, width: double.infinity,
child: Material( child: Material(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
color: Theme.of(context).colorScheme.surfaceContainerHigh, color: _isAnswer ? Colors.green.withOpacity(0.5) : Theme.of(context).colorScheme.surfaceContainerHigh,
child: InkWell( child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () { onTap: () {
@ -1180,7 +1299,7 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
builder: (context) => PostCommentListPopup( builder: (context) => PostCommentListPopup(
postId: widget.data.id, post: widget.data,
commentCount: widget.data.metric.replyCount, commentCount: widget.data.metric.replyCount,
), ),
); );
@ -1188,7 +1307,18 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('postFeaturedComment', style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 16)).tr(), Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Gap(2),
Icon(_isAnswer ? Symbols.task_alt : Symbols.prompt_suggestion, size: 20),
const Gap(10),
Text(
_isAnswer ? 'postQuestionAnswerTitle' : 'postFeaturedComment',
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 15),
).tr(),
],
),
const Gap(4), const Gap(4),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@ -1196,7 +1326,7 @@ class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
CircleAvatar( CircleAvatar(
radius: 12, radius: 12,
backgroundImage: UniversalImage.provider( backgroundImage: UniversalImage.provider(
_featuredComment!.publisher.avatar, sn.getAttachmentUrl(_featuredComment!.publisher.avatar),
), ),
), ),
const Gap(8), const Gap(8),
@ -1292,16 +1422,16 @@ class _PostAbuseReportDialogState extends State<_PostAbuseReportDialog> {
} }
} }
class _PostGetInsightSheet extends StatefulWidget { class _PostGetInsightPopup extends StatefulWidget {
final int postId; final int postId;
const _PostGetInsightSheet({required this.postId}); const _PostGetInsightPopup({required this.postId});
@override @override
State<_PostGetInsightSheet> createState() => _PostGetInsightSheetState(); State<_PostGetInsightPopup> createState() => _PostGetInsightPopupState();
} }
class _PostGetInsightSheetState extends State<_PostGetInsightSheet> { class _PostGetInsightPopupState extends State<_PostGetInsightPopup> {
String? _response; String? _response;
String? _thinkingProcess; String? _thinkingProcess;
@ -1314,8 +1444,14 @@ class _PostGetInsightSheetState extends State<_PostGetInsightSheet> {
receiveTimeout: const Duration(minutes: 10), receiveTimeout: const Duration(minutes: 10),
)); ));
final out = resp.data['response'] as String; final out = resp.data['response'] as String;
final document = XmlDocument.parse(out);
_thinkingProcess = document.getElement('think')?.innerText.trim(); try {
final document = XmlDocument.parse(out);
_thinkingProcess = document.getElement('think')?.innerText.trim();
} catch (_) {
// ignore
}
RegExp cleanThinkingRegExp = RegExp(r'<think>[\s\S]*?</think>'); RegExp cleanThinkingRegExp = RegExp(r'<think>[\s\S]*?</think>');
setState(() => _response = out.replaceAll(cleanThinkingRegExp, '').trim()); setState(() => _response = out.replaceAll(cleanThinkingRegExp, '').trim());
} catch (err) { } catch (err) {

View File

@ -292,7 +292,7 @@ class PostMediaPendingList extends StatelessWidget {
constraints: const BoxConstraints(maxHeight: 120), constraints: const BoxConstraints(maxHeight: 120),
child: Row( child: Row(
children: [ children: [
const Gap(8), const Gap(16),
if (thumbnail != null) if (thumbnail != null)
ContextMenuArea( ContextMenuArea(
contextMenu: _createContextMenu(context, -1, thumbnail!), contextMenu: _createContextMenu(context, -1, thumbnail!),
@ -337,15 +337,10 @@ class _PostMediaPendingItem extends StatelessWidget {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return Container( return Material(
decoration: BoxDecoration( elevation: 4,
border: Border.all( color: Theme.of(context).colorScheme.surfaceContainer,
color: Theme.of(context).dividerColor, borderRadius: BorderRadius.circular(8),
width: 1,
),
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Row( child: Row(

View File

@ -19,6 +19,7 @@ const Map<int, String> kPostVisibilityLevel = {
class PostMetaEditor extends StatelessWidget { class PostMetaEditor extends StatelessWidget {
final PostWriteController controller; final PostWriteController controller;
const PostMetaEditor({super.key, required this.controller}); const PostMetaEditor({super.key, required this.controller});
Future<DateTime?> _selectDate( Future<DateTime?> _selectDate(
@ -87,28 +88,6 @@ class PostMetaEditor extends StatelessWidget {
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
child: Column( child: Column(
children: [ children: [
TextField(
controller: controller.titleController,
decoration: InputDecoration(
labelText: 'fieldPostTitle'.tr(),
border: UnderlineInputBorder(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 24),
if (controller.mode == 'articles') const Gap(4),
if (controller.mode == 'articles')
TextField(
controller: controller.descriptionController,
maxLines: null,
decoration: InputDecoration(
labelText: 'fieldPostDescription'.tr(),
border: UnderlineInputBorder(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 24),
const Gap(4),
PostTagsField( PostTagsField(
initialTags: controller.tags, initialTags: controller.tags,
labelText: 'fieldPostTags'.tr(), labelText: 'fieldPostTags'.tr(),
@ -133,8 +112,7 @@ class PostMetaEditor extends StatelessWidget {
helperMaxLines: 2, helperMaxLines: 2,
border: UnderlineInputBorder(), border: UnderlineInputBorder(),
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 24), ).padding(horizontal: 24),
const Gap(12), const Gap(12),
ListTile( ListTile(
@ -182,8 +160,7 @@ class PostMetaEditor extends StatelessWidget {
leading: Icon(Symbols.person), leading: Icon(Symbols.person),
trailing: Icon(Symbols.chevron_right), trailing: Icon(Symbols.chevron_right),
title: Text('postVisibleUsers').tr(), title: Text('postVisibleUsers').tr(),
subtitle: Text('postSelectedUsers') subtitle: Text('postSelectedUsers').plural(controller.visibleUsers.length),
.plural(controller.visibleUsers.length),
onTap: () { onTap: () {
_selectVisibleUser(context); _selectVisibleUser(context);
}, },
@ -194,8 +171,7 @@ class PostMetaEditor extends StatelessWidget {
leading: Icon(Symbols.person), leading: Icon(Symbols.person),
trailing: Icon(Symbols.chevron_right), trailing: Icon(Symbols.chevron_right),
title: Text('postInvisibleUsers').tr(), title: Text('postInvisibleUsers').tr(),
subtitle: Text('postSelectedUsers') subtitle: Text('postSelectedUsers').plural(controller.invisibleUsers.length),
.plural(controller.invisibleUsers.length),
onTap: () { onTap: () {
_selectInvisibleUser(context); _selectInvisibleUser(context);
}, },
@ -204,9 +180,7 @@ class PostMetaEditor extends StatelessWidget {
leading: const Icon(Symbols.event_available), leading: const Icon(Symbols.event_available),
title: Text('postPublishedAt').tr(), title: Text('postPublishedAt').tr(),
subtitle: Text( subtitle: Text(
controller.publishedAt != null controller.publishedAt != null ? dateFormatter.format(controller.publishedAt!) : 'unset'.tr(),
? dateFormatter.format(controller.publishedAt!)
: 'unset'.tr(),
), ),
trailing: controller.publishedAt != null trailing: controller.publishedAt != null
? IconButton( ? IconButton(
@ -230,9 +204,7 @@ class PostMetaEditor extends StatelessWidget {
leading: const Icon(Symbols.event_busy), leading: const Icon(Symbols.event_busy),
title: Text('postPublishedUntil').tr(), title: Text('postPublishedUntil').tr(),
subtitle: Text( subtitle: Text(
controller.publishedUntil != null controller.publishedUntil != null ? dateFormatter.format(controller.publishedUntil!) : 'unset'.tr(),
? dateFormatter.format(controller.publishedUntil!)
: 'unset'.tr(),
), ),
trailing: controller.publishedUntil != null trailing: controller.publishedUntil != null
? IconButton( ? IconButton(

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