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