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:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user