✨ Account profile page
This commit is contained in:
		| @@ -5,6 +5,8 @@ import 'package:solian/models/post.dart'; | ||||
| import 'package:solian/providers/content/posts.dart'; | ||||
|  | ||||
| class PostListController extends GetxController { | ||||
|   String? author; | ||||
|  | ||||
|   /// The polling source modifier. | ||||
|   /// - `0`: default recommendations | ||||
|   /// - `1`: shuffle mode | ||||
| @@ -13,9 +15,9 @@ class PostListController extends GetxController { | ||||
|   /// The paging controller for infinite loading. | ||||
|   /// Only available when mode is `0`. | ||||
|   PagingController<int, Post> pagingController = | ||||
|   PagingController(firstPageKey: 0); | ||||
|       PagingController(firstPageKey: 0); | ||||
|  | ||||
|   PostListController() { | ||||
|   PostListController({this.author}) { | ||||
|     _initPagingController(); | ||||
|   } | ||||
|  | ||||
| @@ -48,6 +50,7 @@ class PostListController extends GetxController { | ||||
|   RxBool isPreparing = false.obs; | ||||
|  | ||||
|   RxInt focusCursor = 0.obs; | ||||
|  | ||||
|   Post get focusPost => postList[focusCursor.value]; | ||||
|  | ||||
|   RxInt postTotal = 0.obs; | ||||
| @@ -102,17 +105,24 @@ class PostListController extends GetxController { | ||||
|  | ||||
|     Response resp; | ||||
|     try { | ||||
|       resp = await provider.listRecommendations( | ||||
|         pageKey, | ||||
|         channel: mode.value == 0 ? null : 'shuffle', | ||||
|       ); | ||||
|       if (author != null) { | ||||
|         resp = await provider.listPost( | ||||
|           pageKey, | ||||
|           author: author, | ||||
|         ); | ||||
|       } else { | ||||
|         resp = await provider.listRecommendations( | ||||
|           pageKey, | ||||
|           channel: mode.value == 0 ? null : 'shuffle', | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       rethrow; | ||||
|     } finally { | ||||
|       isBusy.value = false; | ||||
|     } | ||||
|  | ||||
|     final PaginationResult result = PaginationResult.fromJson(resp.body); | ||||
|     final result = PaginationResult.fromJson(resp.body); | ||||
|     final out = result.data?.map((e) => Post.fromJson(e)).toList(); | ||||
|  | ||||
|     postTotal.value = result.count; | ||||
|   | ||||
| @@ -45,12 +45,13 @@ class PostProvider extends GetConnect { | ||||
|   } | ||||
|  | ||||
|   Future<Response> listPost(int page, | ||||
|       {int? realm, String? tag, category}) async { | ||||
|       {int? realm, String? author, tag, category}) async { | ||||
|     final queries = [ | ||||
|       'take=${10}', | ||||
|       'offset=$page', | ||||
|       if (tag != null) 'tag=$tag', | ||||
|       if (category != null) 'category=$category', | ||||
|       if (author != null) 'author=$author', | ||||
|       if (realm != null) 'realmId=$realm', | ||||
|     ]; | ||||
|     final resp = await get('/posts?${queries.join('&')}'); | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import 'package:solian/screens/about.dart'; | ||||
| import 'package:solian/screens/account.dart'; | ||||
| import 'package:solian/screens/account/friend.dart'; | ||||
| import 'package:solian/screens/account/personalize.dart'; | ||||
| import 'package:solian/screens/account/profile_page.dart'; | ||||
| import 'package:solian/screens/channel/channel_chat.dart'; | ||||
| import 'package:solian/screens/channel/channel_detail.dart'; | ||||
| import 'package:solian/screens/channel/channel_organize.dart'; | ||||
| @@ -201,6 +202,13 @@ abstract class AppRouter { | ||||
|           child: const PersonalizeScreen(), | ||||
|         ), | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/account/view/:name', | ||||
|         name: 'accountProfilePage', | ||||
|         builder: (context, state) => AccountProfilePage( | ||||
|           name: state.pathParameters['name']!, | ||||
|         ), | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/about', | ||||
|         name: 'about', | ||||
|   | ||||
							
								
								
									
										192
									
								
								lib/screens/account/profile_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										192
									
								
								lib/screens/account/profile_page.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,192 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:get/get.dart'; | ||||
| import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; | ||||
| import 'package:solian/controllers/post_list_controller.dart'; | ||||
| import 'package:solian/exts.dart'; | ||||
| import 'package:solian/models/account.dart'; | ||||
| import 'package:solian/models/attachment.dart'; | ||||
| import 'package:solian/models/pagination.dart'; | ||||
| import 'package:solian/screens/account/notification.dart'; | ||||
| import 'package:solian/services.dart'; | ||||
| import 'package:solian/theme.dart'; | ||||
| import 'package:solian/widgets/account/account_avatar.dart'; | ||||
| import 'package:solian/widgets/app_bar_leading.dart'; | ||||
| import 'package:solian/widgets/attachments/attachment_item.dart'; | ||||
| import 'package:solian/widgets/current_state_action.dart'; | ||||
| import 'package:solian/widgets/feed/feed_list.dart'; | ||||
| import 'package:solian/widgets/sized_container.dart'; | ||||
|  | ||||
| class AccountProfilePage extends StatefulWidget { | ||||
|   final String name; | ||||
|  | ||||
|   const AccountProfilePage({super.key, required this.name}); | ||||
|  | ||||
|   @override | ||||
|   State<AccountProfilePage> createState() => _AccountProfilePageState(); | ||||
| } | ||||
|  | ||||
| class _AccountProfilePageState extends State<AccountProfilePage> { | ||||
|   late final PostListController _postController; | ||||
|   final PagingController<int, Attachment> _albumPagingController = | ||||
|       PagingController(firstPageKey: 0); | ||||
|  | ||||
|   bool _isBusy = true; | ||||
|  | ||||
|   Account? _userinfo; | ||||
|  | ||||
|   Future<void> getUserinfo() async { | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final client = ServiceFinder.configureClient('auth'); | ||||
|     final resp = await client.get('/users/${widget.name}'); | ||||
|     if (resp.statusCode == 200) { | ||||
|       _userinfo = Account.fromJson(resp.body); | ||||
|       setState(() => _isBusy = false); | ||||
|     } else { | ||||
|       context.showErrorDialog(resp.bodyString).then((_) { | ||||
|         Navigator.pop(context); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _postController = PostListController(author: widget.name); | ||||
|     _albumPagingController.addPageRequestListener((pageKey) async { | ||||
|       final client = ServiceFinder.configureClient('files'); | ||||
|       final resp = await client | ||||
|           .get('/attachments?take=10&offset=$pageKey&author=${widget.name}'); | ||||
|       if (resp.statusCode == 200) { | ||||
|         final result = PaginationResult.fromJson(resp.body); | ||||
|         final out = result.data?.map((e) => Attachment.fromJson(e)).toList(); | ||||
|         if (out != null && out.length >= 10) { | ||||
|           _albumPagingController.appendPage(out, pageKey + out.length); | ||||
|         } else if (out != null) { | ||||
|           _albumPagingController.appendLastPage(out); | ||||
|         } | ||||
|       } else { | ||||
|         _albumPagingController.error = resp.bodyString; | ||||
|       } | ||||
|     }); | ||||
|     getUserinfo(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (_isBusy || _userinfo == null) { | ||||
|       return const Center(child: CircularProgressIndicator()); | ||||
|     } | ||||
|  | ||||
|     return Material( | ||||
|       color: Theme.of(context).colorScheme.surface, | ||||
|       child: DefaultTabController( | ||||
|         length: 2, | ||||
|         child: NestedScrollView( | ||||
|           headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { | ||||
|             return [ | ||||
|               SliverAppBar( | ||||
|                 centerTitle: false, | ||||
|                 floating: true, | ||||
|                 toolbarHeight: SolianTheme.toolbarHeight(context), | ||||
|                 leadingWidth: 24, | ||||
|                 leading: AppBarLeadingButton.adaptive(context), | ||||
|                 flexibleSpace: Row( | ||||
|                   children: [ | ||||
|                     const SizedBox(width: 16), | ||||
|                     if (_userinfo != null) | ||||
|                       AccountAvatar(content: _userinfo!.avatar, radius: 16), | ||||
|                     const SizedBox(width: 12), | ||||
|                     Expanded( | ||||
|                       child: Column( | ||||
|                         mainAxisAlignment: MainAxisAlignment.center, | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           if (_userinfo != null) | ||||
|                             Text( | ||||
|                               _userinfo!.nick, | ||||
|                               style: Theme.of(context).textTheme.bodyLarge, | ||||
|                             ), | ||||
|                           if (_userinfo != null) | ||||
|                             Text( | ||||
|                               '@${_userinfo!.name}', | ||||
|                               style: Theme.of(context).textTheme.bodySmall, | ||||
|                             ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                     const BackgroundStateWidget(), | ||||
|                     const NotificationButton(), | ||||
|                     SizedBox( | ||||
|                       width: SolianTheme.isLargeScreen(context) ? 8 : 16, | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 bottom: TabBar( | ||||
|                   tabs: [ | ||||
|                     Tab(text: 'profilePosts'.tr), | ||||
|                     Tab(text: 'profileAlbum'.tr), | ||||
|                   ], | ||||
|                 ), | ||||
|               ) | ||||
|             ]; | ||||
|           }, | ||||
|           body: TabBarView( | ||||
|             physics: const NeverScrollableScrollPhysics(), | ||||
|             children: [ | ||||
|               RefreshIndicator( | ||||
|                 onRefresh: () => _postController.reloadAllOver(), | ||||
|                 child: CustomScrollView(slivers: [ | ||||
|                   if (_userinfo == null) | ||||
|                     const SliverFillRemaining( | ||||
|                       child: Center(child: CircularProgressIndicator()), | ||||
|                     ), | ||||
|                   if (_userinfo != null) | ||||
|                     FeedListWidget( | ||||
|                       controller: _postController.pagingController, | ||||
|                     ), | ||||
|                 ]), | ||||
|               ), | ||||
|               CenteredContainer( | ||||
|                 child: RefreshIndicator( | ||||
|                   onRefresh: () => | ||||
|                       Future.sync(() => _albumPagingController.refresh()), | ||||
|                   child: PagedGridView<int, Attachment>( | ||||
|                     pagingController: _albumPagingController, | ||||
|                     gridDelegate: | ||||
|                         const SliverGridDelegateWithFixedCrossAxisCount( | ||||
|                       crossAxisCount: 3, | ||||
|                       mainAxisSpacing: 8.0, | ||||
|                       crossAxisSpacing: 8.0, | ||||
|                     ), | ||||
|                     builderDelegate: PagedChildBuilderDelegate<Attachment>( | ||||
|                       itemBuilder: (BuildContext context, item, int index) { | ||||
|                         const radius = BorderRadius.all(Radius.circular(8)); | ||||
|                         return Container( | ||||
|                           decoration: BoxDecoration( | ||||
|                             border: Border.all( | ||||
|                               color: Theme.of(context).dividerColor, | ||||
|                               width: 0.3, | ||||
|                             ), | ||||
|                             borderRadius: radius, | ||||
|                           ), | ||||
|                           child: ClipRRect( | ||||
|                             borderRadius: radius, | ||||
|                             child: AttachmentItem( | ||||
|                               item: item, | ||||
|                               parentId: 'album', | ||||
|                             ), | ||||
|                           ), | ||||
|                         ); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -14,6 +14,9 @@ const messagesEnglish = { | ||||
|   'feedSearch': 'Search Feed', | ||||
|   'feedSearchWithTag': 'Searching with tag #@key', | ||||
|   'feedSearchWithCategory': 'Searching in category @category', | ||||
|   'visitProfilePage': 'Visit Profile Page', | ||||
|   'profilePosts': 'Posts', | ||||
|   'profileAlbum': 'Album', | ||||
|   'chat': 'Chat', | ||||
|   'apply': 'Apply', | ||||
|   'cancel': 'Cancel', | ||||
|   | ||||
| @@ -21,6 +21,9 @@ const simplifiedChineseMessages = { | ||||
|   'feedSearch': '搜索资讯', | ||||
|   'feedSearchWithTag': '检索带有 #@key 标签的资讯', | ||||
|   'feedSearchWithCategory': '检索位于分类 @category 的资讯', | ||||
|   'visitProfilePage': '造访个人主页', | ||||
|   'profilePosts': '帖子', | ||||
|   'profileAlbum': '相簿', | ||||
|   'chat': '聊天', | ||||
|   'apply': '应用', | ||||
|   'search': '搜索', | ||||
|   | ||||
| @@ -18,6 +18,7 @@ class AccountHeadingWidget extends StatelessWidget { | ||||
|   final String? desc; | ||||
|   final Account? detail; | ||||
|   final List<AccountBadge>? badges; | ||||
|   final List<Widget>? extraWidgets; | ||||
|  | ||||
|   final Future<Response>? status; | ||||
|   final Function? onEditStatus; | ||||
| @@ -32,6 +33,7 @@ class AccountHeadingWidget extends StatelessWidget { | ||||
|     required this.badges, | ||||
|     this.detail, | ||||
|     this.status, | ||||
|     this.extraWidgets, | ||||
|     this.onEditStatus, | ||||
|   }); | ||||
|  | ||||
| @@ -149,19 +151,6 @@ class AccountHeadingWidget extends StatelessWidget { | ||||
|             ], | ||||
|           ).paddingOnly(left: 116, top: 6), | ||||
|           const SizedBox(height: 4), | ||||
|           if (detail?.suspendedAt != null) | ||||
|             SizedBox( | ||||
|               width: double.infinity, | ||||
|               child: Card( | ||||
|                 child: ListTile( | ||||
|                   title: Text('accountSuspended'.tr), | ||||
|                   subtitle: Text('accountSuspendedAt'.trParams({ | ||||
|                     'date': DateFormat('y/M/d').format(detail!.suspendedAt!), | ||||
|                   })), | ||||
|                   trailing: const Icon(Icons.block), | ||||
|                 ), | ||||
|               ), | ||||
|             ).paddingOnly(left: 16, right: 16), | ||||
|           if (badges?.isNotEmpty ?? false) | ||||
|             SizedBox( | ||||
|               width: double.infinity, | ||||
| @@ -177,7 +166,21 @@ class AccountHeadingWidget extends StatelessWidget { | ||||
|                   vertical: PlatformInfo.isMobile ? 0 : 6, | ||||
|                 ), | ||||
|               ), | ||||
|             ).paddingOnly(left: 16, right: 16), | ||||
|             ).paddingSymmetric(horizontal: 16), | ||||
|           ...?extraWidgets?.map((x) => x.paddingSymmetric(horizontal: 16)), | ||||
|           if (detail?.suspendedAt != null) | ||||
|             SizedBox( | ||||
|               width: double.infinity, | ||||
|               child: Card( | ||||
|                 child: ListTile( | ||||
|                   title: Text('accountSuspended'.tr), | ||||
|                   subtitle: Text('accountSuspendedAt'.trParams({ | ||||
|                     'date': DateFormat('y/M/d').format(detail!.suspendedAt!), | ||||
|                   })), | ||||
|                   trailing: const Icon(Icons.block), | ||||
|                 ), | ||||
|               ), | ||||
|             ).paddingSymmetric(horizontal: 16), | ||||
|           SizedBox( | ||||
|             width: double.infinity, | ||||
|             child: Card( | ||||
| @@ -188,7 +191,7 @@ class AccountHeadingWidget extends StatelessWidget { | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ).paddingOnly(left: 16, right: 16), | ||||
|           ).paddingSymmetric(horizontal: 16), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import 'package:get/get.dart'; | ||||
| import 'package:solian/exts.dart'; | ||||
| import 'package:solian/models/account.dart'; | ||||
| import 'package:solian/providers/account_status.dart'; | ||||
| import 'package:solian/router.dart'; | ||||
| import 'package:solian/services.dart'; | ||||
| import 'package:solian/widgets/account/account_heading.dart'; | ||||
|  | ||||
| @@ -23,9 +24,7 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> { | ||||
|   void getUserinfo() async { | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final client = GetConnect(); | ||||
|     client.httpClient.baseUrl = ServiceFinder.buildUrl('auth', null); | ||||
|  | ||||
|     final client = ServiceFinder.configureClient('auth'); | ||||
|     final resp = await client.get('/users/${widget.account.name}'); | ||||
|     if (resp.statusCode == 200) { | ||||
|       _userinfo = Account.fromJson(resp.body); | ||||
| @@ -39,14 +38,16 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> { | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|  | ||||
|     getUserinfo(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (_isBusy || _userinfo == null) { | ||||
|       return const Center(child: CircularProgressIndicator()); | ||||
|       return SizedBox( | ||||
|         height: MediaQuery.of(context).size.height * 0.75, | ||||
|         child: const Center(child: CircularProgressIndicator()), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return SizedBox( | ||||
| @@ -62,7 +63,28 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> { | ||||
|             desc: _userinfo!.description, | ||||
|             detail: _userinfo!, | ||||
|             badges: _userinfo!.badges, | ||||
|             status: Get.find<StatusProvider>().getSomeoneStatus(_userinfo!.name), | ||||
|             status: | ||||
|                 Get.find<StatusProvider>().getSomeoneStatus(_userinfo!.name), | ||||
|             extraWidgets: [ | ||||
|               Card( | ||||
|                 child: ListTile( | ||||
|                   shape: const RoundedRectangleBorder( | ||||
|                     borderRadius: BorderRadius.all(Radius.circular(8)), | ||||
|                   ), | ||||
|                   title: Text('visitProfilePage'.tr), | ||||
|                   visualDensity: | ||||
|                       const VisualDensity(horizontal: -4, vertical: -2), | ||||
|                   trailing: const Icon(Icons.chevron_right), | ||||
|                   onTap: () { | ||||
|                     AppRouter.instance.goNamed( | ||||
|                       'accountProfilePage', | ||||
|                       pathParameters: {'name': _userinfo!.name}, | ||||
|                     ); | ||||
|                     Navigator.pop(context); | ||||
|                   }, | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ).paddingOnly(top: 16), | ||||
|         ], | ||||
|       ), | ||||
|   | ||||
| @@ -14,6 +14,7 @@ class AttachmentList extends StatefulWidget { | ||||
|   final List<int> attachmentsId; | ||||
|   final bool isGrid; | ||||
|   final bool isForceGrid; | ||||
|   final double flatMaxHeight; | ||||
|  | ||||
|   final double? width; | ||||
|   final double? viewport; | ||||
| @@ -24,6 +25,7 @@ class AttachmentList extends StatefulWidget { | ||||
|     required this.attachmentsId, | ||||
|     this.isGrid = false, | ||||
|     this.isForceGrid = false, | ||||
|     this.flatMaxHeight = 720, | ||||
|     this.width, | ||||
|     this.viewport, | ||||
|   }); | ||||
| @@ -255,20 +257,25 @@ class _AttachmentListState extends State<AttachmentList> { | ||||
|         itemBuilder: (context, idx) { | ||||
|           final element = _attachmentsMeta[idx]; | ||||
|           return Container( | ||||
|               decoration: BoxDecoration( | ||||
|                 border: | ||||
|                     Border.all(color: Theme.of(context).dividerColor, width: 1), | ||||
|                 borderRadius: radius, | ||||
|               ), | ||||
|               child: ClipRRect( | ||||
|                 borderRadius: radius, | ||||
|                 child: _buildEntry(element, idx), | ||||
|               )); | ||||
|             decoration: BoxDecoration( | ||||
|               border: | ||||
|                   Border.all(color: Theme.of(context).dividerColor, width: 1), | ||||
|               borderRadius: radius, | ||||
|             ), | ||||
|             child: ClipRRect( | ||||
|               borderRadius: radius, | ||||
|               child: _buildEntry(element, idx), | ||||
|             ), | ||||
|           ); | ||||
|         }, | ||||
|       ).paddingSymmetric(horizontal: 24); | ||||
|     } | ||||
|  | ||||
|     return Container( | ||||
|       width: MediaQuery.of(context).size.width, | ||||
|       constraints: BoxConstraints( | ||||
|         maxHeight: widget.flatMaxHeight, | ||||
|       ), | ||||
|       decoration: BoxDecoration( | ||||
|         color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|         border: Border.symmetric( | ||||
|   | ||||
| @@ -298,16 +298,10 @@ class _PostItemState extends State<PostItem> { | ||||
|           right: 16, | ||||
|           left: 16, | ||||
|         ), | ||||
|         Container( | ||||
|           width: MediaQuery.of(context).size.width, | ||||
|           constraints: const BoxConstraints( | ||||
|             maxHeight: 720, | ||||
|           ), | ||||
|           child: AttachmentList( | ||||
|             parentId: widget.item.id.toString(), | ||||
|             attachmentsId: attachments, | ||||
|             isGrid: attachments.length > 1, | ||||
|           ), | ||||
|         AttachmentList( | ||||
|           parentId: widget.item.id.toString(), | ||||
|           attachmentsId: attachments, | ||||
|           isGrid: attachments.length > 1, | ||||
|         ), | ||||
|         if (widget.isShowReply && widget.isReactable) | ||||
|           PostQuickAction( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user