import 'dart:ui'; import 'dart:math' as math; 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:sliver_tools/sliver_tools.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/types/realm.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'; import '../../providers/relationship.dart'; import '../abuse_report.dart'; class PostPublisherScreen extends StatefulWidget { final String name; const PostPublisherScreen({super.key, required this.name}); @override State createState() => _PostPublisherScreenState(); } class _PostPublisherScreenState extends State with SingleTickerProviderStateMixin { late final ScrollController _scrollController = ScrollController(); late final TabController _tabController = TabController(length: 3, vsync: this); SnPublisher? _publisher; SnAccount? _account; SnRelationship? _accountRelationship; SnRealm? _realm; Future _fetchPublisher() async { try { final sn = context.read(); final ud = context.read(); final rel = 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); _accountRelationship = await rel.getRelationship(_account!.id); if (_publisher?.realmId != null && _publisher!.realmId != 0) { final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}'); _realm = SnRealm.fromJson(resp.data); } } catch (err) { if (!mounted) return; context.showErrorDialog(err).then((_) { if (mounted) Navigator.pop(context); }); rethrow; } finally { setState(() {}); } } bool _isSubscribing = false; SnSubscription? _subscription; Future _fetchSubscription() async { try { setState(() => _isSubscribing = true); final sn = context.read(); final resp = await sn.client.get( '/cgi/co/subscriptions/users/${_publisher!.id}', ); if (!mounted) return; _subscription = SnSubscription.fromJson(resp.data); } catch (_) { // ignore due to maybe 404 } finally { setState(() => _isSubscribing = false); } } Future _toggleSubscription() async { if (_subscription == null) { try { setState(() => _isSubscribing = true); final sn = context.read(); final resp = await sn.client.post( '/cgi/co/subscriptions/users/${_publisher!.id}', ); if (!mounted) return; _subscription = SnSubscription.fromJson(resp.data); } catch (err) { if (!mounted) return; context.showErrorDialog(err); } finally { setState(() => _isSubscribing = false); } } else { try { setState(() => _isSubscribing = true); final sn = context.read(); await sn.client.delete( '/cgi/co/subscriptions/users/${_publisher!.id}', ); if (!mounted) return; _subscription = null; } catch (err) { if (!mounted) return; context.showErrorDialog(err); } finally { setState(() => _isSubscribing = false); } } } 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; setState(() => _isBusy = true); try { final pt = context.read(); final result = await pt.listPosts( offset: _posts.length, author: widget.name, type: switch (_tabController.index) { 1 => 'story', 2 => 'article', _ => null, }, ); _postCount = result.$2; _posts.addAll(result.$1); } catch (err) { if (!mounted) return; context.showErrorDialog(err); } finally { setState(() => _isBusy = false); } } bool _isWorking = false; Future _blockPublisher() async { if (_isWorking) return; final confirm = await context.showConfirmDialog( 'publisherBlockHint'.tr(args: ['@${_publisher?.name ?? 'unknown'.tr()}']), 'publisherBlockHintDescription'.tr(), ); if (!confirm) return; if (!mounted) return; setState(() => _isWorking = true); try { final sn = context.read(); await sn.client.post('/cgi/id/users/me/relations/block', data: { 'related': _account!.name, }); if (!mounted) return; context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}'])); } catch (err) { if (!mounted) return; context.showErrorDialog(err); } finally { setState(() => _isWorking = false); } } Future _unblockPublisher() async { if (_isWorking) return; setState(() => _isWorking = true); try { final rel = context.read(); await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {}); if (!mounted) return; context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}'])); } catch (err) { if (!mounted) return; context.showErrorDialog(err); } finally { setState(() => _isWorking = false); } } void _updateFetchType() { _posts.clear(); _fetchPosts(); } void _showAbuseReportDialog() { showDialog( context: context, builder: (context) => AbuseReportDialog( resourceLocation: 'pub:${_publisher?.name}', ), ).then((value) { if (value == true && mounted) { _fetchPosts(); context.showSnackbar('abuseReportSubmitted'.tr()); } }); } @override void initState() { super.initState(); _fetchPublisher().then((_) { _fetchPosts(); _fetchSubscription(); }); _scrollController.addListener(_updateAppBarBlur); _tabController.addListener(_updateFetchType); } @override void dispose() { _scrollController.removeListener(_updateAppBarBlur); _scrollController.dispose(); _tabController.removeListener(_updateFetchType); _tabController.dispose(); super.dispose(); } static const kBannerAspectRatio = 7 / 16; @override Widget build(BuildContext context) { final imageHeight = _appBarHeight + kToolbarHeight + 8; const labelShadows = [ Shadow( offset: Offset(1, 1), blurRadius: 5.0, color: Color.fromARGB(255, 0, 0, 0), ), ]; final sn = context.read(); return Scaffold( body: NestedScrollView( controller: _scrollController, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return [ SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: MultiSliver( children: [ SliverAppBar( expandedHeight: _appBarHeight, title: _publisher == null ? Text('loading').tr() : RichText( textAlign: TextAlign.center, text: TextSpan(children: [ TextSpan( text: _publisher!.nick, style: Theme.of(context).textTheme.titleLarge!.copyWith( color: Theme.of(context).appBarTheme.foregroundColor!, shadows: labelShadows, ), ), const TextSpan(text: '\n'), TextSpan( text: '@${_publisher!.name}', style: Theme.of(context).textTheme.bodySmall!.copyWith( color: Colors.white, shadows: labelShadows, ), ), ]), ), pinned: true, flexibleSpace: _publisher != null ? Stack( fit: StackFit.expand, children: [ if (_publisher!.banner.isNotEmpty) UniversalImage( sn.getAttachmentUrl(_publisher!.banner), fit: BoxFit.cover, height: imageHeight, width: _appBarWidth, cacheHeight: imageHeight, cacheWidth: _appBarWidth, ) else Container( color: Theme.of(context).colorScheme.surfaceContainer, ), 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), ], ), ), if (_subscription == null) ElevatedButton.icon( style: ButtonStyle( elevation: WidgetStatePropertyAll(0), ), onPressed: _isSubscribing ? null : _toggleSubscription, label: Text('subscribe').tr(), icon: const Icon(Symbols.add), ) else OutlinedButton.icon( style: ButtonStyle( elevation: WidgetStatePropertyAll(0), ), onPressed: _isSubscribing ? null : _toggleSubscription, label: Text('unsubscribe').tr(), icon: const Icon(Symbols.remove), ), PopupMenuButton( padding: EdgeInsets.zero, style: ButtonStyle( visualDensity: VisualDensity(horizontal: -4, vertical: -4), ), itemBuilder: (BuildContext context) => [ PopupMenuItem( child: Row( children: [ const Icon(Symbols.flag), const Gap(16), Text('report').tr(), ], ), onTap: () => _showAbuseReportDialog(), ), if (_accountRelationship?.status != 2) PopupMenuItem( onTap: _blockPublisher, child: Row( children: [ const Icon(Symbols.block), const Gap(16), Text('friendBlock').tr(), ], ), ) else PopupMenuItem( onTap: _unblockPublisher, child: Row( children: [ const Icon(Symbols.block), const Gap(16), Text('friendUnblock').tr(), ], ), ), ], ), ], ), 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, ), ], ), if (_realm != null) Row( children: [ const Icon(Symbols.group_work), const Gap(8), InkWell( child: Text('publisherAffiliatedBy').tr(args: [ '@${_realm?.alias ?? 'unknown'}', ]), onTap: () { GoRouter.of(context).pushNamed( 'realmDetail', pathParameters: {'alias': _realm!.alias}, ); }, ), const Gap(8), AccountImage(content: _realm?.avatar, radius: 8), ], ), Row( children: [ const Icon(Symbols.tools_wrench), const Gap(8), InkWell( child: Text('publisherRunBy').tr(args: [ '@${_account?.name ?? 'unknown'}', ]), onTap: () { GoRouter.of(context).pushNamed( 'accountProfilePage', pathParameters: { 'name': _account!.name, }, ); }, ), const Gap(8), AccountImage(content: _account?.avatar, radius: 8), ], ), ], ).padding(horizontal: 8), ], ).padding(all: 16), ).center(), ), SliverToBoxAdapter(child: const Divider(height: 1)), TabBar( controller: _tabController, tabs: [ Tab( icon: Icon( Symbols.pages, color: Theme.of(context).colorScheme.onSurface, ), ), Tab( icon: Icon( Symbols.sticky_note_2, color: Theme.of(context).colorScheme.onSurface, ), ), Tab( icon: Icon( Symbols.article, color: Theme.of(context).colorScheme.onSurface, ), ), ], ), SliverToBoxAdapter(child: const Divider(height: 1)), ], ), ), ]; }, body: Column( children: [ Gap(math.max(MediaQuery.of(context).padding.top, 64)), Expanded( child: TabBarView( controller: _tabController, children: List.filled( 3, _PublisherPostList( isBusy: _isBusy, postCount: _postCount, posts: _posts, fetchPosts: _fetchPosts, onChanged: (idx, data) { setState(() => _posts[idx] = data); }, onDeleted: () { _posts.clear(); _fetchPosts(); }, ), ), ), ), ], ), ), ); } } class _PublisherPostList extends StatelessWidget { final bool isBusy; final int? postCount; final List posts; final void Function() fetchPosts; final void Function(int index, SnPost data) onChanged; final void Function() onDeleted; const _PublisherPostList({ super.key, required this.isBusy, required this.postCount, required this.posts, required this.fetchPosts, required this.onChanged, required this.onDeleted, }); @override Widget build(BuildContext context) { return InfiniteList( 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) { onChanged(idx, data); }, onDeleted: onDeleted, ), onTap: () { GoRouter.of(context).pushNamed( 'postDetail', pathParameters: {'slug': posts[idx].id.toString()}, extra: posts[idx], ); }, ); }, separatorBuilder: (context, index) => const Divider(height: 1), ); } }