✨ Post repost, reply and even more optimization!
This commit is contained in:
		| @@ -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<int>.from(json["attachments"]) : null, | ||||
|         attachments: json["attachments"] != null | ||||
|             ? List<int>.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<String, int>() | ||||
|             : {}, | ||||
|       ); | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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<oauth2.Credentials> signin(BuildContext context, String username, String password) async { | ||||
|   Future<oauth2.Credentials> 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<bool> get isAuthorized => storage.containsKey(key: 'auth_credentials'); | ||||
|  | ||||
|   Future<Response> getProfile() => get('/api/users/me'); | ||||
|   Future<Response> getProfile({noCache = false}) async { | ||||
|     if (!noCache && _cacheUserProfileResponse != null) { | ||||
|       return _cacheUserProfileResponse!; | ||||
|     } | ||||
|  | ||||
|     final resp = await get('/api/users/me'); | ||||
|     _cacheUserProfileResponse = resp; | ||||
|     return resp; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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'], | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ], | ||||
|   ); | ||||
|   | ||||
| @@ -22,7 +22,7 @@ class _AccountScreenState extends State<AccountScreen> { | ||||
|     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']), | ||||
|           ), | ||||
|   | ||||
| @@ -30,7 +30,8 @@ class _SignInScreenState extends State<SignInScreen> { | ||||
|       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<SignInScreen> { | ||||
|   @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<SignInScreen> { | ||||
|                   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<SignInScreen> { | ||||
|                   border: const OutlineInputBorder(), | ||||
|                   labelText: 'password'.tr, | ||||
|                 ), | ||||
|                 onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 onTapOutside: (_) => | ||||
|                     FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 onSubmitted: (_) => performAction(context), | ||||
|               ), | ||||
|               const SizedBox(height: 16), | ||||
|   | ||||
| @@ -62,7 +62,7 @@ class _SignUpScreenState extends State<SignUpScreen> { | ||||
|   @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, | ||||
|   | ||||
| @@ -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<HomeScreen> { | ||||
|   final PagingController<int, Post> _pagingController = PagingController(firstPageKey: 0); | ||||
|   final PagingController<int, Post> _pagingController = | ||||
|       PagingController(firstPageKey: 0); | ||||
|  | ||||
|   getPosts(int pageKey) async { | ||||
|     final PostExploreProvider provider = Get.find(); | ||||
| @@ -55,7 +57,8 @@ class _HomeScreenState extends State<HomeScreen> { | ||||
|               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<HomeScreen> { | ||||
|             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<int, Post>.separated( | ||||
| @@ -73,12 +76,25 @@ class _HomeScreenState extends State<HomeScreen> { | ||||
|             builderDelegate: PagedChildBuilderDelegate<Post>( | ||||
|               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), | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|   | ||||
| @@ -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<PostPublishingScreen> createState() => _PostPublishingScreenState(); | ||||
| @@ -45,10 +66,20 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> { | ||||
|     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<PostPublishingScreen> { | ||||
|     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<PostPublishingScreen> { | ||||
|           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<PostPublishingScreen> { | ||||
|               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<PostPublishingScreen> { | ||||
|                     decoration: InputDecoration.collapsed( | ||||
|                       hintText: 'postContentPlaceholder'.tr, | ||||
|                     ), | ||||
|                     onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                     onTapOutside: (_) => | ||||
|                         FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
| @@ -115,7 +243,8 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> { | ||||
|                 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( | ||||
|   | ||||
| @@ -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': '你的反应已被移除', | ||||
|   | ||||
| @@ -98,7 +98,8 @@ class _AttachmentListState extends State<AttachmentList> { | ||||
|       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<AttachmentList> { | ||||
|         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<AttachmentList> { | ||||
|                       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, | ||||
|   | ||||
| @@ -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<AttachmentListFullscreen> createState() => _AttachmentListFullscreenState(); | ||||
|   State<AttachmentListFullscreen> createState() => | ||||
|       _AttachmentListFullscreenState(); | ||||
| } | ||||
|  | ||||
| class _AttachmentListFullscreenState extends State<AttachmentListFullscreen> { | ||||
| @@ -21,7 +21,7 @@ class _AttachmentListFullscreenState extends State<AttachmentListFullscreen> { | ||||
|   @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, | ||||
|   | ||||
| @@ -35,7 +35,8 @@ class AttachmentPublishingPopup extends StatefulWidget { | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<AttachmentPublishingPopup> createState() => _AttachmentPublishingPopupState(); | ||||
|   State<AttachmentPublishingPopup> createState() => | ||||
|       _AttachmentPublishingPopupState(); | ||||
| } | ||||
|  | ||||
| class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> { | ||||
| @@ -97,7 +98,8 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> { | ||||
|     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<File> files = result.paths.map((path) => File(path!)).toList(); | ||||
| @@ -157,7 +159,8 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> { | ||||
|     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<AttachmentPublishingPopup> { | ||||
|     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<AttachmentPublishingPopup> { | ||||
|               '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<AttachmentPublishingPopup> { | ||||
|                     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<AttachmentPublishingPopup> { | ||||
|                                   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<AttachmentPublishingPopup> { | ||||
|                                   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<AttachmentEditingPopup> createState() => _AttachmentEditingPopupState(); | ||||
| @@ -387,7 +412,8 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { | ||||
|     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<AttachmentEditingPopup> { | ||||
|     _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<AttachmentEditingPopup> { | ||||
|         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<AttachmentEditingPopup> { | ||||
|                 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<AttachmentEditingPopup> { | ||||
|                 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<AttachmentEditingPopup> { | ||||
|                 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<AttachmentEditingPopup> { | ||||
|                     }, | ||||
|                   ), | ||||
|                   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<AttachmentEditingPopup> { | ||||
|             ), | ||||
|             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<AttachmentEditingPopup> { | ||||
|       actionsAlignment: MainAxisAlignment.spaceBetween, | ||||
|       actions: <Widget>[ | ||||
|         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<AttachmentEditingPopup> { | ||||
|           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), | ||||
|             ), | ||||
|   | ||||
| @@ -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<PostQuickAction> createState() => _PostQuickActionState(); | ||||
|   State<PostAction> createState() => _PostActionState(); | ||||
| } | ||||
|  | ||||
| class _PostQuickActionState extends State<PostQuickAction> { | ||||
|   bool _isSubmitting = false; | ||||
| class _PostActionState extends State<PostAction> { | ||||
|   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<void> doWidgetReact(String symbol, int attitude) async { | ||||
| class PostDeletionDialog extends StatefulWidget { | ||||
|   final Post item; | ||||
|  | ||||
|   const PostDeletionDialog({super.key, required this.item}); | ||||
|  | ||||
|   @override | ||||
|   State<PostDeletionDialog> createState() => _PostDeletionDialogState(); | ||||
| } | ||||
|  | ||||
| class _PostDeletionDialogState extends State<PostDeletionDialog> { | ||||
|   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: <Widget>[ | ||||
|         TextButton( | ||||
|           onPressed: _isBusy ? null : () => Navigator.pop(context), | ||||
|           child: Text('cancel'.tr), | ||||
|         ), | ||||
|         TextButton( | ||||
|           onPressed: _isBusy ? null : () => performAction(), | ||||
|           child: Text('confirm'.tr), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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<PostItem> createState() => _PostItemState(); | ||||
| @@ -45,7 +50,8 @@ class _PostItemState extends State<PostItem> { | ||||
|                         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<PostItem> { | ||||
|             ) | ||||
|           ], | ||||
|         ).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( | ||||
|   | ||||
							
								
								
									
										141
									
								
								lib/widgets/posts/post_quick_action.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								lib/widgets/posts/post_quick_action.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<PostQuickAction> createState() => _PostQuickActionState(); | ||||
| } | ||||
|  | ||||
| class _PostQuickActionState extends State<PostQuickAction> { | ||||
|   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<void> 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), | ||||
|           ) | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										24
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								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: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user