import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../models/nextcloud_share.dart'; import '../providers/files_controller.dart'; import '../providers/shares_controller.dart'; import '../theme/design_tokens.dart'; import '../widgets/noo/core/noo_segmented_control.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; import '../widgets/noo/noo_layout.dart'; import '../widgets/share_sheet.dart'; import '../widgets/tabs/tab_state_slivers.dart'; /// Which slice of [SharesController.shares] is on screen - `With you` maps /// straight onto the controller's own `sharedWithMe: true` fetch, but the /// controller only ever exposes one boolean scope, so `By you` and `Links` /// both use its `sharedWithMe: false` fetch and are told apart client-side /// by [NextcloudShare.shareType] (see `_visibleShares`). `SharesController` /// caches each of its two scopes after its first fetch, so switching /// between any of the three - including back to `With you` after leaving /// it - only re-fetches a scope that's never been loaded yet, not on every /// single switch. enum _ShareScope { withYou, byYou, links } IconData _shareTypeIcon(ShareType type) { switch (type) { case ShareType.user: return LucideIcons.user; case ShareType.group: return LucideIcons.users; case ShareType.publicLink: return LucideIcons.link; case ShareType.email: return LucideIcons.mail; case ShareType.federated: return LucideIcons.globe; case ShareType.other: return LucideIcons.share2; } } /// "Can edit" when any write bit (update/create/delete) is set on the OCS /// share permissions bitmask, "Can view" otherwise - kept to the two /// broad buckets the row's one-line meta has room for. String _permissionLabel(int permissions) { const update = 2, create = 4, delete = 8; final canWrite = permissions & (update | create | delete) != 0; return canWrite ? 'Can edit' : 'Can view'; } class SharesView extends StatefulWidget { final ScrollController scrollController; /// This tab's own shell top bar, planted as its first sliver - see /// `buildAppTabView`'s doc comment. Null on desktop and while picking. final PreferredSizeWidget? topBar; const SharesView({super.key, required this.scrollController, this.topBar}); @override State createState() => _SharesViewState(); } class _SharesViewState extends State { bool _requested = false; // Mirrors `SharesController.sharedWithMe`'s own default (false, "shared // by me") so the first fetch this triggers matches what's shown. _ShareScope _scope = _ShareScope.byYou; @override Widget build(BuildContext context) { final colors = context.nooColors; final sharesController = context.watch(); if (!_requested) { _requested = true; WidgetsBinding.instance.addPostFrameCallback( (_) => sharesController.fetchAll(), ); } final shares = _visibleShares(sharesController); final isDesktop = NooLayout.isDesktop(context); final List contentSlivers = [ if (widget.topBar != null) topBarSliver(widget.topBar!), SliverPadding( padding: EdgeInsets.fromLTRB( NooLayout.gutter(context), NooSpace.md, NooLayout.gutter(context), 0, ), sliver: SliverToBoxAdapter( child: NooSegmentedControl<_ShareScope>( value: _scope, options: const [ NooSegmentOption(value: _ShareScope.withYou, label: 'With you'), NooSegmentOption(value: _ShareScope.byYou, label: 'By you'), NooSegmentOption(value: _ShareScope.links, label: 'Links'), ], onChanged: (scope) => _selectScope(sharesController, scope), ), ), ), const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)), if (sharesController.isLoading && shares.isEmpty) tabLoadingSliver else if (sharesController.errorMessage != null) tabErrorSliver( context, title: 'Could not load shares', message: sharesController.errorMessage!, onRetry: sharesController.fetchAll, ) else if (shares.isEmpty) tabEmptySliver( context, icon: LucideIcons.share2, message: _emptyMessage(), ) else if (isDesktop) _buildDesktopTable(context, shares) else _buildMobileList(context, shares), ...tabBottomInsetSlivers(context), ]; return ColoredBox( color: colors.bg, child: RefreshIndicator( color: colors.accent, backgroundColor: colors.surface, onRefresh: sharesController.fetchAll, child: CustomScrollView( controller: widget.scrollController, // See files_view.dart's identical fix - without this, pull-to- // refresh can't be triggered on an empty or single-item list. physics: const AlwaysScrollableScrollPhysics(), slivers: contentSlivers, ), ), ); } List _visibleShares(SharesController c) { switch (_scope) { case _ShareScope.withYou: return c.shares; case _ShareScope.byYou: return c.shares .where((s) => s.shareType != ShareType.publicLink) .toList(); case _ShareScope.links: return c.shares .where((s) => s.shareType == ShareType.publicLink) .toList(); } } String _emptyMessage() { switch (_scope) { case _ShareScope.withYou: return 'Nothing has been shared with you'; case _ShareScope.byYou: return "You haven't shared anything yet"; case _ShareScope.links: return 'No share links yet'; } } void _selectScope(SharesController c, _ShareScope scope) { setState(() => _scope = scope); // No-ops (see `SharesController.setSharedWithMe`) when moving between // `byYou`/`links`, which share the same `sharedWithMe: false` fetch. c.setSharedWithMe(scope == _ShareScope.withYou); } // A lazily-built `SliverList`, not `NooGroupedList` (its own `Column` // isn't lazy - see files_view.dart's `_buildMobileRow` doc comment, and // trash_view.dart's `_buildMobileList`, which had the same bug: a heavy // account's full share list built eagerly up front). Each row still // reads as one continuous radius-20 card via per-row corner rounding + // a 1px `line` divider. Widget _buildMobileList(BuildContext context, List shares) { final colors = context.nooColors; return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final share = shares[index]; final row = NooFileRow( kind: NooFileKind.from( name: share.name, isDirectory: share.isFolder, ), name: share.name, meta: '${share.ownerDisplayName} ยท ${_permissionLabel(share.permissions)}', iosStyle: NooLayout.iosStyle(context), onTap: () => _openShareSheet(context, share), trailing: Icon(_shareTypeIcon(share.shareType), size: 16), ); final isFirst = index == 0; final isLast = index == shares.length - 1; return Column( children: [ ClipRRect( borderRadius: BorderRadius.vertical( top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero, bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero, ), child: row, ), if (!isLast) Container(height: 1, color: colors.line), ], ); }, childCount: shares.length), ), ); } Widget _buildDesktopTable(BuildContext context, List shares) { return SliverPadding( padding: const EdgeInsets.symmetric( horizontal: NooSpace.xl, vertical: NooSpace.xs, ), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { if (index == 0) { return const NooFileTableHeader( col2Label: 'Owner', col3Label: 'Permission', ); } final share = shares[index - 1]; return NooFileTableRow( kind: NooFileKind.from( name: share.name, isDirectory: share.isFolder, ), name: share.name, col2: share.ownerDisplayName, col3: _permissionLabel(share.permissions), onTap: () => _openShareSheet(context, share), ); }, childCount: shares.length + 1), ), ); } /// Every row's whole-row tap - a share only carries enough metadata for /// its own row, not the size/dates the Share sheet's header needs, so /// this fetches the real item first (see `FilesController.fetchItemAtPath`) /// rather than opening the sheet straight from [share]. Reused by every /// scope (With you/By you/Links) - the Share sheet is the one place that /// already knows how to copy a link or remove access, so there's no /// separate overflow menu here any more. Future _openShareSheet( BuildContext context, NextcloudShare share, ) async { final files = context.read(); final messenger = ScaffoldMessenger.of(context); final item = await files.fetchItemAtPath(share.path); if (!context.mounted) return; if (item == null) { messenger.showSnackBar( const SnackBar( content: Text('Could not find this item - it may have moved'), behavior: SnackBarBehavior.floating, ), ); return; } await ShareSheet.show(context, item); // The sheet manages shares through `ItemOperations`, not this tab's own // `SharesController` (see its `deleteShare`'s doc comment) - refetch so // a share added/removed from inside it doesn't leave this list stale. if (context.mounted) context.read().fetchAll(); } }