import 'package:dio/dio.dart'; import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/post.dart'; import 'package:island/models/publisher.dart'; import 'package:island/pods/network.dart'; import 'package:island/screens/creators/publishers_form.dart'; import 'package:island/services/responsive.dart'; import 'package:island/utils/text.dart'; import 'package:island/widgets/account/account_picker.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/response.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:styled_widget/styled_widget.dart'; part 'hub.g.dart'; @riverpod Future publisherStats(Ref ref, String? uname) async { if (uname == null) return null; final apiClient = ref.watch(apiClientProvider); final resp = await apiClient.get('/sphere/publishers/$uname/stats'); return SnPublisherStats.fromJson(resp.data); } @riverpod Future publisherIdentity(Ref ref, String uname) async { try { final apiClient = ref.watch(apiClientProvider); final response = await apiClient.get( '/sphere/publishers/$uname/members/me', ); return SnPublisherMember.fromJson(response.data); } catch (err) { if (err is DioException && err.response?.statusCode == 404) { return null; // No identity found, user is not a member } rethrow; } } @riverpod Future> publisherFeatures(Ref ref, String? uname) async { if (uname == null) return {}; final apiClient = ref.watch(apiClientProvider); final response = await apiClient.get('/sphere/publishers/$uname/features'); return Map.from(response.data); } @riverpod Future> publisherInvites(Ref ref) async { final client = ref.watch(apiClientProvider); final resp = await client.get('/sphere/publishers/invites'); return resp.data .map((e) => SnPublisherMember.fromJson(e)) .cast() .toList(); } @riverpod class PublisherMemberListNotifier extends _$PublisherMemberListNotifier with CursorPagingNotifierMixin { static const int _pageSize = 20; @override Future> build(String uname) async { return fetch(); } @override Future> fetch({String? cursor}) async { final apiClient = ref.read(apiClientProvider); final offset = cursor != null ? int.parse(cursor) : 0; final response = await apiClient.get( '/sphere/publishers/$uname/members', queryParameters: {'offset': offset, 'take': _pageSize}, ); final total = int.parse(response.headers.value('X-Total') ?? '0'); final List data = response.data; final members = data.map((e) => SnPublisherMember.fromJson(e)).toList(); final hasMore = offset + members.length < total; final nextCursor = hasMore ? (offset + members.length).toString() : null; return CursorPagingData( items: members, hasMore: hasMore, nextCursor: nextCursor, ); } } class PublisherSelector extends StatelessWidget { final SnPublisher? currentPublisher; final List> publishersMenu; final ValueChanged? onChanged; final bool isReadOnly; const PublisherSelector({ super.key, required this.currentPublisher, required this.publishersMenu, this.onChanged, this.isReadOnly = false, }); @override Widget build(BuildContext context) { if (isReadOnly || currentPublisher == null) { return ProfilePictureWidget( radius: 16, fileId: currentPublisher?.picture?.id, ).center().padding(right: 8); } return DropdownButtonHideUnderline( child: DropdownButton2( value: currentPublisher, hint: CircleAvatar( radius: 16, child: Icon( Symbols.person, color: Theme.of( context, ).colorScheme.onSecondaryContainer.withOpacity(0.9), fill: 1, ), ).center().padding(right: 8), items: publishersMenu, onChanged: onChanged, selectedItemBuilder: (context) { return publishersMenu .map( (e) => ProfilePictureWidget( radius: 16, fileId: e.value?.picture?.id, ).center().padding(right: 8), ) .toList(); }, buttonStyleData: ButtonStyleData( height: 40, padding: const EdgeInsets.only(left: 14, right: 8), decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)), ), dropdownStyleData: DropdownStyleData( width: 320, padding: const EdgeInsets.symmetric(vertical: 6), decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)), ), menuItemStyleData: const MenuItemStyleData( height: 64, padding: EdgeInsets.only(left: 14, right: 14), ), iconStyleData: IconStyleData( icon: Icon(Icons.arrow_drop_down), iconSize: 19, iconEnabledColor: Theme.of(context).appBarTheme.foregroundColor!, iconDisabledColor: Theme.of(context).appBarTheme.foregroundColor!, ), ), ); } } class _PublisherUnselectedWidget extends HookConsumerWidget { final ValueChanged onPublisherSelected; const _PublisherUnselectedWidget({required this.onPublisherSelected}); @override Widget build(BuildContext context, WidgetRef ref) { final publishers = ref.watch(publishersManagedProvider); final publisherInvites = ref.watch(publisherInvitesProvider); final hasPublishers = publishers.value?.isNotEmpty ?? false; return Card( margin: const EdgeInsets.all(16), child: Column( children: [ if (!hasPublishers) ...[ const Icon( Symbols.info, fill: 1, size: 32, ).padding(bottom: 6, top: 24), Text( 'creatorHubUnselectedHint', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ).tr(), const Gap(24), ], if (hasPublishers) ...(publishers.value?.map( (publisher) => ListTile( shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(8)), ), leading: ProfilePictureWidget(file: publisher.picture), title: Text(publisher.nick), subtitle: Text('@${publisher.name}'), onTap: () => onPublisherSelected(publisher), ), ) ?? []), const Divider(height: 1), ListTile( shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(8)), ), leading: const CircleAvatar(child: Icon(Symbols.mail)), title: Text('publisherCollabInvitation').tr(), subtitle: Text( 'publisherCollabInvitationCount', ).plural(publisherInvites.value?.length ?? 0), trailing: const Icon(Symbols.chevron_right), onTap: () { showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => const _PublisherInviteSheet(), ); }, ), ListTile( shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(8)), ), leading: const CircleAvatar(child: Icon(Symbols.add)), title: Text('createPublisher').tr(), subtitle: Text('createPublisherHint').tr(), trailing: const Icon(Symbols.chevron_right), onTap: () { context.pushNamed('creatorNew').then((value) { if (value != null) { ref.invalidate(publishersManagedProvider); } }); }, ), ], ), ); } } class CreatorHubScreen extends HookConsumerWidget { const CreatorHubScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final publishers = ref.watch(publishersManagedProvider); final currentPublisher = useState( publishers.value?.firstOrNull, ); void updatePublisher() { context .pushNamed( 'creatorEdit', pathParameters: {'name': currentPublisher.value!.name}, ) .then((value) async { if (value == null) return; final data = await ref.refresh(publishersManagedProvider.future); currentPublisher.value = data .where((e) => e.id == currentPublisher.value!.id) .firstOrNull; }); } void deletePublisher() { showConfirmAlert('deletePublisherHint'.tr(), 'deletePublisher'.tr()).then( (confirm) { if (confirm) { final client = ref.watch(apiClientProvider); client.delete('/sphere/publishers/${currentPublisher.value!.name}'); ref.invalidate(publishersManagedProvider); currentPublisher.value = null; } }, ); } final List> publishersMenu = publishers.when( data: (data) => data .map( (item) => DropdownMenuItem( value: item, child: ListTile( minTileHeight: 48, leading: ProfilePictureWidget( radius: 16, fileId: item.picture?.id, ), title: Text(item.nick), subtitle: Text('@${item.name}'), trailing: currentPublisher.value?.id == item.id ? const Icon(Icons.check) : null, contentPadding: EdgeInsets.symmetric(horizontal: 8), ), ), ) .toList(), loading: () => [], error: (_, _) => [], ); final publisherStats = ref.watch( publisherStatsProvider(currentPublisher.value?.name), ); final publisherFeatures = ref.watch( publisherFeaturesProvider(currentPublisher.value?.name), ); Widget buildNavigationWidget(bool isWide) { final leftItems = [ ListTile( shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(8)), ), minTileHeight: 48, title: Text('stickers').tr(), trailing: Icon(Symbols.chevron_right), leading: const Icon(Symbols.ar_stickers), onTap: () { context.pushNamed( 'creatorStickers', pathParameters: {'name': currentPublisher.value!.name}, ); }, ), ListTile( shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(8)), ), minTileHeight: 48, title: Text('posts').tr(), trailing: Icon(Symbols.chevron_right), leading: const Icon(Symbols.sticky_note_2), onTap: () { context.pushNamed( 'creatorPosts', pathParameters: {'name': currentPublisher.value!.name}, ); }, ), ListTile( shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(8)), ), minTileHeight: 48, title: Text('polls').tr(), trailing: const Icon(Symbols.chevron_right), leading: const Icon(Symbols.poll), onTap: () { context.pushNamed( 'creatorPolls', pathParameters: {'name': currentPublisher.value!.name}, ); }, ), ListTile( shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(8)), ), minTileHeight: 48, title: const Text('webFeeds').tr(), trailing: const Icon(Symbols.chevron_right), leading: const Icon(Symbols.rss_feed), onTap: () { context.push('/creators/${currentPublisher.value!.name}/feeds'); }, ), ]; final rightItems = [ ListTile( shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(8)), ), minTileHeight: 48, title: Text('publisherMembers').tr(), trailing: const Icon(Symbols.chevron_right), leading: const Icon(Symbols.group), onTap: () { showModalBottomSheet( isScrollControlled: true, context: context, builder: (context) => _PublisherMemberListSheet( publisherUname: currentPublisher.value!.name, ), ); }, ), ExpansionTile( shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(8)), ), title: Text('publisherFeatures').tr(), leading: const Icon(Symbols.flag), tilePadding: const EdgeInsets.only(left: 16, right: 24), minTileHeight: 48, children: [ ...publisherFeatures.when( data: (data) { return data.entries.map((entry) { final keyPrefix = 'publisherFeature${entry.key.capitalizeEachWord()}'; return ListTile( minTileHeight: 48, contentPadding: const EdgeInsets.symmetric(horizontal: 16), leading: Icon( Symbols.circle, color: entry.value ? Colors.green : Colors.red, fill: 1, size: 16, ).padding(left: 2, top: 4), title: Text(keyPrefix).tr(), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${keyPrefix}Description').tr(), if (!entry.value) Text('${keyPrefix}Hint').tr().bold(), ], ), isThreeLine: true, ); }).toList(); }, error: (_, _) => [], loading: () => [], ), ], ), ListTile( shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(8)), ), minTileHeight: 48, title: Text('editPublisher').tr(), trailing: Icon(Symbols.chevron_right), leading: const Icon(Symbols.edit), onTap: updatePublisher, ), ListTile( shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(8)), ), minTileHeight: 48, title: Text('deletePublisher').tr(), trailing: Icon(Symbols.chevron_right), leading: const Icon(Symbols.delete), onTap: deletePublisher, ), ]; if (isWide) { return Row( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: [ Expanded( child: Card( margin: EdgeInsets.zero, child: Column(children: leftItems), ), ), Expanded( child: Card( margin: EdgeInsets.zero, child: Column(children: rightItems), ), ), ], ).padding(horizontal: 12); } else { return Card( margin: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [...leftItems, const Divider(height: 8), ...rightItems], ), ); } } return AppScaffold( isNoBackground: false, appBar: AppBar( leading: const PageBackButton(), title: Text('creatorHub').tr(), actions: [ if (!isWideScreen(context)) PublisherSelector( currentPublisher: currentPublisher.value, publishersMenu: publishersMenu, onChanged: (value) { currentPublisher.value = value; }, ), const Gap(8), ], ), body: LayoutBuilder( builder: (context, constraints) { final isWide = isWideScreen(context); final maxWidth = isWide ? 800.0 : double.infinity; return Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), child: publisherStats.when( data: (stats) => SingleChildScrollView( child: currentPublisher.value == null ? ConstrainedBox( constraints: BoxConstraints(maxWidth: 640), child: _PublisherUnselectedWidget( onPublisherSelected: (publisher) { currentPublisher.value = publisher; }, ), ).center() : isWide ? Column( spacing: 8, children: [ PublisherSelector( currentPublisher: currentPublisher.value, publishersMenu: publishersMenu, onChanged: (value) { currentPublisher.value = value; }, ), if (stats != null) _PublisherStatsWidget( stats: stats, ).padding(horizontal: 12), buildNavigationWidget(true), ], ) : Column( spacing: 12, children: [ if (stats != null) _PublisherStatsWidget( stats: stats, ).padding(horizontal: 16), buildNavigationWidget(false), ], ), ), loading: () => const Center(child: CircularProgressIndicator()), error: (_, _) => const SizedBox.shrink(), ), ), ); }, ), ); } } class _PublisherStatsWidget extends StatelessWidget { final SnPublisherStats stats; const _PublisherStatsWidget({required this.stats}); @override Widget build(BuildContext context) { return SingleChildScrollView( child: Column( spacing: 8, children: [ Row( spacing: 8, children: [ Expanded( child: _buildStatsCard( context, stats.postsCreated.toString(), 'postsCreatedCount', ), ), Expanded( child: _buildStatsCard( context, stats.stickerPacksCreated.toString(), 'stickerPacksCreatedCount', ), ), Expanded( child: _buildStatsCard( context, stats.stickersCreated.toString(), 'stickersCreatedCount', ), ), ], ), Row( spacing: 8, children: [ Expanded( child: _buildStatsCard( context, stats.upvoteReceived.toString(), 'upvoteReceived', ), ), Expanded( child: _buildStatsCard( context, stats.downvoteReceived.toString(), 'downvoteReceived', ), ), ], ), ], ), ); } Widget _buildStatsCard( BuildContext context, String statValue, String statLabel, ) { return Card( margin: EdgeInsets.zero, child: SizedBox( height: 100, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), child: Tooltip( richMessage: TextSpan( text: statLabel.tr(), style: TextStyle(fontWeight: FontWeight.bold), children: [ TextSpan(text: ' '), TextSpan( text: statValue, style: TextStyle(fontWeight: FontWeight.normal), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( statValue, style: Theme.of(context).textTheme.headlineMedium, maxLines: 1, overflow: TextOverflow.ellipsis, ), const Gap(4), Text( statLabel, maxLines: 1, overflow: TextOverflow.ellipsis, ).tr(), ], ), ), ), ), ); } } class PublisherMemberState { final List members; final bool isLoading; final int total; final String? error; const PublisherMemberState({ required this.members, required this.isLoading, required this.total, this.error, }); PublisherMemberState copyWith({ List? members, bool? isLoading, int? total, String? error, }) { return PublisherMemberState( members: members ?? this.members, isLoading: isLoading ?? this.isLoading, total: total ?? this.total, error: error ?? this.error, ); } } final publisherMemberStateProvider = StateNotifierProvider.family< PublisherMemberNotifier, PublisherMemberState, String >((ref, publisherUname) { final apiClient = ref.watch(apiClientProvider); return PublisherMemberNotifier(apiClient, publisherUname); }); class PublisherMemberNotifier extends StateNotifier { final String publisherUname; final Dio _apiClient; PublisherMemberNotifier(this._apiClient, this.publisherUname) : super( const PublisherMemberState(members: [], isLoading: false, total: 0), ); Future loadMore({int offset = 0, int take = 20}) async { if (state.isLoading) return; if (state.total > 0 && state.members.length >= state.total) return; state = state.copyWith(isLoading: true, error: null); try { final response = await _apiClient.get( '/sphere/publishers/$publisherUname/members', queryParameters: {'offset': offset, 'take': take}, ); final total = int.parse(response.headers.value('X-Total') ?? '0'); final List data = response.data; final members = data.map((e) => SnPublisherMember.fromJson(e)).toList(); state = state.copyWith( members: [...state.members, ...members], total: total, isLoading: false, ); } catch (e) { state = state.copyWith(error: e.toString(), isLoading: false); } } void reset() { state = const PublisherMemberState(members: [], isLoading: false, total: 0); } } class _PublisherMemberListSheet extends HookConsumerWidget { final String publisherUname; const _PublisherMemberListSheet({required this.publisherUname}); @override Widget build(BuildContext context, WidgetRef ref) { final publisherIdentity = ref.watch( publisherIdentityProvider(publisherUname), ); final memberListProvider = publisherMemberListNotifierProvider( publisherUname, ); final memberState = ref.watch(publisherMemberStateProvider(publisherUname)); final memberNotifier = ref.read( publisherMemberStateProvider(publisherUname).notifier, ); useEffect(() { Future(() { memberNotifier.loadMore(); }); return null; }, []); Future invitePerson() async { final result = await showModalBottomSheet( useRootNavigator: true, isScrollControlled: true, context: context, builder: (context) => const AccountPickerSheet(), ); if (result == null) return; try { final apiClient = ref.watch(apiClientProvider); await apiClient.post( '/publishers/$publisherUname/invites', data: {'related_user_id': result.id, 'role': 0}, ); // Refresh both providers memberNotifier.reset(); await memberNotifier.loadMore(); ref.invalidate(memberListProvider); } catch (err) { showErrorAlert(err); } } return Container( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.8, ), child: Column( children: [ Padding( padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12), child: Row( children: [ Text( 'members'.plural(memberState.total), style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w600, letterSpacing: -0.5, ), ), const Spacer(), IconButton( icon: const Icon(Symbols.person_add), onPressed: invitePerson, style: IconButton.styleFrom(minimumSize: const Size(36, 36)), ), IconButton( icon: const Icon(Symbols.refresh), onPressed: () { memberNotifier.reset(); memberNotifier.loadMore(); ref.invalidate(memberListProvider); }, ), IconButton( icon: const Icon(Symbols.close), onPressed: () => Navigator.pop(context), style: IconButton.styleFrom(minimumSize: const Size(36, 36)), ), ], ), ), const Divider(height: 1), Expanded( child: PagingHelperView( provider: memberListProvider, futureRefreshable: memberListProvider.future, notifierRefreshable: memberListProvider.notifier, contentBuilder: (data, widgetCount, endItemView) { return ListView.builder( itemCount: widgetCount, itemBuilder: (context, index) { if (index == data.items.length) { return endItemView; } final member = data.items[index]; return ListTile( contentPadding: EdgeInsets.only(left: 16, right: 12), leading: ProfilePictureWidget( fileId: member.account!.profile.picture?.id, ), title: Row( spacing: 6, children: [ Flexible(child: Text(member.account!.nick)), if (member.joinedAt == null) const Icon(Symbols.pending_actions, size: 20), ], ), subtitle: Row( children: [ Text( member.role >= 100 ? 'permissionOwner' : member.role >= 50 ? 'permissionModerator' : 'permissionMember', ).tr(), Text('ยท').bold().padding(horizontal: 6), Expanded(child: Text("@${member.account!.name}")), ], ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if ((publisherIdentity.value?.role ?? 0) >= 50) IconButton( icon: const Icon(Symbols.edit), onPressed: () { showModalBottomSheet( isScrollControlled: true, context: context, builder: (context) => _PublisherMemberRoleSheet( publisherUname: publisherUname, member: member, ), ).then((value) { if (value != null) { // Refresh both providers memberNotifier.reset(); memberNotifier.loadMore(); ref.invalidate(memberListProvider); } }); }, ), if ((publisherIdentity.value?.role ?? 0) >= 50) IconButton( icon: const Icon(Symbols.delete), onPressed: () { showConfirmAlert( 'removePublisherMemberHint'.tr(), 'removePublisherMember'.tr(), ).then((confirm) async { if (confirm != true) return; try { final apiClient = ref.watch( apiClientProvider, ); await apiClient.delete( '/publishers/$publisherUname/members/${member.accountId}', ); // Refresh both providers memberNotifier.reset(); memberNotifier.loadMore(); ref.invalidate(memberListProvider); } catch (err) { showErrorAlert(err); } }); }, ), ], ), ); }, ); }, ), ), ], ), ); } } class _PublisherMemberRoleSheet extends HookConsumerWidget { final String publisherUname; final SnPublisherMember member; const _PublisherMemberRoleSheet({ required this.publisherUname, required this.member, }); @override Widget build(BuildContext context, WidgetRef ref) { final roleController = useTextEditingController( text: member.role.toString(), ); return Container( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: EdgeInsets.only( top: 16, left: 20, right: 16, bottom: 12, ), child: Row( children: [ Text( 'memberRoleEdit'.tr(args: [member.account!.name]), style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w600, letterSpacing: -0.5, ), ), const Spacer(), IconButton( icon: const Icon(Symbols.close), onPressed: () => Navigator.pop(context), style: IconButton.styleFrom( minimumSize: const Size(36, 36), ), ), ], ), ), const Divider(height: 1), Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Autocomplete( optionsBuilder: (TextEditingValue textEditingValue) { if (textEditingValue.text.isEmpty) { return const [100, 50, 0]; } final int? value = int.tryParse(textEditingValue.text); if (value == null) return const [100, 50, 0]; return [100, 50, 0].where( (option) => option.toString().contains(textEditingValue.text), ); }, onSelected: (int selection) { roleController.text = selection.toString(); }, fieldViewBuilder: ( context, controller, focusNode, onFieldSubmitted, ) { return TextField( controller: controller, focusNode: focusNode, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: 'memberRole'.tr(), helperText: 'memberRoleHint'.tr(), ), onTapOutside: (event) => focusNode.unfocus(), ); }, ), const Gap(16), FilledButton.icon( onPressed: () async { try { final newRole = int.parse(roleController.text); if (newRole < 0 || newRole > 100) { throw 'Role must be between 0 and 100'; } final apiClient = ref.read(apiClientProvider); await apiClient.patch( '/publishers/$publisherUname/members/${member.accountId}/role', data: newRole, ); if (context.mounted) Navigator.pop(context, true); } catch (err) { showErrorAlert(err); } }, icon: const Icon(Symbols.save), label: const Text('saveChanges').tr(), ), ], ).padding(vertical: 16, horizontal: 24), ], ), ), ); } } class _PublisherInviteSheet extends HookConsumerWidget { const _PublisherInviteSheet(); @override Widget build(BuildContext context, WidgetRef ref) { final invites = ref.watch(publisherInvitesProvider); Future acceptInvite(SnPublisherMember invite) async { try { final client = ref.read(apiClientProvider); await client.post( '/publishers/invites/${invite.publisher!.name}/accept', ); ref.invalidate(publisherInvitesProvider); ref.invalidate(publishersManagedProvider); } catch (err) { showErrorAlert(err); } } Future declineInvite(SnPublisherMember invite) async { try { final client = ref.read(apiClientProvider); await client.post( '/publishers/invites/${invite.publisher!.name}/decline', ); ref.invalidate(publisherInvitesProvider); } catch (err) { showErrorAlert(err); } } return SheetScaffold( titleText: 'invites'.tr(), actions: [ IconButton( icon: const Icon(Symbols.refresh), style: IconButton.styleFrom(minimumSize: const Size(36, 36)), onPressed: () { ref.invalidate(publisherInvitesProvider); }, ), ], child: invites.when( data: (items) => items.isEmpty ? Center( child: Text( 'invitesEmpty', textAlign: TextAlign.center, ).tr(), ) : ListView.builder( shrinkWrap: true, itemCount: items.length, itemBuilder: (context, index) { final invite = items[index]; return ListTile( leading: ProfilePictureWidget( fileId: invite.publisher!.picture?.id, fallbackIcon: Symbols.group, ), title: Text(invite.publisher!.nick), subtitle: Text( invite.role >= 100 ? 'permissionOwner' : invite.role >= 50 ? 'permissionModerator' : 'permissionMember', ).tr(), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Symbols.check), onPressed: () => acceptInvite(invite), ), IconButton( icon: const Icon(Symbols.close), onPressed: () => declineInvite(invite), ), ], ), ); }, ), loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => ResponseErrorWidget( error: error, onRetry: () => ref.invalidate(publisherInvitesProvider), ), ), ); } }