Basic publisher page

This commit is contained in:
LittleSheep 2024-12-01 22:30:32 +08:00
parent 32739821ba
commit 23c5a1a23e
10 changed files with 347 additions and 21 deletions

View File

@ -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"
}

View File

@ -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": "取消订阅"
}

View File

@ -43,7 +43,7 @@ void main() async {
);
if (!kReleaseMode) {
debugInvertOversizedImages = true;
// debugInvertOversizedImages = true;
}
GoRouter.optionURLReflectsImperativeAPIs = true;

View File

@ -63,10 +63,15 @@ class SnPostContentProvider {
return out;
}
Future<(List<SnPost>, int)> listPosts({int take = 10, int offset = 0}) async {
Future<(List<SnPost>, 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<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),

View File

@ -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;

View File

@ -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',

View File

@ -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<PostPublisherScreen> createState() => _PostPublisherScreenState();
}
class _PostPublisherScreenState extends State<PostPublisherScreen> {
late final ScrollController _scrollController = ScrollController();
SnPublisher? _publisher;
SnAccount? _account;
Future<void> _fetchPublisher() async {
try {
final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>();
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<SnPost> _posts = List.empty(growable: true);
Future<void> _fetchPosts() async {
if (_isBusy) return;
_isBusy = true;
try {
final pt = context.read<SnPostContentProvider>();
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<SnNetworkProvider>();
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),
),
],
),
);
}
}

View File

@ -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,
);
},

View File

@ -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,

View File

@ -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<SnNetworkProvider>();
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),
],
);
}