- Every tab's RefreshIndicator+CustomScrollView was missing an explicit AlwaysScrollableScrollPhysics, so pull-to-refresh silently couldn't be triggered once the list was short enough to fit the viewport (empty, or a single item) - the platform default physics only allows the overscroll drag RefreshIndicator listens for once content overflows. - StickyHeaderDelegate (Files'/Photos'/Favorites' pinned controls row) painted its background from Theme.of(context).colorScheme.surface - Flutter's own Material 3 scheme, reseeded by the user's accent color choice - instead of context.nooColors.bg like the rest of the screen, showing as a stray colored cast behind the row. - NooSegmentedControl gains onSurface: true wherever one sits inside a sheet (Files'/Photos' sort and type-filter controls) - it defaulted to the same surface fill as the sheet itself, so the control had no visible boundary showing it as a grouped unit. - Shares tab: removed the per-row "..." overflow menu on every scope (With you/By you/Links); tapping a row now opens the full Share sheet for that item instead, fetched via the new FilesController.fetchItemAtPath (a share only carries enough metadata for its own row, not what the sheet's header needs). - fetchShares() now reuses _shareFromJson instead of its own thinner inline parser, so url/token/permissions/expireDate are actually populated - a public-link share could never show "Copy link" before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
285 lines
10 KiB
Dart
285 lines
10 KiB
Dart
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;
|
|
|
|
const SharesView({super.key, required this.scrollController});
|
|
|
|
@override
|
|
State<SharesView> createState() => _SharesViewState();
|
|
}
|
|
|
|
class _SharesViewState extends State<SharesView> {
|
|
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<SharesController>();
|
|
|
|
if (!_requested) {
|
|
_requested = true;
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => sharesController.fetchAll(),
|
|
);
|
|
}
|
|
|
|
final shares = _visibleShares(sharesController);
|
|
final isDesktop = NooLayout.isDesktop(context);
|
|
|
|
final List<Widget> contentSlivers = [
|
|
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,
|
|
];
|
|
|
|
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<NextcloudShare> _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<NextcloudShare> 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<NextcloudShare> 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<void> _openShareSheet(
|
|
BuildContext context,
|
|
NextcloudShare share,
|
|
) async {
|
|
final files = context.read<FilesController>();
|
|
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<SharesController>().fetchAll();
|
|
}
|
|
}
|