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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
|
||||
/// Prompts for a link password in a bottom sheet. Resolves to the entered
|
||||
/// password, or null if the sheet was dismissed without submitting. Used both
|
||||
/// when creating a password-protected link and when setting/changing the
|
||||
/// password of an existing one.
|
||||
Future<String?> promptSharePassword(
|
||||
BuildContext context, {
|
||||
required bool isChange,
|
||||
String? policyHint,
|
||||
}) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
useSafeArea: true,
|
||||
builder: (ctx) =>
|
||||
_SharePasswordSheet(isChange: isChange, policyHint: policyHint),
|
||||
);
|
||||
}
|
||||
|
||||
class _SharePasswordSheet extends StatefulWidget {
|
||||
final bool isChange;
|
||||
final String? policyHint;
|
||||
|
||||
const _SharePasswordSheet({required this.isChange, this.policyHint});
|
||||
|
||||
@override
|
||||
State<_SharePasswordSheet> createState() => _SharePasswordSheetState();
|
||||
}
|
||||
|
||||
class _SharePasswordSheetState extends State<_SharePasswordSheet> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
bool _obscured = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final value = _controller.text.trim();
|
||||
if (value.isEmpty) return;
|
||||
Navigator.of(context).pop(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.isChange ? 'Passwort ändern' : 'Passwort setzen';
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 16 + MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.lock_outline)),
|
||||
title: Text(title),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
obscureText: _obscured,
|
||||
autofocus: true,
|
||||
onSubmitted: (_) => _submit(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Passwort',
|
||||
helperText: widget.policyHint,
|
||||
helperMaxLines: 3,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscured
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
tooltip: _obscured ? 'Anzeigen' : 'Verbergen',
|
||||
onPressed: () => setState(() => _obscured = !_obscured),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FilledButton(
|
||||
onPressed: _submit,
|
||||
child: Text(widget.isChange ? 'Ändern' : 'Setzen'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../api/marianumcloud/autocomplete/autocomplete_api.dart';
|
||||
import '../../../../api/marianumcloud/autocomplete/autocomplete_response.dart';
|
||||
import '../../../../api/marianumcloud/files_sharing/queries/share/share.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../utils/debouncer.dart';
|
||||
import '../../../../utils/haptics.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
|
||||
/// Result of [ShareePickerPage]: a recipient to create a share for.
|
||||
class ShareeRef {
|
||||
final int shareType;
|
||||
final String shareWith;
|
||||
final String label;
|
||||
|
||||
const ShareeRef({
|
||||
required this.shareType,
|
||||
required this.shareWith,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
/// Full-screen search for a user or group to share with. Which kinds are
|
||||
/// offered is gated by [allowUsers]/[allowGroups] (derived from the Nextcloud
|
||||
/// sharing capabilities at the call site).
|
||||
class ShareePickerPage extends StatefulWidget {
|
||||
final bool allowUsers;
|
||||
final bool allowGroups;
|
||||
|
||||
const ShareePickerPage({
|
||||
required this.allowUsers,
|
||||
required this.allowGroups,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShareePickerPage> createState() => _ShareePickerPageState();
|
||||
}
|
||||
|
||||
class _ShareePickerPageState extends State<ShareePickerPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
static const String _debounceTag = 'sharee_picker_search';
|
||||
|
||||
String _query = '';
|
||||
|
||||
/// `null` = search every allowed kind at once. Only meaningful when both
|
||||
/// users and groups are allowed (otherwise the single allowed type is forced).
|
||||
int? _selectedShareType;
|
||||
|
||||
Future<AutocompleteResponse>? _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// When only one kind is allowed there is nothing to choose — force it.
|
||||
if (widget.allowUsers != widget.allowGroups) {
|
||||
_selectedShareType = widget.allowUsers ? kShareTypeUser : kShareTypeGroup;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
Debouncer.cancel(_debounceTag);
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<int> _effectiveShareTypes() {
|
||||
if (_selectedShareType != null) return [_selectedShareType!];
|
||||
return [
|
||||
if (widget.allowUsers) kShareTypeUser,
|
||||
if (widget.allowGroups) kShareTypeGroup,
|
||||
];
|
||||
}
|
||||
|
||||
void _runSearch() {
|
||||
final query = _query;
|
||||
if (query.isEmpty) {
|
||||
setState(() => _future = null);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_future = AutocompleteApi().find(
|
||||
query,
|
||||
shareTypes: _effectiveShareTypes(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onQueryChanged(String value) {
|
||||
_query = value.trim();
|
||||
Debouncer.debounce(_debounceTag, const Duration(milliseconds: 350), () {
|
||||
if (mounted) _runSearch();
|
||||
});
|
||||
}
|
||||
|
||||
void _pick(AutocompleteResponseObject object) {
|
||||
Haptics.selection();
|
||||
Navigator.of(context).pop(
|
||||
ShareeRef(
|
||||
shareType: shareTypeFromSource(object.source),
|
||||
shareWith: object.id,
|
||||
label: object.label,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showChips = widget.allowUsers && widget.allowGroups;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Teilen mit…')),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
onChanged: _onQueryChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Name suchen…',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _query.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_query = '';
|
||||
_runSearch();
|
||||
},
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showChips) ...[
|
||||
SizedBox(height: 40, child: _typeSelector()),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Expanded(child: _results()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _typeSelector() => ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
_typeChip(null, 'Alle', Icons.apps),
|
||||
_typeChip(kShareTypeUser, 'Personen', Icons.person_outline),
|
||||
_typeChip(kShareTypeGroup, 'Gruppen', Icons.groups_outlined),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _typeChip(int? type, String label, IconData icon) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
avatar: Icon(icon, size: 18),
|
||||
showCheckmark: false,
|
||||
label: Text(label),
|
||||
selected: _selectedShareType == type,
|
||||
onSelected: (_) {
|
||||
Haptics.selection();
|
||||
setState(() => _selectedShareType = type);
|
||||
_runSearch();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Widget _results() {
|
||||
if (_future == null) {
|
||||
return const Center(child: Text('Tippe, um nach Personen zu suchen.'));
|
||||
}
|
||||
return FutureBuilder<AutocompleteResponse>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: AppProgressIndicator.medium());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
errorToUserMessage(snapshot.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final results = snapshot.data?.data ?? const [];
|
||||
if (results.isEmpty) {
|
||||
return const Center(child: Text('Keine Treffer.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: results.length,
|
||||
itemBuilder: (context, index) => _resultTile(results[index]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resultTile(AutocompleteResponseObject object) {
|
||||
final isGroup = shareTypeFromSource(object.source) == kShareTypeGroup;
|
||||
final leading = isGroup
|
||||
? const CircleAvatar(child: Icon(Icons.groups_outlined))
|
||||
: CircleAvatar(
|
||||
foregroundImage: Image.network(
|
||||
'https://${EndpointData().nextcloud().full()}/avatar/${object.id}/128',
|
||||
).image,
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
child: const Icon(Icons.person),
|
||||
);
|
||||
return ListTile(
|
||||
leading: leading,
|
||||
title: Text(object.label),
|
||||
subtitle: Text(isGroup ? 'Gruppe' : object.id),
|
||||
onTap: () => _pick(object),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
@@ -8,6 +9,7 @@ import '../../../../extensions/date_time.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../share_intent/remote_file_ref.dart';
|
||||
import '../../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
||||
import '../../../../utils/download_manager.dart';
|
||||
import '../../../../utils/file_clipboard.dart';
|
||||
import '../../../../utils/haptics.dart';
|
||||
@@ -16,6 +18,7 @@ import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/info_dialog.dart';
|
||||
import '../../talk/widgets/highlighted_linkify.dart';
|
||||
import '../sharing/share_sheet.dart';
|
||||
import 'file_details_sheet.dart';
|
||||
import 'file_leading.dart';
|
||||
|
||||
@@ -329,7 +332,7 @@ class _FileElementState extends State<FileElement> {
|
||||
if (!widget.file.isDirectory)
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)),
|
||||
title: const Text('Im Talk-Chat teilen'),
|
||||
title: const Text('Im Talk-Chat versenden'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
AppRoutes.openInternalShareToChat(
|
||||
@@ -338,9 +341,26 @@ class _FileElementState extends State<FileElement> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (context.read<NextcloudCapabilitiesCubit>().canShareAtAll)
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.person_add_outlined)),
|
||||
title: const Text('Freigeben'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
showShareSheet(context, widget.file);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.delete_outline)),
|
||||
title: const Text('Löschen'),
|
||||
leading: CenteredLeading(
|
||||
Icon(
|
||||
Icons.delete_outline,
|
||||
color: Theme.of(sheetCtx).colorScheme.error,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'Löschen',
|
||||
style: TextStyle(color: Theme.of(sheetCtx).colorScheme.error),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
_delete();
|
||||
|
||||
@@ -23,7 +23,7 @@ class FileLeading extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final icon = Icon(iconForFile(file), size: size);
|
||||
final fileId = file.fileId;
|
||||
return SizedBox(
|
||||
final base = SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: (file.isDirectory || file.hasPreview != true || fileId == null)
|
||||
@@ -47,5 +47,31 @@ class FileLeading extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Badge incoming shares (shared with the user by someone else).
|
||||
if (file.isSharedWithMe != true) return base;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
base,
|
||||
Positioned(
|
||||
right: -3,
|
||||
bottom: -3,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.errorContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
padding: const EdgeInsets.all(1),
|
||||
child: Icon(
|
||||
Icons.people,
|
||||
size: size * 0.45,
|
||||
color: colors.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user