import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/errors/error_mapper.dart'; import '../../../../api/marianumcloud/files_sharing/file_sharing_api.dart'; import '../../../../api/marianumcloud/files_sharing/file_sharing_api_params.dart'; import '../../../../api/marianumcloud/files_sharing/ocs_path.dart'; import '../../../../api/marianumcloud/files_sharing/queries/share/share.dart'; import '../../../../api/marianumcloud/files_sharing/share_permissions.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import '../../../../utils/clipboard_helper.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/info_dialog.dart'; import '../widgets/file_leading.dart'; import 'share_options_sheet.dart'; import 'share_password_sheet.dart'; /// Opens the sharing sheet for a file/folder: lists existing shares and offers /// gated actions to create new ones (public link, user/group). Capability /// gating mirrors the Nextcloud web UI. void showShareSheet(BuildContext context, CacheableFile file) { showDetailsBottomSheet( context, header: ListTile( leading: CenteredLeading(FileLeading(file: file)), title: Text(file.name), subtitle: const Text('Freigeben'), ), children: (sheetCtx) => [_ShareSheetBody(file: file)], ); } class _ShareSheetBody extends StatefulWidget { final CacheableFile file; const _ShareSheetBody({required this.file}); @override State<_ShareSheetBody> createState() => _ShareSheetBodyState(); } class _ShareSheetBodyState extends State<_ShareSheetBody> { late final String _ocsPath = ocsPathOf(widget.file); Future>? _future; bool _busy = false; /// Last resolved share list — used by the create-link gate without depending /// on the FutureBuilder's snapshot. List? _lastShares; @override void initState() { super.initState(); _future = FileSharingApi().listForPath(_ocsPath); } void _reload() { // Block body: an arrow would return the Future, which setState rejects. setState(() { _future = FileSharingApi().listForPath(_ocsPath); }); } Future _runCreate(FileSharingApiParams params) async { setState(() => _busy = true); try { await FileSharingApi().share(params); if (!mounted) return; _reload(); } catch (e) { if (mounted) { InfoDialog.show( context, errorToUserMessage(e), title: 'Freigabe fehlgeschlagen', copyable: true, ); } } finally { if (mounted) setState(() => _busy = false); } } Future _createPublicLink() async { final caps = context.read(); String? password; if (caps.passwordEnforced) { // The server requires a password — collect it before creating, otherwise // the call is rejected. password = await promptSharePassword( context, isChange: false, policyHint: caps.passwordPolicyHint, ); if (password == null || password.isEmpty || !mounted) return; } await _runCreate( FileSharingApiParams( shareType: kShareTypePublicLink, shareWith: '', path: _ocsPath, permissions: kPermissionRead, password: password, ), ); } Future _addSharee() async { final caps = context.read(); final sharee = await AppRoutes.openShareePicker( context, allowUsers: caps.canShareWithUsers, allowGroups: caps.canShareWithGroups, ); if (sharee == null || !mounted) return; await _runCreate( FileSharingApiParams( shareType: sharee.shareType, shareWith: sharee.shareWith, path: _ocsPath, permissions: kPermissionRead, ), ); } @override Widget build(BuildContext context) { final caps = context.watch(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ if (_busy) const LinearProgressIndicator(), _shareList(), const Divider(height: 1), if (caps.canShareWithUsers || caps.canShareWithGroups) ListTile( leading: const CenteredLeading(Icon(Icons.person_add_outlined)), title: const Text('Person oder Gruppe hinzufügen'), enabled: !_busy, onTap: _busy ? null : _addSharee, ), if (caps.canCreatePublicLinks && _canAddLink(caps)) ListTile( leading: const CenteredLeading(Icon(Icons.add_link)), title: const Text('Öffentlichen Link erstellen'), enabled: !_busy, onTap: _busy ? null : _createPublicLink, ), ], ); } void _openInTalk(Share share) { final token = share.shareWith; if (token == null || token.isEmpty) return; AppRoutes.openChatByToken(context, token); if (Navigator.of(context).canPop()) Navigator.of(context).pop(); } /// Whether another public link may be created — hidden once one exists on a /// server that disallows multiple links. bool _canAddLink(NextcloudCapabilitiesCubit caps) { if (caps.allowsMultipleLinks) return true; final hasLink = _lastShares?.any((s) => s.isPublicLink) ?? false; return !hasLink; } Widget _shareList() { return FutureBuilder>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Padding( padding: EdgeInsets.all(24), child: Center(child: AppProgressIndicator.medium()), ); } if (snapshot.hasError) { return Padding( padding: const EdgeInsets.all(24), child: Column( children: [ Text( errorToUserMessage(snapshot.error), textAlign: TextAlign.center, ), const SizedBox(height: 12), FilledButton.tonal( onPressed: _reload, child: const Text('Erneut versuchen'), ), ], ), ); } final shares = snapshot.data ?? const []; _lastShares = shares; if (shares.isEmpty) { final theme = Theme.of(context); return SizedBox( height: 160, child: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.lock_outline, size: 48, color: theme.colorScheme.outline, ), const SizedBox(height: 12), Text( 'Diese Datei ist noch nicht freigegeben.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ), ); } return Column( mainAxisSize: MainAxisSize.min, children: shares.map(_shareTile).toList(), ); }, ); } Widget _shareTile(Share share) { final preset = presetFromBitmask(share.permissions); final subtitleParts = [ // For links the URL already identifies the row; for everyone else show // the kind so a Talk share isn't mistaken for a plain person share. if (share.isPublicLink && share.url != null) share.url! else share.kindLabel, if (preset != null) preset.label, if (share.hasPassword) 'passwortgeschützt', if (share.expiration != null) 'bis ${share.expiration}', ]; return ListTile( leading: CenteredLeading(Icon(shareIcon(share))), title: Text(share.displayTitle), subtitle: subtitleParts.isEmpty ? null : Text( subtitleParts.join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (share.isPublicLink && share.url != null) IconButton( icon: const Icon(Icons.copy_outlined), tooltip: 'Link kopieren', onPressed: () => copyToClipboard(context, share.url!), ), if (share.isRoom && (share.shareWith?.isNotEmpty ?? false)) IconButton( icon: const Icon(Icons.chat_bubble_outline), tooltip: 'Im Talk-Chat öffnen', onPressed: () => _openInTalk(share), ), IconButton( icon: const Icon(Icons.settings_outlined), tooltip: 'Freigabe bearbeiten', onPressed: () => showShareOptionsSheet(context, share, onChanged: _reload), ), ], ), ); } }