diff --git a/lib/models/post.dart b/lib/models/post.dart index 377b2f1..a04d69a 100755 --- a/lib/models/post.dart +++ b/lib/models/post.dart @@ -56,19 +56,25 @@ class Post { id: json["id"], createdAt: DateTime.parse(json["created_at"]), updatedAt: DateTime.parse(json["updated_at"]), - deletedAt: json["deleted_at"] != null ? DateTime.parse(json['deleted_at']) : null, + deletedAt: json["deleted_at"] != null + ? DateTime.parse(json['deleted_at']) + : null, alias: json["alias"], content: json["content"], tags: json["tags"], categories: json["categories"], reactions: json["reactions"], replies: json["replies"], - attachments: json["attachments"] != null ? List.from(json["attachments"]) : null, + attachments: json["attachments"] != null + ? List.from(json["attachments"]) + : null, replyId: json["reply_id"], repostId: json["repost_id"], realmId: json["realm_id"], - replyTo: json["reply_to"] == null ? null : Post.fromJson(json["reply_to"]), - repostTo: json["repost_to"], + replyTo: + json["reply_to"] != null ? Post.fromJson(json["reply_to"]) : null, + repostTo: + json["repost_to"] != null ? Post.fromJson(json["repost_to"]) : null, realm: json["realm"], publishedAt: json["published_at"], authorId: json["author_id"], @@ -77,8 +83,10 @@ class Post { reactionCount: json["reaction_count"], reactionList: json["reaction_list"] != null ? json["reaction_list"] - .map((key, value) => - MapEntry(key, int.tryParse(value.toString()) ?? (value is double ? value.toInt() : null))) + .map((key, value) => MapEntry( + key, + int.tryParse(value.toString()) ?? + (value is double ? value.toInt() : null))) .cast() : {}, ); @@ -99,7 +107,7 @@ class Post { "repost_id": repostId, "realm_id": realmId, "reply_to": replyTo?.toJson(), - "repost_to": repostTo, + "repost_to": repostTo?.toJson(), "realm": realm, "published_at": publishedAt, "author_id": authorId, diff --git a/lib/providers/auth.dart b/lib/providers/auth.dart index 1177b4d..6f3fc71 100644 --- a/lib/providers/auth.dart +++ b/lib/providers/auth.dart @@ -9,9 +9,12 @@ import 'package:solian/services.dart'; import 'package:oauth2/oauth2.dart' as oauth2; class AuthProvider extends GetConnect { - final deviceEndpoint = Uri.parse('${ServiceFinder.services['passport']}/api/notifications/subscribe'); - final tokenEndpoint = Uri.parse('${ServiceFinder.services['passport']}/api/auth/token'); - final userinfoEndpoint = Uri.parse('${ServiceFinder.services['passport']}/api/users/me'); + final deviceEndpoint = Uri.parse( + '${ServiceFinder.services['passport']}/api/notifications/subscribe'); + final tokenEndpoint = + Uri.parse('${ServiceFinder.services['passport']}/api/auth/token'); + final userinfoEndpoint = + Uri.parse('${ServiceFinder.services['passport']}/api/users/me'); final redirectUrl = Uri.parse('solian://auth'); static const clientId = 'solian'; @@ -44,7 +47,8 @@ class AuthProvider extends GetConnect { tokenEndpoint: tokenEndpoint, expiration: DateTime.now().add(const Duration(minutes: 3)), ); - storage.write(key: 'auth_credentials', value: jsonEncode(credentials!.toJson())); + storage.write( + key: 'auth_credentials', value: jsonEncode(credentials!.toJson())); } if (credentials != null) { @@ -64,7 +68,8 @@ class AuthProvider extends GetConnect { }); } - Future signin(BuildContext context, String username, String password) async { + Future signin( + BuildContext context, String username, String password) async { final resp = await oauth2.resourceOwnerPasswordGrant( tokenEndpoint, username, @@ -83,7 +88,8 @@ class AuthProvider extends GetConnect { expiration: DateTime.now().add(const Duration(minutes: 3)), ); - storage.write(key: 'auth_credentials', value: jsonEncode(credentials!.toJson())); + storage.write( + key: 'auth_credentials', value: jsonEncode(credentials!.toJson())); applyAuthenticator(); return credentials!; @@ -93,7 +99,17 @@ class AuthProvider extends GetConnect { storage.deleteAll(); } + Response? _cacheUserProfileResponse; + Future get isAuthorized => storage.containsKey(key: 'auth_credentials'); - Future getProfile() => get('/api/users/me'); + Future getProfile({noCache = false}) async { + if (!noCache && _cacheUserProfileResponse != null) { + return _cacheUserProfileResponse!; + } + + final resp = await get('/api/users/me'); + _cacheUserProfileResponse = resp; + return resp; + } } diff --git a/lib/router.dart b/lib/router.dart index 358d700..e1d91a0 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -10,7 +10,8 @@ abstract class AppRouter { static GoRouter instance = GoRouter( routes: [ ShellRoute( - builder: (context, state, child) => NavShell(state: state, child: child), + builder: (context, state, child) => + NavShell(state: state, child: child), routes: [ GoRoute( path: "/", @@ -37,7 +38,15 @@ abstract class AppRouter { GoRoute( path: "/posts/publish", name: "postPublishing", - builder: (context, state) => const PostPublishingScreen(), + builder: (context, state) { + final arguments = state.extra as PostPublishingArguments?; + return PostPublishingScreen( + edit: arguments?.edit, + reply: arguments?.reply, + repost: arguments?.repost, + realm: state.uri.queryParameters['realm'], + ); + }, ), ], ); diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 70dd664..1d08a83 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -22,7 +22,7 @@ class _AccountScreenState extends State { final AuthProvider provider = Get.find(); return Material( - color: Theme.of(context).colorScheme.background, + color: Theme.of(context).colorScheme.surface, child: FutureBuilder( future: provider.isAuthorized, builder: (context, snapshot) { @@ -103,8 +103,10 @@ class AccountNameCard extends StatelessWidget { return Material( elevation: 2, child: ListTile( - contentPadding: const EdgeInsets.only(left: 22, right: 34, top: 4, bottom: 4), - leading: AccountAvatar(content: snapshot.data!.body?['avatar'], radius: 24), + contentPadding: + const EdgeInsets.only(left: 22, right: 34, top: 4, bottom: 4), + leading: AccountAvatar( + content: snapshot.data!.body?['avatar'], radius: 24), title: Text(snapshot.data!.body?['nick']), subtitle: Text(snapshot.data!.body?['email']), ), diff --git a/lib/screens/auth/signin.dart b/lib/screens/auth/signin.dart index 6f70866..69ae54d 100644 --- a/lib/screens/auth/signin.dart +++ b/lib/screens/auth/signin.dart @@ -30,7 +30,8 @@ class _SignInScreenState extends State { if (messages.last.contains('risk')) { final ticketId = RegExp(r'ticketId=(\d+)').firstMatch(messages.last); if (ticketId == null) { - context.showErrorDialog('Requested to multi-factor authenticate, but the ticket id was not found'); + context.showErrorDialog( + 'Requested to multi-factor authenticate, but the ticket id was not found'); } showDialog( context: context, @@ -65,7 +66,7 @@ class _SignInScreenState extends State { @override Widget build(BuildContext context) { return Material( - color: Theme.of(context).colorScheme.background, + color: Theme.of(context).colorScheme.surface, child: Center( child: Container( width: MediaQuery.of(context).size.width * 0.6, @@ -87,7 +88,8 @@ class _SignInScreenState extends State { border: const OutlineInputBorder(), labelText: 'username'.tr, ), - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), ), const SizedBox(height: 12), TextField( @@ -101,7 +103,8 @@ class _SignInScreenState extends State { border: const OutlineInputBorder(), labelText: 'password'.tr, ), - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), onSubmitted: (_) => performAction(context), ), const SizedBox(height: 16), diff --git a/lib/screens/auth/signup.dart b/lib/screens/auth/signup.dart index 044ec6b..cf53eb7 100644 --- a/lib/screens/auth/signup.dart +++ b/lib/screens/auth/signup.dart @@ -62,7 +62,7 @@ class _SignUpScreenState extends State { @override Widget build(BuildContext context) { return Material( - color: Theme.of(context).colorScheme.background, + color: Theme.of(context).colorScheme.surface, child: Center( child: Container( width: MediaQuery.of(context).size.width * 0.6, diff --git a/lib/screens/home.dart b/lib/screens/home.dart index c3a628b..8602c4d 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -6,6 +6,7 @@ import 'package:solian/models/post.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/providers/content/post_explore.dart'; import 'package:solian/router.dart'; +import 'package:solian/widgets/posts/post_action.dart'; import 'package:solian/widgets/posts/post_item.dart'; class HomeScreen extends StatefulWidget { @@ -16,7 +17,8 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - final PagingController _pagingController = PagingController(firstPageKey: 0); + final PagingController _pagingController = + PagingController(firstPageKey: 0); getPosts(int pageKey) async { final PostExploreProvider provider = Get.find(); @@ -55,7 +57,8 @@ class _HomeScreenState extends State { return FloatingActionButton( child: const Icon(Icons.add), onPressed: () async { - final value = await AppRouter.instance.pushNamed('postPublishing'); + final value = + await AppRouter.instance.pushNamed('postPublishing'); if (value != null) { _pagingController.refresh(); } @@ -65,7 +68,7 @@ class _HomeScreenState extends State { return Container(); }), body: Material( - color: Theme.of(context).colorScheme.background, + color: Theme.of(context).colorScheme.surface, child: RefreshIndicator( onRefresh: () => Future.sync(() => _pagingController.refresh()), child: PagedListView.separated( @@ -73,12 +76,25 @@ class _HomeScreenState extends State { builderDelegate: PagedChildBuilderDelegate( itemBuilder: (context, item, index) { return GestureDetector( - child: PostItem(key: Key('p${item.alias}'), item: item), + child: PostItem(key: Key('p${item.alias}'), item: item) + .paddingSymmetric( + vertical: (item.attachments?.isEmpty ?? false) ? 8 : 0, + ), onTap: () {}, + onLongPress: () { + showModalBottomSheet( + useRootNavigator: true, + context: context, + builder: (context) => PostAction(item: item), + ).then((value) { + if (value == true) _pagingController.refresh(); + }); + }, ); }, ), - separatorBuilder: (_, __) => const Divider(thickness: 0.3, height: 0.3), + separatorBuilder: (_, __) => + const Divider(thickness: 0.3, height: 0.3), ), ), ), diff --git a/lib/screens/posts/publish.dart b/lib/screens/posts/publish.dart index 9ccca9c..98469ec 100644 --- a/lib/screens/posts/publish.dart +++ b/lib/screens/posts/publish.dart @@ -2,15 +2,36 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; import 'package:solian/exts.dart'; +import 'package:solian/models/post.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/router.dart'; import 'package:solian/services.dart'; import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/shells/nav_shell.dart' as shell; import 'package:solian/widgets/attachments/attachment_publish.dart'; +import 'package:solian/widgets/posts/post_item.dart'; + +class PostPublishingArguments { + final Post? edit; + final Post? reply; + final Post? repost; + + PostPublishingArguments({this.edit, this.reply, this.repost}); +} class PostPublishingScreen extends StatefulWidget { - const PostPublishingScreen({super.key}); + final Post? edit; + final Post? reply; + final Post? repost; + final String? realm; + + const PostPublishingScreen({ + super.key, + this.edit, + this.reply, + this.repost, + this.realm, + }); @override State createState() => _PostPublishingScreenState(); @@ -45,10 +66,20 @@ class _PostPublishingScreenState extends State { client.httpClient.baseUrl = ServiceFinder.services['interactive']; client.httpClient.addAuthenticator(auth.reqAuthenticator); - final resp = await client.post('/api/posts', { + final payload = { 'content': _contentController.value.text, 'attachments': _attachments, - }); + if (widget.edit != null) 'alias': widget.edit!.alias, + if (widget.reply != null) 'reply_to': widget.reply!.id, + if (widget.repost != null) 'repost_to': widget.repost!.id, + }; + + Response resp; + if (widget.edit != null) { + resp = await client.put('/api/posts/${widget.edit!.id}', payload); + } else { + resp = await client.post('/api/posts', payload); + } if (resp.statusCode != 200) { context.showErrorDialog(resp.bodyString); } else { @@ -58,12 +89,36 @@ class _PostPublishingScreenState extends State { setState(() => _isSubmitting = false); } + void syncWidget() { + if (widget.edit != null) { + _contentController.text = widget.edit!.content; + _attachments = widget.edit!.attachments ?? List.empty(); + } + } + + void cancelAction() { + AppRouter.instance.pop(); + } + + @override + void initState() { + syncWidget(); + super.initState(); + } + @override Widget build(BuildContext context) { final AuthProvider auth = Get.find(); + final notifyBannerActions = [ + TextButton( + onPressed: cancelAction, + child: Text('cancel'.tr), + ) + ]; + return Material( - color: Theme.of(context).colorScheme.background, + color: Theme.of(context).colorScheme.surface, child: Scaffold( appBar: AppBar( title: Text('postPublishing'.tr), @@ -79,13 +134,84 @@ class _PostPublishingScreenState extends State { top: false, child: Column( children: [ - _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(), + if (_isSubmitting) + const LinearProgressIndicator().animate().scaleX(), + if (widget.edit != null) + MaterialBanner( + leading: const Icon(Icons.edit), + leadingPadding: const EdgeInsets.only(left: 10, right: 20), + dividerColor: Colors.transparent, + content: Text('postEditingNotify'.tr), + actions: notifyBannerActions, + ), + if (widget.reply != null) + Container( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Column( + children: [ + MaterialBanner( + leading: const Icon(Icons.reply), + leadingPadding: + const EdgeInsets.only(left: 10, right: 20), + backgroundColor: Colors.transparent, + dividerColor: Colors.transparent, + content: Text( + 'postReplyingNotify'.trParams( + {'username': '@${widget.reply!.author.name}'}, + ), + ), + actions: notifyBannerActions, + ), + const Divider(thickness: 0.3, height: 0.3), + Container( + constraints: const BoxConstraints(maxHeight: 280), + child: SingleChildScrollView( + child: PostItem( + item: widget.reply!, + isReactable: false, + ), + ), + ), + ], + ), + ), + if (widget.repost != null) + Container( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Column( + children: [ + MaterialBanner( + leading: const Icon(Icons.redo), + leadingPadding: + const EdgeInsets.only(left: 10, right: 20), + dividerColor: Colors.transparent, + content: Text( + 'postRepostingNotify'.trParams( + {'username': '@${widget.repost!.author.name}'}, + ), + ), + actions: notifyBannerActions, + ), + const Divider(thickness: 0.3, height: 0.3), + Container( + constraints: const BoxConstraints(maxHeight: 280), + child: SingleChildScrollView( + child: PostItem( + item: widget.repost!, + isReactable: false, + ), + ), + ), + ], + ), + ), FutureBuilder( future: auth.getProfile(), builder: (context, snapshot) { if (snapshot.hasData) { return ListTile( - leading: AccountAvatar(content: snapshot.data?.body!['avatar'], radius: 22), + leading: AccountAvatar( + content: snapshot.data?.body!['avatar'], radius: 22), title: Text(snapshot.data?.body!['nick']), subtitle: Text('postIdentityNotify'.tr), ); @@ -97,7 +223,8 @@ class _PostPublishingScreenState extends State { const Divider(thickness: 0.3), Expanded( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: TextField( maxLines: null, autofocus: true, @@ -107,7 +234,8 @@ class _PostPublishingScreenState extends State { decoration: InputDecoration.collapsed( hintText: 'postContentPlaceholder'.tr, ), - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), ), ), ), @@ -115,7 +243,8 @@ class _PostPublishingScreenState extends State { constraints: const BoxConstraints(minHeight: 56), decoration: BoxDecoration( border: Border( - top: BorderSide(width: 0.3, color: Theme.of(context).dividerColor), + top: BorderSide( + width: 0.3, color: Theme.of(context).dividerColor), ), ), child: Row( diff --git a/lib/translations.dart b/lib/translations.dart index 737a2a8..dc76088 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -12,7 +12,10 @@ class SolianMessages extends Translations { 'apply': 'Apply', 'cancel': 'Cancel', 'confirm': 'Confirm', + 'edit': 'Edit', 'delete': 'Delete', + 'reply': 'Reply', + 'repost': 'Repost', 'errorHappened': 'An error occurred', 'email': 'Email', 'username': 'Username', @@ -26,20 +29,30 @@ class SolianMessages extends Translations { 'aspectRatioPortrait': 'Portrait', 'aspectRatioLandscape': 'Landscape', 'signin': 'Sign in', - 'signinCaption': 'Sign in to create post, start a realm, message your friend and more!', + 'signinCaption': + 'Sign in to create post, start a realm, message your friend and more!', 'signinRiskDetected': 'Risk detected, click Next to open a webpage and signin through it to pass security check.', 'signup': 'Sign up', - 'signupCaption': 'Create an account on Solarpass and then get the access of entire Solar Network!', + 'signupCaption': + 'Create an account on Solarpass and then get the access of entire Solar Network!', 'signout': 'Sign out', 'riskDetection': 'Risk Detected', 'matureContent': 'Mature Content', - 'matureContentCaption': 'The content is rated and may not suitable for everyone to view', + 'matureContentCaption': + 'The content is rated and may not suitable for everyone to view', 'postAction': 'Post', 'postPublishing': 'Post a post', 'postIdentityNotify': 'You will post this post as', 'postContentPlaceholder': 'What\'s happened?!', 'postReaction': 'Reactions of the Post', + 'postActionList': 'Actions of Post', + 'postEditingNotify': 'You\'re editing as post from you.', + 'postReplyingNotify': 'You\'re replying a post from @username.', + 'postRepostingNotify': 'You\'re reposting a post from @username.', + 'postDeletionConfirm': 'Confirm post deletion', + 'postDeletionConfirmCaption': + 'Are your sure to delete post "@content"? this action cannot be undone!', 'reactAdd': 'React', 'reactCompleted': 'Your reaction has been added', 'reactUncompleted': 'Your reaction has been removed', @@ -58,10 +71,13 @@ class SolianMessages extends Translations { 'next': '下一步', 'cancel': '取消', 'confirm': '确认', + 'edit': '编辑', 'delete': '删除', 'page': '页面', 'home': '首页', 'apply': '应用', + 'reply': '回复', + 'repost': '转帖', 'errorHappened': '发生错误了', 'email': '邮件地址', 'username': '用户名', @@ -88,6 +104,12 @@ class SolianMessages extends Translations { 'postIdentityNotify': '你将会以本身份发表帖子', 'postContentPlaceholder': '发生什么事了?!', 'postReaction': '帖子的反应', + 'postActionList': '帖子的操作', + 'postEditingNotify': '你正在编辑一个你发布的帖子', + 'postReplyingNotify': '你正在回一个来自 @username 的帖子', + 'postRepostingNotify': '你正在转一个来自 @username 的帖子', + 'postDeletionConfirm': '确认删除帖子', + 'postDeletionConfirmCaption': '你确定要删除帖子 “@content” 吗?该操作不可不可撤销。', 'reactAdd': '作出反应', 'reactCompleted': '你的反应已被添加', 'reactUncompleted': '你的反应已被移除', diff --git a/lib/widgets/attachments/attachment_list.dart b/lib/widgets/attachments/attachment_list.dart index 3a21acf..4238cb2 100644 --- a/lib/widgets/attachments/attachment_list.dart +++ b/lib/widgets/attachments/attachment_list.dart @@ -98,7 +98,8 @@ class _AttachmentListState extends State { return AspectRatio( aspectRatio: _aspectRatio, child: Container( - decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceVariant), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh), child: const Center( child: CircularProgressIndicator(), ), @@ -118,14 +119,18 @@ class _AttachmentListState extends State { return GestureDetector( child: Container( width: MediaQuery.of(context).size.width, - decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceVariant), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + ), child: Stack( fit: StackFit.expand, children: [ AttachmentItem( key: Key('a${element!.uuid}'), item: element, - badge: _attachmentsMeta.length > 1 ? '${idx + 1}/${_attachmentsMeta.length}' : null, + badge: _attachmentsMeta.length > 1 + ? '${idx + 1}/${_attachmentsMeta.length}' + : null, showHideButton: !element.isMature || _showMature, onHide: () { setState(() => _showMature = false); @@ -147,11 +152,15 @@ class _AttachmentListState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.visibility_off, color: Colors.white, size: 32), + const Icon(Icons.visibility_off, + color: Colors.white, size: 32), const SizedBox(height: 8), Text( 'matureContent'.tr, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16), ), Text( 'matureContentCaption'.tr, diff --git a/lib/widgets/attachments/attachment_list_fullscreen.dart b/lib/widgets/attachments/attachment_list_fullscreen.dart index 9eb8ebb..2eb9e23 100644 --- a/lib/widgets/attachments/attachment_list_fullscreen.dart +++ b/lib/widgets/attachments/attachment_list_fullscreen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:solian/models/attachment.dart'; import 'package:solian/providers/content/attachment_item.dart'; -import 'package:solian/services.dart'; class AttachmentListFullscreen extends StatefulWidget { final Attachment attachment; @@ -9,7 +8,8 @@ class AttachmentListFullscreen extends StatefulWidget { const AttachmentListFullscreen({super.key, required this.attachment}); @override - State createState() => _AttachmentListFullscreenState(); + State createState() => + _AttachmentListFullscreenState(); } class _AttachmentListFullscreenState extends State { @@ -21,7 +21,7 @@ class _AttachmentListFullscreenState extends State { @override Widget build(BuildContext context) { return Material( - color: Theme.of(context).colorScheme.background, + color: Theme.of(context).colorScheme.surface, child: GestureDetector( child: SizedBox( height: MediaQuery.of(context).size.height, diff --git a/lib/widgets/attachments/attachment_publish.dart b/lib/widgets/attachments/attachment_publish.dart index 049ad4b..1e65034 100644 --- a/lib/widgets/attachments/attachment_publish.dart +++ b/lib/widgets/attachments/attachment_publish.dart @@ -35,7 +35,8 @@ class AttachmentPublishingPopup extends StatefulWidget { }); @override - State createState() => _AttachmentPublishingPopupState(); + State createState() => + _AttachmentPublishingPopupState(); } class _AttachmentPublishingPopupState extends State { @@ -97,7 +98,8 @@ class _AttachmentPublishingPopupState extends State { final AuthProvider auth = Get.find(); if (!await auth.isAuthorized) return; - FilePickerResult? result = await FilePicker.platform.pickFiles(allowMultiple: true); + FilePickerResult? result = + await FilePicker.platform.pickFiles(allowMultiple: true); if (result == null) return; List files = result.paths.map((path) => File(path!)).toList(); @@ -157,7 +159,8 @@ class _AttachmentPublishingPopupState extends State { client.httpClient.baseUrl = ServiceFinder.services['paperclip']; client.httpClient.addAuthenticator(auth.reqAuthenticator); - final filePayload = MultipartFile(await file.readAsBytes(), filename: basename(file.path)); + final filePayload = + MultipartFile(await file.readAsBytes(), filename: basename(file.path)); final fileAlt = basename(file.path).contains('.') ? basename(file.path).substring(0, basename(file.path).lastIndexOf('.')) : basename(file.path); @@ -187,7 +190,17 @@ class _AttachmentPublishingPopupState extends State { if (bytes == 0) return '0 Bytes'; const k = 1024; final dm = decimals < 0 ? 0 : decimals; - final sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + final sizes = [ + 'Bytes', + 'KiB', + 'MiB', + 'GiB', + 'TiB', + 'PiB', + 'EiB', + 'ZiB', + 'YiB' + ]; final i = (math.log(bytes) / math.log(k)).floor().toInt(); return '${(bytes / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}'; } @@ -240,7 +253,7 @@ class _AttachmentPublishingPopupState extends State { 'attachmentAdd'.tr, style: Theme.of(context).textTheme.headlineSmall, ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), - _isBusy ? const LinearProgressIndicator().animate().scaleX() : Container(), + if (_isBusy) const LinearProgressIndicator().animate().scaleX(), Expanded( child: Builder(builder: (context) { if (_isFirstTimeBusy && _isBusy) { @@ -255,7 +268,8 @@ class _AttachmentPublishingPopupState extends State { final element = _attachments[index]; final fileType = element!.mimetype.split('/').first; return Container( - padding: const EdgeInsets.only(left: 16, right: 8, bottom: 16), + padding: + const EdgeInsets.only(left: 16, right: 8, bottom: 16), child: Row( children: [ Expanded( @@ -266,7 +280,8 @@ class _AttachmentPublishingPopupState extends State { element.alt, overflow: TextOverflow.ellipsis, maxLines: 1, - style: const TextStyle(fontWeight: FontWeight.bold), + style: const TextStyle( + fontWeight: FontWeight.bold), ), Text( '${fileType[0].toUpperCase()}${fileType.substring(1)} · ${formatBytes(element.size)}', @@ -287,12 +302,18 @@ class _AttachmentPublishingPopupState extends State { return AttachmentEditingPopup( item: element, onDelete: () { - setState(() => _attachments.removeAt(index)); - widget.onUpdate(_attachments.map((e) => e!.id).toList()); + setState( + () => _attachments.removeAt(index)); + widget.onUpdate(_attachments + .map((e) => e!.id) + .toList()); }, onUpdate: (item) { - setState(() => _attachments[index] = item); - widget.onUpdate(_attachments.map((e) => e!.id).toList()); + setState( + () => _attachments[index] = item); + widget.onUpdate(_attachments + .map((e) => e!.id) + .toList()); }, ); }, @@ -363,7 +384,11 @@ class AttachmentEditingPopup extends StatefulWidget { final Function onDelete; final Function(Attachment item) onUpdate; - const AttachmentEditingPopup({super.key, required this.item, required this.onDelete, required this.onUpdate}); + const AttachmentEditingPopup( + {super.key, + required this.item, + required this.onDelete, + required this.onUpdate}); @override State createState() => _AttachmentEditingPopupState(); @@ -387,7 +412,8 @@ class _AttachmentEditingPopupState extends State { setState(() => _isBusy = true); var resp = await client.put('/api/attachments/${widget.item.id}', { 'metadata': { - if (_hasAspectRatio) 'ratio': double.tryParse(_ratioController.value.text) ?? 1, + if (_hasAspectRatio) + 'ratio': double.tryParse(_ratioController.value.text) ?? 1, }, 'alt': _altController.value.text, 'usage': widget.item.usage, @@ -426,7 +452,8 @@ class _AttachmentEditingPopupState extends State { _altController.text = widget.item.alt; if (['image', 'video'].contains(widget.item.mimetype.split('/').first)) { - _ratioController.text = widget.item.metadata?['ratio']?.toString() ?? 1.toString(); + _ratioController.text = + widget.item.metadata?['ratio']?.toString() ?? 1.toString(); _hasAspectRatio = true; } } @@ -446,12 +473,11 @@ class _AttachmentEditingPopupState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - _isBusy - ? ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: const LinearProgressIndicator().animate().scaleX(), - ) - : Container(), + if (_isBusy) + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: const LinearProgressIndicator().animate().scaleX(), + ), const SizedBox(height: 18), TextField( controller: _altController, @@ -461,7 +487,8 @@ class _AttachmentEditingPopupState extends State { border: const OutlineInputBorder(), labelText: 'attachmentAlt'.tr, ), - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), ), const SizedBox(height: 16), TextField( @@ -473,7 +500,8 @@ class _AttachmentEditingPopupState extends State { border: const OutlineInputBorder(), labelText: 'aspectRatio'.tr, ), - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), ), const SizedBox(height: 5), SingleChildScrollView( @@ -483,7 +511,8 @@ class _AttachmentEditingPopupState extends State { runSpacing: 0, children: [ ActionChip( - avatar: Icon(Icons.square_rounded, color: Theme.of(context).colorScheme.onSurfaceVariant), + avatar: Icon(Icons.square_rounded, + color: Theme.of(context).colorScheme.onSurfaceVariant), label: Text('aspectRatioSquare'.tr), onPressed: () { if (_hasAspectRatio) { @@ -492,20 +521,24 @@ class _AttachmentEditingPopupState extends State { }, ), ActionChip( - avatar: Icon(Icons.portrait, color: Theme.of(context).colorScheme.onSurfaceVariant), + avatar: Icon(Icons.portrait, + color: Theme.of(context).colorScheme.onSurfaceVariant), label: Text('aspectRatioPortrait'.tr), onPressed: () { if (_hasAspectRatio) { - setState(() => _ratioController.text = (9 / 16).toString()); + setState( + () => _ratioController.text = (9 / 16).toString()); } }, ), ActionChip( - avatar: Icon(Icons.landscape, color: Theme.of(context).colorScheme.onSurfaceVariant), + avatar: Icon(Icons.landscape, + color: Theme.of(context).colorScheme.onSurfaceVariant), label: Text('aspectRatioLandscape'.tr), onPressed: () { if (_hasAspectRatio) { - setState(() => _ratioController.text = (16 / 9).toString()); + setState( + () => _ratioController.text = (16 / 9).toString()); } }, ), @@ -514,7 +547,8 @@ class _AttachmentEditingPopupState extends State { ), Card( child: CheckboxListTile( - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10))), title: Text('matureContent'.tr), secondary: const Icon(Icons.visibility_off), value: _isMature, @@ -530,7 +564,8 @@ class _AttachmentEditingPopupState extends State { actionsAlignment: MainAxisAlignment.spaceBetween, actions: [ TextButton( - style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error), onPressed: () { deleteAttachment().then((_) { Navigator.pop(context); @@ -542,7 +577,9 @@ class _AttachmentEditingPopupState extends State { mainAxisSize: MainAxisSize.min, children: [ TextButton( - style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant), + style: TextButton.styleFrom( + foregroundColor: + Theme.of(context).colorScheme.onSurfaceVariant), onPressed: () => Navigator.pop(context), child: Text('cancel'.tr), ), diff --git a/lib/widgets/posts/post_action.dart b/lib/widgets/posts/post_action.dart index e2c1d06..9d13d60 100644 --- a/lib/widgets/posts/post_action.dart +++ b/lib/widgets/posts/post_action.dart @@ -1,115 +1,192 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:get/get.dart'; import 'package:solian/exts.dart'; import 'package:solian/models/post.dart'; -import 'package:solian/models/reaction.dart'; import 'package:solian/providers/auth.dart'; +import 'package:solian/router.dart'; +import 'package:solian/screens/posts/publish.dart'; import 'package:solian/services.dart'; -import 'package:solian/widgets/posts/post_reaction.dart'; -class PostQuickAction extends StatefulWidget { +class PostAction extends StatefulWidget { final Post item; - final void Function(String symbol, int num) onReact; - const PostQuickAction({super.key, required this.item, required this.onReact}); + const PostAction({super.key, required this.item}); @override - State createState() => _PostQuickActionState(); + State createState() => _PostActionState(); } -class _PostQuickActionState extends State { - bool _isSubmitting = false; +class _PostActionState extends State { + bool _isBusy = true; + bool _canModifyContent = false; - void showReactMenu() { - showModalBottomSheet( - useRootNavigator: true, - isScrollControlled: true, - context: context, - builder: (context) => PostReactionPopup( - item: widget.item, - onReact: (key, value) { - doWidgetReact(key, value.attitude); - }, + void checkAbleToModifyContent() async { + final AuthProvider provider = Get.find(); + if (!await provider.isAuthorized) return; + + setState(() => _isBusy = true); + + final prof = await provider.getProfile(); + setState(() { + _canModifyContent = prof.body?['id'] == widget.item.author.externalId; + _isBusy = false; + }); + } + + @override + void initState() { + super.initState(); + + checkAbleToModifyContent(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'postActionList'.tr, + style: Theme.of(context).textTheme.headlineSmall, + ), + Text( + '#${widget.item.id.toString().padLeft(8, '0')}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), + if (_isBusy) const LinearProgressIndicator().animate().scaleX(), + Expanded( + child: ListView( + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Icons.reply), + title: Text('reply'.tr), + onTap: () async { + final value = await AppRouter.instance.pushNamed( + 'postPublishing', + extra: PostPublishingArguments(reply: widget.item), + ); + if (value != null) { + Navigator.pop(context, true); + } + }, + ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Icons.redo), + title: Text('repost'.tr), + onTap: () async { + final value = await AppRouter.instance.pushNamed( + 'postPublishing', + extra: PostPublishingArguments(repost: widget.item), + ); + if (value != null) { + Navigator.pop(context, true); + } + }, + ), + if (_canModifyContent) + const Divider(thickness: 0.3, height: 0.3) + .paddingSymmetric(vertical: 16), + if (_canModifyContent) + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Icons.edit), + title: Text('edit'.tr), + onTap: () async { + final value = await AppRouter.instance.pushNamed( + 'postPublishing', + extra: PostPublishingArguments(edit: widget.item), + ); + if (value != null) { + Navigator.pop(context, true); + } + }, + ), + if (_canModifyContent) + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Icons.delete), + title: Text('delete'.tr), + onTap: () async { + final value = await showDialog( + context: context, + builder: (context) => + PostDeletionDialog(item: widget.item), + ); + if (value != null) { + Navigator.pop(context, true); + } + }, + ), + ], + ), + ), + ], ), ); } +} - Future doWidgetReact(String symbol, int attitude) async { +class PostDeletionDialog extends StatefulWidget { + final Post item; + + const PostDeletionDialog({super.key, required this.item}); + + @override + State createState() => _PostDeletionDialogState(); +} + +class _PostDeletionDialogState extends State { + bool _isBusy = false; + + void performAction() async { final AuthProvider auth = Get.find(); - - if (_isSubmitting) return; if (!await auth.isAuthorized) return; final client = GetConnect(); client.httpClient.baseUrl = ServiceFinder.services['interactive']; client.httpClient.addAuthenticator(auth.reqAuthenticator); - setState(() => _isSubmitting = true); + setState(() => _isBusy = true); + final resp = await client.delete('/api/posts/${widget.item.id}'); + setState(() => _isBusy = false); - final resp = await client.post('/api/posts/${widget.item.alias}/react', { - 'symbol': symbol, - 'attitude': attitude, - }); - if (resp.statusCode == 201) { - widget.onReact(symbol, 1); - context.showSnackbar('reactCompleted'.tr); - } else if (resp.statusCode == 204) { - widget.onReact(symbol, -1); - context.showSnackbar('reactUncompleted'.tr); - } else { + if (resp.statusCode != 200) { context.showErrorDialog(resp.bodyString); + } else { + Navigator.pop(context, true); } - - setState(() => _isSubmitting = false); } @override Widget build(BuildContext context) { - const density = VisualDensity(horizontal: -4, vertical: -3); - - return SizedBox( - height: 32, - width: double.infinity, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ActionChip( - avatar: const Icon(Icons.comment), - label: Text(widget.item.replyCount.toString()), - visualDensity: density, - onPressed: () {}, - ), - const VerticalDivider(thickness: 0.3, width: 0.3, indent: 8, endIndent: 8).paddingOnly(left: 8), - Expanded( - child: ListView( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - children: [ - ...widget.item.reactionList.entries.map((x) { - final info = reactions[x.key]; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ActionChip( - avatar: Text(info!.icon), - label: Text(x.value.toString()), - tooltip: ':${x.key}:', - visualDensity: density, - onPressed: _isSubmitting ? null : () => doWidgetReact(x.key, info.attitude), - ), - ); - }), - ActionChip( - avatar: const Icon(Icons.add_reaction, color: Colors.teal), - label: Text('reactAdd'.tr), - visualDensity: density, - onPressed: () => showReactMenu(), - ), - ], - ).paddingOnly(left: 8), - ) - ], - ), + return AlertDialog( + title: Text('postDeletionConfirm'.tr), + content: Text('postDeletionConfirmCaption'.trParams({ + 'content': widget.item.content + .substring(0, min(widget.item.content.length, 60)) + .trim(), + })), + actions: [ + TextButton( + onPressed: _isBusy ? null : () => Navigator.pop(context), + child: Text('cancel'.tr), + ), + TextButton( + onPressed: _isBusy ? null : () => performAction(), + child: Text('confirm'.tr), + ), + ], ); } } diff --git a/lib/widgets/posts/post_item.dart b/lib/widgets/posts/post_item.dart index 63787fd..fb17347 100644 --- a/lib/widgets/posts/post_item.dart +++ b/lib/widgets/posts/post_item.dart @@ -5,13 +5,18 @@ import 'package:get/get_utils/get_utils.dart'; import 'package:solian/models/post.dart'; import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/attachments/attachment_list.dart'; -import 'package:solian/widgets/posts/post_action.dart'; +import 'package:solian/widgets/posts/post_quick_action.dart'; import 'package:timeago/timeago.dart' show format; class PostItem extends StatefulWidget { final Post item; + final bool isReactable; - const PostItem({super.key, required this.item}); + const PostItem({ + super.key, + required this.item, + this.isReactable = true, + }); @override State createState() => _PostItemState(); @@ -45,7 +50,8 @@ class _PostItemState extends State { item.author.nick, style: const TextStyle(fontWeight: FontWeight.bold), ).paddingOnly(left: 12), - Text(format(item.createdAt, locale: 'en_short')).paddingOnly(left: 4), + Text(format(item.createdAt, locale: 'en_short')) + .paddingOnly(left: 4), ], ), Markdown( @@ -59,17 +65,19 @@ class _PostItemState extends State { ) ], ).paddingOnly( - top: 18, + top: 10, bottom: hasAttachment ? 10 : 0, right: 16, left: 16, ), AttachmentList(attachmentsId: item.attachments ?? List.empty()), PostQuickAction( + isReactable: widget.isReactable, item: widget.item, onReact: (symbol, changes) { setState(() { - item.reactionList[symbol] = (item.reactionList[symbol] ?? 0) + changes; + item.reactionList[symbol] = + (item.reactionList[symbol] ?? 0) + changes; }); }, ).paddingOnly( diff --git a/lib/widgets/posts/post_quick_action.dart b/lib/widgets/posts/post_quick_action.dart new file mode 100644 index 0000000..02b9a5e --- /dev/null +++ b/lib/widgets/posts/post_quick_action.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:solian/exts.dart'; +import 'package:solian/models/post.dart'; +import 'package:solian/models/reaction.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/services.dart'; +import 'package:solian/widgets/posts/post_reaction.dart'; + +class PostQuickAction extends StatefulWidget { + final Post item; + final bool isReactable; + final void Function(String symbol, int num) onReact; + + const PostQuickAction({ + super.key, + required this.item, + this.isReactable = true, + required this.onReact, + }); + + @override + State createState() => _PostQuickActionState(); +} + +class _PostQuickActionState extends State { + bool _isSubmitting = false; + + void showReactMenu() { + showModalBottomSheet( + useRootNavigator: true, + isScrollControlled: true, + context: context, + builder: (context) => PostReactionPopup( + item: widget.item, + onReact: (key, value) { + doWidgetReact(key, value.attitude); + }, + ), + ); + } + + Future doWidgetReact(String symbol, int attitude) async { + if (!widget.isReactable) return; + + final AuthProvider auth = Get.find(); + + if (_isSubmitting) return; + if (!await auth.isAuthorized) return; + + final client = GetConnect(); + client.httpClient.baseUrl = ServiceFinder.services['interactive']; + client.httpClient.addAuthenticator(auth.reqAuthenticator); + + setState(() => _isSubmitting = true); + + final resp = await client.post('/api/posts/${widget.item.alias}/react', { + 'symbol': symbol, + 'attitude': attitude, + }); + if (resp.statusCode == 201) { + widget.onReact(symbol, 1); + context.showSnackbar('reactCompleted'.tr); + } else if (resp.statusCode == 204) { + widget.onReact(symbol, -1); + context.showSnackbar('reactUncompleted'.tr); + } else { + context.showErrorDialog(resp.bodyString); + } + + setState(() => _isSubmitting = false); + } + + @override + void initState() { + super.initState(); + + if (!widget.isReactable && widget.item.reactionList.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onReact('thumb_up', 0); + }); + } + } + + @override + Widget build(BuildContext context) { + const density = VisualDensity(horizontal: -4, vertical: -3); + + return SizedBox( + height: 32, + width: double.infinity, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (widget.isReactable) + ActionChip( + avatar: const Icon(Icons.comment), + label: Text(widget.item.replyCount.toString()), + visualDensity: density, + onPressed: () {}, + ), + if (widget.isReactable) + const VerticalDivider( + thickness: 0.3, width: 0.3, indent: 8, endIndent: 8) + .paddingOnly(left: 8), + Expanded( + child: ListView( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: [ + ...widget.item.reactionList.entries.map((x) { + final info = reactions[x.key]; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ActionChip( + avatar: Text(info!.icon), + label: Text(x.value.toString()), + tooltip: ':${x.key}:', + visualDensity: density, + onPressed: _isSubmitting + ? null + : () => doWidgetReact(x.key, info.attitude), + ), + ); + }), + if (widget.isReactable) + ActionChip( + avatar: const Icon(Icons.add_reaction, color: Colors.teal), + label: Text('reactAdd'.tr), + visualDensity: density, + onPressed: () => showReactMenu(), + ), + ], + ).paddingOnly(left: 8), + ) + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 691607f..2b362e2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -372,26 +372,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: d4c8f568c60af6b6daa74c80fc04411765769882600f6bf9cd4b391c96de42ce + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "3.0.1" lints: dependency: transitive description: @@ -436,10 +436,10 @@ packages: dependency: transitive description: name: meta - sha256: "25dfcaf170a0190f47ca6355bdd4552cb8924b430512ff0cafb8db9bd41fe33b" + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.14.0" + version: "1.12.0" mime: dependency: transitive description: @@ -585,10 +585,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" timeago: dependency: "direct main" description: @@ -681,10 +681,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" web: dependency: transitive description: