implemented a comprehensive Nextcloud file sharing system with support for user, group, and public link shares with gating based on server-side permissions; added sharing management interfaces including a share sheet; updated the file list with visual badges for incoming shares and improved OCS API response handling.

This commit is contained in:
2026-06-02 21:42:08 +02:00
parent b6d06dd3b4
commit baa26a6e79
33 changed files with 2453 additions and 29 deletions
@@ -0,0 +1,288 @@
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<List<Share>>? _future;
bool _busy = false;
/// Last resolved share list — used by the create-link gate without depending
/// on the FutureBuilder's snapshot.
List<Share>? _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<void> _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<void> _createPublicLink() async {
final caps = context.read<NextcloudCapabilitiesCubit>();
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<void> _addSharee() async {
final caps = context.read<NextcloudCapabilitiesCubit>();
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<NextcloudCapabilitiesCubit>();
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<List<Share>>(
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 <Share>[];
_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 = <String>[
// 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),
),
],
),
);
}
}