300 lines
9.6 KiB
Dart
300 lines
9.6 KiB
Dart
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,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|