From 23c5a1a23ee5a6ea841a7e5555cc13df9353addc Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 1 Dec 2024 22:30:32 +0800 Subject: [PATCH] :sparkles: Basic publisher page --- assets/translations/en.json | 16 +- assets/translations/zh.json | 12 +- lib/main.dart | 2 +- lib/providers/post.dart | 7 +- lib/providers/websocket.dart | 6 - lib/router.dart | 8 + lib/screens/post/post_publisher.dart | 272 +++++++++++++++++++++ lib/widgets/navigation/app_background.dart | 9 +- lib/widgets/post/post_item.dart | 3 +- lib/widgets/post/publisher_popover.dart | 33 ++- 10 files changed, 347 insertions(+), 21 deletions(-) create mode 100644 lib/screens/post/post_publisher.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index ef7501b..b195ef9 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -102,7 +102,13 @@ "publisherTotalUpvote": "Upvote", "publisherTotalDownvote": "Downvote", "publisherSocialPoint": "Social Point", - "publisherJoinedAt": "Joined At", + "publisherJoinedAt": "Joined At {}", + "publisherSocialPointTotal": { + "zero": "No social point", + "one": "{} social point", + "other": "{} social points" + }, + "publisherRunBy": "Run by {}", "fieldPublisherBelongToRealm": "Belongs to", "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm", "writePostTypeStory": "Post a story", @@ -176,7 +182,7 @@ "fieldChatBelongToRealm": "Belongs to", "fieldChatBelongToRealmUnset": "Unset Channel Belongs to Realm", "channelEditingNotice": "You are editing channel {}", - "channelDeleted": "Chat channel {} has been deleted." , + "channelDeleted": "Chat channel {} has been deleted.", "channelDelete": "Delete channel {}", "channelDeleteDescription": "Are you sure you want to delete this channel? This operation is irreversible, all messages in this channel will be permanently deleted.", "channelDetailPersonalRegion": "Personal", @@ -256,7 +262,7 @@ "postDelete": "Delete post {}", "postDeleteDescription": "Are you sure you want to delete this post? This operation is irreversible.", "postDeleted": "Post {} has been deleted.", - "call" : "Call", + "call": "Call", "callOngoingNotice": "A call is ongoing", "callJoin": "Join", "callResume": "Resume", @@ -343,5 +349,7 @@ "friendDelete": "Delete relation with {}", "friendDeleteDescription": "Are you sure you want to delete the relation with {}? This operation is irreversible.", "friendRequestAccept": "Accept", - "friendRequestDecline": "Decline" + "friendRequestDecline": "Decline", + "subscribe": "Subscribe", + "unsubscribe": "Unsubscribe" } diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 6baabb4..f2345c7 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -102,7 +102,13 @@ "publisherTotalUpvote": "总顶数", "publisherTotalDownvote": "总踩数", "publisherSocialPoint": "社会信用点", - "publisherJoinedAt": "加入于", + "publisherJoinedAt": "加入于 {}", + "publisherSocialPointTotal": { + "zero": "无社会信用点", + "one": "{} 点社会信用点", + "other": "{} 点社会信用点" + }, + "publisherRunBy": "由 {} 管理", "fieldPublisherBelongToRealm": "所属领域", "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域", "writePostTypeStory": "发动态", @@ -343,5 +349,7 @@ "friendDelete": "遗忘跟 {} 的关系", "friendDeleteDescription": "你确定要遗忘跟 {} 的关系吗?这个操作无法撤销。", "friendRequestAccept": "接受", - "friendRequestDecline": "拒绝" + "friendRequestDecline": "拒绝", + "subscribe": "订阅", + "unsubscribe": "取消订阅" } diff --git a/lib/main.dart b/lib/main.dart index fa65d2c..51714e2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,7 +43,7 @@ void main() async { ); if (!kReleaseMode) { - debugInvertOversizedImages = true; + // debugInvertOversizedImages = true; } GoRouter.optionURLReflectsImperativeAPIs = true; diff --git a/lib/providers/post.dart b/lib/providers/post.dart index 34278f5..f18e84f 100644 --- a/lib/providers/post.dart +++ b/lib/providers/post.dart @@ -63,10 +63,15 @@ class SnPostContentProvider { return out; } - Future<(List, int)> listPosts({int take = 10, int offset = 0}) async { + Future<(List, int)> listPosts({ + int take = 10, + int offset = 0, + String? author, + }) async { final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { 'take': take, 'offset': offset, + if (author != null) 'author': author, }); final List out = await _preloadRelatedDataInBatch( List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), diff --git a/lib/providers/websocket.dart b/lib/providers/websocket.dart index 3421983..10a45a3 100644 --- a/lib/providers/websocket.dart +++ b/lib/providers/websocket.dart @@ -89,12 +89,6 @@ class WebSocketProvider extends ChangeNotifier { final packet = WebSocketPackage.fromJson(jsonDecode(event)); log('Websocket incoming message: ${packet.method} ${packet.message}'); stream.sink.add(packet); - // TODO handle notification - // if (packet.method == 'notifications.new') { - // final NotificationProvider nty = Get.find(); - // nty.notifications.add(Notification.fromJson(packet.payload!)); - // nty.notificationUnread.value++; - // } }, onDone: () { isConnected = false; diff --git a/lib/router.dart b/lib/router.dart index 099b8af..23bedf7 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -20,6 +20,7 @@ import 'package:surface/screens/home.dart'; import 'package:surface/screens/notification.dart'; import 'package:surface/screens/post/post_detail.dart'; import 'package:surface/screens/post/post_editor.dart'; +import 'package:surface/screens/post/post_publisher.dart'; import 'package:surface/screens/post/post_search.dart'; import 'package:surface/screens/realm.dart'; import 'package:surface/screens/realm/manage.dart'; @@ -77,6 +78,13 @@ final _appRoutes = [ child: PostSearchScreen(), ), ), + GoRoute( + path: '/pub/:name', + name: 'postPublisher', + builder: (context, state) => AppBackground( + child: PostPublisherScreen(name: state.pathParameters['name']!), + ), + ), GoRoute( path: '/:slug', name: 'postDetail', diff --git a/lib/screens/post/post_publisher.dart b/lib/screens/post/post_publisher.dart new file mode 100644 index 0000000..a25ef1f --- /dev/null +++ b/lib/screens/post/post_publisher.dart @@ -0,0 +1,272 @@ +import 'dart:ui'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/providers/post.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/providers/user_directory.dart'; +import 'package:surface/types/account.dart'; +import 'package:surface/types/post.dart'; +import 'package:surface/widgets/account/account_image.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/post/post_item.dart'; +import 'package:surface/widgets/universal_image.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +class PostPublisherScreen extends StatefulWidget { + final String name; + const PostPublisherScreen({super.key, required this.name}); + + @override + State createState() => _PostPublisherScreenState(); +} + +class _PostPublisherScreenState extends State { + late final ScrollController _scrollController = ScrollController(); + + SnPublisher? _publisher; + SnAccount? _account; + + Future _fetchPublisher() async { + try { + final sn = context.read(); + final ud = context.read(); + final resp = await sn.client.get('/cgi/co/publishers/${widget.name}'); + if (!mounted) return; + _publisher = SnPublisher.fromJson(resp.data); + _account = await ud.getAccount(_publisher?.accountId); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err).then((_) { + if (mounted) Navigator.pop(context); + }); + } finally { + setState(() {}); + } + } + + double _appBarBlur = 0.0; + + late final _appBarWidth = MediaQuery.of(context).size.width; + late final _appBarHeight = + (_appBarWidth * kBannerAspectRatio).roundToDouble(); + + void _updateAppBarBlur() { + if (_scrollController.offset > _appBarHeight) return; + setState(() { + _appBarBlur = + (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0); + }); + } + + bool _isBusy = false; + + int? _postCount; + final List _posts = List.empty(growable: true); + + Future _fetchPosts() async { + if (_isBusy) return; + _isBusy = true; + try { + final pt = context.read(); + final result = await pt.listPosts( + offset: _posts.length, + author: widget.name, + ); + _postCount = result.$2; + _posts.addAll(result.$1); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + _isBusy = false; + setState(() {}); + } + } + + @override + void initState() { + super.initState(); + _fetchPublisher().then((_) { + _fetchPosts(); + }); + _scrollController.addListener(_updateAppBarBlur); + } + + @override + void dispose() { + _scrollController.removeListener(_updateAppBarBlur); + _scrollController.dispose(); + super.dispose(); + } + + static const kBannerAspectRatio = 7 / 16; + + @override + Widget build(BuildContext context) { + final imageHeight = _appBarHeight + kToolbarHeight + 8; + + final sn = context.read(); + return Scaffold( + body: CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAppBar( + expandedHeight: _appBarHeight, + title: Text(_publisher?.nick ?? 'loading'.tr()), + pinned: true, + flexibleSpace: _publisher != null + ? Stack( + fit: StackFit.expand, + children: [ + UniversalImage( + sn.getAttachmentUrl(_publisher!.banner), + fit: BoxFit.cover, + height: imageHeight, + width: _appBarWidth, + cacheHeight: imageHeight, + cacheWidth: _appBarWidth, + ), + Positioned( + top: 0, + left: 0, + right: 0, + height: 56 + MediaQuery.of(context).padding.top, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: _appBarBlur, + sigmaY: _appBarBlur, + ), + child: Container( + color: Colors.black.withOpacity( + clampDouble(_appBarBlur * 0.1, 0, 0.5), + ), + ), + ), + ), + ), + ], + ) + : null, + ), + if (_publisher != null) + SliverToBoxAdapter( + child: Container( + constraints: const BoxConstraints(maxWidth: 640), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AccountImage( + content: _publisher!.avatar, + radius: 28, + ), + const Gap(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _publisher!.nick, + style: Theme.of(context).textTheme.titleMedium, + ).bold(), + Text('@${_publisher!.name}').fontSize(13), + ], + ), + ), + ElevatedButton( + style: + ButtonStyle(elevation: WidgetStatePropertyAll(0)), + onPressed: () {}, + child: Text('subscribe').tr(), + ), + ], + ).padding(right: 8), + const Gap(12), + Text(_publisher!.description).padding(horizontal: 8), + const Gap(12), + Column( + children: [ + Row( + children: [ + const Icon(Symbols.calendar_add_on), + const Gap(8), + Text('publisherJoinedAt').tr(args: [ + DateFormat('y/M/d').format(_publisher!.createdAt) + ]), + ], + ), + Row( + children: [ + const Icon(Symbols.trending_up), + const Gap(8), + Text('publisherSocialPointTotal').plural( + _publisher!.totalUpvote - + _publisher!.totalDownvote, + ), + ], + ), + Row( + children: [ + const Icon(Symbols.tools_wrench), + const Gap(8), + InkWell( + child: Text('publisherRunBy').tr(args: [ + '@${_account?.name ?? 'unknown'}', + ]), + onTap: () {}, + ), + const Gap(8), + AccountImage(content: _account?.avatar, radius: 8), + ], + ), + ], + ).padding(horizontal: 8), + ], + ).padding(all: 16), + ).center(), + ), + SliverToBoxAdapter( + child: const Divider(height: 1), + ), + SliverInfiniteList( + itemCount: _posts.length, + isLoading: _isBusy, + hasReachedMax: _postCount != null && _posts.length >= _postCount!, + onFetchData: _fetchPosts, + itemBuilder: (context, idx) { + return GestureDetector( + child: PostItem( + data: _posts[idx], + maxWidth: 640, + onChanged: (data) { + setState(() => _posts[idx] = data); + }, + onDeleted: () { + _posts.clear(); + _fetchPosts(); + }, + ), + onTap: () { + GoRouter.of(context).pushNamed( + 'postDetail', + pathParameters: {'slug': _posts[idx].id.toString()}, + extra: _posts[idx], + ); + }, + ); + }, + separatorBuilder: (context, index) => const Divider(height: 1), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/navigation/app_background.dart b/lib/widgets/navigation/app_background.dart index 9b5d212..3cf297a 100644 --- a/lib/widgets/navigation/app_background.dart +++ b/lib/widgets/navigation/app_background.dart @@ -89,12 +89,15 @@ class AppBackground extends StatelessWidget { return _buildWithBackgroundImage(context, file, child); } } + + return Material( + color: Theme.of(context).colorScheme.surface, + child: child, + ); } return Material( - color: isRoot - ? Theme.of(context).colorScheme.surface - : Colors.transparent, + color: Colors.transparent, child: child, ); }, diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 625c822..312a338 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -273,13 +273,14 @@ class _PostContentHeader extends StatelessWidget { ), onTap: () { showPopover( + backgroundColor: Theme.of(context).colorScheme.surface, context: context, transition: PopoverTransition.other, bodyBuilder: (context) => SizedBox( width: 400, child: PublisherPopoverCard( data: data.publisher, - ).padding(horizontal: 16, vertical: 16), + ), ), direction: PopoverDirection.bottom, arrowHeight: 5, diff --git a/lib/widgets/post/publisher_popover.dart b/lib/widgets/post/publisher_popover.dart index 29dd7a1..81cefec 100644 --- a/lib/widgets/post/publisher_popover.dart +++ b/lib/widgets/post/publisher_popover.dart @@ -1,10 +1,14 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/providers/sn_network.dart'; import 'package:surface/types/post.dart'; import 'package:surface/widgets/account/account_image.dart'; +import 'package:surface/widgets/universal_image.dart'; class PublisherPopoverCard extends StatelessWidget { final SnPublisher data; @@ -12,10 +16,25 @@ class PublisherPopoverCard extends StatelessWidget { @override Widget build(BuildContext context) { + final sn = context.read(); + return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + if (data.banner.isNotEmpty) + Container( + color: Theme.of(context).colorScheme.surfaceContainer, + child: AspectRatio( + aspectRatio: 16 / 7, + child: AutoResizeUniversalImage( + sn.getAttachmentUrl(data.banner), + fit: BoxFit.cover, + ), + ), + ), + // Top padding + Gap(16), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -35,14 +54,20 @@ class PublisherPopoverCard extends StatelessWidget { ), ), IconButton( - onPressed: () {}, + onPressed: () { + Navigator.pop(context); + GoRouter.of(context).pushNamed( + 'postPublisher', + pathParameters: {'name': data.name}, + ); + }, icon: const Icon(Symbols.chevron_right), padding: EdgeInsets.zero, visualDensity: const VisualDensity(horizontal: -4, vertical: -4), ), const Gap(8) ], - ), + ).padding(horizontal: 16), const Gap(16), Row( children: [ @@ -92,7 +117,9 @@ class PublisherPopoverCard extends StatelessWidget { ), ), ], - ), + ).padding(horizontal: 16), + // Bottom padding + const Gap(16), ], ); }