import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import '../models/nextcloud_item.dart'; import '../models/nextcloud_share.dart'; import '../models/nextcloud_sharee.dart'; import '../providers/server_provider.dart'; import 'details/details_sheet.dart' show DetailsHeader; import 'gradual_bottom_sheet.dart'; IconData _shareTypeIcon(ShareType type) { switch (type) { case ShareType.user: return Icons.person_rounded; case ShareType.group: return Icons.groups_rounded; case ShareType.publicLink: return Icons.link_rounded; case ShareType.email: return Icons.email_rounded; case ShareType.federated: return Icons.public_rounded; case ShareType.other: return Icons.share_rounded; } } /// Builds the "internal link" URL for a file, or null if [fileId] isn't a /// numeric Nextcloud file id (WebDAV `oc:fileid` was missing for this item). String? internalLinkFor(String serverUrl, String fileId) { if (int.tryParse(fileId) == null) return null; var url = serverUrl.trim(); if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'https://$url'; } if (url.endsWith('/')) url = url.substring(0, url.length - 1); return '$url/index.php/f/$fileId'; } /// Standalone "Share" bottom sheet for a single file/folder — replicates /// the web client's Sharing panel: search users/teams, "Others with /// access" (inherited shares), an internal link to copy, an email-share /// field, and a public link. Every "Share" action in the app (swipe, /// multi-select, media viewer) opens this same sheet. class ShareSheet extends StatefulWidget { final NextcloudItem item; final ScrollController? scrollController; const ShareSheet({super.key, required this.item, this.scrollController}); static Future show(BuildContext context, NextcloudItem item) { return showGradualBottomSheet( context, builder: (context, scrollController) => ShareSheet(item: item, scrollController: scrollController), ); } @override State createState() => _ShareSheetState(); } class _ShareSheetState extends State { bool _requested = false; bool _isLoading = true; List _shares = []; List _inherited = []; List _searchResults = []; bool _isSearching = false; bool _isAddingEmail = false; bool _isSharingFile = false; final _searchController = TextEditingController(); final _emailController = TextEditingController(); @override void dispose() { _searchController.dispose(); _emailController.dispose(); super.dispose(); } Future _load() async { final provider = context.read(); final results = await Future.wait([ provider.fetchItemShares(widget.item), provider.fetchInheritedShares(widget.item), ]); if (!mounted) return; setState(() { _shares = results[0]; _inherited = results[1]; _isLoading = false; }); } Future _search(String query) async { final trimmed = query.trim(); if (trimmed.isEmpty) { setState(() => _searchResults = []); return; } setState(() => _isSearching = true); final provider = context.read(); final results = await provider.searchSharees(trimmed); if (!mounted) return; setState(() { _searchResults = results; _isSearching = false; }); } Future _addSharee(NextcloudSharee sharee) async { final provider = context.read(); final messenger = ScaffoldMessenger.of(context); final share = await provider.createShare( path: widget.item.path, shareType: sharee.shareTypeValue, shareWith: sharee.shareWith, ); if (!mounted) return; if (share == null) { messenger.showSnackBar( const SnackBar( content: Text('Could not share this item'), behavior: SnackBarBehavior.floating, ), ); return; } setState(() { _shares = [..._shares, share]; _searchResults = []; _searchController.clear(); }); messenger.showSnackBar( SnackBar( content: Text('Shared with ${sharee.label}'), behavior: SnackBarBehavior.floating, ), ); } Future _addEmailShare() async { final email = _emailController.text.trim(); if (email.isEmpty) return; setState(() => _isAddingEmail = true); final provider = context.read(); final messenger = ScaffoldMessenger.of(context); final share = await provider.createShare( path: widget.item.path, shareType: 4, shareWith: email, ); if (!mounted) return; if (share == null) { setState(() => _isAddingEmail = false); messenger.showSnackBar( const SnackBar( content: Text('Could not create email share'), behavior: SnackBarBehavior.floating, ), ); return; } setState(() { _shares = [..._shares, share]; _emailController.clear(); _isAddingEmail = false; }); messenger.showSnackBar( SnackBar( content: Text('Shared with $email'), behavior: SnackBarBehavior.floating, ), ); } Future _createPublicLink() async { final provider = context.read(); final messenger = ScaffoldMessenger.of(context); final share = await provider.createShare( path: widget.item.path, shareType: 3, ); if (!mounted) return; if (share == null) { messenger.showSnackBar( const SnackBar( content: Text('Could not create public link'), behavior: SnackBarBehavior.floating, ), ); return; } setState(() => _shares = [..._shares, share]); } Future _removeShare(NextcloudShare share) async { final provider = context.read(); final messenger = ScaffoldMessenger.of(context); final success = await provider.deleteShare(share); if (!mounted) return; if (success) { setState(() => _shares = _shares.where((s) => s.id != share.id).toList()); } else { messenger.showSnackBar( const SnackBar( content: Text('Could not remove share'), behavior: SnackBarBehavior.floating, ), ); } } void _copy(String text, String label) { Clipboard.setData(ClipboardData(text: text)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Copied $label'), behavior: SnackBarBehavior.floating, ), ); } /// Downloads the item to a scratch file and hands it to Android's native /// "Share to..." sheet (`share_plus`) - a completely different action /// from the rest of this sheet (which shares *within* Nextcloud, via /// users/links) and the only one here that reads the file's actual /// bytes. Future _shareFileDirectly() async { final provider = context.read(); if (provider.service == null) return; setState(() => _isSharingFile = true); final messenger = ScaffoldMessenger.of(context); try { final tempDir = await getTemporaryDirectory(); final tempPath = p.join(tempDir.path, widget.item.name); await provider.service!.downloadToFile(widget.item.path, tempPath); if (!mounted) return; await SharePlus.instance.share( ShareParams( files: [XFile(tempPath)], fileNameOverrides: [widget.item.name], ), ); } catch (_) { if (mounted) { messenger.showSnackBar( const SnackBar( content: Text('Could not share the file'), behavior: SnackBarBehavior.floating, ), ); } } finally { if (mounted) setState(() => _isSharingFile = false); } } Widget _sectionHeader(String label) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Row( children: [ Text( label, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(width: 6), Icon( Icons.info_outline_rounded, size: 16, color: colorScheme.onSurfaceVariant, ), ], ); } Widget _shareRow( NextcloudShare share, { bool removable = true, bool copyUrl = false, }) { final colorScheme = Theme.of(context).colorScheme; final label = switch (share.shareType) { ShareType.publicLink => 'Public link', _ => share.sharedWithDisplayName ?? 'Shared', }; return ListTile( contentPadding: EdgeInsets.zero, leading: Icon( _shareTypeIcon(share.shareType), color: colorScheme.primary, ), title: Text(label), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (copyUrl && share.url != null) IconButton( icon: const Icon(Icons.copy_rounded), tooltip: 'Copy link', onPressed: () => _copy(share.url!, 'link'), ), if (removable) IconButton( icon: Icon(Icons.close_rounded, color: colorScheme.error), tooltip: 'Remove', onPressed: () => _removeShare(share), ), ], ), ); } @override Widget build(BuildContext context) { if (!_requested) { _requested = true; WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } final theme = Theme.of(context); final colorScheme = theme.colorScheme; final provider = context.watch(); if (_isLoading) { return const Center(child: CircularProgressIndicator()); } final userGroupShares = _shares .where( (s) => s.shareType == ShareType.user || s.shareType == ShareType.group, ) .toList(); final publicLinkShares = _shares .where((s) => s.shareType == ShareType.publicLink) .toList(); final emailShares = _shares .where((s) => s.shareType == ShareType.email) .toList(); final internalLink = internalLinkFor(provider.serverUrl, widget.item.id); return ListView( controller: widget.scrollController, padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), children: [ // Same icon/name/meta header as DetailsSheet, so this and its // sibling per-item bottom sheet open on identical chrome - just a // bare title `Text` here read as under-designed by comparison. DetailsHeader( item: widget.item, padding: const EdgeInsets.only(top: 12, bottom: 8), ), if (!widget.item.isFolder) ...[ const SizedBox(height: 4), ListTile( contentPadding: EdgeInsets.zero, leading: _isSharingFile ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ) : const Icon(Icons.ios_share_rounded), title: const Text('Share file directly'), subtitle: const Text('Send the file itself via another app'), onTap: _isSharingFile ? null : _shareFileDirectly, ), const SizedBox(height: 12), const Divider(), ], const SizedBox(height: 12), _sectionHeader('Internal shares'), const SizedBox(height: 8), TextField( controller: _searchController, decoration: InputDecoration( isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), hintText: 'Type names or teams', prefixIcon: const Icon(Icons.search_rounded), suffixIcon: _isSearching ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), ) : null, border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), filled: true, fillColor: colorScheme.surfaceContainerLow, ), onChanged: _search, ), for (final sharee in _searchResults) ListTile( contentPadding: EdgeInsets.zero, leading: Icon( sharee.type == ShareeType.group ? Icons.groups_rounded : Icons.person_rounded, ), title: Text(sharee.label), subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null, onTap: () => _addSharee(sharee), ), for (final share in userGroupShares) _shareRow(share), if (_inherited.isNotEmpty) ExpansionTile( tilePadding: EdgeInsets.zero, leading: const Icon(Icons.more_horiz_rounded), title: Text('Others with access (${_inherited.length})'), children: [ for (final share in _inherited) _shareRow(share, removable: false), ], ), if (internalLink != null) ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.link_rounded), title: const Text('Internal link'), subtitle: const Text('For people who already have access'), trailing: IconButton( icon: const Icon(Icons.copy_rounded), tooltip: 'Copy link', onPressed: () => _copy(internalLink, 'internal link'), ), ), const SizedBox(height: 20), const Divider(), const SizedBox(height: 12), _sectionHeader('External shares'), const SizedBox(height: 8), TextField( controller: _emailController, keyboardType: TextInputType.emailAddress, enabled: !_isAddingEmail, decoration: InputDecoration( isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), hintText: 'Type an email', prefixIcon: const Icon(Icons.email_rounded), suffixIcon: _isAddingEmail ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), ) : null, border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), filled: true, fillColor: colorScheme.surfaceContainerLow, ), onSubmitted: (_) => _addEmailShare(), ), for (final share in emailShares) _shareRow(share), const SizedBox(height: 8), for (final share in publicLinkShares) _shareRow(share, copyUrl: true), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.link_rounded), title: const Text('Create public link'), trailing: IconButton( icon: const Icon(Icons.add_rounded), tooltip: 'Create public link', onPressed: _createPublicLink, ), ), ], ); } }