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,299 @@
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/queries/share/share.dart';
import '../../../../api/marianumcloud/files_sharing/queries/share/share_update_params.dart';
import '../../../../api/marianumcloud/files_sharing/share_permissions.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/info_dialog.dart';
import 'share_password_sheet.dart';
/// Two-digit zero-padded helper for building ISO `YYYY-MM-DD` strings (the
/// format the OCS API expects for `expireDate`).
String _two(int n) => n.toString().padLeft(2, '0');
String _isoDate(DateTime d) => '${d.year}-${_two(d.month)}-${_two(d.day)}';
IconData shareIcon(Share share) {
if (share.isPublicLink) return Icons.link;
if (share.isGroup) return Icons.groups_outlined;
if (share.isRoom) return Icons.chat_bubble_outline;
if (share.isEmail) return Icons.email_outlined;
return Icons.person_outline;
}
/// Opens the edit/manage sheet for a single [share]. [onChanged] fires after
/// every successful mutation so the parent share list can refresh.
void showShareOptionsSheet(
BuildContext context,
Share share, {
required VoidCallback onChanged,
}) {
showDetailsBottomSheet(
context,
header: ListTile(
leading: CenteredLeading(Icon(shareIcon(share))),
title: Text(share.displayTitle),
subtitle: Text(share.kindLabel),
),
children: (sheetCtx) => [
_ShareOptionsBody(share: share, onChanged: onChanged),
],
);
}
class _ShareOptionsBody extends StatefulWidget {
final Share share;
final VoidCallback onChanged;
const _ShareOptionsBody({required this.share, required this.onChanged});
@override
State<_ShareOptionsBody> createState() => _ShareOptionsBodyState();
}
class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
late Share _share = widget.share;
bool _busy = false;
Future<void> _mutate(Future<Share> Function() action) async {
setState(() => _busy = true);
try {
final updated = await action();
if (!mounted) return;
setState(() => _share = updated);
widget.onChanged();
} catch (e) {
if (mounted) {
InfoDialog.show(
context,
errorToUserMessage(e),
title: 'Fehler',
copyable: true,
);
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
List<SharePreset> _availablePresets() => [
SharePreset.readOnly,
SharePreset.edit,
if (_share.isFolder) SharePreset.fileDrop,
];
Future<void> _setPreset(SharePreset preset) async {
final caps = context.read<NextcloudCapabilitiesCubit>();
await _mutate(
() => FileSharingApi().update(
_share.id,
ShareUpdateParams(
permissions: permissionsFor(
preset,
allowReshare: caps.canReshare,
isFolder: _share.isFolder,
),
),
),
);
}
Future<void> _pickExpiry() async {
final caps = context.read<NextcloudCapabilitiesCubit>();
final now = DateTime.now();
final maxDays = caps.expireDays;
final lastDate = maxDays != null && maxDays > 0
? now.add(Duration(days: maxDays))
: now.add(const Duration(days: 365 * 5));
final picked = await showDatePicker(
context: context,
initialDate: now.add(const Duration(days: 7)),
firstDate: now,
lastDate: lastDate,
);
if (picked == null) return;
await _mutate(
() => FileSharingApi().update(
_share.id,
ShareUpdateParams(expireDate: _isoDate(picked)),
),
);
}
Future<void> _clearExpiry() => _mutate(
() => FileSharingApi().update(
_share.id,
const ShareUpdateParams(expireDate: ''),
),
);
Future<void> _changePassword() async {
final caps = context.read<NextcloudCapabilitiesCubit>();
final value = await promptSharePassword(
context,
isChange: _share.hasPassword,
policyHint: caps.passwordPolicyHint,
);
if (value == null || value.isEmpty || !mounted) return;
await _mutate(
() => FileSharingApi().update(
_share.id,
ShareUpdateParams(password: value),
),
);
}
Future<void> _clearPassword() => _mutate(
() => FileSharingApi().update(
_share.id,
const ShareUpdateParams(password: ''),
),
);
void _openInTalk() {
final token = _share.shareWith;
if (token == null || token.isEmpty) return;
AppRoutes.openChatByToken(context, token);
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
}
Future<void> _confirmDelete() async {
// Use showDialog directly (not asDialog) so we get the AsyncDialogAction's
// `true` result and can close the options sheet afterwards — popping inside
// onConfirmAsync would target the dialog route, not the sheet.
final dialog = ConfirmDialog(
title: 'Freigabe löschen?',
content: 'Die Freigabe wird aufgehoben.',
confirmButton: 'Löschen',
onConfirmAsync: () => FileSharingApi().remove(_share.id),
);
final deleted = await showDialog<bool>(
context: context,
builder: dialog.build,
);
if (deleted != true || !mounted) return;
widget.onChanged();
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final caps = context.watch<NextcloudCapabilitiesCubit>();
final currentPreset = presetFromBitmask(_share.permissions);
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_busy) const LinearProgressIndicator(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text('Berechtigung', style: theme.textTheme.labelLarge),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Wrap(
spacing: 8,
children: _availablePresets()
.map(
(p) => ChoiceChip(
label: Text(p.label),
selected: currentPreset == p,
onSelected: _busy ? null : (_) => _setPreset(p),
),
)
.toList(),
),
),
if (_share.isRoom && (_share.shareWith?.isNotEmpty ?? false)) ...[
const Divider(height: 24),
ListTile(
leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)),
title: const Text('Im Talk-Chat öffnen'),
onTap: _busy ? null : _openInTalk,
),
],
if (_share.isPublicLink) ...[
const Divider(height: 24),
if (_share.url != null)
ListTile(
leading: const CenteredLeading(Icon(Icons.link_outlined)),
title: const Text('Öffentlicher Link'),
subtitle: Text(
_share.url!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
onPressed: () => copyToClipboard(context, _share.url!),
icon: const Icon(Icons.copy_outlined),
),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.event_outlined)),
title: const Text('Ablaufdatum'),
subtitle: Text(_share.expiration ?? 'Kein Ablaufdatum'),
trailing: _share.expiration != null && !caps.expireEnforced
? IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Ablaufdatum entfernen',
onPressed: _busy ? null : _clearExpiry,
)
: null,
onTap: _busy ? null : _pickExpiry,
),
ListTile(
leading: CenteredLeading(
Icon(
_share.hasPassword
? Icons.lock_outline
: Icons.lock_open_outlined,
),
),
title: Text(
_share.hasPassword ? 'Passwortgeschützt' : 'Kein Passwort',
),
subtitle: Text(
caps.passwordEnforced && !_share.hasPassword
? 'Erforderlich tippen zum Festlegen'
: _share.hasPassword
? 'Tippen zum Ändern'
: 'Tippen zum Festlegen',
),
trailing: _share.hasPassword && !caps.passwordEnforced
? IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Passwort entfernen',
onPressed: _busy ? null : _clearPassword,
)
: null,
onTap: _busy ? null : _changePassword,
),
],
const Divider(height: 24),
ListTile(
leading: CenteredLeading(
Icon(Icons.delete_outline, color: theme.colorScheme.error),
),
title: Text(
'Freigabe löschen',
style: TextStyle(color: theme.colorScheme.error),
),
onTap: _busy ? null : _confirmDelete,
),
],
);
}
}