✨ Post repost, reply and even more optimization!
This commit is contained in:
		| @@ -56,19 +56,25 @@ class Post { | |||||||
|         id: json["id"], |         id: json["id"], | ||||||
|         createdAt: DateTime.parse(json["created_at"]), |         createdAt: DateTime.parse(json["created_at"]), | ||||||
|         updatedAt: DateTime.parse(json["updated_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"], |         alias: json["alias"], | ||||||
|         content: json["content"], |         content: json["content"], | ||||||
|         tags: json["tags"], |         tags: json["tags"], | ||||||
|         categories: json["categories"], |         categories: json["categories"], | ||||||
|         reactions: json["reactions"], |         reactions: json["reactions"], | ||||||
|         replies: json["replies"], |         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"], |         replyId: json["reply_id"], | ||||||
|         repostId: json["repost_id"], |         repostId: json["repost_id"], | ||||||
|         realmId: json["realm_id"], |         realmId: json["realm_id"], | ||||||
|         replyTo: json["reply_to"] == null ? null : Post.fromJson(json["reply_to"]), |         replyTo: | ||||||
|         repostTo: json["repost_to"], |             json["reply_to"] != null ? Post.fromJson(json["reply_to"]) : null, | ||||||
|  |         repostTo: | ||||||
|  |             json["repost_to"] != null ? Post.fromJson(json["repost_to"]) : null, | ||||||
|         realm: json["realm"], |         realm: json["realm"], | ||||||
|         publishedAt: json["published_at"], |         publishedAt: json["published_at"], | ||||||
|         authorId: json["author_id"], |         authorId: json["author_id"], | ||||||
| @@ -77,8 +83,10 @@ class Post { | |||||||
|         reactionCount: json["reaction_count"], |         reactionCount: json["reaction_count"], | ||||||
|         reactionList: json["reaction_list"] != null |         reactionList: json["reaction_list"] != null | ||||||
|             ? json["reaction_list"] |             ? json["reaction_list"] | ||||||
|                 .map((key, value) => |                 .map((key, value) => MapEntry( | ||||||
|                     MapEntry(key, int.tryParse(value.toString()) ?? (value is double ? value.toInt() : null))) |                     key, | ||||||
|  |                     int.tryParse(value.toString()) ?? | ||||||
|  |                         (value is double ? value.toInt() : null))) | ||||||
|                 .cast<String, int>() |                 .cast<String, int>() | ||||||
|             : {}, |             : {}, | ||||||
|       ); |       ); | ||||||
| @@ -99,7 +107,7 @@ class Post { | |||||||
|         "repost_id": repostId, |         "repost_id": repostId, | ||||||
|         "realm_id": realmId, |         "realm_id": realmId, | ||||||
|         "reply_to": replyTo?.toJson(), |         "reply_to": replyTo?.toJson(), | ||||||
|         "repost_to": repostTo, |         "repost_to": repostTo?.toJson(), | ||||||
|         "realm": realm, |         "realm": realm, | ||||||
|         "published_at": publishedAt, |         "published_at": publishedAt, | ||||||
|         "author_id": authorId, |         "author_id": authorId, | ||||||
|   | |||||||
| @@ -9,9 +9,12 @@ import 'package:solian/services.dart'; | |||||||
| import 'package:oauth2/oauth2.dart' as oauth2; | import 'package:oauth2/oauth2.dart' as oauth2; | ||||||
|  |  | ||||||
| class AuthProvider extends GetConnect { | class AuthProvider extends GetConnect { | ||||||
|   final deviceEndpoint = Uri.parse('${ServiceFinder.services['passport']}/api/notifications/subscribe'); |   final deviceEndpoint = Uri.parse( | ||||||
|   final tokenEndpoint = Uri.parse('${ServiceFinder.services['passport']}/api/auth/token'); |       '${ServiceFinder.services['passport']}/api/notifications/subscribe'); | ||||||
|   final userinfoEndpoint = Uri.parse('${ServiceFinder.services['passport']}/api/users/me'); |   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'); |   final redirectUrl = Uri.parse('solian://auth'); | ||||||
|  |  | ||||||
|   static const clientId = 'solian'; |   static const clientId = 'solian'; | ||||||
| @@ -44,7 +47,8 @@ class AuthProvider extends GetConnect { | |||||||
|         tokenEndpoint: tokenEndpoint, |         tokenEndpoint: tokenEndpoint, | ||||||
|         expiration: DateTime.now().add(const Duration(minutes: 3)), |         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) { |     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( |     final resp = await oauth2.resourceOwnerPasswordGrant( | ||||||
|       tokenEndpoint, |       tokenEndpoint, | ||||||
|       username, |       username, | ||||||
| @@ -83,7 +88,8 @@ class AuthProvider extends GetConnect { | |||||||
|       expiration: DateTime.now().add(const Duration(minutes: 3)), |       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(); |     applyAuthenticator(); | ||||||
|  |  | ||||||
|     return credentials!; |     return credentials!; | ||||||
| @@ -93,7 +99,17 @@ class AuthProvider extends GetConnect { | |||||||
|     storage.deleteAll(); |     storage.deleteAll(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Response? _cacheUserProfileResponse; | ||||||
|  |  | ||||||
|   Future<bool> get isAuthorized => storage.containsKey(key: 'auth_credentials'); |   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( |   static GoRouter instance = GoRouter( | ||||||
|     routes: [ |     routes: [ | ||||||
|       ShellRoute( |       ShellRoute( | ||||||
|         builder: (context, state, child) => NavShell(state: state, child: child), |         builder: (context, state, child) => | ||||||
|  |             NavShell(state: state, child: child), | ||||||
|         routes: [ |         routes: [ | ||||||
|           GoRoute( |           GoRoute( | ||||||
|             path: "/", |             path: "/", | ||||||
| @@ -37,7 +38,15 @@ abstract class AppRouter { | |||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: "/posts/publish", |         path: "/posts/publish", | ||||||
|         name: "postPublishing", |         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(); |     final AuthProvider provider = Get.find(); | ||||||
|  |  | ||||||
|     return Material( |     return Material( | ||||||
|       color: Theme.of(context).colorScheme.background, |       color: Theme.of(context).colorScheme.surface, | ||||||
|       child: FutureBuilder( |       child: FutureBuilder( | ||||||
|         future: provider.isAuthorized, |         future: provider.isAuthorized, | ||||||
|         builder: (context, snapshot) { |         builder: (context, snapshot) { | ||||||
| @@ -103,8 +103,10 @@ class AccountNameCard extends StatelessWidget { | |||||||
|         return Material( |         return Material( | ||||||
|           elevation: 2, |           elevation: 2, | ||||||
|           child: ListTile( |           child: ListTile( | ||||||
|             contentPadding: const EdgeInsets.only(left: 22, right: 34, top: 4, bottom: 4), |             contentPadding: | ||||||
|             leading: AccountAvatar(content: snapshot.data!.body?['avatar'], radius: 24), |                 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']), |             title: Text(snapshot.data!.body?['nick']), | ||||||
|             subtitle: Text(snapshot.data!.body?['email']), |             subtitle: Text(snapshot.data!.body?['email']), | ||||||
|           ), |           ), | ||||||
|   | |||||||
| @@ -30,7 +30,8 @@ class _SignInScreenState extends State<SignInScreen> { | |||||||
|       if (messages.last.contains('risk')) { |       if (messages.last.contains('risk')) { | ||||||
|         final ticketId = RegExp(r'ticketId=(\d+)').firstMatch(messages.last); |         final ticketId = RegExp(r'ticketId=(\d+)').firstMatch(messages.last); | ||||||
|         if (ticketId == null) { |         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( |         showDialog( | ||||||
|           context: context, |           context: context, | ||||||
| @@ -65,7 +66,7 @@ class _SignInScreenState extends State<SignInScreen> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Material( |     return Material( | ||||||
|       color: Theme.of(context).colorScheme.background, |       color: Theme.of(context).colorScheme.surface, | ||||||
|       child: Center( |       child: Center( | ||||||
|         child: Container( |         child: Container( | ||||||
|           width: MediaQuery.of(context).size.width * 0.6, |           width: MediaQuery.of(context).size.width * 0.6, | ||||||
| @@ -87,7 +88,8 @@ class _SignInScreenState extends State<SignInScreen> { | |||||||
|                   border: const OutlineInputBorder(), |                   border: const OutlineInputBorder(), | ||||||
|                   labelText: 'username'.tr, |                   labelText: 'username'.tr, | ||||||
|                 ), |                 ), | ||||||
|                 onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                 onTapOutside: (_) => | ||||||
|  |                     FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|               ), |               ), | ||||||
|               const SizedBox(height: 12), |               const SizedBox(height: 12), | ||||||
|               TextField( |               TextField( | ||||||
| @@ -101,7 +103,8 @@ class _SignInScreenState extends State<SignInScreen> { | |||||||
|                   border: const OutlineInputBorder(), |                   border: const OutlineInputBorder(), | ||||||
|                   labelText: 'password'.tr, |                   labelText: 'password'.tr, | ||||||
|                 ), |                 ), | ||||||
|                 onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |                 onTapOutside: (_) => | ||||||
|  |                     FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 onSubmitted: (_) => performAction(context), |                 onSubmitted: (_) => performAction(context), | ||||||
|               ), |               ), | ||||||
|               const SizedBox(height: 16), |               const SizedBox(height: 16), | ||||||
|   | |||||||
| @@ -62,7 +62,7 @@ class _SignUpScreenState extends State<SignUpScreen> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Material( |     return Material( | ||||||
|       color: Theme.of(context).colorScheme.background, |       color: Theme.of(context).colorScheme.surface, | ||||||
|       child: Center( |       child: Center( | ||||||
|         child: Container( |         child: Container( | ||||||
|           width: MediaQuery.of(context).size.width * 0.6, |           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/auth.dart'; | ||||||
| import 'package:solian/providers/content/post_explore.dart'; | import 'package:solian/providers/content/post_explore.dart'; | ||||||
| import 'package:solian/router.dart'; | import 'package:solian/router.dart'; | ||||||
|  | import 'package:solian/widgets/posts/post_action.dart'; | ||||||
| import 'package:solian/widgets/posts/post_item.dart'; | import 'package:solian/widgets/posts/post_item.dart'; | ||||||
|  |  | ||||||
| class HomeScreen extends StatefulWidget { | class HomeScreen extends StatefulWidget { | ||||||
| @@ -16,7 +17,8 @@ class HomeScreen extends StatefulWidget { | |||||||
| } | } | ||||||
|  |  | ||||||
| class _HomeScreenState extends State<HomeScreen> { | 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 { |   getPosts(int pageKey) async { | ||||||
|     final PostExploreProvider provider = Get.find(); |     final PostExploreProvider provider = Get.find(); | ||||||
| @@ -55,7 +57,8 @@ class _HomeScreenState extends State<HomeScreen> { | |||||||
|               return FloatingActionButton( |               return FloatingActionButton( | ||||||
|                 child: const Icon(Icons.add), |                 child: const Icon(Icons.add), | ||||||
|                 onPressed: () async { |                 onPressed: () async { | ||||||
|                   final value = await AppRouter.instance.pushNamed('postPublishing'); |                   final value = | ||||||
|  |                       await AppRouter.instance.pushNamed('postPublishing'); | ||||||
|                   if (value != null) { |                   if (value != null) { | ||||||
|                     _pagingController.refresh(); |                     _pagingController.refresh(); | ||||||
|                   } |                   } | ||||||
| @@ -65,7 +68,7 @@ class _HomeScreenState extends State<HomeScreen> { | |||||||
|             return Container(); |             return Container(); | ||||||
|           }), |           }), | ||||||
|       body: Material( |       body: Material( | ||||||
|         color: Theme.of(context).colorScheme.background, |         color: Theme.of(context).colorScheme.surface, | ||||||
|         child: RefreshIndicator( |         child: RefreshIndicator( | ||||||
|           onRefresh: () => Future.sync(() => _pagingController.refresh()), |           onRefresh: () => Future.sync(() => _pagingController.refresh()), | ||||||
|           child: PagedListView<int, Post>.separated( |           child: PagedListView<int, Post>.separated( | ||||||
| @@ -73,12 +76,25 @@ class _HomeScreenState extends State<HomeScreen> { | |||||||
|             builderDelegate: PagedChildBuilderDelegate<Post>( |             builderDelegate: PagedChildBuilderDelegate<Post>( | ||||||
|               itemBuilder: (context, item, index) { |               itemBuilder: (context, item, index) { | ||||||
|                 return GestureDetector( |                 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: () {}, |                   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:flutter_animate/flutter_animate.dart'; | ||||||
| import 'package:get/get.dart'; | import 'package:get/get.dart'; | ||||||
| import 'package:solian/exts.dart'; | import 'package:solian/exts.dart'; | ||||||
|  | import 'package:solian/models/post.dart'; | ||||||
| import 'package:solian/providers/auth.dart'; | import 'package:solian/providers/auth.dart'; | ||||||
| import 'package:solian/router.dart'; | import 'package:solian/router.dart'; | ||||||
| import 'package:solian/services.dart'; | import 'package:solian/services.dart'; | ||||||
| import 'package:solian/widgets/account/account_avatar.dart'; | import 'package:solian/widgets/account/account_avatar.dart'; | ||||||
| import 'package:solian/shells/nav_shell.dart' as shell; | import 'package:solian/shells/nav_shell.dart' as shell; | ||||||
| import 'package:solian/widgets/attachments/attachment_publish.dart'; | 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 { | 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 |   @override | ||||||
|   State<PostPublishingScreen> createState() => _PostPublishingScreenState(); |   State<PostPublishingScreen> createState() => _PostPublishingScreenState(); | ||||||
| @@ -45,10 +66,20 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> { | |||||||
|     client.httpClient.baseUrl = ServiceFinder.services['interactive']; |     client.httpClient.baseUrl = ServiceFinder.services['interactive']; | ||||||
|     client.httpClient.addAuthenticator(auth.reqAuthenticator); |     client.httpClient.addAuthenticator(auth.reqAuthenticator); | ||||||
|  |  | ||||||
|     final resp = await client.post('/api/posts', { |     final payload = { | ||||||
|       'content': _contentController.value.text, |       'content': _contentController.value.text, | ||||||
|       'attachments': _attachments, |       '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) { |     if (resp.statusCode != 200) { | ||||||
|       context.showErrorDialog(resp.bodyString); |       context.showErrorDialog(resp.bodyString); | ||||||
|     } else { |     } else { | ||||||
| @@ -58,12 +89,36 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> { | |||||||
|     setState(() => _isSubmitting = false); |     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 |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final AuthProvider auth = Get.find(); |     final AuthProvider auth = Get.find(); | ||||||
|  |  | ||||||
|  |     final notifyBannerActions = [ | ||||||
|  |       TextButton( | ||||||
|  |         onPressed: cancelAction, | ||||||
|  |         child: Text('cancel'.tr), | ||||||
|  |       ) | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|     return Material( |     return Material( | ||||||
|       color: Theme.of(context).colorScheme.background, |       color: Theme.of(context).colorScheme.surface, | ||||||
|       child: Scaffold( |       child: Scaffold( | ||||||
|         appBar: AppBar( |         appBar: AppBar( | ||||||
|           title: Text('postPublishing'.tr), |           title: Text('postPublishing'.tr), | ||||||
| @@ -79,13 +134,84 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> { | |||||||
|           top: false, |           top: false, | ||||||
|           child: Column( |           child: Column( | ||||||
|             children: [ |             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( |               FutureBuilder( | ||||||
|                 future: auth.getProfile(), |                 future: auth.getProfile(), | ||||||
|                 builder: (context, snapshot) { |                 builder: (context, snapshot) { | ||||||
|                   if (snapshot.hasData) { |                   if (snapshot.hasData) { | ||||||
|                     return ListTile( |                     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']), |                       title: Text(snapshot.data?.body!['nick']), | ||||||
|                       subtitle: Text('postIdentityNotify'.tr), |                       subtitle: Text('postIdentityNotify'.tr), | ||||||
|                     ); |                     ); | ||||||
| @@ -97,7 +223,8 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> { | |||||||
|               const Divider(thickness: 0.3), |               const Divider(thickness: 0.3), | ||||||
|               Expanded( |               Expanded( | ||||||
|                 child: Container( |                 child: Container( | ||||||
|                   padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), |                   padding: | ||||||
|  |                       const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||||
|                   child: TextField( |                   child: TextField( | ||||||
|                     maxLines: null, |                     maxLines: null, | ||||||
|                     autofocus: true, |                     autofocus: true, | ||||||
| @@ -107,7 +234,8 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> { | |||||||
|                     decoration: InputDecoration.collapsed( |                     decoration: InputDecoration.collapsed( | ||||||
|                       hintText: 'postContentPlaceholder'.tr, |                       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), |                 constraints: const BoxConstraints(minHeight: 56), | ||||||
|                 decoration: BoxDecoration( |                 decoration: BoxDecoration( | ||||||
|                   border: Border( |                   border: Border( | ||||||
|                     top: BorderSide(width: 0.3, color: Theme.of(context).dividerColor), |                     top: BorderSide( | ||||||
|  |                         width: 0.3, color: Theme.of(context).dividerColor), | ||||||
|                   ), |                   ), | ||||||
|                 ), |                 ), | ||||||
|                 child: Row( |                 child: Row( | ||||||
|   | |||||||
| @@ -12,7 +12,10 @@ class SolianMessages extends Translations { | |||||||
|           'apply': 'Apply', |           'apply': 'Apply', | ||||||
|           'cancel': 'Cancel', |           'cancel': 'Cancel', | ||||||
|           'confirm': 'Confirm', |           'confirm': 'Confirm', | ||||||
|  |           'edit': 'Edit', | ||||||
|           'delete': 'Delete', |           'delete': 'Delete', | ||||||
|  |           'reply': 'Reply', | ||||||
|  |           'repost': 'Repost', | ||||||
|           'errorHappened': 'An error occurred', |           'errorHappened': 'An error occurred', | ||||||
|           'email': 'Email', |           'email': 'Email', | ||||||
|           'username': 'Username', |           'username': 'Username', | ||||||
| @@ -26,20 +29,30 @@ class SolianMessages extends Translations { | |||||||
|           'aspectRatioPortrait': 'Portrait', |           'aspectRatioPortrait': 'Portrait', | ||||||
|           'aspectRatioLandscape': 'Landscape', |           'aspectRatioLandscape': 'Landscape', | ||||||
|           'signin': 'Sign in', |           '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': |           'signinRiskDetected': | ||||||
|               'Risk detected, click Next to open a webpage and signin through it to pass security check.', |               'Risk detected, click Next to open a webpage and signin through it to pass security check.', | ||||||
|           'signup': 'Sign up', |           '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', |           'signout': 'Sign out', | ||||||
|           'riskDetection': 'Risk Detected', |           'riskDetection': 'Risk Detected', | ||||||
|           'matureContent': 'Mature Content', |           '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', |           'postAction': 'Post', | ||||||
|           'postPublishing': 'Post a post', |           'postPublishing': 'Post a post', | ||||||
|           'postIdentityNotify': 'You will post this post as', |           'postIdentityNotify': 'You will post this post as', | ||||||
|           'postContentPlaceholder': 'What\'s happened?!', |           'postContentPlaceholder': 'What\'s happened?!', | ||||||
|           'postReaction': 'Reactions of the Post', |           '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', |           'reactAdd': 'React', | ||||||
|           'reactCompleted': 'Your reaction has been added', |           'reactCompleted': 'Your reaction has been added', | ||||||
|           'reactUncompleted': 'Your reaction has been removed', |           'reactUncompleted': 'Your reaction has been removed', | ||||||
| @@ -58,10 +71,13 @@ class SolianMessages extends Translations { | |||||||
|           'next': '下一步', |           'next': '下一步', | ||||||
|           'cancel': '取消', |           'cancel': '取消', | ||||||
|           'confirm': '确认', |           'confirm': '确认', | ||||||
|  |           'edit': '编辑', | ||||||
|           'delete': '删除', |           'delete': '删除', | ||||||
|           'page': '页面', |           'page': '页面', | ||||||
|           'home': '首页', |           'home': '首页', | ||||||
|           'apply': '应用', |           'apply': '应用', | ||||||
|  |           'reply': '回复', | ||||||
|  |           'repost': '转帖', | ||||||
|           'errorHappened': '发生错误了', |           'errorHappened': '发生错误了', | ||||||
|           'email': '邮件地址', |           'email': '邮件地址', | ||||||
|           'username': '用户名', |           'username': '用户名', | ||||||
| @@ -88,6 +104,12 @@ class SolianMessages extends Translations { | |||||||
|           'postIdentityNotify': '你将会以本身份发表帖子', |           'postIdentityNotify': '你将会以本身份发表帖子', | ||||||
|           'postContentPlaceholder': '发生什么事了?!', |           'postContentPlaceholder': '发生什么事了?!', | ||||||
|           'postReaction': '帖子的反应', |           'postReaction': '帖子的反应', | ||||||
|  |           'postActionList': '帖子的操作', | ||||||
|  |           'postEditingNotify': '你正在编辑一个你发布的帖子', | ||||||
|  |           'postReplyingNotify': '你正在回一个来自 @username 的帖子', | ||||||
|  |           'postRepostingNotify': '你正在转一个来自 @username 的帖子', | ||||||
|  |           'postDeletionConfirm': '确认删除帖子', | ||||||
|  |           'postDeletionConfirmCaption': '你确定要删除帖子 “@content” 吗?该操作不可不可撤销。', | ||||||
|           'reactAdd': '作出反应', |           'reactAdd': '作出反应', | ||||||
|           'reactCompleted': '你的反应已被添加', |           'reactCompleted': '你的反应已被添加', | ||||||
|           'reactUncompleted': '你的反应已被移除', |           'reactUncompleted': '你的反应已被移除', | ||||||
|   | |||||||
| @@ -98,7 +98,8 @@ class _AttachmentListState extends State<AttachmentList> { | |||||||
|       return AspectRatio( |       return AspectRatio( | ||||||
|         aspectRatio: _aspectRatio, |         aspectRatio: _aspectRatio, | ||||||
|         child: Container( |         child: Container( | ||||||
|           decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceVariant), |           decoration: BoxDecoration( | ||||||
|  |               color: Theme.of(context).colorScheme.surfaceContainerHigh), | ||||||
|           child: const Center( |           child: const Center( | ||||||
|             child: CircularProgressIndicator(), |             child: CircularProgressIndicator(), | ||||||
|           ), |           ), | ||||||
| @@ -118,14 +119,18 @@ class _AttachmentListState extends State<AttachmentList> { | |||||||
|         return GestureDetector( |         return GestureDetector( | ||||||
|           child: Container( |           child: Container( | ||||||
|             width: MediaQuery.of(context).size.width, |             width: MediaQuery.of(context).size.width, | ||||||
|             decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceVariant), |             decoration: BoxDecoration( | ||||||
|  |               color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||||
|  |             ), | ||||||
|             child: Stack( |             child: Stack( | ||||||
|               fit: StackFit.expand, |               fit: StackFit.expand, | ||||||
|               children: [ |               children: [ | ||||||
|                 AttachmentItem( |                 AttachmentItem( | ||||||
|                   key: Key('a${element!.uuid}'), |                   key: Key('a${element!.uuid}'), | ||||||
|                   item: element, |                   item: element, | ||||||
|                   badge: _attachmentsMeta.length > 1 ? '${idx + 1}/${_attachmentsMeta.length}' : null, |                   badge: _attachmentsMeta.length > 1 | ||||||
|  |                       ? '${idx + 1}/${_attachmentsMeta.length}' | ||||||
|  |                       : null, | ||||||
|                   showHideButton: !element.isMature || _showMature, |                   showHideButton: !element.isMature || _showMature, | ||||||
|                   onHide: () { |                   onHide: () { | ||||||
|                     setState(() => _showMature = false); |                     setState(() => _showMature = false); | ||||||
| @@ -147,11 +152,15 @@ class _AttachmentListState extends State<AttachmentList> { | |||||||
|                       child: Column( |                       child: Column( | ||||||
|                         mainAxisAlignment: MainAxisAlignment.center, |                         mainAxisAlignment: MainAxisAlignment.center, | ||||||
|                         children: [ |                         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), |                           const SizedBox(height: 8), | ||||||
|                           Text( |                           Text( | ||||||
|                             'matureContent'.tr, |                             '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( |                           Text( | ||||||
|                             'matureContentCaption'.tr, |                             'matureContentCaption'.tr, | ||||||
|   | |||||||
| @@ -1,7 +1,6 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:solian/models/attachment.dart'; | import 'package:solian/models/attachment.dart'; | ||||||
| import 'package:solian/providers/content/attachment_item.dart'; | import 'package:solian/providers/content/attachment_item.dart'; | ||||||
| import 'package:solian/services.dart'; |  | ||||||
|  |  | ||||||
| class AttachmentListFullscreen extends StatefulWidget { | class AttachmentListFullscreen extends StatefulWidget { | ||||||
|   final Attachment attachment; |   final Attachment attachment; | ||||||
| @@ -9,7 +8,8 @@ class AttachmentListFullscreen extends StatefulWidget { | |||||||
|   const AttachmentListFullscreen({super.key, required this.attachment}); |   const AttachmentListFullscreen({super.key, required this.attachment}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<AttachmentListFullscreen> createState() => _AttachmentListFullscreenState(); |   State<AttachmentListFullscreen> createState() => | ||||||
|  |       _AttachmentListFullscreenState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _AttachmentListFullscreenState extends State<AttachmentListFullscreen> { | class _AttachmentListFullscreenState extends State<AttachmentListFullscreen> { | ||||||
| @@ -21,7 +21,7 @@ class _AttachmentListFullscreenState extends State<AttachmentListFullscreen> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Material( |     return Material( | ||||||
|       color: Theme.of(context).colorScheme.background, |       color: Theme.of(context).colorScheme.surface, | ||||||
|       child: GestureDetector( |       child: GestureDetector( | ||||||
|         child: SizedBox( |         child: SizedBox( | ||||||
|           height: MediaQuery.of(context).size.height, |           height: MediaQuery.of(context).size.height, | ||||||
|   | |||||||
| @@ -35,7 +35,8 @@ class AttachmentPublishingPopup extends StatefulWidget { | |||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<AttachmentPublishingPopup> createState() => _AttachmentPublishingPopupState(); |   State<AttachmentPublishingPopup> createState() => | ||||||
|  |       _AttachmentPublishingPopupState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> { | class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> { | ||||||
| @@ -97,7 +98,8 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> { | |||||||
|     final AuthProvider auth = Get.find(); |     final AuthProvider auth = Get.find(); | ||||||
|     if (!await auth.isAuthorized) return; |     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; |     if (result == null) return; | ||||||
|  |  | ||||||
|     List<File> files = result.paths.map((path) => File(path!)).toList(); |     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.baseUrl = ServiceFinder.services['paperclip']; | ||||||
|     client.httpClient.addAuthenticator(auth.reqAuthenticator); |     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('.') |     final fileAlt = basename(file.path).contains('.') | ||||||
|         ? basename(file.path).substring(0, basename(file.path).lastIndexOf('.')) |         ? basename(file.path).substring(0, basename(file.path).lastIndexOf('.')) | ||||||
|         : basename(file.path); |         : basename(file.path); | ||||||
| @@ -187,7 +190,17 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> { | |||||||
|     if (bytes == 0) return '0 Bytes'; |     if (bytes == 0) return '0 Bytes'; | ||||||
|     const k = 1024; |     const k = 1024; | ||||||
|     final dm = decimals < 0 ? 0 : decimals; |     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(); |     final i = (math.log(bytes) / math.log(k)).floor().toInt(); | ||||||
|     return '${(bytes / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}'; |     return '${(bytes / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}'; | ||||||
|   } |   } | ||||||
| @@ -240,7 +253,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> { | |||||||
|               'attachmentAdd'.tr, |               'attachmentAdd'.tr, | ||||||
|               style: Theme.of(context).textTheme.headlineSmall, |               style: Theme.of(context).textTheme.headlineSmall, | ||||||
|             ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), |             ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), | ||||||
|             _isBusy ? const LinearProgressIndicator().animate().scaleX() : Container(), |             if (_isBusy) const LinearProgressIndicator().animate().scaleX(), | ||||||
|             Expanded( |             Expanded( | ||||||
|               child: Builder(builder: (context) { |               child: Builder(builder: (context) { | ||||||
|                 if (_isFirstTimeBusy && _isBusy) { |                 if (_isFirstTimeBusy && _isBusy) { | ||||||
| @@ -255,7 +268,8 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> { | |||||||
|                     final element = _attachments[index]; |                     final element = _attachments[index]; | ||||||
|                     final fileType = element!.mimetype.split('/').first; |                     final fileType = element!.mimetype.split('/').first; | ||||||
|                     return Container( |                     return Container( | ||||||
|                       padding: const EdgeInsets.only(left: 16, right: 8, bottom: 16), |                       padding: | ||||||
|  |                           const EdgeInsets.only(left: 16, right: 8, bottom: 16), | ||||||
|                       child: Row( |                       child: Row( | ||||||
|                         children: [ |                         children: [ | ||||||
|                           Expanded( |                           Expanded( | ||||||
| @@ -266,7 +280,8 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> { | |||||||
|                                   element.alt, |                                   element.alt, | ||||||
|                                   overflow: TextOverflow.ellipsis, |                                   overflow: TextOverflow.ellipsis, | ||||||
|                                   maxLines: 1, |                                   maxLines: 1, | ||||||
|                                   style: const TextStyle(fontWeight: FontWeight.bold), |                                   style: const TextStyle( | ||||||
|  |                                       fontWeight: FontWeight.bold), | ||||||
|                                 ), |                                 ), | ||||||
|                                 Text( |                                 Text( | ||||||
|                                   '${fileType[0].toUpperCase()}${fileType.substring(1)} · ${formatBytes(element.size)}', |                                   '${fileType[0].toUpperCase()}${fileType.substring(1)} · ${formatBytes(element.size)}', | ||||||
| @@ -287,12 +302,18 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> { | |||||||
|                                   return AttachmentEditingPopup( |                                   return AttachmentEditingPopup( | ||||||
|                                     item: element, |                                     item: element, | ||||||
|                                     onDelete: () { |                                     onDelete: () { | ||||||
|                                       setState(() => _attachments.removeAt(index)); |                                       setState( | ||||||
|                                       widget.onUpdate(_attachments.map((e) => e!.id).toList()); |                                           () => _attachments.removeAt(index)); | ||||||
|  |                                       widget.onUpdate(_attachments | ||||||
|  |                                           .map((e) => e!.id) | ||||||
|  |                                           .toList()); | ||||||
|                                     }, |                                     }, | ||||||
|                                     onUpdate: (item) { |                                     onUpdate: (item) { | ||||||
|                                       setState(() => _attachments[index] = item); |                                       setState( | ||||||
|                                       widget.onUpdate(_attachments.map((e) => e!.id).toList()); |                                           () => _attachments[index] = item); | ||||||
|  |                                       widget.onUpdate(_attachments | ||||||
|  |                                           .map((e) => e!.id) | ||||||
|  |                                           .toList()); | ||||||
|                                     }, |                                     }, | ||||||
|                                   ); |                                   ); | ||||||
|                                 }, |                                 }, | ||||||
| @@ -363,7 +384,11 @@ class AttachmentEditingPopup extends StatefulWidget { | |||||||
|   final Function onDelete; |   final Function onDelete; | ||||||
|   final Function(Attachment item) onUpdate; |   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 |   @override | ||||||
|   State<AttachmentEditingPopup> createState() => _AttachmentEditingPopupState(); |   State<AttachmentEditingPopup> createState() => _AttachmentEditingPopupState(); | ||||||
| @@ -387,7 +412,8 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { | |||||||
|     setState(() => _isBusy = true); |     setState(() => _isBusy = true); | ||||||
|     var resp = await client.put('/api/attachments/${widget.item.id}', { |     var resp = await client.put('/api/attachments/${widget.item.id}', { | ||||||
|       'metadata': { |       'metadata': { | ||||||
|         if (_hasAspectRatio) 'ratio': double.tryParse(_ratioController.value.text) ?? 1, |         if (_hasAspectRatio) | ||||||
|  |           'ratio': double.tryParse(_ratioController.value.text) ?? 1, | ||||||
|       }, |       }, | ||||||
|       'alt': _altController.value.text, |       'alt': _altController.value.text, | ||||||
|       'usage': widget.item.usage, |       'usage': widget.item.usage, | ||||||
| @@ -426,7 +452,8 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { | |||||||
|     _altController.text = widget.item.alt; |     _altController.text = widget.item.alt; | ||||||
|  |  | ||||||
|     if (['image', 'video'].contains(widget.item.mimetype.split('/').first)) { |     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; |       _hasAspectRatio = true; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -446,12 +473,11 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { | |||||||
|         child: Column( |         child: Column( | ||||||
|           mainAxisSize: MainAxisSize.min, |           mainAxisSize: MainAxisSize.min, | ||||||
|           children: [ |           children: [ | ||||||
|             _isBusy |             if (_isBusy) | ||||||
|                 ? ClipRRect( |               ClipRRect( | ||||||
|                     borderRadius: const BorderRadius.all(Radius.circular(8)), |                 borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||||
|                     child: const LinearProgressIndicator().animate().scaleX(), |                 child: const LinearProgressIndicator().animate().scaleX(), | ||||||
|                   ) |               ), | ||||||
|                 : Container(), |  | ||||||
|             const SizedBox(height: 18), |             const SizedBox(height: 18), | ||||||
|             TextField( |             TextField( | ||||||
|               controller: _altController, |               controller: _altController, | ||||||
| @@ -461,7 +487,8 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { | |||||||
|                 border: const OutlineInputBorder(), |                 border: const OutlineInputBorder(), | ||||||
|                 labelText: 'attachmentAlt'.tr, |                 labelText: 'attachmentAlt'.tr, | ||||||
|               ), |               ), | ||||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |               onTapOutside: (_) => | ||||||
|  |                   FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|             ), |             ), | ||||||
|             const SizedBox(height: 16), |             const SizedBox(height: 16), | ||||||
|             TextField( |             TextField( | ||||||
| @@ -473,7 +500,8 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { | |||||||
|                 border: const OutlineInputBorder(), |                 border: const OutlineInputBorder(), | ||||||
|                 labelText: 'aspectRatio'.tr, |                 labelText: 'aspectRatio'.tr, | ||||||
|               ), |               ), | ||||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), |               onTapOutside: (_) => | ||||||
|  |                   FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|             ), |             ), | ||||||
|             const SizedBox(height: 5), |             const SizedBox(height: 5), | ||||||
|             SingleChildScrollView( |             SingleChildScrollView( | ||||||
| @@ -483,7 +511,8 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { | |||||||
|                 runSpacing: 0, |                 runSpacing: 0, | ||||||
|                 children: [ |                 children: [ | ||||||
|                   ActionChip( |                   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), |                     label: Text('aspectRatioSquare'.tr), | ||||||
|                     onPressed: () { |                     onPressed: () { | ||||||
|                       if (_hasAspectRatio) { |                       if (_hasAspectRatio) { | ||||||
| @@ -492,20 +521,24 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { | |||||||
|                     }, |                     }, | ||||||
|                   ), |                   ), | ||||||
|                   ActionChip( |                   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), |                     label: Text('aspectRatioPortrait'.tr), | ||||||
|                     onPressed: () { |                     onPressed: () { | ||||||
|                       if (_hasAspectRatio) { |                       if (_hasAspectRatio) { | ||||||
|                         setState(() => _ratioController.text = (9 / 16).toString()); |                         setState( | ||||||
|  |                             () => _ratioController.text = (9 / 16).toString()); | ||||||
|                       } |                       } | ||||||
|                     }, |                     }, | ||||||
|                   ), |                   ), | ||||||
|                   ActionChip( |                   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), |                     label: Text('aspectRatioLandscape'.tr), | ||||||
|                     onPressed: () { |                     onPressed: () { | ||||||
|                       if (_hasAspectRatio) { |                       if (_hasAspectRatio) { | ||||||
|                         setState(() => _ratioController.text = (16 / 9).toString()); |                         setState( | ||||||
|  |                             () => _ratioController.text = (16 / 9).toString()); | ||||||
|                       } |                       } | ||||||
|                     }, |                     }, | ||||||
|                   ), |                   ), | ||||||
| @@ -514,7 +547,8 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { | |||||||
|             ), |             ), | ||||||
|             Card( |             Card( | ||||||
|               child: CheckboxListTile( |               child: CheckboxListTile( | ||||||
|                 shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), |                 shape: const RoundedRectangleBorder( | ||||||
|  |                     borderRadius: BorderRadius.all(Radius.circular(10))), | ||||||
|                 title: Text('matureContent'.tr), |                 title: Text('matureContent'.tr), | ||||||
|                 secondary: const Icon(Icons.visibility_off), |                 secondary: const Icon(Icons.visibility_off), | ||||||
|                 value: _isMature, |                 value: _isMature, | ||||||
| @@ -530,7 +564,8 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { | |||||||
|       actionsAlignment: MainAxisAlignment.spaceBetween, |       actionsAlignment: MainAxisAlignment.spaceBetween, | ||||||
|       actions: <Widget>[ |       actions: <Widget>[ | ||||||
|         TextButton( |         TextButton( | ||||||
|           style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error), |           style: TextButton.styleFrom( | ||||||
|  |               foregroundColor: Theme.of(context).colorScheme.error), | ||||||
|           onPressed: () { |           onPressed: () { | ||||||
|             deleteAttachment().then((_) { |             deleteAttachment().then((_) { | ||||||
|               Navigator.pop(context); |               Navigator.pop(context); | ||||||
| @@ -542,7 +577,9 @@ class _AttachmentEditingPopupState extends State<AttachmentEditingPopup> { | |||||||
|           mainAxisSize: MainAxisSize.min, |           mainAxisSize: MainAxisSize.min, | ||||||
|           children: [ |           children: [ | ||||||
|             TextButton( |             TextButton( | ||||||
|               style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant), |               style: TextButton.styleFrom( | ||||||
|  |                   foregroundColor: | ||||||
|  |                       Theme.of(context).colorScheme.onSurfaceVariant), | ||||||
|               onPressed: () => Navigator.pop(context), |               onPressed: () => Navigator.pop(context), | ||||||
|               child: Text('cancel'.tr), |               child: Text('cancel'.tr), | ||||||
|             ), |             ), | ||||||
|   | |||||||
| @@ -1,115 +1,192 @@ | |||||||
|  | import 'dart:math'; | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_animate/flutter_animate.dart'; | ||||||
| import 'package:get/get.dart'; | import 'package:get/get.dart'; | ||||||
| import 'package:solian/exts.dart'; | import 'package:solian/exts.dart'; | ||||||
| import 'package:solian/models/post.dart'; | import 'package:solian/models/post.dart'; | ||||||
| import 'package:solian/models/reaction.dart'; |  | ||||||
| import 'package:solian/providers/auth.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/services.dart'; | ||||||
| import 'package:solian/widgets/posts/post_reaction.dart'; |  | ||||||
|  |  | ||||||
| class PostQuickAction extends StatefulWidget { | class PostAction extends StatefulWidget { | ||||||
|   final Post item; |   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 |   @override | ||||||
|   State<PostQuickAction> createState() => _PostQuickActionState(); |   State<PostAction> createState() => _PostActionState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _PostQuickActionState extends State<PostQuickAction> { | class _PostActionState extends State<PostAction> { | ||||||
|   bool _isSubmitting = false; |   bool _isBusy = true; | ||||||
|  |   bool _canModifyContent = false; | ||||||
|  |  | ||||||
|   void showReactMenu() { |   void checkAbleToModifyContent() async { | ||||||
|     showModalBottomSheet( |     final AuthProvider provider = Get.find(); | ||||||
|       useRootNavigator: true, |     if (!await provider.isAuthorized) return; | ||||||
|       isScrollControlled: true, |  | ||||||
|       context: context, |     setState(() => _isBusy = true); | ||||||
|       builder: (context) => PostReactionPopup( |  | ||||||
|         item: widget.item, |     final prof = await provider.getProfile(); | ||||||
|         onReact: (key, value) { |     setState(() { | ||||||
|           doWidgetReact(key, value.attitude); |       _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(); |     final AuthProvider auth = Get.find(); | ||||||
|  |  | ||||||
|     if (_isSubmitting) return; |  | ||||||
|     if (!await auth.isAuthorized) return; |     if (!await auth.isAuthorized) return; | ||||||
|  |  | ||||||
|     final client = GetConnect(); |     final client = GetConnect(); | ||||||
|     client.httpClient.baseUrl = ServiceFinder.services['interactive']; |     client.httpClient.baseUrl = ServiceFinder.services['interactive']; | ||||||
|     client.httpClient.addAuthenticator(auth.reqAuthenticator); |     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', { |     if (resp.statusCode != 200) { | ||||||
|       '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); |       context.showErrorDialog(resp.bodyString); | ||||||
|  |     } else { | ||||||
|  |       Navigator.pop(context, true); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     setState(() => _isSubmitting = false); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     const density = VisualDensity(horizontal: -4, vertical: -3); |     return AlertDialog( | ||||||
|  |       title: Text('postDeletionConfirm'.tr), | ||||||
|     return SizedBox( |       content: Text('postDeletionConfirmCaption'.trParams({ | ||||||
|       height: 32, |         'content': widget.item.content | ||||||
|       width: double.infinity, |             .substring(0, min(widget.item.content.length, 60)) | ||||||
|       child: Row( |             .trim(), | ||||||
|         mainAxisAlignment: MainAxisAlignment.start, |       })), | ||||||
|         crossAxisAlignment: CrossAxisAlignment.center, |       actions: <Widget>[ | ||||||
|         children: [ |         TextButton( | ||||||
|           ActionChip( |           onPressed: _isBusy ? null : () => Navigator.pop(context), | ||||||
|             avatar: const Icon(Icons.comment), |           child: Text('cancel'.tr), | ||||||
|             label: Text(widget.item.replyCount.toString()), |         ), | ||||||
|             visualDensity: density, |         TextButton( | ||||||
|             onPressed: () {}, |           onPressed: _isBusy ? null : () => performAction(), | ||||||
|           ), |           child: Text('confirm'.tr), | ||||||
|           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), |  | ||||||
|           ) |  | ||||||
|         ], |  | ||||||
|       ), |  | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -5,13 +5,18 @@ import 'package:get/get_utils/get_utils.dart'; | |||||||
| import 'package:solian/models/post.dart'; | import 'package:solian/models/post.dart'; | ||||||
| import 'package:solian/widgets/account/account_avatar.dart'; | import 'package:solian/widgets/account/account_avatar.dart'; | ||||||
| import 'package:solian/widgets/attachments/attachment_list.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; | import 'package:timeago/timeago.dart' show format; | ||||||
|  |  | ||||||
| class PostItem extends StatefulWidget { | class PostItem extends StatefulWidget { | ||||||
|   final Post item; |   final Post item; | ||||||
|  |   final bool isReactable; | ||||||
|  |  | ||||||
|   const PostItem({super.key, required this.item}); |   const PostItem({ | ||||||
|  |     super.key, | ||||||
|  |     required this.item, | ||||||
|  |     this.isReactable = true, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<PostItem> createState() => _PostItemState(); |   State<PostItem> createState() => _PostItemState(); | ||||||
| @@ -45,7 +50,8 @@ class _PostItemState extends State<PostItem> { | |||||||
|                         item.author.nick, |                         item.author.nick, | ||||||
|                         style: const TextStyle(fontWeight: FontWeight.bold), |                         style: const TextStyle(fontWeight: FontWeight.bold), | ||||||
|                       ).paddingOnly(left: 12), |                       ).paddingOnly(left: 12), | ||||||
|                       Text(format(item.createdAt, locale: 'en_short')).paddingOnly(left: 4), |                       Text(format(item.createdAt, locale: 'en_short')) | ||||||
|  |                           .paddingOnly(left: 4), | ||||||
|                     ], |                     ], | ||||||
|                   ), |                   ), | ||||||
|                   Markdown( |                   Markdown( | ||||||
| @@ -59,17 +65,19 @@ class _PostItemState extends State<PostItem> { | |||||||
|             ) |             ) | ||||||
|           ], |           ], | ||||||
|         ).paddingOnly( |         ).paddingOnly( | ||||||
|           top: 18, |           top: 10, | ||||||
|           bottom: hasAttachment ? 10 : 0, |           bottom: hasAttachment ? 10 : 0, | ||||||
|           right: 16, |           right: 16, | ||||||
|           left: 16, |           left: 16, | ||||||
|         ), |         ), | ||||||
|         AttachmentList(attachmentsId: item.attachments ?? List.empty()), |         AttachmentList(attachmentsId: item.attachments ?? List.empty()), | ||||||
|         PostQuickAction( |         PostQuickAction( | ||||||
|  |           isReactable: widget.isReactable, | ||||||
|           item: widget.item, |           item: widget.item, | ||||||
|           onReact: (symbol, changes) { |           onReact: (symbol, changes) { | ||||||
|             setState(() { |             setState(() { | ||||||
|               item.reactionList[symbol] = (item.reactionList[symbol] ?? 0) + changes; |               item.reactionList[symbol] = | ||||||
|  |                   (item.reactionList[symbol] ?? 0) + changes; | ||||||
|             }); |             }); | ||||||
|           }, |           }, | ||||||
|         ).paddingOnly( |         ).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 |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: leak_tracker |       name: leak_tracker | ||||||
|       sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" |       sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "10.0.5" |     version: "10.0.4" | ||||||
|   leak_tracker_flutter_testing: |   leak_tracker_flutter_testing: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: leak_tracker_flutter_testing |       name: leak_tracker_flutter_testing | ||||||
|       sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 |       sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.1" |     version: "3.0.3" | ||||||
|   leak_tracker_testing: |   leak_tracker_testing: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: leak_tracker_testing |       name: leak_tracker_testing | ||||||
|       sha256: d4c8f568c60af6b6daa74c80fc04411765769882600f6bf9cd4b391c96de42ce |       sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.3" |     version: "3.0.1" | ||||||
|   lints: |   lints: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -436,10 +436,10 @@ packages: | |||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: meta |       name: meta | ||||||
|       sha256: "25dfcaf170a0190f47ca6355bdd4552cb8924b430512ff0cafb8db9bd41fe33b" |       sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.14.0" |     version: "1.12.0" | ||||||
|   mime: |   mime: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -585,10 +585,10 @@ packages: | |||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: test_api |       name: test_api | ||||||
|       sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" |       sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.6.1" |     version: "0.7.0" | ||||||
|   timeago: |   timeago: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -681,10 +681,10 @@ packages: | |||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: vm_service |       name: vm_service | ||||||
|       sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 |       sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "13.0.0" |     version: "14.2.1" | ||||||
|   web: |   web: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user