Compare commits

..

28 Commits

Author SHA1 Message Date
6e544c0b6c 🚀 Launch 2.3.2+69 2025-02-15 19:58:11 +08:00
7d56c5ef31 🐛 Fix reply / forward post type will follow the target post 2025-02-15 19:45:33 +08:00
c2df1af16d Reply & repost indicator 2025-02-15 19:43:41 +08:00
a8143c6453 🐛 Fix publisher list did not update after created 2025-02-15 19:23:02 +08:00
04065061e0 Leave realm 2025-02-15 19:20:34 +08:00
226eb452e5 Community and public chat, realm 2025-02-15 19:16:54 +08:00
a6715b0872 Chat return new line 2025-02-15 19:08:40 +08:00
43e3404dbb Delete publisher 2025-02-15 18:43:06 +08:00
c91cf7c813 🐛 Fix send empty message 2025-02-15 18:12:35 +08:00
42ac12b53e 🔨 Fix linux build script 2025-02-15 13:48:25 +08:00
63567bf708 🔨 Fix linux build missing deps 2025-02-15 13:43:21 +08:00
5d3cadefef 🔨 Add linux build pipeline 2025-02-15 13:39:00 +08:00
251fbb2503 🐛 Try to fix github action build error 2025-02-15 13:34:23 +08:00
0b31d32217 💄 Fix some designs issue
🐛 Fix web some pages error
2025-02-15 13:06:25 +08:00
5ddd4fed2e 🐛 Fix missing new publisher button 2025-02-15 01:17:09 +08:00
48b6d5f6c1 🐛 Fix poll 2025-02-15 00:16:06 +08:00
b83b0b5efb 🚀 Launch 2.3.2+67 2025-02-13 22:54:30 +08:00
cb24bd953d Poll participate 2025-02-13 22:35:53 +08:00
4937dee182 Poll editor 2025-02-12 23:56:45 +08:00
d612097bb1 🐛 Fix publisher edit has no header 2025-02-12 19:48:49 +08:00
058d668b6b 💄 Optimize post video displaying 2025-02-12 19:13:08 +08:00
8b19462c3a 🐛 Fix post video bug 2025-02-12 16:56:36 +08:00
0a381ef09b 🚀 Launch 2.3.2+66 2025-02-11 21:59:01 +08:00
9b84e912b2 🐛 Fix post item width issue 2025-02-11 21:35:53 +08:00
b3254e0f2f Realm discovery 2025-02-11 21:31:53 +08:00
f0a3bbe023 🐛 Bug fixes 2025-02-10 18:00:15 +08:00
df81c84438 🐛 Bug fixes 2025-02-10 17:54:31 +08:00
8b12395fca 💄 Add more actions to video post editor 2025-02-10 11:51:42 +08:00
40 changed files with 2412 additions and 244 deletions

View File

@ -38,4 +38,27 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: build-output-windows name: build-output-windows
path: build/windows/x64/runner/Release path: build/windows/x64/runner/Release
build-linux:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev
sudo apt-get install libmpv-dev mpv
sudo apt-get install libayatana-appindicator3-dev
sudo apt-get install keybinder-3.0
- run: flutter pub get
- run: flutter build linux
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: build-output-linux
path: build/linux/x64/release/bundle

View File

@ -5,7 +5,7 @@ meta {
} }
post { post {
url: {{endpoint}}/cgi/id/dev/notify/1 url: {{endpoint}}/cgi/id/dev/notify/122
body: json body: json
auth: inherit auth: inherit
} }
@ -15,12 +15,9 @@ body:json {
"client_id": "{{third_client_id}}", "client_id": "{{third_client_id}}",
"client_secret":"{{third_client_tk}}", "client_secret":"{{third_client_tk}}",
"type": "general", "type": "general",
"subject": "测试", "subject": "处理该帐号 @solian 的决定",
"subtitle": "Alphabot です", "subtitle": "违反用户协议",
"content": "全新通知动画", "content": "您的帐号违反了我们用户协议中关于冒充我们官方的行为,至此做出停权的决定。还请见谅。该决定是最终决定,不接受上诉。",
"metadata": {
"image": "D2EDbcrsTugs3xk5"
},
"priority": 10 "priority": 10
} }
} }

View File

@ -15,7 +15,7 @@ body:json {
"client_id": "alphabot", "client_id": "alphabot",
"client_secret": "_uR0sVnHTh", "client_secret": "_uR0sVnHTh",
"remark": "新年红包", "remark": "新年红包",
"amount": 9705, "amount": 150,
"payee_id": 2 "payee_id": 18
} }
} }

View File

@ -27,6 +27,7 @@
"screenChatNew": "New Channel", "screenChatNew": "New Channel",
"screenRealm": "Realm", "screenRealm": "Realm",
"screenRealmManage": "Edit Realm", "screenRealmManage": "Edit Realm",
"screenRealmDiscovery": "Realm Discovery",
"screenRealmNew": "New Realm", "screenRealmNew": "New Realm",
"screenNotification": "Notification", "screenNotification": "Notification",
"screenPostSearch": "Search Posts", "screenPostSearch": "Search Posts",
@ -619,5 +620,35 @@
"postQuestionAnswered": "Answered Question", "postQuestionAnswered": "Answered Question",
"postQuestionAnswerSelect": "Select as Answer", "postQuestionAnswerSelect": "Select as Answer",
"postQuestionAnswerSelected": "Answer has been selected, reward has been applied.", "postQuestionAnswerSelected": "Answer has been selected, reward has been applied.",
"postVideoUpload": "Upload Video" "postVideoUpload": "Upload Video",
"realmJoin": "Join Realm",
"realmCommunityHint": "This realm is a community realm, you can freely join.",
"realmCommunityPublicChannelsHint": "The public channels in this realm",
"realmJoined": "Joined realm {}.",
"join": "Join",
"pollEditorNew": "New Poll",
"pollEditorEdit": "Edit Poll",
"pollEditorDelete": "Delete Poll",
"pollEditorDeleteDescription": "Are you sure you want to delete this poll? This operation is irreversible.",
"pollEditorUnlink": "Unlink Poll",
"pollOptionAdd": "Add Option",
"pollOptionName": "Option Name",
"pollLinkExisting": "Link existing poll",
"pollAnswered": "Answered the poll.",
"pollVotes": {
"one": "{} vote",
"other": "{} votes"
},
"publisherDelete": "Delete Publisher {}",
"publisherDeleteDescription": "Are you sure you want to delete this publisher? This operation is irreversible.",
"channelIsPublic": "Public Channel",
"channelIsPublicDescription": "The channel is public, anyone can join.",
"channelIsCommunity": "Community Channel",
"channelIsCommunityDescription": "Currently, community channel has nothing special yet.",
"realmIsPublic": "Public Realm",
"realmIsPublicDescription": "The realm is public, anyone can join.",
"realmIsCommunity": "Community Realm",
"realmIsCommunityDescription": "Community realm will be displayed on the discover page.",
"realmLeave": "Leave Realm",
"realmLeaveDescription": "Leave the current realm and delete the realm's identity."
} }

View File

@ -25,6 +25,7 @@
"screenChatNew": "新建聊天频道", "screenChatNew": "新建聊天频道",
"screenRealm": "领域", "screenRealm": "领域",
"screenRealmManage": "编辑领域", "screenRealmManage": "编辑领域",
"screenRealmDiscovery": "发现领域",
"screenRealmNew": "新建领域", "screenRealmNew": "新建领域",
"screenNotification": "通知", "screenNotification": "通知",
"screenPostSearch": "搜索帖子", "screenPostSearch": "搜索帖子",
@ -618,5 +619,35 @@
"postQuestionAnswerTitle": "精选解答", "postQuestionAnswerTitle": "精选解答",
"postQuestionAnswerSelect": "选择解答", "postQuestionAnswerSelect": "选择解答",
"postQuestionAnswerSelected": "解答已选择,奖励已发放。", "postQuestionAnswerSelected": "解答已选择,奖励已发放。",
"postVideoUpload": "上传视频" "postVideoUpload": "上传视频",
"realmJoin": "加入领域",
"realmCommunityHint": "该领域是一个社区领域,你可以自由加入。",
"realmCommunityPublicChannelsHint": "该领域包含的公共频道",
"realmJoined": "已加入领域 {}。",
"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": "离开当前领域,并且删除领域中的身份。"
} }

View File

@ -25,6 +25,7 @@
"screenChatNew": "新建聊天頻道", "screenChatNew": "新建聊天頻道",
"screenRealm": "領域", "screenRealm": "領域",
"screenRealmManage": "編輯領域", "screenRealmManage": "編輯領域",
"screenRealmDiscovery": "發現領域",
"screenRealmNew": "新建領域", "screenRealmNew": "新建領域",
"screenNotification": "通知", "screenNotification": "通知",
"screenPostSearch": "搜索帖子", "screenPostSearch": "搜索帖子",
@ -139,6 +140,7 @@
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題", "writePostTypeQuestion": "提問題",
"writePostTypeVideo": "發視頻",
"fieldPostPublisher": "帖子發佈者", "fieldPostPublisher": "帖子發佈者",
"fieldPostContent": "發生什麼事了?!", "fieldPostContent": "發生什麼事了?!",
"fieldPostTitle": "標題", "fieldPostTitle": "標題",
@ -616,5 +618,11 @@
"postQuestionAnswered": "已解答的問題", "postQuestionAnswered": "已解答的問題",
"postQuestionAnswerTitle": "精選解答", "postQuestionAnswerTitle": "精選解答",
"postQuestionAnswerSelect": "選擇解答", "postQuestionAnswerSelect": "選擇解答",
"postQuestionAnswerSelected": "解答已選擇,獎勵已發放。" "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。",
"postVideoUpload": "上傳視頻",
"realmJoin": "加入領域",
"realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
"realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
"realmJoined": "已加入領域 {}。",
"join": "加入"
} }

View File

@ -25,6 +25,7 @@
"screenChatNew": "新建聊天頻道", "screenChatNew": "新建聊天頻道",
"screenRealm": "領域", "screenRealm": "領域",
"screenRealmManage": "編輯領域", "screenRealmManage": "編輯領域",
"screenRealmDiscovery": "發現領域",
"screenRealmNew": "新建領域", "screenRealmNew": "新建領域",
"screenNotification": "通知", "screenNotification": "通知",
"screenPostSearch": "搜索帖子", "screenPostSearch": "搜索帖子",
@ -139,6 +140,7 @@
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題", "writePostTypeQuestion": "提問題",
"writePostTypeVideo": "發視頻",
"fieldPostPublisher": "帖子發佈者", "fieldPostPublisher": "帖子發佈者",
"fieldPostContent": "發生什麼事了?!", "fieldPostContent": "發生什麼事了?!",
"fieldPostTitle": "標題", "fieldPostTitle": "標題",
@ -616,5 +618,11 @@
"postQuestionAnswered": "已解答的問題", "postQuestionAnswered": "已解答的問題",
"postQuestionAnswerTitle": "精選解答", "postQuestionAnswerTitle": "精選解答",
"postQuestionAnswerSelect": "選擇解答", "postQuestionAnswerSelect": "選擇解答",
"postQuestionAnswerSelected": "解答已選擇,獎勵已發放。" "postQuestionAnswerSelected": "解答已選擇,獎勵已發放。",
"postVideoUpload": "上傳視頻",
"realmJoin": "加入領域",
"realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
"realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
"realmJoined": "已加入領域 {}。",
"join": "加入"
} }

View File

@ -16,6 +16,7 @@ import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/types/poll.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
@ -199,6 +200,7 @@ class PostWriteController extends ChangeNotifier {
List<PostWriteMedia> attachments = List.empty(growable: true); List<PostWriteMedia> attachments = List.empty(growable: true);
DateTime? publishedAt, publishedUntil; DateTime? publishedAt, publishedUntil;
SnAttachment? videoAttachment; SnAttachment? videoAttachment;
SnPoll? poll;
Future<void> fetchRelatedPost( Future<void> fetchRelatedPost(
BuildContext context, { BuildContext context, {
@ -220,6 +222,7 @@ class PostWriteController extends ChangeNotifier {
contentController.text = post.body['content'] ?? ''; contentController.text = post.body['content'] ?? '';
aliasController.text = post.alias ?? ''; aliasController.text = post.alias ?? '';
rewardController.text = post.body['reward']?.toString() ?? ''; rewardController.text = post.body['reward']?.toString() ?? '';
videoAttachment = post.preload?.video;
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);
@ -228,6 +231,7 @@ class PostWriteController extends ChangeNotifier {
tags = List.from(post.tags.map((ele) => ele.alias), growable: true); tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
categories = List.from(post.categories.map((ele) => ele.alias), growable: true); categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
poll = post.preload?.poll;
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
thumbnail = PostWriteMedia(post.preload!.thumbnail); thumbnail = PostWriteMedia(post.preload!.thumbnail);
@ -366,6 +370,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!.toJson(), if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
if (repostingPost != null) 'repost_to': repostingPost!.toJson(), if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
if (poll != null) 'poll': poll!.toJson(),
}), }),
); );
}); });
@ -395,6 +400,7 @@ class PostWriteController extends ChangeNotifier {
if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal(); if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null; replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null; repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null;
temporaryRestored = true; temporaryRestored = true;
notifyListeners(); notifyListeners();
}); });
@ -510,6 +516,7 @@ class PostWriteController extends ChangeNotifier {
if (repostingPost != null) 'repost_to': repostingPost!.id, if (repostingPost != null) 'repost_to': repostingPost!.id,
if (reward != null) 'reward': reward, if (reward != null) 'reward': reward,
if (videoAttachment != null) 'video': videoAttachment!.rid, if (videoAttachment != null) 'video': videoAttachment!.rid,
if (poll != null) 'poll': poll!.id,
}, },
onSendProgress: (count, total) { onSendProgress: (count, total) {
progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2); progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
@ -641,6 +648,11 @@ class PostWriteController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setPoll(SnPoll? value) {
poll = value;
notifyListeners();
}
void reset() { void reset() {
publishedAt = null; publishedAt = null;
publishedUntil = null; publishedUntil = null;

View File

@ -47,6 +47,8 @@ import 'package:tray_manager/tray_manager.dart';
import 'package:version/version.dart'; import 'package:version/version.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
import 'package:in_app_review/in_app_review.dart'; import 'package:in_app_review/in_app_review.dart';
import 'package:image_picker_android/image_picker_android.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
@pragma('vm:entry-point') @pragma('vm:entry-point')
void appBackgroundDispatcher() { void appBackgroundDispatcher() {
@ -107,6 +109,13 @@ void main() async {
} }
} }
if (!kIsWeb && Platform.isAndroid) {
final ImagePickerPlatform imagePickerImplementation = ImagePickerPlatform.instance;
if (imagePickerImplementation is ImagePickerAndroid) {
imagePickerImplementation.useAndroidPhotoPicker = true;
}
}
runApp(const SolianApp()); runApp(const SolianApp());
} }
@ -160,8 +169,8 @@ class SolianApp extends StatelessWidget {
), ),
), ),
breakpoints: [ breakpoints: [
const Breakpoint(start: 0, end: 450, name: MOBILE), const Breakpoint(start: 0, end: 600, name: MOBILE),
const Breakpoint(start: 451, end: 800, name: TABLET), const Breakpoint(start: 601, end: 800, name: TABLET),
const Breakpoint(start: 801, end: 1920, name: DESKTOP), const Breakpoint(start: 801, end: 1920, name: DESKTOP),
], ],
); );

View File

@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/poll.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
class SnPostContentProvider { class SnPostContentProvider {
@ -16,6 +17,11 @@ class SnPostContentProvider {
_attach = context.read<SnAttachmentProvider>(); _attach = context.read<SnAttachmentProvider>();
} }
Future<SnPoll> _fetchPoll(int id) async {
final resp = await _sn.client.get('/cgi/co/polls/$id');
return SnPoll.fromJson(resp.data);
}
Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async { Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
Set<String> rids = {}; Set<String> rids = {};
for (var i = 0; i < out.length; i++) { for (var i = 0; i < out.length; i++) {
@ -35,11 +41,17 @@ class SnPostContentProvider {
final attachments = await _attach.getMultiple(rids.toList()); final attachments = await _attach.getMultiple(rids.toList());
for (var i = 0; i < out.length; i++) { for (var i = 0; i < out.length; i++) {
SnPoll? poll;
if (out[i].pollId != null) {
poll = await _fetchPoll(out[i].pollId!);
}
out[i] = out[i].copyWith( out[i] = out[i].copyWith(
preload: SnPostPreload( preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull, thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull,
attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(), attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(),
video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull, video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull,
poll: poll,
), ),
); );
} }
@ -67,11 +79,18 @@ class SnPostContentProvider {
} }
final attachments = await _attach.getMultiple(rids.toList()); final attachments = await _attach.getMultiple(rids.toList());
SnPoll? poll;
if (out.pollId != null) {
poll = await _fetchPoll(out.pollId!);
}
out = out.copyWith( out = out.copyWith(
preload: SnPostPreload( preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull, thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(), attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull, video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull,
poll: poll,
), ),
); );

View File

@ -42,22 +42,22 @@ class WebSocketProvider extends ChangeNotifier {
_connectCompleter = null; _connectCompleter = null;
} }
_connectCompleter = Completer<void>();
if (!_ua.isAuthorized) return; if (!_ua.isAuthorized) return;
if (isConnected || conn != null) { if (isConnected || conn != null) {
disconnect(); disconnect();
} }
final atk = await _sn.getFreshAtk();
final uri = Uri.parse(
'${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
);
isBusy = true;
notifyListeners();
try { try {
_connectCompleter = Completer<void>();
final atk = await _sn.getFreshAtk();
final uri = Uri.parse(
'${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
);
isBusy = true;
notifyListeners();
conn = WebSocketChannel.connect(uri); conn = WebSocketChannel.connect(uri);
await conn!.ready; await conn!.ready;
_wsStream = conn!.stream.asBroadcastStream(); _wsStream = conn!.stream.asBroadcastStream();
@ -82,6 +82,7 @@ class WebSocketProvider extends ChangeNotifier {
isBusy = false; isBusy = false;
notifyListeners(); notifyListeners();
_connectCompleter!.complete(); _connectCompleter!.complete();
_connectCompleter = null;
} }
} }

View File

@ -31,6 +31,7 @@ import 'package:surface/screens/post/post_search.dart';
import 'package:surface/screens/realm.dart'; import 'package:surface/screens/realm.dart';
import 'package:surface/screens/realm/manage.dart'; import 'package:surface/screens/realm/manage.dart';
import 'package:surface/screens/realm/realm_detail.dart'; import 'package:surface/screens/realm/realm_detail.dart';
import 'package:surface/screens/realm/realm_discovery.dart';
import 'package:surface/screens/settings.dart'; import 'package:surface/screens/settings.dart';
import 'package:surface/screens/sharing.dart'; import 'package:surface/screens/sharing.dart';
import 'package:surface/screens/wallet.dart'; import 'package:surface/screens/wallet.dart';
@ -192,11 +193,6 @@ final _appRoutes = [
child: const RealmScreen(), child: const RealmScreen(),
), ),
routes: [ routes: [
GoRoute(
path: '/:alias',
name: 'realmDetail',
builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!),
),
GoRoute( GoRoute(
path: '/manage', path: '/manage',
name: 'realmManage', name: 'realmManage',
@ -204,6 +200,16 @@ final _appRoutes = [
editingRealmAlias: state.uri.queryParameters['editing'], editingRealmAlias: state.uri.queryParameters['editing'],
), ),
), ),
GoRoute(
path: '/discovery',
name: 'realmDiscovery',
builder: (context, state) => const RealmDiscoveryScreen(),
),
GoRoute(
path: '/:alias',
name: 'realmDetail',
builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!),
),
], ],
), ),
GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [ GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [

View File

@ -74,7 +74,10 @@ class _AbuseReportScreenState extends State<AbuseReportScreen> {
), ),
const Divider(height: 1), const Divider(height: 1),
if (_isBusy) if (_isBusy)
const CircularProgressIndicator().padding(all: 24).center() Padding(
padding: const EdgeInsets.all(24),
child: const CircularProgressIndicator(),
).center()
else else
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(

View File

@ -178,6 +178,10 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
return AppScaffold( return AppScaffold(
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenAccountPublisherEdit').tr(),
),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [

View File

@ -45,6 +45,33 @@ class _PublisherScreenState extends State<PublisherScreen> {
} }
} }
Future<void> _deletePublisher(SnPublisher publisher) async {
final confirm = await context.showConfirmDialog(
'publisherDelete'.tr(args: ['#${publisher.name}']),
'publisherDeleteDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
setState(() => _isBusy = true);
try {
await context
.read<SnNetworkProvider>()
.client
.delete('/cgi/co/publishers/${publisher.name}');
if (!mounted) return;
context.showSnackbar('publisherDeleted'.tr(args: ['#${publisher.name}']));
_publishers.remove(publisher);
_fetchPublishers();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -118,6 +145,18 @@ class _PublisherScreenState extends State<PublisherScreen> {
}); });
}, },
), ),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete),
const Gap(16),
Text('delete').tr(),
],
),
onTap: () {
_deletePublisher(publisher);
},
),
], ],
), ),
); );

View File

@ -123,8 +123,10 @@ class _AlbumScreenState extends State<AlbumScreen> {
), ),
if (_isBusy) if (_isBusy)
SliverToBoxAdapter( SliverToBoxAdapter(
child: child: Padding(
const CircularProgressIndicator().padding(all: 24).center(), padding: const EdgeInsets.all(24),
child: const CircularProgressIndicator(),
).center(),
), ),
], ],
), ),

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -17,6 +18,7 @@ import 'package:uuid/uuid.dart';
class ChatManageScreen extends StatefulWidget { class ChatManageScreen extends StatefulWidget {
final String? editingChannelAlias; final String? editingChannelAlias;
const ChatManageScreen({super.key, this.editingChannelAlias}); const ChatManageScreen({super.key, this.editingChannelAlias});
@override @override
@ -33,6 +35,11 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
List<SnRealm>? _realms; List<SnRealm>? _realms;
SnRealm? _belongToRealm; SnRealm? _belongToRealm;
SnChannel? _editingChannel;
bool _isPublic = false;
bool _isCommunity = false;
Future<void> _fetchRealms() async { Future<void> _fetchRealms() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
@ -41,6 +48,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
_realms = List<SnRealm>.from( _realms = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
); );
if (_editingChannel != null) {
_belongToRealm = _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
}
} catch (err) { } catch (err) {
if (mounted) context.showErrorDialog(err); if (mounted) context.showErrorDialog(err);
} finally { } finally {
@ -48,8 +58,6 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
} }
} }
SnChannel? _editingChannel;
Future<void> _fetchChannel() async { Future<void> _fetchChannel() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -62,6 +70,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
_aliasController.text = _editingChannel!.alias; _aliasController.text = _editingChannel!.alias;
_nameController.text = _editingChannel!.name; _nameController.text = _editingChannel!.name;
_descriptionController.text = _editingChannel!.description; _descriptionController.text = _editingChannel!.description;
_isPublic = _editingChannel!.isPublic;
_isCommunity = _editingChannel!.isCommunity;
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -83,6 +93,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
: uuid.v4().replaceAll('-', '').substring(0, 12), : uuid.v4().replaceAll('-', '').substring(0, 12),
'name': _nameController.text, 'name': _nameController.text,
'description': _descriptionController.text, 'description': _descriptionController.text,
'is_public': _isPublic,
'is_community': _isCommunity,
}; };
try { try {
@ -124,9 +136,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
title: widget.editingChannelAlias != null title: widget.editingChannelAlias != null ? Text('screenChatManage').tr() : Text('screenChatNew').tr(),
? Text('screenChatManage').tr()
: Text('screenChatNew').tr(),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: Column(
@ -138,8 +148,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
leadingPadding: const EdgeInsets.only(left: 10, right: 20), leadingPadding: const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
content: Text( content: Text(
'channelEditingNotice' 'channelEditingNotice'.tr(args: ['#${_editingChannel!.alias}']),
.tr(args: ['#${_editingChannel!.alias}']),
), ),
actions: [ actions: [
TextButton( TextButton(
@ -162,6 +171,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
items: [ items: [
...(_realms?.map( ...(_realms?.map(
(SnRealm item) => DropdownMenuItem<SnRealm>( (SnRealm item) => DropdownMenuItem<SnRealm>(
enabled: _editingChannel == null || _editingChannel?.realmId == item.id,
value: item, value: item,
child: Row( child: Row(
children: [ children: [
@ -179,15 +189,12 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(item.name).textStyle(Theme.of(context) Text(item.name).textStyle(Theme.of(context).textTheme.bodyMedium!),
.textTheme
.bodyMedium!),
Text( Text(
item.description, item.description,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
).textStyle( ).textStyle(Theme.of(context).textTheme.bodySmall!),
Theme.of(context).textTheme.bodySmall!),
], ],
), ),
), ),
@ -197,14 +204,14 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
) ?? ) ??
[]), []),
DropdownMenuItem<SnRealm>( DropdownMenuItem<SnRealm>(
enabled: _editingChannel == null,
value: null, value: null,
child: Row( child: Row(
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 16, radius: 16,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: foregroundColor: Theme.of(context).colorScheme.onSurface,
Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.clear), child: const Icon(Symbols.clear),
), ),
const Gap(12), const Gap(12),
@ -213,9 +220,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('fieldChatBelongToRealmUnset') Text('fieldChatBelongToRealmUnset').tr().textStyle(
.tr()
.textStyle(
Theme.of(context).textTheme.bodyMedium!, Theme.of(context).textTheme.bodyMedium!,
), ),
], ],
@ -231,10 +236,10 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
}, },
buttonStyleData: const ButtonStyleData( buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(right: 16), padding: EdgeInsets.only(right: 16),
height: 60, height: 48,
), ),
menuItemStyleData: const MenuItemStyleData( menuItemStyleData: const MenuItemStyleData(
height: 60, height: 48,
), ),
), ),
), ),
@ -250,8 +255,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
helperText: 'fieldChatAliasHint'.tr(), helperText: 'fieldChatAliasHint'.tr(),
helperMaxLines: 2, helperMaxLines: 2,
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(4), const Gap(4),
TextField( TextField(
@ -260,8 +264,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldChatName'.tr(), labelText: 'fieldChatName'.tr(),
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(4), const Gap(4),
TextField( TextField(
@ -272,8 +275,24 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
labelText: 'fieldChatDescription'.tr(), labelText: 'fieldChatDescription'.tr(),
), ),
onTapOutside: (_) => onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
FocusManager.instance.primaryFocus?.unfocus(), ),
const Gap(12),
CheckboxListTile(
value: _isPublic,
title: Text('channelIsPublic'.tr()),
subtitle: Text('channelIsPublicDescription'.tr()),
onChanged: (value) {
setState(() => _isPublic = value ?? false);
},
),
CheckboxListTile(
value: _isCommunity,
title: Text('channelIsCommunity'.tr()),
subtitle: Text('channelIsCommunityDescription'.tr()),
onChanged: (value) {
setState(() => _isCommunity = value ?? false);
},
), ),
const Gap(12), const Gap(12),
Row( Row(

View File

@ -261,17 +261,15 @@ class _ExploreScreenState extends State<ExploreScreen> {
hasReachedMax: _postCount != null && _posts.length >= _postCount!, hasReachedMax: _postCount != null && _posts.length >= _postCount!,
onFetchData: _fetchPosts, onFetchData: _fetchPosts,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return Center( return OpenablePostItem(
child: OpenablePostItem( 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(); },
},
),
); );
}, },
separatorBuilder: (_, __) => const Gap(8), separatorBuilder: (_, __) => const Gap(8),

View File

@ -131,6 +131,7 @@ class _HomeDashUpdateWidget extends StatelessWidget {
return Container( return Container(
padding: padding, padding: padding,
child: Card( child: Card(
margin: EdgeInsets.zero,
child: ListTile( child: ListTile(
leading: Icon(Symbols.update), leading: Icon(Symbols.update),
title: Text('updateAvailable').tr(), title: Text('updateAvailable').tr(),
@ -180,6 +181,7 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
return Column( return Column(
children: days.map((ele) { children: days.map((ele) {
return Card( return Card(
margin: EdgeInsets.zero,
child: ListTile( child: ListTile(
leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24), leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24),
title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']), title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']),
@ -203,6 +205,7 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
final progress = dayz.getSpecialDayProgress(lastOne.$2, date); final progress = dayz.getSpecialDayProgress(lastOne.$2, date);
final diff = nextOne.$2.difference(DateTime.now()); final diff = nextOne.$2.difference(DateTime.now());
return Card( return Card(
margin: EdgeInsets.zero,
child: ListTile( child: ListTile(
leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24), leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24),
title: Text('pending$name').tr(args: [RelativeTime(context).format(date).replaceFirst('in', '').trim()]), title: Text('pending$name').tr(args: [RelativeTime(context).format(date).replaceFirst('in', '').trim()]),
@ -270,6 +273,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
margin: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -469,6 +473,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
margin: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -594,6 +599,7 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
margin: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -667,11 +673,13 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isBusy) { if (_isBusy) {
return Card( return Card(
margin: EdgeInsets.zero,
child: CircularProgressIndicator().center(), child: CircularProgressIndicator().center(),
); );
} }
return Card( return Card(
margin: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View File

@ -6,7 +6,6 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.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:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart'; 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';
@ -17,7 +16,6 @@ import 'package:surface/widgets/navigation/app_background.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_comment_list.dart'; import 'package:surface/widgets/post/post_comment_list.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';
class PostDetailScreen extends StatefulWidget { class PostDetailScreen extends StatefulWidget {
final String slug; final String slug;
@ -64,7 +62,8 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.watch<UserProvider>(); final ua = context.watch<UserProvider>();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final double maxWidth = _data?.type == 'video' ? double.infinity : 640;
return AppBackground( return AppBackground(
isRoot: widget.onBack != null, isRoot: widget.onBack != null,
@ -114,7 +113,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: PostItem( child: PostItem(
data: _data!, data: _data!,
maxWidth: 640, maxWidth: maxWidth,
showComments: false, showComments: false,
showFullPost: true, showFullPost: true,
onChanged: (data) { onChanged: (data) {
@ -125,11 +124,11 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
}, },
), ),
), ),
const SliverToBoxAdapter(child: Divider(height: 1)), if (_data != null && _data!.type != 'video') const SliverToBoxAdapter(child: Divider(height: 1)),
if (_data != null) if (_data != null && _data!.type != 'video')
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 640), constraints: BoxConstraints(maxWidth: maxWidth),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@ -142,51 +141,30 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
).padding(horizontal: 20, vertical: 12).center(), ).padding(horizontal: 20, vertical: 12).center(),
), ),
), ),
if (_data != null && ua.isAuthorized) if (_data != null && ua.isAuthorized && _data!.type != 'video')
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: PostCommentQuickAction(
height: 240, parentPost: _data!,
constraints: const BoxConstraints(maxWidth: 640), maxWidth: maxWidth,
margin: onPosted: () {
ResponsiveBreakpoints.of(context).largerThan(MOBILE) ? const EdgeInsets.all(8) : EdgeInsets.zero, setState(() {
decoration: BoxDecoration( _data = _data!.copyWith(
borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE) metric: _data!.metric.copyWith(
? const BorderRadius.all(Radius.circular(8)) replyCount: _data!.metric.replyCount + 1,
: BorderRadius.zero, ),
border: ResponsiveBreakpoints.of(context).largerThan(MOBILE) );
? Border.all( });
color: Theme.of(context).dividerColor, _childListKey.currentState!.refresh();
width: 1 / devicePixelRatio, },
) ),
: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
),
),
child: PostMiniEditor(
postReplyId: _data!.id,
onPost: () {
setState(() {
_data = _data!.copyWith(
metric: _data!.metric.copyWith(
replyCount: _data!.metric.replyCount + 1,
),
);
});
_childListKey.currentState!.refresh();
},
),
).center(),
), ),
if (_data != null) if (_data != null && _data!.type != 'video')
PostCommentSliverList( PostCommentSliverList(
key: _childListKey, key: _childListKey,
parentPost: _data!, parentPost: _data!,
maxWidth: 640, maxWidth: maxWidth,
), ),
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), if (_data != null && _data!.type == 'video') SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
], ],
), ),
), ),

View File

@ -6,7 +6,9 @@ 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:flutter_context_menu/flutter_context_menu.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';
@ -14,11 +16,15 @@ 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_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
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/attachment/attachment_input.dart';
import 'package:surface/widgets/attachment/attachment_item.dart'; import 'package:surface/widgets/attachment/attachment_item.dart';
import 'package:surface/widgets/attachment/pending_attachment_alt.dart';
import 'package:surface/widgets/attachment/pending_attachment_boost.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/markdown_content.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
@ -26,10 +32,9 @@ 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 'package:surface/widgets/post/post_poll_editor.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../../widgets/attachment/attachment_input.dart';
class PostEditorExtra { class PostEditorExtra {
final String? text; final String? text;
final String? title; final String? title;
@ -106,7 +111,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
final HotKey _pasteHotKey = HotKey( final HotKey _pasteHotKey = HotKey(
key: PhysicalKeyboardKey.keyV, key: PhysicalKeyboardKey.keyV,
modifiers: [Platform.isMacOS ? HotKeyModifier.meta : HotKeyModifier.control], modifiers: [(!kIsWeb && Platform.isMacOS) ? HotKeyModifier.meta : HotKeyModifier.control],
scope: HotKeyScope.inapp, scope: HotKeyScope.inapp,
); );
@ -132,14 +137,36 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
builder: (context) => _PostPublisherPopup( builder: (context) => _PostPublisherPopup(
controller: _writeController, controller: _writeController,
publishers: _publishers, publishers: _publishers,
onUpdate: () {
_fetchPublishers();
},
), ),
); );
} }
void _showPollEditorDialog() async {
final poll = await showDialog<dynamic>(
context: context,
builder: (context) => PollEditorDialog(
poll: _writeController.poll,
),
);
if (poll == null) return;
if (!mounted) return;
if (poll == false) {
_writeController.setPoll(null);
} else {
_writeController.setPoll(poll);
}
}
@override @override
void dispose() { void dispose() {
_writeController.dispose(); _writeController.dispose();
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) hotKeyManager.unregister(_pasteHotKey); if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
hotKeyManager.unregister(_pasteHotKey);
}
super.dispose(); super.dispose();
} }
@ -229,12 +256,68 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
], ],
), ),
), ),
if (_writeController.replyingPost != null)
Container(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20, right: 20),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / MediaQuery.of(context).devicePixelRatio,
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.reply, size: 16),
const Gap(10),
Text('@${_writeController.replyingPost!.publisher.name}').bold(),
const Gap(4),
Expanded(
child: Text(
_writeController.replyingPost!.body['content'],
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (_writeController.repostingPost != null)
Container(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20, right: 20),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / MediaQuery.of(context).devicePixelRatio,
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.forward, size: 16),
const Gap(10),
Text('@${_writeController.repostingPost!.publisher.name}').bold(),
const Gap(4),
Expanded(
child: Text(
_writeController.repostingPost!.body['content'],
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
Expanded( Expanded(
child: Stack( child: Stack(
children: [ children: [
SingleChildScrollView( SingleChildScrollView(
padding: EdgeInsets.only(bottom: 160), padding: EdgeInsets.only(bottom: 160),
child: switch (_writeController.mode) { child: StyledWidget(switch (_writeController.mode) {
'stories' => _PostStoryEditor( 'stories' => _PostStoryEditor(
controller: _writeController, controller: _writeController,
onTapPublisher: _showPublisherPopup, onTapPublisher: _showPublisherPopup,
@ -252,7 +335,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
onTapPublisher: _showPublisherPopup, onTapPublisher: _showPublisherPopup,
), ),
_ => const Placeholder(), _ => const Placeholder(),
}, })
.padding(top: 8),
), ),
if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null) if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null)
Positioned( Positioned(
@ -300,7 +384,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
LoadingIndicator(isActive: _isLoading),
if (_writeController.isBusy && _writeController.progress != null) if (_writeController.isBusy && _writeController.progress != null)
TweenAnimationBuilder<double>( TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _writeController.progress), tween: Tween(begin: 0, end: _writeController.progress),
@ -309,6 +392,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
) )
else if (_writeController.isBusy) else if (_writeController.isBusy)
const LinearProgressIndicator(value: null, minHeight: 2), const LinearProgressIndicator(value: null, minHeight: 2),
LoadingIndicator(isActive: _isLoading),
const Gap(4),
Container( Container(
child: _writeController.temporaryRestored child: _writeController.temporaryRestored
? Container( ? Container(
@ -356,6 +441,18 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
}); });
}, },
), ),
if (_writeController.mode == 'stories')
IconButton(
icon: Icon(Symbols.poll, color: Theme.of(context).colorScheme.primary),
style: ButtonStyle(
backgroundColor: _writeController.poll == null
? null
: WidgetStatePropertyAll(Theme.of(context).colorScheme.surfaceContainer),
),
onPressed: () {
_showPollEditorDialog();
},
),
], ],
), ),
), ),
@ -378,7 +475,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
], ],
).padding( ).padding(
bottom: MediaQuery.of(context).padding.bottom + 8, bottom: MediaQuery.of(context).padding.bottom + 8,
top: 4,
), ),
), ),
], ],
@ -400,8 +496,9 @@ class _PostEditorActionScrollBehavior extends MaterialScrollBehavior {
class _PostPublisherPopup extends StatelessWidget { class _PostPublisherPopup extends StatelessWidget {
final PostWriteController controller; final PostWriteController controller;
final List<SnPublisher>? publishers; final List<SnPublisher>? publishers;
final Function onUpdate;
const _PostPublisherPopup({required this.controller, this.publishers}); const _PostPublisherPopup({required this.controller, this.publishers, required this.onUpdate});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -416,6 +513,20 @@ class _PostPublisherPopup extends StatelessWidget {
Text('accountPublishers', style: Theme.of(context).textTheme.titleLarge).tr(), Text('accountPublishers', style: Theme.of(context).textTheme.titleLarge).tr(),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
ListTile(
leading: const Icon(Symbols.add),
title: Text('publishersNew').tr(),
subtitle: Text('publisherNewSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
if (value == true) {
onUpdate();
}
});
},
),
const Divider(height: 1),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: publishers?.length ?? 0, itemCount: publishers?.length ?? 0,
@ -716,6 +827,74 @@ class _PostVideoEditor extends StatelessWidget {
controller.setVideoAttachment(video); controller.setVideoAttachment(video);
} }
void _setAlt(BuildContext context) async {
if (controller.videoAttachment == null) return;
final result = await showDialog<SnAttachment?>(
context: context,
builder: (context) => PendingAttachmentAltDialog(media: PostWriteMedia(controller.videoAttachment)),
);
if (result == null) return;
controller.setVideoAttachment(result);
}
Future<void> _createBoost(BuildContext context) async {
if (controller.videoAttachment == null) return;
final result = await showDialog<SnAttachmentBoost?>(
context: context,
builder: (context) => PendingAttachmentBoostDialog(media: PostWriteMedia(controller.videoAttachment)),
);
if (result == null) return;
final newAttach = controller.videoAttachment!.copyWith(
boosts: [...controller.videoAttachment!.boosts, result],
);
controller.setVideoAttachment(newAttach);
}
void _setThumbnail(BuildContext context) async {
if (controller.videoAttachment == null) return;
final thumbnail = await showDialog<SnAttachment?>(
context: context,
builder: (context) => AttachmentInputDialog(
title: 'attachmentSetThumbnail'.tr(),
pool: 'interactive',
analyzeNow: true,
),
);
if (thumbnail == null) return;
if (!context.mounted) return;
try {
final attach = context.read<SnAttachmentProvider>();
final newAttach = await attach.updateOne(
controller.videoAttachment!,
thumbnailId: thumbnail.id,
);
controller.setVideoAttachment(newAttach);
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
}
Future<void> _deleteAttachment(BuildContext context) async {
if (controller.videoAttachment == null) return;
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/uc/attachments/${controller.videoAttachment!.id}');
controller.setVideoAttachment(null);
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
@ -772,35 +951,78 @@ class _PostVideoEditor extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor), border: Border.all(color: Theme.of(context).dividerColor),
), ),
child: InkWell( child: ContextMenuRegion(
borderRadius: BorderRadius.circular(16), contextMenu: ContextMenu(
child: AspectRatio( entries: [
aspectRatio: 16 / 9, MenuItem(
child: controller.videoAttachment == null label: 'attachmentSetAlt'.tr(),
? Center( icon: Symbols.description,
child: Row( onSelected: () {
mainAxisSize: MainAxisSize.min, _setAlt(context);
crossAxisAlignment: CrossAxisAlignment.center, },
mainAxisAlignment: MainAxisAlignment.center, ),
children: [ MenuItem(
const Icon(Icons.add), label: 'attachmentBoost'.tr(),
const Gap(4), icon: Symbols.bolt,
Text('postVideoUpload'.tr()), onSelected: () {
], _createBoost(context);
), },
) ),
: ClipRRect( MenuItem(
borderRadius: BorderRadius.circular(16), label: 'attachmentSetThumbnail'.tr(),
child: AttachmentItem( icon: Symbols.image,
data: controller.videoAttachment!, onSelected: () {
heroTag: const Uuid().v4(), _setThumbnail(context);
), },
), ),
MenuItem(
label: 'attachmentCopyRandomId'.tr(),
icon: Symbols.content_copy,
onSelected: () {
Clipboard.setData(ClipboardData(text: controller.videoAttachment!.rid));
},
),
MenuItem(
label: 'delete'.tr(),
icon: Symbols.delete,
onSelected: () => _deleteAttachment(context),
),
MenuItem(
label: 'unlink'.tr(),
icon: Symbols.link_off,
onSelected: () {
controller.setVideoAttachment(null);
},
),
],
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: controller.videoAttachment == null ? () => _selectVideo(context) : null,
child: AspectRatio(
aspectRatio: 16 / 9,
child: controller.videoAttachment == null
? Center(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.add),
const Gap(4),
Text('postVideoUpload'.tr()),
],
),
)
: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AttachmentItem(
data: controller.videoAttachment!,
heroTag: const Uuid().v4(),
),
),
),
), ),
onTap: () {
if (controller.videoAttachment != null) return;
_selectVideo(context);
},
), ),
), ),
], ],

View File

@ -100,6 +100,12 @@ class _RealmScreenState extends State<RealmScreen> {
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenRealm').tr(), title: Text('screenRealm').tr(),
actions: [ actions: [
IconButton(
icon: const Icon(Symbols.globe),
onPressed: () {
GoRouter.of(context).pushNamed('realmDiscovery');
},
),
IconButton( IconButton(
icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module), icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
onPressed: () { onPressed: () {

View File

@ -50,6 +50,8 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
_aliasController.text = out.alias; _aliasController.text = out.alias;
_nameController.text = out.name; _nameController.text = out.name;
_descriptionController.text = out.description; _descriptionController.text = out.description;
_isPublic = out.isPublic;
_isCommunity = out.isCommunity;
} catch (err) { } catch (err) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
if (context.mounted) context.showErrorDialog(err); if (context.mounted) context.showErrorDialog(err);
@ -67,6 +69,9 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
final _imagePicker = ImagePicker(); final _imagePicker = ImagePicker();
bool _isPublic = false;
bool _isCommunity = false;
Future<void> _updateImage(String place) async { Future<void> _updateImage(String place) async {
final image = await _imagePicker.pickImage(source: ImageSource.gallery); final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return; if (image == null) return;
@ -138,6 +143,8 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
'description': _descriptionController.text, 'description': _descriptionController.text,
'avatar': _avatar, 'avatar': _avatar,
'banner': _banner, 'banner': _banner,
'is_public': _isPublic,
'is_community': _isCommunity,
}; };
try { try {
@ -293,6 +300,23 @@ class _RealmManageScreenState extends State<RealmManageScreen> {
FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
CheckboxListTile(
value: _isPublic,
title: Text('realmIsPublic'.tr()),
subtitle: Text('realmIsPublicDescription'.tr()),
onChanged: (value) {
setState(() => _isPublic = value ?? false);
},
),
CheckboxListTile(
value: _isCommunity,
title: Text('realmIsCommunity'.tr()),
subtitle: Text('realmIsCommunityDescription'.tr()),
onChanged: (value) {
setState(() => _isCommunity = value ?? false);
},
),
const Gap(12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [

View File

@ -357,6 +357,28 @@ class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> {
} }
} }
Future<void> _leaveRealm() async {
final confirm = await context.showConfirmDialog(
'realmLeave'.tr(),
'realmLeaveDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
final sn = context.read<SnNetworkProvider>();
try {
await sn.client.delete('/cgi/id/realms/${widget.realm!.alias}/members/me');
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
@ -367,22 +389,31 @@ class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> {
children: [ children: [
const Gap(8), const Gap(8),
ListTile( ListTile(
leading: const Icon(Symbols.edit), leading: const Icon(Symbols.logout),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
title: Text('realmEdit').tr(), title: Text('realmLeave').tr(),
subtitle: Text('realmEditDescription').tr(), subtitle: Text('realmLeaveDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () { onTap: _isBusy ? null : () => _leaveRealm(),
GoRouter.of(context).pushNamed(
'realmManage',
queryParameters: {'editing': widget.realm!.alias},
).then((value) {
if (value != null) {
widget.onUpdate();
}
});
},
), ),
if (isOwned)
ListTile(
leading: const Icon(Symbols.edit),
trailing: const Icon(Symbols.chevron_right),
title: Text('realmEdit').tr(),
subtitle: Text('realmEditDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
GoRouter.of(context).pushNamed(
'realmManage',
queryParameters: {'editing': widget.realm!.alias},
).then((value) {
if (value != null) {
widget.onUpdate();
}
});
},
),
if (isOwned) if (isOwned)
ListTile( ListTile(
leading: const Icon(Symbols.delete), leading: const Icon(Symbols.delete),

View File

@ -0,0 +1,290 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/universal_image.dart';
class RealmDiscoveryScreen extends StatefulWidget {
const RealmDiscoveryScreen({super.key});
@override
State<RealmDiscoveryScreen> createState() => _RealmDiscoveryScreenState();
}
class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
List<SnRealm>? _realms;
bool _isBusy = false;
Future<void> _fetchRealms() async {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/realms');
_realms = List<SnRealm>.from(
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
);
} catch (err) {
if (mounted) context.showErrorDialog(err);
rethrow;
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchRealms();
}
@override
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
return AppScaffold(
appBar: AppBar(
title: Text('screenRealmDiscovery').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: _fetchRealms,
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: _realms?.length ?? 0,
itemBuilder: (context, idx) {
final realm = _realms![idx];
return Container(
constraints: BoxConstraints(maxWidth: 640),
child: Card(
margin: const EdgeInsets.all(12),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: (realm.banner?.isEmpty ?? true)
? const SizedBox.shrink()
: AutoResizeUniversalImage(
sn.getAttachmentUrl(realm.banner!),
fit: BoxFit.cover,
),
),
),
Positioned(
bottom: -30,
left: 18,
child: AccountImage(
content: realm.avatar,
radius: 24,
fallbackWidget: const Icon(Symbols.group, size: 24),
),
),
],
),
),
const Gap(20 + 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!),
Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!),
],
).padding(horizontal: 24, bottom: 14),
],
),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => _RealmJoinPopup(realm: realm),
);
},
),
),
).center();
},
),
),
),
],
),
);
}
}
class _RealmJoinPopup extends StatefulWidget {
final SnRealm realm;
const _RealmJoinPopup({required this.realm});
@override
State<_RealmJoinPopup> createState() => _RealmJoinPopupState();
}
class _RealmJoinPopupState extends State<_RealmJoinPopup> {
final List<String> _planJoinChannels = List.empty(growable: true);
List<SnChannel>? _channels;
bool _isBusy = false;
bool _isJoining = false;
Future<void> _fetchPublicChannels() async {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public');
final out = List<SnChannel>.from(
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
);
setState(() => _channels = out);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _joinRealm() async {
try {
setState(() => _isJoining = true);
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client.post('/cgi/id/realms/${widget.realm.alias}/members', data: {
'related': ua.user?.name,
});
await _joinSelectedChannels();
if (!mounted) return;
context.showSnackbar('realmJoined'.tr(args: [widget.realm.name]));
Navigator.pop(context);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isJoining = false);
}
}
Future<void> _joinSelectedChannels() async {
if (_planJoinChannels.isEmpty) return;
for (final channel in _planJoinChannels) {
try {
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client.post('/cgi/im/channels/${widget.realm.alias}/$channel/members', data: {
'related': ua.user?.name,
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
}
@override
void initState() {
super.initState();
_fetchPublicChannels();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.group_add, size: 24),
const Gap(16),
Text('realmJoin', style: Theme.of(context).textTheme.titleLarge).tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.realm.name,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
widget.realm.description,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
ElevatedButton(
onPressed: _isJoining ? null : () => _joinRealm(),
child: Text('join'.tr()),
),
],
).padding(horizontal: 24, bottom: 12),
const Divider(height: 1),
LoadingIndicator(isActive: _isBusy),
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium)
.padding(horizontal: 24, vertical: 8),
),
Expanded(
child: ListView.builder(
itemCount: _channels?.length ?? 0,
itemBuilder: (context, index) {
final channel = _channels![index];
return CheckboxListTile(
value: _planJoinChannels.contains(channel.alias),
title: Text(channel.name),
subtitle: Text(
channel.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
secondary: AccountImage(
content: null,
fallbackWidget: const Icon(Symbols.chat, size: 20),
),
onChanged: (value) {
value ??= false;
if (value) {
setState(() => _planJoinChannels.add(channel.alias));
} else {
setState(() => _planJoinChannels.remove(channel.alias));
}
},
);
},
),
),
],
);
}
}

45
lib/types/poll.dart Normal file
View File

@ -0,0 +1,45 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'poll.freezed.dart';
part 'poll.g.dart';
@freezed
class SnPoll with _$SnPoll {
const factory SnPoll({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required dynamic deletedAt,
required dynamic expiredAt,
required List<SnPollOption> options,
required int accountId,
required SnPollMetric metric,
}) = _SnPoll;
factory SnPoll.fromJson(Map<String, Object?> json) => _$SnPollFromJson(json);
}
@freezed
class SnPollMetric with _$SnPollMetric {
const factory SnPollMetric({
required int totalAnswer,
@Default({}) Map<String, int> byOptions,
@Default({}) Map<String, double> byOptionsPercentage,
}) = _SnPollMetric;
factory SnPollMetric.fromJson(Map<String, Object?> json) =>
_$SnPollMetricFromJson(json);
}
@freezed
class SnPollOption with _$SnPollOption {
const factory SnPollOption({
required String id,
required String icon,
required String name,
required String description,
}) = _SnPollOption;
factory SnPollOption.fromJson(Map<String, Object?> json) =>
_$SnPollOptionFromJson(json);
}

761
lib/types/poll.freezed.dart Normal file
View File

@ -0,0 +1,761 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'poll.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnPoll _$SnPollFromJson(Map<String, dynamic> json) {
return _SnPoll.fromJson(json);
}
/// @nodoc
mixin _$SnPoll {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
dynamic get deletedAt => throw _privateConstructorUsedError;
dynamic get expiredAt => throw _privateConstructorUsedError;
List<SnPollOption> get options => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError;
SnPollMetric get metric => throw _privateConstructorUsedError;
/// Serializes this SnPoll to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnPollCopyWith<SnPoll> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnPollCopyWith<$Res> {
factory $SnPollCopyWith(SnPoll value, $Res Function(SnPoll) then) =
_$SnPollCopyWithImpl<$Res, SnPoll>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
dynamic expiredAt,
List<SnPollOption> options,
int accountId,
SnPollMetric metric});
$SnPollMetricCopyWith<$Res> get metric;
}
/// @nodoc
class _$SnPollCopyWithImpl<$Res, $Val extends SnPoll>
implements $SnPollCopyWith<$Res> {
_$SnPollCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? expiredAt = freezed,
Object? options = null,
Object? accountId = null,
Object? metric = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
expiredAt: freezed == expiredAt
? _value.expiredAt
: expiredAt // ignore: cast_nullable_to_non_nullable
as dynamic,
options: null == options
? _value.options
: options // ignore: cast_nullable_to_non_nullable
as List<SnPollOption>,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
metric: null == metric
? _value.metric
: metric // ignore: cast_nullable_to_non_nullable
as SnPollMetric,
) as $Val);
}
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPollMetricCopyWith<$Res> get metric {
return $SnPollMetricCopyWith<$Res>(_value.metric, (value) {
return _then(_value.copyWith(metric: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$SnPollImplCopyWith<$Res> implements $SnPollCopyWith<$Res> {
factory _$$SnPollImplCopyWith(
_$SnPollImpl value, $Res Function(_$SnPollImpl) then) =
__$$SnPollImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
dynamic expiredAt,
List<SnPollOption> options,
int accountId,
SnPollMetric metric});
@override
$SnPollMetricCopyWith<$Res> get metric;
}
/// @nodoc
class __$$SnPollImplCopyWithImpl<$Res>
extends _$SnPollCopyWithImpl<$Res, _$SnPollImpl>
implements _$$SnPollImplCopyWith<$Res> {
__$$SnPollImplCopyWithImpl(
_$SnPollImpl _value, $Res Function(_$SnPollImpl) _then)
: super(_value, _then);
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? expiredAt = freezed,
Object? options = null,
Object? accountId = null,
Object? metric = null,
}) {
return _then(_$SnPollImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
expiredAt: freezed == expiredAt
? _value.expiredAt
: expiredAt // ignore: cast_nullable_to_non_nullable
as dynamic,
options: null == options
? _value._options
: options // ignore: cast_nullable_to_non_nullable
as List<SnPollOption>,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
metric: null == metric
? _value.metric
: metric // ignore: cast_nullable_to_non_nullable
as SnPollMetric,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnPollImpl implements _SnPoll {
const _$SnPollImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.expiredAt,
required final List<SnPollOption> options,
required this.accountId,
required this.metric})
: _options = options;
factory _$SnPollImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPollImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final dynamic deletedAt;
@override
final dynamic expiredAt;
final List<SnPollOption> _options;
@override
List<SnPollOption> get options {
if (_options is EqualUnmodifiableListView) return _options;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_options);
}
@override
final int accountId;
@override
final SnPollMetric metric;
@override
String toString() {
return 'SnPoll(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, expiredAt: $expiredAt, options: $options, accountId: $accountId, metric: $metric)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnPollImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
const DeepCollectionEquality().equals(other.deletedAt, deletedAt) &&
const DeepCollectionEquality().equals(other.expiredAt, expiredAt) &&
const DeepCollectionEquality().equals(other._options, _options) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.metric, metric) || other.metric == metric));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
const DeepCollectionEquality().hash(deletedAt),
const DeepCollectionEquality().hash(expiredAt),
const DeepCollectionEquality().hash(_options),
accountId,
metric);
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnPollImplCopyWith<_$SnPollImpl> get copyWith =>
__$$SnPollImplCopyWithImpl<_$SnPollImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnPollImplToJson(
this,
);
}
}
abstract class _SnPoll implements SnPoll {
const factory _SnPoll(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final dynamic deletedAt,
required final dynamic expiredAt,
required final List<SnPollOption> options,
required final int accountId,
required final SnPollMetric metric}) = _$SnPollImpl;
factory _SnPoll.fromJson(Map<String, dynamic> json) = _$SnPollImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
dynamic get deletedAt;
@override
dynamic get expiredAt;
@override
List<SnPollOption> get options;
@override
int get accountId;
@override
SnPollMetric get metric;
/// Create a copy of SnPoll
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnPollImplCopyWith<_$SnPollImpl> get copyWith =>
throw _privateConstructorUsedError;
}
SnPollMetric _$SnPollMetricFromJson(Map<String, dynamic> json) {
return _SnPollMetric.fromJson(json);
}
/// @nodoc
mixin _$SnPollMetric {
int get totalAnswer => throw _privateConstructorUsedError;
Map<String, int> get byOptions => throw _privateConstructorUsedError;
Map<String, double> get byOptionsPercentage =>
throw _privateConstructorUsedError;
/// Serializes this SnPollMetric to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnPollMetric
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnPollMetricCopyWith<SnPollMetric> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnPollMetricCopyWith<$Res> {
factory $SnPollMetricCopyWith(
SnPollMetric value, $Res Function(SnPollMetric) then) =
_$SnPollMetricCopyWithImpl<$Res, SnPollMetric>;
@useResult
$Res call(
{int totalAnswer,
Map<String, int> byOptions,
Map<String, double> byOptionsPercentage});
}
/// @nodoc
class _$SnPollMetricCopyWithImpl<$Res, $Val extends SnPollMetric>
implements $SnPollMetricCopyWith<$Res> {
_$SnPollMetricCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnPollMetric
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? totalAnswer = null,
Object? byOptions = null,
Object? byOptionsPercentage = null,
}) {
return _then(_value.copyWith(
totalAnswer: null == totalAnswer
? _value.totalAnswer
: totalAnswer // ignore: cast_nullable_to_non_nullable
as int,
byOptions: null == byOptions
? _value.byOptions
: byOptions // ignore: cast_nullable_to_non_nullable
as Map<String, int>,
byOptionsPercentage: null == byOptionsPercentage
? _value.byOptionsPercentage
: byOptionsPercentage // ignore: cast_nullable_to_non_nullable
as Map<String, double>,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnPollMetricImplCopyWith<$Res>
implements $SnPollMetricCopyWith<$Res> {
factory _$$SnPollMetricImplCopyWith(
_$SnPollMetricImpl value, $Res Function(_$SnPollMetricImpl) then) =
__$$SnPollMetricImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int totalAnswer,
Map<String, int> byOptions,
Map<String, double> byOptionsPercentage});
}
/// @nodoc
class __$$SnPollMetricImplCopyWithImpl<$Res>
extends _$SnPollMetricCopyWithImpl<$Res, _$SnPollMetricImpl>
implements _$$SnPollMetricImplCopyWith<$Res> {
__$$SnPollMetricImplCopyWithImpl(
_$SnPollMetricImpl _value, $Res Function(_$SnPollMetricImpl) _then)
: super(_value, _then);
/// Create a copy of SnPollMetric
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? totalAnswer = null,
Object? byOptions = null,
Object? byOptionsPercentage = null,
}) {
return _then(_$SnPollMetricImpl(
totalAnswer: null == totalAnswer
? _value.totalAnswer
: totalAnswer // ignore: cast_nullable_to_non_nullable
as int,
byOptions: null == byOptions
? _value._byOptions
: byOptions // ignore: cast_nullable_to_non_nullable
as Map<String, int>,
byOptionsPercentage: null == byOptionsPercentage
? _value._byOptionsPercentage
: byOptionsPercentage // ignore: cast_nullable_to_non_nullable
as Map<String, double>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnPollMetricImpl implements _SnPollMetric {
const _$SnPollMetricImpl(
{required this.totalAnswer,
final Map<String, int> byOptions = const {},
final Map<String, double> byOptionsPercentage = const {}})
: _byOptions = byOptions,
_byOptionsPercentage = byOptionsPercentage;
factory _$SnPollMetricImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPollMetricImplFromJson(json);
@override
final int totalAnswer;
final Map<String, int> _byOptions;
@override
@JsonKey()
Map<String, int> get byOptions {
if (_byOptions is EqualUnmodifiableMapView) return _byOptions;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_byOptions);
}
final Map<String, double> _byOptionsPercentage;
@override
@JsonKey()
Map<String, double> get byOptionsPercentage {
if (_byOptionsPercentage is EqualUnmodifiableMapView)
return _byOptionsPercentage;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_byOptionsPercentage);
}
@override
String toString() {
return 'SnPollMetric(totalAnswer: $totalAnswer, byOptions: $byOptions, byOptionsPercentage: $byOptionsPercentage)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnPollMetricImpl &&
(identical(other.totalAnswer, totalAnswer) ||
other.totalAnswer == totalAnswer) &&
const DeepCollectionEquality()
.equals(other._byOptions, _byOptions) &&
const DeepCollectionEquality()
.equals(other._byOptionsPercentage, _byOptionsPercentage));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
totalAnswer,
const DeepCollectionEquality().hash(_byOptions),
const DeepCollectionEquality().hash(_byOptionsPercentage));
/// Create a copy of SnPollMetric
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnPollMetricImplCopyWith<_$SnPollMetricImpl> get copyWith =>
__$$SnPollMetricImplCopyWithImpl<_$SnPollMetricImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnPollMetricImplToJson(
this,
);
}
}
abstract class _SnPollMetric implements SnPollMetric {
const factory _SnPollMetric(
{required final int totalAnswer,
final Map<String, int> byOptions,
final Map<String, double> byOptionsPercentage}) = _$SnPollMetricImpl;
factory _SnPollMetric.fromJson(Map<String, dynamic> json) =
_$SnPollMetricImpl.fromJson;
@override
int get totalAnswer;
@override
Map<String, int> get byOptions;
@override
Map<String, double> get byOptionsPercentage;
/// Create a copy of SnPollMetric
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnPollMetricImplCopyWith<_$SnPollMetricImpl> get copyWith =>
throw _privateConstructorUsedError;
}
SnPollOption _$SnPollOptionFromJson(Map<String, dynamic> json) {
return _SnPollOption.fromJson(json);
}
/// @nodoc
mixin _$SnPollOption {
String get id => throw _privateConstructorUsedError;
String get icon => throw _privateConstructorUsedError;
String get name => throw _privateConstructorUsedError;
String get description => throw _privateConstructorUsedError;
/// Serializes this SnPollOption to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnPollOption
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnPollOptionCopyWith<SnPollOption> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnPollOptionCopyWith<$Res> {
factory $SnPollOptionCopyWith(
SnPollOption value, $Res Function(SnPollOption) then) =
_$SnPollOptionCopyWithImpl<$Res, SnPollOption>;
@useResult
$Res call({String id, String icon, String name, String description});
}
/// @nodoc
class _$SnPollOptionCopyWithImpl<$Res, $Val extends SnPollOption>
implements $SnPollOptionCopyWith<$Res> {
_$SnPollOptionCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnPollOption
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? icon = null,
Object? name = null,
Object? description = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
icon: null == icon
? _value.icon
: icon // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnPollOptionImplCopyWith<$Res>
implements $SnPollOptionCopyWith<$Res> {
factory _$$SnPollOptionImplCopyWith(
_$SnPollOptionImpl value, $Res Function(_$SnPollOptionImpl) then) =
__$$SnPollOptionImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({String id, String icon, String name, String description});
}
/// @nodoc
class __$$SnPollOptionImplCopyWithImpl<$Res>
extends _$SnPollOptionCopyWithImpl<$Res, _$SnPollOptionImpl>
implements _$$SnPollOptionImplCopyWith<$Res> {
__$$SnPollOptionImplCopyWithImpl(
_$SnPollOptionImpl _value, $Res Function(_$SnPollOptionImpl) _then)
: super(_value, _then);
/// Create a copy of SnPollOption
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? icon = null,
Object? name = null,
Object? description = null,
}) {
return _then(_$SnPollOptionImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
icon: null == icon
? _value.icon
: icon // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnPollOptionImpl implements _SnPollOption {
const _$SnPollOptionImpl(
{required this.id,
required this.icon,
required this.name,
required this.description});
factory _$SnPollOptionImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPollOptionImplFromJson(json);
@override
final String id;
@override
final String icon;
@override
final String name;
@override
final String description;
@override
String toString() {
return 'SnPollOption(id: $id, icon: $icon, name: $name, description: $description)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnPollOptionImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.icon, icon) || other.icon == icon) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.description, description) ||
other.description == description));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, icon, name, description);
/// Create a copy of SnPollOption
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnPollOptionImplCopyWith<_$SnPollOptionImpl> get copyWith =>
__$$SnPollOptionImplCopyWithImpl<_$SnPollOptionImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnPollOptionImplToJson(
this,
);
}
}
abstract class _SnPollOption implements SnPollOption {
const factory _SnPollOption(
{required final String id,
required final String icon,
required final String name,
required final String description}) = _$SnPollOptionImpl;
factory _SnPollOption.fromJson(Map<String, dynamic> json) =
_$SnPollOptionImpl.fromJson;
@override
String get id;
@override
String get icon;
@override
String get name;
@override
String get description;
/// Create a copy of SnPollOption
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnPollOptionImplCopyWith<_$SnPollOptionImpl> get copyWith =>
throw _privateConstructorUsedError;
}

69
lib/types/poll.g.dart Normal file
View File

@ -0,0 +1,69 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'poll.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnPollImpl _$$SnPollImplFromJson(Map<String, dynamic> json) => _$SnPollImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'],
expiredAt: json['expired_at'],
options: (json['options'] as List<dynamic>)
.map((e) => SnPollOption.fromJson(e as Map<String, dynamic>))
.toList(),
accountId: (json['account_id'] as num).toInt(),
metric: SnPollMetric.fromJson(json['metric'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SnPollImplToJson(_$SnPollImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt,
'expired_at': instance.expiredAt,
'options': instance.options.map((e) => e.toJson()).toList(),
'account_id': instance.accountId,
'metric': instance.metric.toJson(),
};
_$SnPollMetricImpl _$$SnPollMetricImplFromJson(Map<String, dynamic> json) =>
_$SnPollMetricImpl(
totalAnswer: (json['total_answer'] as num).toInt(),
byOptions: (json['by_options'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
) ??
const {},
byOptionsPercentage:
(json['by_options_percentage'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toDouble()),
) ??
const {},
);
Map<String, dynamic> _$$SnPollMetricImplToJson(_$SnPollMetricImpl instance) =>
<String, dynamic>{
'total_answer': instance.totalAnswer,
'by_options': instance.byOptions,
'by_options_percentage': instance.byOptionsPercentage,
};
_$SnPollOptionImpl _$$SnPollOptionImplFromJson(Map<String, dynamic> json) =>
_$SnPollOptionImpl(
id: json['id'] as String,
icon: json['icon'] as String,
name: json['name'] as String,
description: json['description'] as String,
);
Map<String, dynamic> _$$SnPollOptionImplToJson(_$SnPollOptionImpl instance) =>
<String, dynamic>{
'id': instance.id,
'icon': instance.icon,
'name': instance.name,
'description': instance.description,
};

View File

@ -1,5 +1,6 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/types/poll.dart';
part 'post.freezed.dart'; part 'post.freezed.dart';
part 'post.g.dart'; part 'post.g.dart';
@ -37,6 +38,7 @@ class SnPost with _$SnPost {
required int totalUpvote, required int totalUpvote,
required int totalDownvote, required int totalDownvote,
required int publisherId, required int publisherId,
required int? pollId,
required SnPublisher publisher, required SnPublisher publisher,
required SnMetric metric, required SnMetric metric,
SnPostPreload? preload, SnPostPreload? preload,
@ -90,6 +92,7 @@ class SnPostPreload with _$SnPostPreload {
required SnAttachment? thumbnail, required SnAttachment? thumbnail,
required List<SnAttachment?>? attachments, required List<SnAttachment?>? attachments,
required SnAttachment? video, required SnAttachment? video,
required SnPoll? poll,
}) = _SnPostPreload; }) = _SnPostPreload;
factory SnPostPreload.fromJson(Map<String, Object?> json) => factory SnPostPreload.fromJson(Map<String, Object?> json) =>

View File

@ -48,6 +48,7 @@ mixin _$SnPost {
int get totalUpvote => throw _privateConstructorUsedError; int get totalUpvote => throw _privateConstructorUsedError;
int get totalDownvote => throw _privateConstructorUsedError; int get totalDownvote => throw _privateConstructorUsedError;
int get publisherId => throw _privateConstructorUsedError; int get publisherId => throw _privateConstructorUsedError;
int? get pollId => throw _privateConstructorUsedError;
SnPublisher get publisher => throw _privateConstructorUsedError; SnPublisher get publisher => throw _privateConstructorUsedError;
SnMetric get metric => throw _privateConstructorUsedError; SnMetric get metric => throw _privateConstructorUsedError;
SnPostPreload? get preload => throw _privateConstructorUsedError; SnPostPreload? get preload => throw _privateConstructorUsedError;
@ -95,6 +96,7 @@ abstract class $SnPostCopyWith<$Res> {
int totalUpvote, int totalUpvote,
int totalDownvote, int totalDownvote,
int publisherId, int publisherId,
int? pollId,
SnPublisher publisher, SnPublisher publisher,
SnMetric metric, SnMetric metric,
SnPostPreload? preload}); SnPostPreload? preload});
@ -149,6 +151,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
Object? totalUpvote = null, Object? totalUpvote = null,
Object? totalDownvote = null, Object? totalDownvote = null,
Object? publisherId = null, Object? publisherId = null,
Object? pollId = freezed,
Object? publisher = null, Object? publisher = null,
Object? metric = null, Object? metric = null,
Object? preload = freezed, Object? preload = freezed,
@ -266,6 +269,10 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
? _value.publisherId ? _value.publisherId
: publisherId // ignore: cast_nullable_to_non_nullable : publisherId // ignore: cast_nullable_to_non_nullable
as int, as int,
pollId: freezed == pollId
? _value.pollId
: pollId // ignore: cast_nullable_to_non_nullable
as int?,
publisher: null == publisher publisher: null == publisher
? _value.publisher ? _value.publisher
: publisher // ignore: cast_nullable_to_non_nullable : publisher // ignore: cast_nullable_to_non_nullable
@ -380,6 +387,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
int totalUpvote, int totalUpvote,
int totalDownvote, int totalDownvote,
int publisherId, int publisherId,
int? pollId,
SnPublisher publisher, SnPublisher publisher,
SnMetric metric, SnMetric metric,
SnPostPreload? preload}); SnPostPreload? preload});
@ -437,6 +445,7 @@ class __$$SnPostImplCopyWithImpl<$Res>
Object? totalUpvote = null, Object? totalUpvote = null,
Object? totalDownvote = null, Object? totalDownvote = null,
Object? publisherId = null, Object? publisherId = null,
Object? pollId = freezed,
Object? publisher = null, Object? publisher = null,
Object? metric = null, Object? metric = null,
Object? preload = freezed, Object? preload = freezed,
@ -554,6 +563,10 @@ class __$$SnPostImplCopyWithImpl<$Res>
? _value.publisherId ? _value.publisherId
: publisherId // ignore: cast_nullable_to_non_nullable : publisherId // ignore: cast_nullable_to_non_nullable
as int, as int,
pollId: freezed == pollId
? _value.pollId
: pollId // ignore: cast_nullable_to_non_nullable
as int?,
publisher: null == publisher publisher: null == publisher
? _value.publisher ? _value.publisher
: publisher // ignore: cast_nullable_to_non_nullable : publisher // ignore: cast_nullable_to_non_nullable
@ -602,6 +615,7 @@ class _$SnPostImpl extends _SnPost {
required this.totalUpvote, required this.totalUpvote,
required this.totalDownvote, required this.totalDownvote,
required this.publisherId, required this.publisherId,
required this.pollId,
required this.publisher, required this.publisher,
required this.metric, required this.metric,
this.preload}) this.preload})
@ -719,6 +733,8 @@ class _$SnPostImpl extends _SnPost {
@override @override
final int publisherId; final int publisherId;
@override @override
final int? pollId;
@override
final SnPublisher publisher; final SnPublisher publisher;
@override @override
final SnMetric metric; final SnMetric metric;
@ -727,7 +743,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, 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, publisherId: $publisherId, pollId: $pollId, publisher: $publisher, metric: $metric, preload: $preload)';
} }
@override @override
@ -782,6 +798,7 @@ class _$SnPostImpl extends _SnPost {
other.totalDownvote == totalDownvote) && other.totalDownvote == totalDownvote) &&
(identical(other.publisherId, publisherId) || (identical(other.publisherId, publisherId) ||
other.publisherId == publisherId) && other.publisherId == publisherId) &&
(identical(other.pollId, pollId) || other.pollId == pollId) &&
(identical(other.publisher, publisher) || (identical(other.publisher, publisher) ||
other.publisher == publisher) && other.publisher == publisher) &&
(identical(other.metric, metric) || other.metric == metric) && (identical(other.metric, metric) || other.metric == metric) &&
@ -820,6 +837,7 @@ class _$SnPostImpl extends _SnPost {
totalUpvote, totalUpvote,
totalDownvote, totalDownvote,
publisherId, publisherId,
pollId,
publisher, publisher,
metric, metric,
preload preload
@ -871,6 +889,7 @@ abstract class _SnPost extends SnPost {
required final int totalUpvote, required final int totalUpvote,
required final int totalDownvote, required final int totalDownvote,
required final int publisherId, required final int publisherId,
required final int? pollId,
required final SnPublisher publisher, required final SnPublisher publisher,
required final SnMetric metric, required final SnMetric metric,
final SnPostPreload? preload}) = _$SnPostImpl; final SnPostPreload? preload}) = _$SnPostImpl;
@ -935,6 +954,8 @@ abstract class _SnPost extends SnPost {
@override @override
int get publisherId; int get publisherId;
@override @override
int? get pollId;
@override
SnPublisher get publisher; SnPublisher get publisher;
@override @override
SnMetric get metric; SnMetric get metric;
@ -1568,6 +1589,7 @@ mixin _$SnPostPreload {
SnAttachment? get thumbnail => throw _privateConstructorUsedError; SnAttachment? get thumbnail => throw _privateConstructorUsedError;
List<SnAttachment?>? get attachments => throw _privateConstructorUsedError; List<SnAttachment?>? get attachments => throw _privateConstructorUsedError;
SnAttachment? get video => throw _privateConstructorUsedError; SnAttachment? get video => throw _privateConstructorUsedError;
SnPoll? get poll => throw _privateConstructorUsedError;
/// Serializes this SnPostPreload to a JSON map. /// Serializes this SnPostPreload to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -1588,10 +1610,12 @@ abstract class $SnPostPreloadCopyWith<$Res> {
$Res call( $Res call(
{SnAttachment? thumbnail, {SnAttachment? thumbnail,
List<SnAttachment?>? attachments, List<SnAttachment?>? attachments,
SnAttachment? video}); SnAttachment? video,
SnPoll? poll});
$SnAttachmentCopyWith<$Res>? get thumbnail; $SnAttachmentCopyWith<$Res>? get thumbnail;
$SnAttachmentCopyWith<$Res>? get video; $SnAttachmentCopyWith<$Res>? get video;
$SnPollCopyWith<$Res>? get poll;
} }
/// @nodoc /// @nodoc
@ -1612,6 +1636,7 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
Object? thumbnail = freezed, Object? thumbnail = freezed,
Object? attachments = freezed, Object? attachments = freezed,
Object? video = freezed, Object? video = freezed,
Object? poll = freezed,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
thumbnail: freezed == thumbnail thumbnail: freezed == thumbnail
@ -1626,6 +1651,10 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
? _value.video ? _value.video
: video // ignore: cast_nullable_to_non_nullable : video // ignore: cast_nullable_to_non_nullable
as SnAttachment?, as SnAttachment?,
poll: freezed == poll
? _value.poll
: poll // ignore: cast_nullable_to_non_nullable
as SnPoll?,
) as $Val); ) as $Val);
} }
@ -1656,6 +1685,20 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
return _then(_value.copyWith(video: value) as $Val); return _then(_value.copyWith(video: value) as $Val);
}); });
} }
/// Create a copy of SnPostPreload
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPollCopyWith<$Res>? get poll {
if (_value.poll == null) {
return null;
}
return $SnPollCopyWith<$Res>(_value.poll!, (value) {
return _then(_value.copyWith(poll: value) as $Val);
});
}
} }
/// @nodoc /// @nodoc
@ -1669,12 +1712,15 @@ abstract class _$$SnPostPreloadImplCopyWith<$Res>
$Res call( $Res call(
{SnAttachment? thumbnail, {SnAttachment? thumbnail,
List<SnAttachment?>? attachments, List<SnAttachment?>? attachments,
SnAttachment? video}); SnAttachment? video,
SnPoll? poll});
@override @override
$SnAttachmentCopyWith<$Res>? get thumbnail; $SnAttachmentCopyWith<$Res>? get thumbnail;
@override @override
$SnAttachmentCopyWith<$Res>? get video; $SnAttachmentCopyWith<$Res>? get video;
@override
$SnPollCopyWith<$Res>? get poll;
} }
/// @nodoc /// @nodoc
@ -1693,6 +1739,7 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
Object? thumbnail = freezed, Object? thumbnail = freezed,
Object? attachments = freezed, Object? attachments = freezed,
Object? video = freezed, Object? video = freezed,
Object? poll = freezed,
}) { }) {
return _then(_$SnPostPreloadImpl( return _then(_$SnPostPreloadImpl(
thumbnail: freezed == thumbnail thumbnail: freezed == thumbnail
@ -1707,6 +1754,10 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
? _value.video ? _value.video
: video // ignore: cast_nullable_to_non_nullable : video // ignore: cast_nullable_to_non_nullable
as SnAttachment?, as SnAttachment?,
poll: freezed == poll
? _value.poll
: poll // ignore: cast_nullable_to_non_nullable
as SnPoll?,
)); ));
} }
} }
@ -1717,7 +1768,8 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
const _$SnPostPreloadImpl( const _$SnPostPreloadImpl(
{required this.thumbnail, {required this.thumbnail,
required final List<SnAttachment?>? attachments, required final List<SnAttachment?>? attachments,
required this.video}) required this.video,
required this.poll})
: _attachments = attachments; : _attachments = attachments;
factory _$SnPostPreloadImpl.fromJson(Map<String, dynamic> json) => factory _$SnPostPreloadImpl.fromJson(Map<String, dynamic> json) =>
@ -1737,10 +1789,12 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
@override @override
final SnAttachment? video; final SnAttachment? video;
@override
final SnPoll? poll;
@override @override
String toString() { String toString() {
return 'SnPostPreload(thumbnail: $thumbnail, attachments: $attachments, video: $video)'; return 'SnPostPreload(thumbnail: $thumbnail, attachments: $attachments, video: $video, poll: $poll)';
} }
@override @override
@ -1752,13 +1806,14 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
other.thumbnail == thumbnail) && other.thumbnail == thumbnail) &&
const DeepCollectionEquality() const DeepCollectionEquality()
.equals(other._attachments, _attachments) && .equals(other._attachments, _attachments) &&
(identical(other.video, video) || other.video == video)); (identical(other.video, video) || other.video == video) &&
(identical(other.poll, poll) || other.poll == poll));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType, thumbnail, int get hashCode => Object.hash(runtimeType, thumbnail,
const DeepCollectionEquality().hash(_attachments), video); const DeepCollectionEquality().hash(_attachments), video, poll);
/// Create a copy of SnPostPreload /// Create a copy of SnPostPreload
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -1780,7 +1835,8 @@ abstract class _SnPostPreload implements SnPostPreload {
const factory _SnPostPreload( const factory _SnPostPreload(
{required final SnAttachment? thumbnail, {required final SnAttachment? thumbnail,
required final List<SnAttachment?>? attachments, required final List<SnAttachment?>? attachments,
required final SnAttachment? video}) = _$SnPostPreloadImpl; required final SnAttachment? video,
required final SnPoll? poll}) = _$SnPostPreloadImpl;
factory _SnPostPreload.fromJson(Map<String, dynamic> json) = factory _SnPostPreload.fromJson(Map<String, dynamic> json) =
_$SnPostPreloadImpl.fromJson; _$SnPostPreloadImpl.fromJson;
@ -1791,6 +1847,8 @@ abstract class _SnPostPreload implements SnPostPreload {
List<SnAttachment?>? get attachments; List<SnAttachment?>? get attachments;
@override @override
SnAttachment? get video; SnAttachment? get video;
@override
SnPoll? get poll;
/// Create a copy of SnPostPreload /// Create a copy of SnPostPreload
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.

View File

@ -63,6 +63,7 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
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(),
publisherId: (json['publisher_id'] as num).toInt(), publisherId: (json['publisher_id'] as num).toInt(),
pollId: (json['poll_id'] as num?)?.toInt(),
publisher: publisher:
SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>), SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
metric: SnMetric.fromJson(json['metric'] as Map<String, dynamic>), metric: SnMetric.fromJson(json['metric'] as Map<String, dynamic>),
@ -101,6 +102,7 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'total_upvote': instance.totalUpvote, 'total_upvote': instance.totalUpvote,
'total_downvote': instance.totalDownvote, 'total_downvote': instance.totalDownvote,
'publisher_id': instance.publisherId, 'publisher_id': instance.publisherId,
'poll_id': instance.pollId,
'publisher': instance.publisher.toJson(), 'publisher': instance.publisher.toJson(),
'metric': instance.metric.toJson(), 'metric': instance.metric.toJson(),
'preload': instance.preload?.toJson(), 'preload': instance.preload?.toJson(),
@ -168,6 +170,9 @@ _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) =>
video: json['video'] == null video: json['video'] == null
? null ? null
: SnAttachment.fromJson(json['video'] as Map<String, dynamic>), : SnAttachment.fromJson(json['video'] as Map<String, dynamic>),
poll: json['poll'] == null
? null
: SnPoll.fromJson(json['poll'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) => Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) =>
@ -175,6 +180,7 @@ Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) =>
'thumbnail': instance.thumbnail?.toJson(), 'thumbnail': instance.thumbnail?.toJson(),
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(), 'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
'video': instance.video?.toJson(), 'video': instance.video?.toJson(),
'poll': instance.poll?.toJson(),
}; };
_$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl( _$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl(

View File

@ -6,6 +6,7 @@ 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';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/account.dart'; import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
@ -49,10 +50,19 @@ class _AccountSelectState extends State<AccountSelect> {
Future<void> _getFriends() async { Future<void> _getFriends() async {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/relations?status=1'); final resp = await sn.client.get('/cgi/id/users/me/relations?status=1');
if (!mounted) return;
final ua = context.read<UserProvider>();
setState(() { setState(() {
_relativeUsers.addAll( _relativeUsers.addAll(
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [], resp.data?.map((e) {
final rel = SnRelationship.fromJson(e);
if (rel.relatedId == ua.user?.id) {
return rel.account!;
} else {
return rel.related!;
}
}).cast<SnAccount>(),
); );
}); });
} }
@ -123,13 +133,9 @@ class _AccountSelectState extends State<AccountSelect> {
), ),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: _pendingUsers.isEmpty itemCount: _pendingUsers.isEmpty ? _relativeUsers.length : _pendingUsers.length,
? _relativeUsers.length
: _pendingUsers.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
var user = _pendingUsers.isEmpty var user = _pendingUsers.isEmpty ? _relativeUsers[index] : _pendingUsers[index];
? _relativeUsers[index]
: _pendingUsers[index];
return ListTile( return ListTile(
title: Text(user.nick), title: Text(user.nick),
subtitle: Text(user.name), subtitle: Text(user.name),
@ -148,8 +154,7 @@ class _AccountSelectState extends State<AccountSelect> {
} }
setState(() { setState(() {
final idx = _selectedUsers final idx = _selectedUsers.indexWhere((x) => x.id == user.id);
.indexWhere((x) => x.id == user.id);
if (idx != -1) { if (idx != -1) {
_selectedUsers.removeAt(idx); _selectedUsers.removeAt(idx);
} else { } else {

View File

@ -45,7 +45,12 @@ class ChatMessageInputState extends State<ChatMessageInput> {
final HotKey _pasteHotKey = HotKey( final HotKey _pasteHotKey = HotKey(
key: PhysicalKeyboardKey.keyV, key: PhysicalKeyboardKey.keyV,
modifiers: [Platform.isMacOS ? HotKeyModifier.meta : HotKeyModifier.control], modifiers: [(!kIsWeb && Platform.isMacOS) ? HotKeyModifier.meta : HotKeyModifier.control],
scope: HotKeyScope.inapp,
);
final HotKey _newLineHotKey = HotKey(
key: PhysicalKeyboardKey.enter,
modifiers: [(!kIsWeb && Platform.isMacOS) ? HotKeyModifier.meta : HotKeyModifier.control],
scope: HotKeyScope.inapp, scope: HotKeyScope.inapp,
); );
@ -61,6 +66,10 @@ class ChatMessageInputState extends State<ChatMessageInput> {
)); ));
setState(() {}); setState(() {});
}); });
hotKeyManager.register(_newLineHotKey, keyDownHandler: (_) async {
if (_contentController.text.isEmpty) return;
_contentController.text += '\n';
});
} }
@override @override
@ -112,6 +121,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
} }
Future<void> _sendMessage() async { Future<void> _sendMessage() async {
if (_contentController.text.isEmpty && _attachments.isEmpty) return;
if (_isBusy) return; if (_isBusy) return;
final attach = context.read<SnAttachmentProvider>(); final attach = context.read<SnAttachmentProvider>();
@ -203,7 +213,11 @@ class ChatMessageInputState extends State<ChatMessageInput> {
void dispose() { void dispose() {
_contentController.dispose(); _contentController.dispose();
_focusNode.dispose(); _focusNode.dispose();
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) hotKeyManager.unregister(_pasteHotKey); _dismissEmojiPicker();
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
hotKeyManager.unregister(_pasteHotKey);
hotKeyManager.unregister(_newLineHotKey);
}
super.dispose(); super.dispose();
} }
@ -343,6 +357,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
_sendMessage(); _sendMessage();
_focusNode.requestFocus(); _focusNode.requestFocus();
}, },
maxLines: null,
), ),
), ),
const Gap(8), const Gap(8),
@ -351,10 +366,9 @@ class ChatMessageInputState extends State<ChatMessageInput> {
Symbols.mood, Symbols.mood,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
visualDensity: const VisualDensity( visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
horizontal: -4, padding: EdgeInsets.zero,
vertical: -4, constraints: const BoxConstraints(),
),
onPressed: () { onPressed: () {
_showEmojiPicker(context); _showEmojiPicker(context);
}, },
@ -372,10 +386,9 @@ class ChatMessageInputState extends State<ChatMessageInput> {
Symbols.send, Symbols.send,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
visualDensity: const VisualDensity( visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
horizontal: -4, padding: EdgeInsets.zero,
vertical: -4, constraints: const BoxConstraints(),
),
), ),
], ],
), ),

View File

@ -58,6 +58,7 @@ class AppScaffold extends StatelessWidget {
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SizedBox.expand( body: SizedBox.expand(
child: AppBackground( child: AppBackground(
isRoot: true,
child: Column( child: Column(
children: [ children: [
IgnorePointer(child: SizedBox(height: appBar != null ? appBarHeight + safeTop : 0)), IgnorePointer(child: SizedBox(height: appBar != null ? appBarHeight + safeTop : 0)),

View File

@ -4,6 +4,7 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.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:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart'; 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';
@ -15,6 +16,47 @@ import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../../providers/sn_network.dart'; import '../../providers/sn_network.dart';
class PostCommentQuickAction extends StatelessWidget {
final double? maxWidth;
final SnPost parentPost;
final Function? onPosted;
const PostCommentQuickAction({super.key, this.maxWidth, required this.parentPost, this.onPosted});
@override
Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Container(
height: 240,
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
margin: ResponsiveBreakpoints.of(context).largerThan(MOBILE) ? const EdgeInsets.symmetric(vertical: 8) : EdgeInsets.zero,
decoration: BoxDecoration(
borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? const BorderRadius.all(Radius.circular(8))
: BorderRadius.zero,
border: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? Border.all(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
)
: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
),
),
child: PostMiniEditor(
postReplyId: parentPost.id,
onPost: () {
onPosted?.call();
},
),
);
}
}
class PostCommentSliverList extends StatefulWidget { class PostCommentSliverList extends StatefulWidget {
final SnPost parentPost; final SnPost parentPost;
final double? maxWidth; final double? maxWidth;
@ -71,6 +113,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
Future<void> refresh() async { Future<void> refresh() async {
_posts.clear(); _posts.clear();
_postCount = null;
_fetchPosts(); _fetchPosts();
} }

View File

@ -36,6 +36,7 @@ import 'package:surface/widgets/markdown_content.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:surface/widgets/post/post_comment_list.dart'; import 'package:surface/widgets/post/post_comment_list.dart';
import 'package:surface/widgets/post/post_meta_editor.dart'; import 'package:surface/widgets/post/post_meta_editor.dart';
import 'package:surface/widgets/post/post_poll.dart';
import 'package:surface/widgets/post/post_reaction.dart'; import 'package:surface/widgets/post/post_reaction.dart';
import 'package:surface/widgets/post/publisher_popover.dart'; import 'package:surface/widgets/post/publisher_popover.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
@ -69,32 +70,34 @@ class OpenablePostItem extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>(); final cfg = context.read<ConfigProvider>();
return OpenContainer( return Center(
closedBuilder: (_, __) => Container( child: OpenContainer(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), closedBuilder: (_, __) => Container(
child: PostItem( constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
data: data, child: PostItem(
maxWidth: maxWidth, data: data,
showComments: showComments, maxWidth: maxWidth,
showFullPost: showFullPost, showComments: showComments,
onChanged: onChanged, showFullPost: showFullPost,
onDeleted: onDeleted, onChanged: onChanged,
onSelectAnswer: onSelectAnswer, 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)), 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)),
),
), ),
); );
} }
@ -193,6 +196,57 @@ class PostItem extends StatelessWidget {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
final isAuthor = ua.isAuthorized && data.publisher.accountId == ua.user?.id; final isAuthor = ua.isAuthorized && data.publisher.accountId == ua.user?.id;
// Video full view
if (showFullPost && data.type == 'video' && ResponsiveBreakpoints.of(context).largerThan(TABLET)) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_PostContentHeader(
data: data,
isAuthor: isAuthor,
isRelativeDate: !showFullPost,
onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: onSelectAnswer,
onDeleted: () {
if (onDeleted != null) {}
},
).padding(bottom: 8),
if (data.preload?.video != null) _PostVideoPlayer(data: data).padding(bottom: 8),
_PostHeadline(data: data).padding(horizontal: 4, bottom: 8),
_PostFeaturedComment(data: data),
_PostBottomAction(
data: data,
showComments: true,
showReactions: showReactions,
onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context),
onChanged: _onChanged,
),
],
),
),
const Gap(4),
SizedBox(
width: 340,
child: CustomScrollView(
shrinkWrap: true,
slivers: [
PostCommentSliverList(
parentPost: data,
),
],
),
),
],
);
}
// Article headline preview // Article headline preview
if (!showFullPost && data.type == 'article') { if (!showFullPost && data.type == 'article') {
return Container( return Container(
@ -336,6 +390,7 @@ class PostItem extends StatelessWidget {
fit: showFullPost ? BoxFit.cover : BoxFit.contain, fit: showFullPost ? BoxFit.cover : BoxFit.contain,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
), ),
if (data.preload?.poll != null) PostPoll(poll: data.preload!.poll!).padding(horizontal: 12, vertical: 4),
if (data.body['content'] != null && (cfg.prefs.getBool(kAppExpandPostLink) ?? true)) if (data.body['content'] != null && (cfg.prefs.getBool(kAppExpandPostLink) ?? true))
LinkPreviewWidget( LinkPreviewWidget(
text: data.body['content'], text: data.body['content'],
@ -910,7 +965,7 @@ class _PostContentHeader extends StatelessWidget {
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postEditor', 'postEditor',
pathParameters: {'mode': data.typePlural}, pathParameters: {'mode': 'stories'},
queryParameters: {'replying': data.id.toString()}, queryParameters: {'replying': data.id.toString()},
); );
}, },
@ -926,7 +981,7 @@ class _PostContentHeader extends StatelessWidget {
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postEditor', 'postEditor',
pathParameters: {'mode': data.typePlural}, pathParameters: {'mode': 'stories'},
queryParameters: {'reposting': data.id.toString()}, queryParameters: {'reposting': data.id.toString()},
); );
}, },

View File

@ -0,0 +1,138 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/poll.dart';
import 'package:surface/widgets/dialog.dart';
class PostPoll extends StatefulWidget {
final SnPoll poll;
const PostPoll({super.key, required this.poll});
@override
State<PostPoll> createState() => _PostPollState();
}
class _PostPollState extends State<PostPoll> {
bool _isBusy = false;
late SnPoll _poll;
@override
void initState() {
_poll = widget.poll;
_fetchAnswer();
super.initState();
}
String? _answeredChoice;
Future<void> _refreshPoll() async {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/polls/${widget.poll.id}');
if (!mounted) return;
setState(() => _poll = SnPoll.fromJson(resp.data!));
}
Future<void> _fetchAnswer() async {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp =
await sn.client.get('/cgi/co/polls/${widget.poll.id}/answer');
_answeredChoice = resp.data?['answer'];
if (!mounted) return;
setState(() {});
} catch (err) {
if (!mounted) return;
// ignore because it may not found
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _voteForOption(SnPollOption option) async {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/co/polls/${widget.poll.id}/answer', data: {
'answer': option.id,
});
if (!mounted) return;
HapticFeedback.heavyImpact();
_answeredChoice = option.id;
_refreshPoll();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (final option in _poll.options)
Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
height: 60,
width: MediaQuery.of(context).size.width *
(_poll.metric.byOptionsPercentage[option.id] ?? 0)
.toDouble(),
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
),
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
minTileHeight: 60,
leading: _answeredChoice == option.id
? const Icon(Symbols.circle, fill: 1)
: const Icon(Symbols.circle),
title: Text(option.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'pollVotes'
.plural(_poll.metric.byOptions[option.id] ?? 0),
),
Text(' · ').padding(horizontal: 4),
Text(
'${((_poll.metric.byOptionsPercentage[option.id] ?? 0).toDouble() * 100).toStringAsFixed(2)}%',
),
],
),
if (option.description.isNotEmpty)
Text(option.description),
],
),
onTap: _isBusy ? null : () => _voteForOption(option),
),
],
)
],
),
);
}
}

View File

@ -0,0 +1,201 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/poll.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:uuid/uuid.dart';
class PollEditorDialog extends StatefulWidget {
final SnPoll? poll;
const PollEditorDialog({super.key, this.poll});
@override
State<PollEditorDialog> createState() => _PollEditorDialogState();
}
class _PollEditorDialogState extends State<PollEditorDialog> {
final TextEditingController _linkController = TextEditingController();
final List<SnPollOption> _pollOptions = List.empty(growable: true);
bool _isBusy = false;
Future<void> _fetchPoll() async {
if (_linkController.text.isEmpty) return;
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/polls/${_linkController.text}');
final out = SnPoll.fromJson(resp.data);
if (!mounted) return;
Navigator.pop(context, out);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _applyPost() async {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = widget.poll == null
? await sn.client.post('/cgi/co/polls', data: {
'options': _pollOptions.where((ele) => ele.name.isNotEmpty).toList(),
})
: await sn.client.put('/cgi/co/polls/${widget.poll!.id}', data: {
'options': _pollOptions.where((ele) => ele.name.isNotEmpty).toList(),
});
final out = SnPoll.fromJson(resp.data);
if (!mounted) return;
Navigator.pop(context, out);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _deletePoll() async {
final confirm = await context.showConfirmDialog(
'pollEditorDelete'.tr(),
'pollEditorDeleteDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/co/polls/${widget.poll!.id}');
if (!mounted) return;
Navigator.pop(context, false);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_pollOptions.addAll(widget.poll?.options ?? []);
}
@override
void dispose() {
_linkController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.poll == null ? 'pollEditorNew' : 'pollEditorEdit').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: [
if (widget.poll == null)
TextField(
controller: _linkController,
decoration: InputDecoration(
isDense: true,
labelText: 'pollLinkExisting'.tr(),
prefixText: '#',
suffixIcon: IconButton(
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
onPressed: _isBusy ? null : () => _fetchPoll(),
icon: const Icon(Icons.keyboard_arrow_right),
),
border: const OutlineInputBorder(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (int i = 0; i < _pollOptions.length; i++)
ListTile(
leading: const Icon(Symbols.circle),
title: TextFormField(
decoration: InputDecoration.collapsed(
hintText: 'pollOptionName'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
initialValue: _pollOptions[i].name,
onChanged: (value) {
// Looks like we don't need set state here cuz it got internal updated.
_pollOptions[i] = _pollOptions[i].copyWith(name: value);
},
),
trailing: IconButton(
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
setState(() => _pollOptions.removeAt(i));
},
icon: const Icon(Icons.close),
),
),
ListTile(
leading: const Icon(Symbols.add),
title: Text('pollOptionAdd').tr(),
onTap: () {
setState(
() => _pollOptions.add(
SnPollOption(id: const Uuid().v4(), icon: '', name: '', description: ''),
),
);
},
),
],
),
),
if (widget.poll != null)
Card(
margin: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Symbols.delete),
trailing: const Icon(Symbols.chevron_right),
title: Text('pollEditorDelete').tr(),
onTap: _isBusy ? null : () => _deletePoll(),
),
ListTile(
leading: const Icon(Symbols.link_off),
trailing: const Icon(Symbols.chevron_right),
title: Text('pollEditorUnlink').tr(),
onTap: _isBusy ? null : () => Navigator.pop(context, false),
),
],
),
),
],
),
actions: [
TextButton(
onPressed: _isBusy ? null : () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
TextButton(
onPressed: _isBusy ? null : () => _applyPost(),
child: Text('dialogConfirm'.tr()),
),
],
);
}
}

View File

@ -138,26 +138,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: build_daemon name: build_daemon
sha256: "294a2edaf4814a378725bfe6358210196f5ea37af89ecd81bfa32960113d4948" sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.3" version: "4.0.4"
build_resolvers: build_resolvers:
dependency: transitive dependency: transitive
description: description:
name: build_resolvers name: build_resolvers
sha256: "99d3980049739a985cf9b21f30881f46db3ebc62c5b8d5e60e27440876b1ba1e" sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.3" version: "2.4.4"
build_runner: build_runner:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: build_runner name: build_runner
sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.14" version: "2.4.15"
build_runner_core: build_runner_core:
dependency: transitive dependency: transitive
description: description:
@ -354,10 +354,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: dart_webrtc name: dart_webrtc
sha256: e65506edb452148220efab53d8d2f8bb9d827bd8bcd53cf3a3e6df70b27f3d86 sha256: "3b3ff59c66cbc1577ed0f28d7005b5163555208fb1697a42207424ab8baa27c5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.10" version: "1.5.0"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@ -498,10 +498,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: cacfdc5abe93e64d418caa9256eef663499ad791bb688d9fd12c85a311968fba sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.3.2" version: "8.3.7"
file_saver: file_saver:
dependency: "direct main" dependency: "direct main"
description: description:
@ -772,10 +772,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_markdown name: flutter_markdown
sha256: b3ff1ef5fb3924ee02b4d38b974ffae3969d50603e68787684ee9dd45f6f144a sha256: e7bbc718adc9476aa14cfddc1ef048d2e21e4e8f18311aaac723266db9f9e7b5
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6+1" version: "0.7.6+2"
flutter_native_splash: flutter_native_splash:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -838,10 +838,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_webrtc name: flutter_webrtc
sha256: e917067abeef2400e6a7a03db53a6e1418551e54809f18ab80447ac323eb77e4 sha256: "572df3de6c828e571db4b75b4a96a15c2f34fa3d420a84438f44a3158b22e81a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.8" version: "0.12.9"
freezed: freezed:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -1043,7 +1043,7 @@ packages:
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
image_picker_android: image_picker_android:
dependency: transitive dependency: "direct main"
description: description:
name: image_picker_android name: image_picker_android
sha256: b62d34a506e12bb965e824b6db4fbf709ee4589cf5d3e99b45ab2287b008ee0c sha256: b62d34a506e12bb965e824b6db4fbf709ee4589cf5d3e99b45ab2287b008ee0c
@ -1766,18 +1766,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: "688ee90fbfb6989c980254a56cb26ebe9bb30a3a2dff439a78894211f73de67a" sha256: "846849e3e9b68f3ef4b60c60cf4b3e02e9321bc7f4d8c4692cf87ffa82fc8a3a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.1" version: "2.5.2"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: "650584dcc0a39856f369782874e562efd002a9c94aec032412c9eb81419cce1f" sha256: ea86be7b7114f9e94fddfbb52649e59a03d6627ccd2387ebddcd6624719e9f16
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.4" version: "2.4.5"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
@ -1830,10 +1830,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shelf_web_socket name: shelf_web_socket
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" version: "3.0.0"
shortid: shortid:
dependency: transitive dependency: transitive
description: description:
@ -2187,10 +2187,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: video_compress name: video_compress
sha256: "5b42d89f3970c956bad7a86c29682b0892c11a4ddf95ae6e29897ee28788e377" sha256: "31bc5cdb9a02ba666456e5e1907393c28e6e0e972980d7d8d619a7beda0d4f20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.3" version: "3.1.4"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2.3.2+65 version: 2.3.2+69
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@ -120,6 +120,7 @@ dependencies:
xml: ^6.5.0 xml: ^6.5.0
tray_manager: ^0.3.2 tray_manager: ^0.3.2
hotkey_manager: ^0.2.3 hotkey_manager: ^0.2.3
image_picker_android: ^0.8.12+20
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: