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,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),
);
}
}