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:
@@ -3,25 +3,39 @@ import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'autocomplete_response.dart';
|
||||
|
||||
class AutocompleteApi {
|
||||
Future<AutocompleteResponse> find(String query) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'core/autocomplete/get',
|
||||
queryParameters: {
|
||||
/// Searches sharees (users by default). Pass [shareTypes] to widen the search
|
||||
/// — e.g. `[0, 1]` for both users and groups (0 = user, 1 = group).
|
||||
Future<AutocompleteResponse> find(
|
||||
String query, {
|
||||
List<int> shareTypes = const [0],
|
||||
}) async {
|
||||
// NextcloudOcs.uri serialises every query value via `toString()`, which
|
||||
// would turn the `shareTypes[]` list into `"[0, 1]"`. Build the Uri here so
|
||||
// Dart encodes the list as repeated `shareTypes[]=0&shareTypes[]=1` params.
|
||||
final endpoint = EndpointData().nextcloud();
|
||||
final uri = Uri.https(
|
||||
endpoint.domain,
|
||||
'${endpoint.path}/ocs/v2.php/core/autocomplete/get',
|
||||
{
|
||||
'format': 'json',
|
||||
'search': query,
|
||||
'itemType': ' ',
|
||||
'itemId': ' ',
|
||||
'shareTypes[]': ['0'],
|
||||
'shareTypes[]': shareTypes.map((t) => t.toString()).toList(),
|
||||
'limit': '10',
|
||||
},
|
||||
);
|
||||
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||
final response = await http.get(uri, headers: NextcloudOcs.headers());
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception(
|
||||
'Api call failed with ${response.statusCode}: ${response.body}',
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../files_sharing/queries/share/share.dart';
|
||||
|
||||
part 'autocomplete_response.g.dart';
|
||||
|
||||
/// Maps an autocomplete result's `source` to the matching Nextcloud share type.
|
||||
/// Groups become [kShareTypeGroup]; everything else (users, and any unknown
|
||||
/// source) defaults to [kShareTypeUser].
|
||||
int shareTypeFromSource(String? source) {
|
||||
if (source != null && source.startsWith('groups')) return kShareTypeGroup;
|
||||
return kShareTypeUser;
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class AutocompleteResponse {
|
||||
List<AutocompleteResponseObject> data;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'nextcloud_sharing_capabilities.dart';
|
||||
|
||||
/// Fetches the current user's Nextcloud capabilities via OCS
|
||||
/// `GET cloud/capabilities` and extracts the `files_sharing` block. This is the
|
||||
/// per-user, group-aware source of truth the sharing UI gates on — no custom
|
||||
/// backend involved.
|
||||
class GetNextcloudCapabilities {
|
||||
Future<NextcloudSharingCapabilities> run() async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'cloud/capabilities',
|
||||
queryParameters: {'format': 'json'},
|
||||
);
|
||||
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
technicalDetails: 'cloud/capabilities: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final data =
|
||||
(decoded['ocs'] as Map<String, dynamic>?)?['data']
|
||||
as Map<String, dynamic>?;
|
||||
final capabilities = data?['capabilities'] as Map<String, dynamic>?;
|
||||
final filesSharing = capabilities?['files_sharing'];
|
||||
if (filesSharing is! Map<String, dynamic>) {
|
||||
// Server doesn't advertise files_sharing (app disabled) — treat as no
|
||||
// sharing capability rather than failing the whole load.
|
||||
return const NextcloudSharingCapabilities();
|
||||
}
|
||||
final passwordPolicy = capabilities?['password_policy'];
|
||||
return NextcloudSharingCapabilities.fromFilesSharing(
|
||||
filesSharing,
|
||||
passwordPolicy: passwordPolicy is Map<String, dynamic>
|
||||
? passwordPolicy
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/// Subset of Nextcloud's `files_sharing` capabilities block that the mobile
|
||||
/// sharing UI gates on. Nextcloud reports these per authenticated user, so a
|
||||
/// group that an admin excluded from creating public links sees
|
||||
/// `public.enabled == false` here — exactly how the web UI hides those buttons.
|
||||
///
|
||||
/// The block is deeply nested and varies between server versions, so this is
|
||||
/// parsed by hand from the raw OCS map with safe fallbacks rather than via
|
||||
/// code generation. Missing fields default to the most restrictive value so a
|
||||
/// newer/older server never accidentally unlocks a capability.
|
||||
class NextcloudSharingCapabilities {
|
||||
/// `files_sharing.api_enabled` — master switch. When false the user may not
|
||||
/// create any share (user, group or link).
|
||||
final bool apiEnabled;
|
||||
|
||||
/// `files_sharing.public.enabled` — public link shares allowed.
|
||||
final bool publicEnabled;
|
||||
|
||||
/// `files_sharing.public.multiple_links` — more than one link per file.
|
||||
final bool publicMultipleLinks;
|
||||
|
||||
/// `files_sharing.public.upload` — public upload / file-drop folders.
|
||||
final bool publicUploadEnabled;
|
||||
|
||||
/// `files_sharing.public.password.enforced` — a password is mandatory on
|
||||
/// public links, so the create flow must collect one upfront.
|
||||
final bool publicPasswordEnforced;
|
||||
|
||||
/// `files_sharing.public.expire_date.enabled`.
|
||||
final bool publicExpireEnabled;
|
||||
|
||||
/// `files_sharing.public.expire_date.days` — default/maximum lifetime.
|
||||
final int? publicExpireDays;
|
||||
|
||||
/// `files_sharing.public.expire_date.enforced` — expiry cannot be removed.
|
||||
final bool publicExpireEnforced;
|
||||
|
||||
/// `files_sharing.group.enabled` (falls back to the older `group_sharing`).
|
||||
final bool groupEnabled;
|
||||
|
||||
/// `files_sharing.resharing` — recipients may reshare.
|
||||
final bool resharing;
|
||||
|
||||
// --- password_policy (a sibling capability of files_sharing) ---
|
||||
// These let the link-password UI state the rules up front instead of only
|
||||
// surfacing them after the server rejects a weak password. The
|
||||
// "non-common password" (breach) check can only be enforced server-side.
|
||||
|
||||
/// `password_policy.minLength`.
|
||||
final int? passwordMinLength;
|
||||
|
||||
/// `password_policy.enforceUpperLowerCase`.
|
||||
final bool passwordEnforceUpperLower;
|
||||
|
||||
/// `password_policy.enforceNumericCharacters`.
|
||||
final bool passwordEnforceNumeric;
|
||||
|
||||
/// `password_policy.enforceSpecialCharacters`.
|
||||
final bool passwordEnforceSpecial;
|
||||
|
||||
const NextcloudSharingCapabilities({
|
||||
this.apiEnabled = false,
|
||||
this.publicEnabled = false,
|
||||
this.publicMultipleLinks = false,
|
||||
this.publicUploadEnabled = false,
|
||||
this.publicPasswordEnforced = false,
|
||||
this.publicExpireEnabled = false,
|
||||
this.publicExpireDays,
|
||||
this.publicExpireEnforced = false,
|
||||
this.groupEnabled = false,
|
||||
this.resharing = false,
|
||||
this.passwordMinLength,
|
||||
this.passwordEnforceUpperLower = false,
|
||||
this.passwordEnforceNumeric = false,
|
||||
this.passwordEnforceSpecial = false,
|
||||
});
|
||||
|
||||
/// Parses the `files_sharing` sub-map of an OCS `cloud/capabilities`
|
||||
/// response, plus the optional sibling `password_policy` map. Tolerates
|
||||
/// missing intermediate maps and type drift.
|
||||
factory NextcloudSharingCapabilities.fromFilesSharing(
|
||||
Map<String, dynamic> filesSharing, {
|
||||
Map<String, dynamic>? passwordPolicy,
|
||||
}) {
|
||||
Map<String, dynamic>? sub(Map<String, dynamic>? m, String key) {
|
||||
final value = m?[key];
|
||||
return value is Map<String, dynamic> ? value : null;
|
||||
}
|
||||
|
||||
bool boolAt(Map<String, dynamic>? m, String key) => m?[key] == true;
|
||||
int? intAt(Map<String, dynamic>? m, String key) {
|
||||
final v = m?[key];
|
||||
if (v is int) return v;
|
||||
if (v is String) return int.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
final public = sub(filesSharing, 'public');
|
||||
final password = sub(public, 'password');
|
||||
final expire = sub(public, 'expire_date');
|
||||
final group = sub(filesSharing, 'group');
|
||||
|
||||
return NextcloudSharingCapabilities(
|
||||
apiEnabled: boolAt(filesSharing, 'api_enabled'),
|
||||
publicEnabled: boolAt(public, 'enabled'),
|
||||
publicMultipleLinks: boolAt(public, 'multiple_links'),
|
||||
publicUploadEnabled: boolAt(public, 'upload'),
|
||||
publicPasswordEnforced: boolAt(password, 'enforced'),
|
||||
publicExpireEnabled: boolAt(expire, 'enabled'),
|
||||
publicExpireDays: intAt(expire, 'days'),
|
||||
publicExpireEnforced: boolAt(expire, 'enforced'),
|
||||
// Newer servers nest it under `group.enabled`; older ones expose a flat
|
||||
// `group_sharing` boolean.
|
||||
groupEnabled:
|
||||
boolAt(group, 'enabled') || boolAt(filesSharing, 'group_sharing'),
|
||||
resharing: boolAt(filesSharing, 'resharing'),
|
||||
passwordMinLength: intAt(passwordPolicy, 'minLength'),
|
||||
passwordEnforceUpperLower: boolAt(
|
||||
passwordPolicy,
|
||||
'enforceUpperLowerCase',
|
||||
),
|
||||
passwordEnforceNumeric: boolAt(
|
||||
passwordPolicy,
|
||||
'enforceNumericCharacters',
|
||||
),
|
||||
passwordEnforceSpecial: boolAt(
|
||||
passwordPolicy,
|
||||
'enforceSpecialCharacters',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,144 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'file_sharing_api_params.dart';
|
||||
import 'queries/share/share.dart';
|
||||
import 'queries/share/share_update_params.dart';
|
||||
|
||||
/// OCS `files_sharing` API (Nextcloud Sharing API v1). Per the official docs:
|
||||
/// list/delete pass parameters via the URL (query / path), create and update
|
||||
/// pass them in the request **body** (form-urlencoded), and every call adds
|
||||
/// `format=json` so the server replies with JSON instead of XML.
|
||||
///
|
||||
/// All calls surface failures as [ServerException] so they map to friendly
|
||||
/// messages via `errorToUserMessage`.
|
||||
class FileSharingApi {
|
||||
Future<void> share(FileSharingApiParams query) async {
|
||||
static const String _base = 'apps/files_sharing/api/v1/shares';
|
||||
|
||||
/// Creates a share. Returns the created [Share] (callers that don't need it —
|
||||
/// e.g. Talk file sharing — can ignore the result).
|
||||
Future<Share> share(FileSharingApiParams query) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'apps/files_sharing/api/v1/shares',
|
||||
queryParameters: query.toJson(),
|
||||
_base,
|
||||
queryParameters: {'format': 'json'},
|
||||
);
|
||||
final response = await http.post(endpoint, headers: NextcloudOcs.headers());
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception(
|
||||
'Api call failed with ${response.statusCode}: ${response.body}',
|
||||
final response = await http.post(
|
||||
endpoint,
|
||||
headers: NextcloudOcs.headers(),
|
||||
body: _stringForm(query.toJson()),
|
||||
);
|
||||
return _decodeShare(response, action: 'Freigabe erstellen');
|
||||
}
|
||||
|
||||
/// Lists shares for the given OCS [path] (see `ocs_path.dart`). [reshares]
|
||||
/// includes shares the current user received and re-shared.
|
||||
Future<List<Share>> listForPath(String path, {bool reshares = false}) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
_base,
|
||||
queryParameters: {
|
||||
'format': 'json',
|
||||
'path': path,
|
||||
'reshares': reshares.toString(),
|
||||
'subfiles': 'false',
|
||||
},
|
||||
);
|
||||
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||
final data = _decodeData(response, action: 'Freigaben laden');
|
||||
if (data is! List) return const [];
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Share.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// Updates an existing share. Returns the updated [Share].
|
||||
Future<Share> update(int shareId, ShareUpdateParams params) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'$_base/$shareId',
|
||||
queryParameters: {'format': 'json'},
|
||||
);
|
||||
final response = await http.put(
|
||||
endpoint,
|
||||
headers: NextcloudOcs.headers(),
|
||||
body: params.toQuery(),
|
||||
);
|
||||
return _decodeShare(response, action: 'Freigabe ändern');
|
||||
}
|
||||
|
||||
/// Deletes (revokes) a share.
|
||||
Future<void> remove(int shareId) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'$_base/$shareId',
|
||||
queryParameters: {'format': 'json'},
|
||||
);
|
||||
final response = await http.delete(
|
||||
endpoint,
|
||||
headers: NextcloudOcs.headers(),
|
||||
);
|
||||
_decodeData(response, action: 'Freigabe löschen');
|
||||
}
|
||||
|
||||
/// Stringifies a json map into form fields (the OCS body is
|
||||
/// `application/x-www-form-urlencoded`). Null values are already dropped by
|
||||
/// the params' `includeIfNull: false`.
|
||||
Map<String, String> _stringForm(Map<String, dynamic> json) =>
|
||||
json.map((key, value) => MapEntry(key, value.toString()));
|
||||
|
||||
/// Decodes a single-share response (create/update). Throws if the payload is
|
||||
/// not a share object.
|
||||
Share _decodeShare(http.Response response, {required String action}) {
|
||||
final data = _decodeData(response, action: action);
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
technicalDetails: 'Unerwartete Antwort für "$action": ${response.body}',
|
||||
);
|
||||
}
|
||||
return Share.fromJson(data);
|
||||
}
|
||||
|
||||
/// Validates the HTTP/OCS envelope and returns the `ocs.data` payload, or
|
||||
/// throws a [ServerException] carrying the server's OCS message when present.
|
||||
///
|
||||
/// Every access is type-checked rather than cast: PHP's `json_encode` turns
|
||||
/// an empty associative array into a JSON array (`[]`), and `statuscode` can
|
||||
/// arrive as a string — a hard `as` cast on either would throw a raw
|
||||
/// TypeError that surfaces as the generic "something went wrong" message.
|
||||
Object? _decodeData(http.Response response, {required String action}) {
|
||||
dynamic decoded;
|
||||
try {
|
||||
decoded = jsonDecode(response.body);
|
||||
} catch (_) {
|
||||
decoded = null;
|
||||
}
|
||||
final ocs = decoded is Map<String, dynamic> ? decoded['ocs'] : null;
|
||||
final ocsMap = ocs is Map<String, dynamic> ? ocs : null;
|
||||
final meta = ocsMap?['meta'];
|
||||
final metaMap = meta is Map<String, dynamic> ? meta : null;
|
||||
|
||||
final rawStatus = metaMap?['statuscode'];
|
||||
final ocsStatus = rawStatus is int
|
||||
? rawStatus
|
||||
: (rawStatus is String ? int.tryParse(rawStatus) : null);
|
||||
final rawMessage = metaMap?['message'];
|
||||
final ocsMessage = rawMessage is String ? rawMessage : null;
|
||||
|
||||
// OCS v2 mirrors the HTTP status; accept any 2xx. Success OCS statuscodes
|
||||
// are 100 (v1 carry-over) or 200.
|
||||
final httpOk = response.statusCode >= 200 && response.statusCode < 300;
|
||||
final ocsOk = ocsStatus == null || ocsStatus == 100 || ocsStatus == 200;
|
||||
if (!httpOk || !ocsOk) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
userMessage: ocsMessage != null && ocsMessage.isNotEmpty
|
||||
? '$action fehlgeschlagen: $ocsMessage'
|
||||
: null,
|
||||
technicalDetails: '$action: ${response.statusCode} ${response.body}',
|
||||
);
|
||||
}
|
||||
return ocsMap?['data'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'file_sharing_api_params.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
// includeIfNull:false so the optional sharing fields below are only sent when
|
||||
// set — otherwise the OCS query would carry `permissions=null` etc. and be
|
||||
// rejected.
|
||||
@JsonSerializable(includeIfNull: false)
|
||||
class FileSharingApiParams {
|
||||
int shareType;
|
||||
String shareWith;
|
||||
@@ -10,12 +13,24 @@ class FileSharingApiParams {
|
||||
String? referenceId;
|
||||
String? talkMetaData;
|
||||
|
||||
/// Permission bitmask (see `share_permissions.dart`).
|
||||
int? permissions;
|
||||
|
||||
/// Public link password.
|
||||
String? password;
|
||||
|
||||
/// Expiry as `YYYY-MM-DD`.
|
||||
String? expireDate;
|
||||
|
||||
FileSharingApiParams({
|
||||
required this.shareType,
|
||||
required this.shareWith,
|
||||
required this.path,
|
||||
this.referenceId,
|
||||
this.talkMetaData,
|
||||
this.permissions,
|
||||
this.password,
|
||||
this.expireDate,
|
||||
});
|
||||
|
||||
factory FileSharingApiParams.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -14,6 +14,9 @@ FileSharingApiParams _$FileSharingApiParamsFromJson(
|
||||
path: json['path'] as String,
|
||||
referenceId: json['referenceId'] as String?,
|
||||
talkMetaData: json['talkMetaData'] as String?,
|
||||
permissions: (json['permissions'] as num?)?.toInt(),
|
||||
password: json['password'] as String?,
|
||||
expireDate: json['expireDate'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$FileSharingApiParamsToJson(
|
||||
@@ -22,6 +25,9 @@ Map<String, dynamic> _$FileSharingApiParamsToJson(
|
||||
'shareType': instance.shareType,
|
||||
'shareWith': instance.shareWith,
|
||||
'path': instance.path,
|
||||
'referenceId': instance.referenceId,
|
||||
'talkMetaData': instance.talkMetaData,
|
||||
'referenceId': ?instance.referenceId,
|
||||
'talkMetaData': ?instance.talkMetaData,
|
||||
'permissions': ?instance.permissions,
|
||||
'password': ?instance.password,
|
||||
'expireDate': ?instance.expireDate,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../webdav/queries/list_files/cacheable_file.dart';
|
||||
|
||||
/// Converts a [CacheableFile.path] (relative to the WebDAV files root, folders
|
||||
/// ending in `/`) into the path the OCS `files_sharing` API expects: rooted
|
||||
/// with a single leading slash and without a trailing slash on folders.
|
||||
///
|
||||
/// Examples:
|
||||
/// '' -> '/' (files root)
|
||||
/// 'Documents/x.pdf' -> '/Documents/x.pdf'
|
||||
/// 'Documents/' -> '/Documents'
|
||||
/// '/Shared/a/' -> '/Shared/a'
|
||||
String ocsPathFor(String webdavPath) {
|
||||
final trimmed = webdavPath.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return trimmed.isEmpty ? '/' : '/$trimmed';
|
||||
}
|
||||
|
||||
/// Convenience wrapper for a [CacheableFile].
|
||||
String ocsPathOf(CacheableFile file) => ocsPathFor(file.path);
|
||||
@@ -0,0 +1,108 @@
|
||||
/// Nextcloud share types (subset the app uses).
|
||||
const int kShareTypeUser = 0;
|
||||
const int kShareTypeGroup = 1;
|
||||
const int kShareTypePublicLink = 3;
|
||||
const int kShareTypeEmail = 4;
|
||||
|
||||
/// A Talk conversation ("room") the file is linked into.
|
||||
const int kShareTypeRoom = 10;
|
||||
|
||||
/// A single share as returned by the OCS `files_sharing` API.
|
||||
///
|
||||
/// Parsed by hand rather than via code generation: OCS is inconsistent about
|
||||
/// types across versions (e.g. `id`/`share_type` may arrive as either strings
|
||||
/// or numbers) and omits optional fields entirely, so defensive parsing is
|
||||
/// safer than generated `as int` casts.
|
||||
class Share {
|
||||
final int id;
|
||||
final int shareType;
|
||||
final int permissions;
|
||||
|
||||
/// Server path of the shared item (e.g. `/Documents/x.pdf`).
|
||||
final String? path;
|
||||
|
||||
/// `'file'` or `'folder'`.
|
||||
final String? itemType;
|
||||
|
||||
/// Recipient id (user/group id); empty for public links.
|
||||
final String? shareWith;
|
||||
final String? shareWithDisplayname;
|
||||
|
||||
/// Public link URL (only set for [kShareTypePublicLink]).
|
||||
final String? url;
|
||||
|
||||
/// Raw expiration as `"YYYY-MM-DD HH:MM:SS"` (or null when none).
|
||||
final String? expiration;
|
||||
final String? label;
|
||||
|
||||
/// Redacted password marker: the server returns `null` when no password is
|
||||
/// set and a placeholder (`"redacted"`) when one is — never the real value.
|
||||
final String? password;
|
||||
|
||||
const Share({
|
||||
required this.id,
|
||||
required this.shareType,
|
||||
required this.permissions,
|
||||
this.path,
|
||||
this.itemType,
|
||||
this.shareWith,
|
||||
this.shareWithDisplayname,
|
||||
this.url,
|
||||
this.expiration,
|
||||
this.label,
|
||||
this.password,
|
||||
});
|
||||
|
||||
bool get isPublicLink => shareType == kShareTypePublicLink;
|
||||
bool get isGroup => shareType == kShareTypeGroup;
|
||||
bool get isEmail => shareType == kShareTypeEmail;
|
||||
bool get isRoom => shareType == kShareTypeRoom;
|
||||
bool get isFolder => itemType == 'folder';
|
||||
|
||||
/// Whether a (link) password is currently set. See [password].
|
||||
bool get hasPassword => password != null && password!.isNotEmpty;
|
||||
|
||||
/// Best display title for the share row.
|
||||
String get displayTitle {
|
||||
if (isPublicLink) return label?.isNotEmpty == true ? label! : 'Link';
|
||||
final name = shareWithDisplayname;
|
||||
if (name != null && name.isNotEmpty) return name;
|
||||
return shareWith ?? 'Unbekannt';
|
||||
}
|
||||
|
||||
/// Human label for the kind of share (for subtitles/headers).
|
||||
String get kindLabel {
|
||||
if (isPublicLink) return 'Öffentlicher Link';
|
||||
if (isGroup) return 'Gruppe';
|
||||
if (isRoom) return 'Talk-Chat';
|
||||
if (isEmail) return 'E-Mail';
|
||||
if (shareType == kShareTypeUser) return 'Person';
|
||||
return 'Freigabe';
|
||||
}
|
||||
|
||||
static int _asInt(Object? value, {int fallback = 0}) {
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static String? _asString(Object? value) {
|
||||
if (value == null) return null;
|
||||
final s = value.toString();
|
||||
return s.isEmpty ? null : s;
|
||||
}
|
||||
|
||||
factory Share.fromJson(Map<String, dynamic> json) => Share(
|
||||
id: _asInt(json['id']),
|
||||
shareType: _asInt(json['share_type']),
|
||||
permissions: _asInt(json['permissions']),
|
||||
path: _asString(json['path']),
|
||||
itemType: _asString(json['item_type']),
|
||||
shareWith: _asString(json['share_with']),
|
||||
shareWithDisplayname: _asString(json['share_with_displayname']),
|
||||
url: _asString(json['url']),
|
||||
expiration: _asString(json['expiration']),
|
||||
label: _asString(json['label']),
|
||||
password: _asString(json['password']),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// Parameters for updating an existing share via OCS `PUT shares/{id}`.
|
||||
/// Only the fields that are explicitly set are sent — every field is optional
|
||||
/// and a null is omitted from the request (sending `permissions=null` would be
|
||||
/// rejected by the server).
|
||||
///
|
||||
/// Use an empty string for [expireDate]/[password] to explicitly clear the
|
||||
/// value server-side (Nextcloud treats `expireDate=` as "remove expiry").
|
||||
class ShareUpdateParams {
|
||||
final int? permissions;
|
||||
final String? password;
|
||||
final String? expireDate;
|
||||
|
||||
const ShareUpdateParams({this.permissions, this.password, this.expireDate});
|
||||
|
||||
Map<String, String> toQuery() => {
|
||||
if (permissions != null) 'permissions': permissions.toString(),
|
||||
'password': ?password,
|
||||
'expireDate': ?expireDate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/// Nextcloud share permission bitmask helpers. These mirror the constants the
|
||||
/// OCS `files_sharing` API expects in the `permissions` field. Kept as pure
|
||||
/// functions (no Flutter/IO) so they are unit-testable.
|
||||
library;
|
||||
|
||||
/// Individual permission bits (Nextcloud `OCS\Constants`).
|
||||
const int kPermissionRead = 1;
|
||||
const int kPermissionUpdate = 2;
|
||||
const int kPermissionCreate = 4;
|
||||
const int kPermissionDelete = 8;
|
||||
const int kPermissionShare = 16;
|
||||
|
||||
/// User-facing presets that map onto a bitmask.
|
||||
enum SharePreset {
|
||||
/// Recipient can only view/download.
|
||||
readOnly,
|
||||
|
||||
/// Recipient can view, edit, add and remove (full editing).
|
||||
edit,
|
||||
|
||||
/// Upload-only "file request" — recipient can add files to a folder but not
|
||||
/// see existing contents. Only meaningful for folders.
|
||||
fileDrop,
|
||||
}
|
||||
|
||||
extension SharePresetLabel on SharePreset {
|
||||
String get label {
|
||||
switch (this) {
|
||||
case SharePreset.readOnly:
|
||||
return 'Nur Lesen';
|
||||
case SharePreset.edit:
|
||||
return 'Bearbeiten';
|
||||
case SharePreset.fileDrop:
|
||||
return 'Datei-Anfrage';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if [mask] contains the given [flag].
|
||||
bool hasPermission(int mask, int flag) => mask & flag == flag;
|
||||
|
||||
/// Builds the permission bitmask for a [preset].
|
||||
///
|
||||
/// [isFolder] matters for the `edit` preset: a file can only carry
|
||||
/// read+update, while a folder additionally supports create+delete. Nextcloud
|
||||
/// rejects create/delete on a file ("Failed to update share"), so they must be
|
||||
/// omitted there. When [allowReshare] is true the reshare bit is added to the
|
||||
/// editing presets — mirroring how the Nextcloud clients respect the
|
||||
/// `resharing` capability.
|
||||
int permissionsFor(
|
||||
SharePreset preset, {
|
||||
bool allowReshare = false,
|
||||
bool isFolder = false,
|
||||
}) {
|
||||
switch (preset) {
|
||||
case SharePreset.readOnly:
|
||||
return kPermissionRead;
|
||||
case SharePreset.edit:
|
||||
var base = kPermissionRead | kPermissionUpdate;
|
||||
if (isFolder) base |= kPermissionCreate | kPermissionDelete;
|
||||
return allowReshare ? base | kPermissionShare : base;
|
||||
case SharePreset.fileDrop:
|
||||
return kPermissionCreate;
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies an arbitrary permission bitmask into the closest preset, or null
|
||||
/// if it doesn't match any (e.g. a custom combination). The reshare bit is
|
||||
/// ignored for matching so an "edit" share stays "edit" regardless of reshare.
|
||||
SharePreset? presetFromBitmask(int mask) {
|
||||
final normalized = mask & ~kPermissionShare;
|
||||
if (normalized == kPermissionCreate) return SharePreset.fileDrop;
|
||||
if (normalized == kPermissionRead) return SharePreset.readOnly;
|
||||
// Any read share that also carries a write bit (update/create/delete) is
|
||||
// surfaced as "edit".
|
||||
const writeBits = kPermissionUpdate | kPermissionCreate | kPermissionDelete;
|
||||
if (hasPermission(normalized, kPermissionRead) &&
|
||||
normalized & writeBits != 0) {
|
||||
return SharePreset.edit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -24,6 +24,11 @@ class CacheableFile {
|
||||
/// when a preview is going to load anyway.
|
||||
bool? hasPreview;
|
||||
|
||||
/// True when this entry is an incoming share — i.e. shared with the current
|
||||
/// user by someone else (`nc:mount-type == 'shared'`). Used to badge the
|
||||
/// file/folder in the list. Nullable so older cached entries decode fine.
|
||||
bool? isSharedWithMe;
|
||||
|
||||
CacheableFile({
|
||||
required this.path,
|
||||
required this.isDirectory,
|
||||
@@ -35,6 +40,7 @@ class CacheableFile {
|
||||
this.modifiedAt,
|
||||
this.fileId,
|
||||
this.hasPreview,
|
||||
this.isSharedWithMe,
|
||||
});
|
||||
|
||||
CacheableFile.fromDavFile(WebDavFile file) {
|
||||
@@ -48,6 +54,11 @@ class CacheableFile {
|
||||
modifiedAt = file.lastModified;
|
||||
fileId = int.tryParse(file.fileId ?? '');
|
||||
hasPreview = file.hasPreview;
|
||||
// Incoming share: the item is mounted into the user's files by someone
|
||||
// else. Outgoing shares ([isSharedByMe]) can't be derived from WebDAV with
|
||||
// the pinned package, so they are filled in by ListFiles via one OCS call
|
||||
// per folder.
|
||||
isSharedWithMe = file.props.ncmounttype == 'shared';
|
||||
}
|
||||
|
||||
factory CacheableFile.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -22,6 +22,7 @@ CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) =>
|
||||
: DateTime.parse(json['modifiedAt'] as String),
|
||||
fileId: (json['fileId'] as num?)?.toInt(),
|
||||
hasPreview: json['hasPreview'] as bool?,
|
||||
isSharedWithMe: json['isSharedWithMe'] as bool?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
||||
@@ -36,4 +37,5 @@ Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
||||
'modifiedAt': instance.modifiedAt?.toIso8601String(),
|
||||
'fileId': instance.fileId,
|
||||
'hasPreview': instance.hasPreview,
|
||||
'isSharedWithMe': instance.isSharedWithMe,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../../webdav_api.dart';
|
||||
@@ -37,19 +39,46 @@ class ListFiles extends WebdavApi<ListFilesParams> {
|
||||
ocsize: true,
|
||||
nccreationtime: true,
|
||||
nchaspreview: true,
|
||||
// 'shared' here means an incoming share (mounted into the user's files
|
||||
// by someone else); used to badge those entries in the list.
|
||||
ncmounttype: true,
|
||||
);
|
||||
|
||||
var files = await _fetch(webdav, prop, timeout);
|
||||
// A freshly-entered incoming share sometimes answers its first PROPFIND
|
||||
// without the OC/NC props (no fileid / has-preview / mount-type) while the
|
||||
// share mount warms up server-side — which drops thumbnails AND share
|
||||
// badges together. Retry a couple of times so the folder self-heals
|
||||
// instead of needing manual re-entry.
|
||||
for (var attempt = 0; attempt < 2 && _looksIncomplete(files); attempt++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
files = await _fetch(webdav, prop, timeout);
|
||||
}
|
||||
|
||||
return ListFilesResponse(files);
|
||||
}
|
||||
|
||||
Future<Set<CacheableFile>> _fetch(
|
||||
WebDavClient webdav,
|
||||
WebDavPropWithoutValues prop,
|
||||
Duration timeout,
|
||||
) async {
|
||||
final davFiles =
|
||||
(await webdav
|
||||
.propfind(PathUri.parse(params.path), prop: prop)
|
||||
.timeout(timeout))
|
||||
.toWebDavFiles();
|
||||
final files = davFiles.map(CacheableFile.fromDavFile).toSet();
|
||||
|
||||
// somehow the current working folder is also listed, it is filtered here.
|
||||
files.removeWhere(
|
||||
(element) => element.path == '/${params.path}/' || element.path == '/',
|
||||
);
|
||||
|
||||
return ListFilesResponse(files);
|
||||
return files;
|
||||
}
|
||||
|
||||
/// True when the server returned entries but none carry a `fileId` — a sign
|
||||
/// the OC/NC properties were omitted (cold share mount), so thumbnails and
|
||||
/// share badges would be missing for the whole folder.
|
||||
bool _looksIncomplete(Set<CacheableFile> files) =>
|
||||
files.isNotEmpty && files.every((file) => file.fileId == null);
|
||||
}
|
||||
|
||||
@@ -40,9 +40,7 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
|
||||
/// [invalidate].
|
||||
static int _cacheTimeFor(String path) {
|
||||
final stripped = path.replaceAll('/', '').trim();
|
||||
return stripped.isEmpty
|
||||
? RequestCache.cacheDay
|
||||
: RequestCache.cacheNothing;
|
||||
return stripped.isEmpty ? RequestCache.cacheDay : RequestCache.cacheNothing;
|
||||
}
|
||||
|
||||
/// Triggers a root-listing fetch in the background if no cached payload
|
||||
|
||||
@@ -33,6 +33,7 @@ import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
||||
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import 'state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
||||
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import 'storage/settings.dart';
|
||||
@@ -161,6 +162,9 @@ Future<void> main() async {
|
||||
),
|
||||
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
||||
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
|
||||
BlocProvider<NextcloudCapabilitiesCubit>(
|
||||
create: (_) => NextcloudCapabilitiesCubit(),
|
||||
),
|
||||
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
||||
BlocProvider<ChatBloc>(
|
||||
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
||||
@@ -201,6 +205,7 @@ class _MainState extends State<Main> {
|
||||
// change, so the loggedIn listener below never fires — refresh
|
||||
// capabilities here.
|
||||
unawaited(context.read<CapabilitiesCubit>().load());
|
||||
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -274,6 +279,9 @@ class _MainState extends State<Main> {
|
||||
// for the newly authenticated user.
|
||||
if (accountState.status == AccountStatus.loggedIn) {
|
||||
unawaited(context.read<CapabilitiesCubit>().load());
|
||||
unawaited(
|
||||
context.read<NextcloudCapabilitiesCubit>().load(),
|
||||
);
|
||||
}
|
||||
if (accountState.status != AccountStatus.loggedOut) return;
|
||||
// A pending share would otherwise survive logout and be
|
||||
@@ -297,6 +305,8 @@ class _MainState extends State<Main> {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
final breakerBloc = context.read<BreakerBloc>();
|
||||
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
||||
final nextcloudCapabilitiesCubit = context
|
||||
.read<NextcloudCapabilitiesCubit>();
|
||||
// Defer the actual wipe until after this frame so the
|
||||
// App tree (TimetableBloc/ChatListBloc watchers etc.)
|
||||
// is already torn down. Resetting blocs while App is
|
||||
@@ -310,6 +320,7 @@ class _MainState extends State<Main> {
|
||||
chatBloc: chatBloc,
|
||||
breakerBloc: breakerBloc,
|
||||
capabilitiesCubit: capabilitiesCubit,
|
||||
nextcloudCapabilitiesCubit: nextcloudCapabilitiesCubit,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -355,6 +366,7 @@ Future<void> _wipeUserState({
|
||||
required ChatBloc chatBloc,
|
||||
required BreakerBloc breakerBloc,
|
||||
required CapabilitiesCubit capabilitiesCubit,
|
||||
required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit,
|
||||
}) async {
|
||||
try {
|
||||
// Reset user-data blocs whose tree is no longer mounted after the
|
||||
@@ -368,6 +380,7 @@ Future<void> _wipeUserState({
|
||||
chatBloc.reset(),
|
||||
breakerBloc.reset(),
|
||||
capabilitiesCubit.reset(),
|
||||
nextcloudCapabilitiesCubit.reset(),
|
||||
]);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.clear();
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
||||
import '../view/pages/files/files.dart';
|
||||
import '../view/pages/files/sharing/sharee_picker_page.dart';
|
||||
import '../view/pages/foreign_timetable/element_picker_page.dart';
|
||||
import '../view/pages/marianum_message/marianum_message_view.dart';
|
||||
import '../view/pages/more/feedback/feedback_dialog.dart';
|
||||
@@ -91,6 +92,27 @@ class AppRoutes {
|
||||
return result as TimetableElementRef?;
|
||||
}
|
||||
|
||||
/// Opens the user/group picker for sharing a file and resolves to the chosen
|
||||
/// recipient (or null if dismissed). Which kinds are offered is gated by the
|
||||
/// Nextcloud sharing capabilities at the call site.
|
||||
static Future<ShareeRef?> openShareePicker(
|
||||
BuildContext context, {
|
||||
required bool allowUsers,
|
||||
required bool allowGroups,
|
||||
}) async {
|
||||
// Push untyped (T = dynamic) and cast the popped result ourselves —
|
||||
// pushScreen can't carry a concrete non-Widget T (see openElementPicker).
|
||||
final result = await pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
screen: ShareePickerPage(
|
||||
allowUsers: allowUsers,
|
||||
allowGroups: allowGroups,
|
||||
),
|
||||
);
|
||||
return result as ShareeRef?;
|
||||
}
|
||||
|
||||
static void openMarianumMessage(
|
||||
BuildContext context,
|
||||
String basePath,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
|
||||
import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart';
|
||||
import 'nextcloud_capabilities_state.dart';
|
||||
|
||||
/// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so
|
||||
/// the last known flags are available immediately on cold start (no
|
||||
/// feature-flicker) and offline. [load] refreshes them from the server after
|
||||
/// login. Same fail-safe contract as [CapabilitiesCubit]: a failed fetch keeps
|
||||
/// the cached flags and never silently grants a capability.
|
||||
class NextcloudCapabilitiesCubit
|
||||
extends HydratedCubit<NextcloudCapabilitiesState> {
|
||||
NextcloudCapabilitiesCubit() : super(const NextcloudCapabilitiesState());
|
||||
|
||||
/// Master switch — the user may create at least one kind of share.
|
||||
bool get canShareAtAll => state.apiEnabled;
|
||||
|
||||
/// User (person) shares are allowed whenever the share API is enabled;
|
||||
/// Nextcloud has no separate per-type toggle for them.
|
||||
bool get canShareWithUsers => state.apiEnabled;
|
||||
|
||||
bool get canShareWithGroups => state.apiEnabled && state.groupEnabled;
|
||||
|
||||
bool get canCreatePublicLinks => state.apiEnabled && state.publicEnabled;
|
||||
|
||||
bool get passwordEnforced => state.publicPasswordEnforced;
|
||||
|
||||
bool get expireEnforced =>
|
||||
state.publicExpireEnabled && state.publicExpireEnforced;
|
||||
|
||||
int? get expireDays => state.publicExpireDays;
|
||||
|
||||
bool get canReshare => state.resharing;
|
||||
|
||||
bool get allowsMultipleLinks => state.publicMultipleLinks;
|
||||
|
||||
bool get supportsFileDrop => state.publicUploadEnabled;
|
||||
|
||||
/// Human-readable summary of the server's link-password rules, or null if no
|
||||
/// rules are advertised. The breach-list ("non-common password") check is
|
||||
/// server-only and intentionally omitted — it can't be validated up front.
|
||||
String? get passwordPolicyHint {
|
||||
final parts = <String>[];
|
||||
final min = state.passwordMinLength;
|
||||
if (min != null && min > 0) parts.add('mind. $min Zeichen');
|
||||
if (state.passwordEnforceUpperLower) {
|
||||
parts.add('Groß- und Kleinbuchstaben');
|
||||
}
|
||||
if (state.passwordEnforceNumeric) parts.add('mind. eine Zahl');
|
||||
if (state.passwordEnforceSpecial) parts.add('mind. ein Sonderzeichen');
|
||||
if (parts.isEmpty) return null;
|
||||
return 'Anforderungen: ${parts.join(', ')}';
|
||||
}
|
||||
|
||||
/// Refreshes capabilities from the server. On any failure the previously
|
||||
/// hydrated flags are kept but the state is marked `loaded`.
|
||||
Future<void> load() async {
|
||||
try {
|
||||
final caps = await GetNextcloudCapabilities().run();
|
||||
emit(
|
||||
NextcloudCapabilitiesState(
|
||||
apiEnabled: caps.apiEnabled,
|
||||
publicEnabled: caps.publicEnabled,
|
||||
publicMultipleLinks: caps.publicMultipleLinks,
|
||||
publicUploadEnabled: caps.publicUploadEnabled,
|
||||
publicPasswordEnforced: caps.publicPasswordEnforced,
|
||||
publicExpireEnabled: caps.publicExpireEnabled,
|
||||
publicExpireDays: caps.publicExpireDays,
|
||||
publicExpireEnforced: caps.publicExpireEnforced,
|
||||
groupEnabled: caps.groupEnabled,
|
||||
resharing: caps.resharing,
|
||||
passwordMinLength: caps.passwordMinLength,
|
||||
passwordEnforceUpperLower: caps.passwordEnforceUpperLower,
|
||||
passwordEnforceNumeric: caps.passwordEnforceNumeric,
|
||||
passwordEnforceSpecial: caps.passwordEnforceSpecial,
|
||||
loaded: true,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
log('Failed to load Nextcloud capabilities: $e');
|
||||
emit(state.copyWith(loaded: true));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reset() async => emit(const NextcloudCapabilitiesState());
|
||||
|
||||
@override
|
||||
NextcloudCapabilitiesState fromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
return NextcloudCapabilitiesState.fromJson(json);
|
||||
} catch (_) {
|
||||
return const NextcloudCapabilitiesState();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic>? toJson(NextcloudCapabilitiesState state) =>
|
||||
state.toJson();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'nextcloud_capabilities_state.freezed.dart';
|
||||
part 'nextcloud_capabilities_state.g.dart';
|
||||
|
||||
/// Hydrated snapshot of the user's Nextcloud `files_sharing` capabilities.
|
||||
/// Mirrors the fields the sharing UI gates on; see
|
||||
/// `NextcloudSharingCapabilities` for the per-field meaning.
|
||||
@freezed
|
||||
abstract class NextcloudCapabilitiesState with _$NextcloudCapabilitiesState {
|
||||
const factory NextcloudCapabilitiesState({
|
||||
@Default(false) bool apiEnabled,
|
||||
@Default(false) bool publicEnabled,
|
||||
@Default(false) bool publicMultipleLinks,
|
||||
@Default(false) bool publicUploadEnabled,
|
||||
@Default(false) bool publicPasswordEnforced,
|
||||
@Default(false) bool publicExpireEnabled,
|
||||
int? publicExpireDays,
|
||||
@Default(false) bool publicExpireEnforced,
|
||||
@Default(false) bool groupEnabled,
|
||||
@Default(false) bool resharing,
|
||||
int? passwordMinLength,
|
||||
@Default(false) bool passwordEnforceUpperLower,
|
||||
@Default(false) bool passwordEnforceNumeric,
|
||||
@Default(false) bool passwordEnforceSpecial,
|
||||
// Whether a capability response (or a definitive failure) has been observed
|
||||
// at least once this session.
|
||||
@Default(false) bool loaded,
|
||||
}) = _NextcloudCapabilitiesState;
|
||||
|
||||
factory NextcloudCapabilitiesState.fromJson(Map<String, Object?> json) =>
|
||||
_$NextcloudCapabilitiesStateFromJson(json);
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'nextcloud_capabilities_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$NextcloudCapabilitiesState {
|
||||
|
||||
bool get apiEnabled; bool get publicEnabled; bool get publicMultipleLinks; bool get publicUploadEnabled; bool get publicPasswordEnforced; bool get publicExpireEnabled; int? get publicExpireDays; bool get publicExpireEnforced; bool get groupEnabled; bool get resharing; int? get passwordMinLength; bool get passwordEnforceUpperLower; bool get passwordEnforceNumeric; bool get passwordEnforceSpecial;// Whether a capability response (or a definitive failure) has been observed
|
||||
// at least once this session.
|
||||
bool get loaded;
|
||||
/// Create a copy of NextcloudCapabilitiesState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$NextcloudCapabilitiesStateCopyWith<NextcloudCapabilitiesState> get copyWith => _$NextcloudCapabilitiesStateCopyWithImpl<NextcloudCapabilitiesState>(this as NextcloudCapabilitiesState, _$identity);
|
||||
|
||||
/// Serializes this NextcloudCapabilitiesState to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is NextcloudCapabilitiesState&&(identical(other.apiEnabled, apiEnabled) || other.apiEnabled == apiEnabled)&&(identical(other.publicEnabled, publicEnabled) || other.publicEnabled == publicEnabled)&&(identical(other.publicMultipleLinks, publicMultipleLinks) || other.publicMultipleLinks == publicMultipleLinks)&&(identical(other.publicUploadEnabled, publicUploadEnabled) || other.publicUploadEnabled == publicUploadEnabled)&&(identical(other.publicPasswordEnforced, publicPasswordEnforced) || other.publicPasswordEnforced == publicPasswordEnforced)&&(identical(other.publicExpireEnabled, publicExpireEnabled) || other.publicExpireEnabled == publicExpireEnabled)&&(identical(other.publicExpireDays, publicExpireDays) || other.publicExpireDays == publicExpireDays)&&(identical(other.publicExpireEnforced, publicExpireEnforced) || other.publicExpireEnforced == publicExpireEnforced)&&(identical(other.groupEnabled, groupEnabled) || other.groupEnabled == groupEnabled)&&(identical(other.resharing, resharing) || other.resharing == resharing)&&(identical(other.passwordMinLength, passwordMinLength) || other.passwordMinLength == passwordMinLength)&&(identical(other.passwordEnforceUpperLower, passwordEnforceUpperLower) || other.passwordEnforceUpperLower == passwordEnforceUpperLower)&&(identical(other.passwordEnforceNumeric, passwordEnforceNumeric) || other.passwordEnforceNumeric == passwordEnforceNumeric)&&(identical(other.passwordEnforceSpecial, passwordEnforceSpecial) || other.passwordEnforceSpecial == passwordEnforceSpecial)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,apiEnabled,publicEnabled,publicMultipleLinks,publicUploadEnabled,publicPasswordEnforced,publicExpireEnabled,publicExpireDays,publicExpireEnforced,groupEnabled,resharing,passwordMinLength,passwordEnforceUpperLower,passwordEnforceNumeric,passwordEnforceSpecial,loaded);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NextcloudCapabilitiesState(apiEnabled: $apiEnabled, publicEnabled: $publicEnabled, publicMultipleLinks: $publicMultipleLinks, publicUploadEnabled: $publicUploadEnabled, publicPasswordEnforced: $publicPasswordEnforced, publicExpireEnabled: $publicExpireEnabled, publicExpireDays: $publicExpireDays, publicExpireEnforced: $publicExpireEnforced, groupEnabled: $groupEnabled, resharing: $resharing, passwordMinLength: $passwordMinLength, passwordEnforceUpperLower: $passwordEnforceUpperLower, passwordEnforceNumeric: $passwordEnforceNumeric, passwordEnforceSpecial: $passwordEnforceSpecial, loaded: $loaded)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $NextcloudCapabilitiesStateCopyWith<$Res> {
|
||||
factory $NextcloudCapabilitiesStateCopyWith(NextcloudCapabilitiesState value, $Res Function(NextcloudCapabilitiesState) _then) = _$NextcloudCapabilitiesStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool apiEnabled, bool publicEnabled, bool publicMultipleLinks, bool publicUploadEnabled, bool publicPasswordEnforced, bool publicExpireEnabled, int? publicExpireDays, bool publicExpireEnforced, bool groupEnabled, bool resharing, int? passwordMinLength, bool passwordEnforceUpperLower, bool passwordEnforceNumeric, bool passwordEnforceSpecial, bool loaded
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$NextcloudCapabilitiesStateCopyWithImpl<$Res>
|
||||
implements $NextcloudCapabilitiesStateCopyWith<$Res> {
|
||||
_$NextcloudCapabilitiesStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final NextcloudCapabilitiesState _self;
|
||||
final $Res Function(NextcloudCapabilitiesState) _then;
|
||||
|
||||
/// Create a copy of NextcloudCapabilitiesState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? apiEnabled = null,Object? publicEnabled = null,Object? publicMultipleLinks = null,Object? publicUploadEnabled = null,Object? publicPasswordEnforced = null,Object? publicExpireEnabled = null,Object? publicExpireDays = freezed,Object? publicExpireEnforced = null,Object? groupEnabled = null,Object? resharing = null,Object? passwordMinLength = freezed,Object? passwordEnforceUpperLower = null,Object? passwordEnforceNumeric = null,Object? passwordEnforceSpecial = null,Object? loaded = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
apiEnabled: null == apiEnabled ? _self.apiEnabled : apiEnabled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicEnabled: null == publicEnabled ? _self.publicEnabled : publicEnabled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicMultipleLinks: null == publicMultipleLinks ? _self.publicMultipleLinks : publicMultipleLinks // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicUploadEnabled: null == publicUploadEnabled ? _self.publicUploadEnabled : publicUploadEnabled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicPasswordEnforced: null == publicPasswordEnforced ? _self.publicPasswordEnforced : publicPasswordEnforced // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicExpireEnabled: null == publicExpireEnabled ? _self.publicExpireEnabled : publicExpireEnabled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicExpireDays: freezed == publicExpireDays ? _self.publicExpireDays : publicExpireDays // ignore: cast_nullable_to_non_nullable
|
||||
as int?,publicExpireEnforced: null == publicExpireEnforced ? _self.publicExpireEnforced : publicExpireEnforced // ignore: cast_nullable_to_non_nullable
|
||||
as bool,groupEnabled: null == groupEnabled ? _self.groupEnabled : groupEnabled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,resharing: null == resharing ? _self.resharing : resharing // ignore: cast_nullable_to_non_nullable
|
||||
as bool,passwordMinLength: freezed == passwordMinLength ? _self.passwordMinLength : passwordMinLength // ignore: cast_nullable_to_non_nullable
|
||||
as int?,passwordEnforceUpperLower: null == passwordEnforceUpperLower ? _self.passwordEnforceUpperLower : passwordEnforceUpperLower // ignore: cast_nullable_to_non_nullable
|
||||
as bool,passwordEnforceNumeric: null == passwordEnforceNumeric ? _self.passwordEnforceNumeric : passwordEnforceNumeric // ignore: cast_nullable_to_non_nullable
|
||||
as bool,passwordEnforceSpecial: null == passwordEnforceSpecial ? _self.passwordEnforceSpecial : passwordEnforceSpecial // ignore: cast_nullable_to_non_nullable
|
||||
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [NextcloudCapabilitiesState].
|
||||
extension NextcloudCapabilitiesStatePatterns on NextcloudCapabilitiesState {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _NextcloudCapabilitiesState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _NextcloudCapabilitiesState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _NextcloudCapabilitiesState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _NextcloudCapabilitiesState():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _NextcloudCapabilitiesState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _NextcloudCapabilitiesState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool apiEnabled, bool publicEnabled, bool publicMultipleLinks, bool publicUploadEnabled, bool publicPasswordEnforced, bool publicExpireEnabled, int? publicExpireDays, bool publicExpireEnforced, bool groupEnabled, bool resharing, int? passwordMinLength, bool passwordEnforceUpperLower, bool passwordEnforceNumeric, bool passwordEnforceSpecial, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _NextcloudCapabilitiesState() when $default != null:
|
||||
return $default(_that.apiEnabled,_that.publicEnabled,_that.publicMultipleLinks,_that.publicUploadEnabled,_that.publicPasswordEnforced,_that.publicExpireEnabled,_that.publicExpireDays,_that.publicExpireEnforced,_that.groupEnabled,_that.resharing,_that.passwordMinLength,_that.passwordEnforceUpperLower,_that.passwordEnforceNumeric,_that.passwordEnforceSpecial,_that.loaded);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool apiEnabled, bool publicEnabled, bool publicMultipleLinks, bool publicUploadEnabled, bool publicPasswordEnforced, bool publicExpireEnabled, int? publicExpireDays, bool publicExpireEnforced, bool groupEnabled, bool resharing, int? passwordMinLength, bool passwordEnforceUpperLower, bool passwordEnforceNumeric, bool passwordEnforceSpecial, bool loaded) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _NextcloudCapabilitiesState():
|
||||
return $default(_that.apiEnabled,_that.publicEnabled,_that.publicMultipleLinks,_that.publicUploadEnabled,_that.publicPasswordEnforced,_that.publicExpireEnabled,_that.publicExpireDays,_that.publicExpireEnforced,_that.groupEnabled,_that.resharing,_that.passwordMinLength,_that.passwordEnforceUpperLower,_that.passwordEnforceNumeric,_that.passwordEnforceSpecial,_that.loaded);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool apiEnabled, bool publicEnabled, bool publicMultipleLinks, bool publicUploadEnabled, bool publicPasswordEnforced, bool publicExpireEnabled, int? publicExpireDays, bool publicExpireEnforced, bool groupEnabled, bool resharing, int? passwordMinLength, bool passwordEnforceUpperLower, bool passwordEnforceNumeric, bool passwordEnforceSpecial, bool loaded)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _NextcloudCapabilitiesState() when $default != null:
|
||||
return $default(_that.apiEnabled,_that.publicEnabled,_that.publicMultipleLinks,_that.publicUploadEnabled,_that.publicPasswordEnforced,_that.publicExpireEnabled,_that.publicExpireDays,_that.publicExpireEnforced,_that.groupEnabled,_that.resharing,_that.passwordMinLength,_that.passwordEnforceUpperLower,_that.passwordEnforceNumeric,_that.passwordEnforceSpecial,_that.loaded);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _NextcloudCapabilitiesState implements NextcloudCapabilitiesState {
|
||||
const _NextcloudCapabilitiesState({this.apiEnabled = false, this.publicEnabled = false, this.publicMultipleLinks = false, this.publicUploadEnabled = false, this.publicPasswordEnforced = false, this.publicExpireEnabled = false, this.publicExpireDays, this.publicExpireEnforced = false, this.groupEnabled = false, this.resharing = false, this.passwordMinLength, this.passwordEnforceUpperLower = false, this.passwordEnforceNumeric = false, this.passwordEnforceSpecial = false, this.loaded = false});
|
||||
factory _NextcloudCapabilitiesState.fromJson(Map<String, dynamic> json) => _$NextcloudCapabilitiesStateFromJson(json);
|
||||
|
||||
@override@JsonKey() final bool apiEnabled;
|
||||
@override@JsonKey() final bool publicEnabled;
|
||||
@override@JsonKey() final bool publicMultipleLinks;
|
||||
@override@JsonKey() final bool publicUploadEnabled;
|
||||
@override@JsonKey() final bool publicPasswordEnforced;
|
||||
@override@JsonKey() final bool publicExpireEnabled;
|
||||
@override final int? publicExpireDays;
|
||||
@override@JsonKey() final bool publicExpireEnforced;
|
||||
@override@JsonKey() final bool groupEnabled;
|
||||
@override@JsonKey() final bool resharing;
|
||||
@override final int? passwordMinLength;
|
||||
@override@JsonKey() final bool passwordEnforceUpperLower;
|
||||
@override@JsonKey() final bool passwordEnforceNumeric;
|
||||
@override@JsonKey() final bool passwordEnforceSpecial;
|
||||
// Whether a capability response (or a definitive failure) has been observed
|
||||
// at least once this session.
|
||||
@override@JsonKey() final bool loaded;
|
||||
|
||||
/// Create a copy of NextcloudCapabilitiesState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$NextcloudCapabilitiesStateCopyWith<_NextcloudCapabilitiesState> get copyWith => __$NextcloudCapabilitiesStateCopyWithImpl<_NextcloudCapabilitiesState>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$NextcloudCapabilitiesStateToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _NextcloudCapabilitiesState&&(identical(other.apiEnabled, apiEnabled) || other.apiEnabled == apiEnabled)&&(identical(other.publicEnabled, publicEnabled) || other.publicEnabled == publicEnabled)&&(identical(other.publicMultipleLinks, publicMultipleLinks) || other.publicMultipleLinks == publicMultipleLinks)&&(identical(other.publicUploadEnabled, publicUploadEnabled) || other.publicUploadEnabled == publicUploadEnabled)&&(identical(other.publicPasswordEnforced, publicPasswordEnforced) || other.publicPasswordEnforced == publicPasswordEnforced)&&(identical(other.publicExpireEnabled, publicExpireEnabled) || other.publicExpireEnabled == publicExpireEnabled)&&(identical(other.publicExpireDays, publicExpireDays) || other.publicExpireDays == publicExpireDays)&&(identical(other.publicExpireEnforced, publicExpireEnforced) || other.publicExpireEnforced == publicExpireEnforced)&&(identical(other.groupEnabled, groupEnabled) || other.groupEnabled == groupEnabled)&&(identical(other.resharing, resharing) || other.resharing == resharing)&&(identical(other.passwordMinLength, passwordMinLength) || other.passwordMinLength == passwordMinLength)&&(identical(other.passwordEnforceUpperLower, passwordEnforceUpperLower) || other.passwordEnforceUpperLower == passwordEnforceUpperLower)&&(identical(other.passwordEnforceNumeric, passwordEnforceNumeric) || other.passwordEnforceNumeric == passwordEnforceNumeric)&&(identical(other.passwordEnforceSpecial, passwordEnforceSpecial) || other.passwordEnforceSpecial == passwordEnforceSpecial)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,apiEnabled,publicEnabled,publicMultipleLinks,publicUploadEnabled,publicPasswordEnforced,publicExpireEnabled,publicExpireDays,publicExpireEnforced,groupEnabled,resharing,passwordMinLength,passwordEnforceUpperLower,passwordEnforceNumeric,passwordEnforceSpecial,loaded);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NextcloudCapabilitiesState(apiEnabled: $apiEnabled, publicEnabled: $publicEnabled, publicMultipleLinks: $publicMultipleLinks, publicUploadEnabled: $publicUploadEnabled, publicPasswordEnforced: $publicPasswordEnforced, publicExpireEnabled: $publicExpireEnabled, publicExpireDays: $publicExpireDays, publicExpireEnforced: $publicExpireEnforced, groupEnabled: $groupEnabled, resharing: $resharing, passwordMinLength: $passwordMinLength, passwordEnforceUpperLower: $passwordEnforceUpperLower, passwordEnforceNumeric: $passwordEnforceNumeric, passwordEnforceSpecial: $passwordEnforceSpecial, loaded: $loaded)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$NextcloudCapabilitiesStateCopyWith<$Res> implements $NextcloudCapabilitiesStateCopyWith<$Res> {
|
||||
factory _$NextcloudCapabilitiesStateCopyWith(_NextcloudCapabilitiesState value, $Res Function(_NextcloudCapabilitiesState) _then) = __$NextcloudCapabilitiesStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
bool apiEnabled, bool publicEnabled, bool publicMultipleLinks, bool publicUploadEnabled, bool publicPasswordEnforced, bool publicExpireEnabled, int? publicExpireDays, bool publicExpireEnforced, bool groupEnabled, bool resharing, int? passwordMinLength, bool passwordEnforceUpperLower, bool passwordEnforceNumeric, bool passwordEnforceSpecial, bool loaded
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$NextcloudCapabilitiesStateCopyWithImpl<$Res>
|
||||
implements _$NextcloudCapabilitiesStateCopyWith<$Res> {
|
||||
__$NextcloudCapabilitiesStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _NextcloudCapabilitiesState _self;
|
||||
final $Res Function(_NextcloudCapabilitiesState) _then;
|
||||
|
||||
/// Create a copy of NextcloudCapabilitiesState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? apiEnabled = null,Object? publicEnabled = null,Object? publicMultipleLinks = null,Object? publicUploadEnabled = null,Object? publicPasswordEnforced = null,Object? publicExpireEnabled = null,Object? publicExpireDays = freezed,Object? publicExpireEnforced = null,Object? groupEnabled = null,Object? resharing = null,Object? passwordMinLength = freezed,Object? passwordEnforceUpperLower = null,Object? passwordEnforceNumeric = null,Object? passwordEnforceSpecial = null,Object? loaded = null,}) {
|
||||
return _then(_NextcloudCapabilitiesState(
|
||||
apiEnabled: null == apiEnabled ? _self.apiEnabled : apiEnabled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicEnabled: null == publicEnabled ? _self.publicEnabled : publicEnabled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicMultipleLinks: null == publicMultipleLinks ? _self.publicMultipleLinks : publicMultipleLinks // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicUploadEnabled: null == publicUploadEnabled ? _self.publicUploadEnabled : publicUploadEnabled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicPasswordEnforced: null == publicPasswordEnforced ? _self.publicPasswordEnforced : publicPasswordEnforced // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicExpireEnabled: null == publicExpireEnabled ? _self.publicExpireEnabled : publicExpireEnabled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,publicExpireDays: freezed == publicExpireDays ? _self.publicExpireDays : publicExpireDays // ignore: cast_nullable_to_non_nullable
|
||||
as int?,publicExpireEnforced: null == publicExpireEnforced ? _self.publicExpireEnforced : publicExpireEnforced // ignore: cast_nullable_to_non_nullable
|
||||
as bool,groupEnabled: null == groupEnabled ? _self.groupEnabled : groupEnabled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,resharing: null == resharing ? _self.resharing : resharing // ignore: cast_nullable_to_non_nullable
|
||||
as bool,passwordMinLength: freezed == passwordMinLength ? _self.passwordMinLength : passwordMinLength // ignore: cast_nullable_to_non_nullable
|
||||
as int?,passwordEnforceUpperLower: null == passwordEnforceUpperLower ? _self.passwordEnforceUpperLower : passwordEnforceUpperLower // ignore: cast_nullable_to_non_nullable
|
||||
as bool,passwordEnforceNumeric: null == passwordEnforceNumeric ? _self.passwordEnforceNumeric : passwordEnforceNumeric // ignore: cast_nullable_to_non_nullable
|
||||
as bool,passwordEnforceSpecial: null == passwordEnforceSpecial ? _self.passwordEnforceSpecial : passwordEnforceSpecial // ignore: cast_nullable_to_non_nullable
|
||||
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,48 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'nextcloud_capabilities_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_NextcloudCapabilitiesState _$NextcloudCapabilitiesStateFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _NextcloudCapabilitiesState(
|
||||
apiEnabled: json['apiEnabled'] as bool? ?? false,
|
||||
publicEnabled: json['publicEnabled'] as bool? ?? false,
|
||||
publicMultipleLinks: json['publicMultipleLinks'] as bool? ?? false,
|
||||
publicUploadEnabled: json['publicUploadEnabled'] as bool? ?? false,
|
||||
publicPasswordEnforced: json['publicPasswordEnforced'] as bool? ?? false,
|
||||
publicExpireEnabled: json['publicExpireEnabled'] as bool? ?? false,
|
||||
publicExpireDays: (json['publicExpireDays'] as num?)?.toInt(),
|
||||
publicExpireEnforced: json['publicExpireEnforced'] as bool? ?? false,
|
||||
groupEnabled: json['groupEnabled'] as bool? ?? false,
|
||||
resharing: json['resharing'] as bool? ?? false,
|
||||
passwordMinLength: (json['passwordMinLength'] as num?)?.toInt(),
|
||||
passwordEnforceUpperLower:
|
||||
json['passwordEnforceUpperLower'] as bool? ?? false,
|
||||
passwordEnforceNumeric: json['passwordEnforceNumeric'] as bool? ?? false,
|
||||
passwordEnforceSpecial: json['passwordEnforceSpecial'] as bool? ?? false,
|
||||
loaded: json['loaded'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$NextcloudCapabilitiesStateToJson(
|
||||
_NextcloudCapabilitiesState instance,
|
||||
) => <String, dynamic>{
|
||||
'apiEnabled': instance.apiEnabled,
|
||||
'publicEnabled': instance.publicEnabled,
|
||||
'publicMultipleLinks': instance.publicMultipleLinks,
|
||||
'publicUploadEnabled': instance.publicUploadEnabled,
|
||||
'publicPasswordEnforced': instance.publicPasswordEnforced,
|
||||
'publicExpireEnabled': instance.publicExpireEnabled,
|
||||
'publicExpireDays': instance.publicExpireDays,
|
||||
'publicExpireEnforced': instance.publicExpireEnforced,
|
||||
'groupEnabled': instance.groupEnabled,
|
||||
'resharing': instance.resharing,
|
||||
'passwordMinLength': instance.passwordMinLength,
|
||||
'passwordEnforceUpperLower': instance.passwordEnforceUpperLower,
|
||||
'passwordEnforceNumeric': instance.passwordEnforceNumeric,
|
||||
'passwordEnforceSpecial': instance.passwordEnforceSpecial,
|
||||
'loaded': instance.loaded,
|
||||
};
|
||||
@@ -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