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,
),
],
);
}
}
@@ -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),
);
}
}
+23 -3
View File
@@ -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();
+27 -1
View File
@@ -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,
),
),
),
],
);
}
}