Add Trash/Shares/Recent tabs, file sharing, Details sheet, and rebrand to Noo

Major additions since the initial rebuild:
- New tabs: Trash, Shares, Recent, configurable tab visibility/order/default
- Nextcloud sharing: search users/teams, internal link, email shares,
  public links, "others with access" - in its own Share bottom sheet
- Details bottom sheet (Info/Versions/Activity) with a persistent peek
  panel in the media viewer, reachable from selection, swipe, and the
  media viewer
- Files/Photos: tap-and-hold multi-select with a sticky selection toolbar,
  swipe actions (favorite/delete/share), sticky sort/filter controls,
  list/grid views, breadcrumb chips, whole-account Photos view
- Media viewer: pinch/double-tap zoom on images, predictive-back support,
  fixed PDF zoom-out floor
- Settings: Material You theming incl. AMOLED black, configurable bottom
  bar tabs, swipe actions, media seek bar style, and folder-listing cache
  policy (never/periodic/manual) to cut down on refetching
- Login flow moved to an in-app Chrome Custom Tab with retry-tolerant
  polling
- Replaced ad hoc widgets with native Material 3 equivalents (toggle
  IconButtons, SliverAppBar selection toolbar, SearchBar, drag handles)
- Rebrand: app renamed to Noo throughout, package id -> dev.ayushya.noo,
  new adaptive launcher icon

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 22:44:42 -04:00
co-authored by Claude Sonnet 5
parent f62d912ef7
commit d68cc43368
75 changed files with 6491 additions and 1213 deletions
+930 -1
View File
@@ -4,7 +4,10 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart' as xml;
import '../models/nextcloud_file_version.dart';
import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart';
extension XmlElementHelper on xml.XmlNode {
Iterable<xml.XmlElement> findLocalChildren(String localName) {
@@ -80,7 +83,7 @@ class NextcloudService {
'Authorization': 'Basic $credentials',
'OCS-APIRequest': 'true',
'Accept': 'application/json, application/xml',
'User-Agent': 'Nextcloud-Flutter-Client/1.0',
'User-Agent': 'Noo/1.0',
};
}
@@ -523,6 +526,332 @@ class NextcloudService {
return results;
}
/// Finds every image/video anywhere in the account (not just the current
/// folder) via the same WebDAV SEARCH-REPORT mechanism Nextcloud's own
/// Files app uses for its "Photos" filter, so the Photos tab can show a
/// true all-folders library instead of just the currently browsed folder.
Future<List<NextcloudItem>> fetchAllMedia() async {
final url = '$_cleanServerUrl/remote.php/dav/';
debugPrint('[Nextcloud DAV] SEARCH for all media from $url');
final body =
'''<?xml version="1.0" encoding="utf-8" ?>
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:basicsearch>
<d:select>
<d:prop>
<d:displayname/>
<d:getcontentlength/>
<d:getlastmodified/>
<d:creationdate/>
<d:getcontenttype/>
<d:resourcetype/>
<oc:favorite/>
<oc:fileid/>
<nc:mount-type/>
</d:prop>
</d:select>
<d:from>
<d:scope>
<d:href>/files/$username</d:href>
<d:depth>infinity</d:depth>
</d:scope>
</d:from>
<d:where>
<d:or>
<d:like>
<d:prop><d:getcontenttype/></d:prop>
<d:literal>image/%</d:literal>
</d:like>
<d:like>
<d:prop><d:getcontenttype/></d:prop>
<d:literal>video/%</d:literal>
</d:like>
</d:or>
</d:where>
<d:orderby>
<d:order>
<d:prop><d:getlastmodified/></d:prop>
<d:descending/>
</d:order>
</d:orderby>
<d:limit>
<d:nresults>2000</d:nresults>
</d:limit>
</d:basicsearch>
</d:searchrequest>''';
final request = http.Request('SEARCH', Uri.parse(url))
..headers.addAll({..._headers, 'Content-Type': 'text/xml'})
..body = body;
final streamed = await http.Client().send(request);
final responseBody = await streamed.stream.bytesToString();
debugPrint(
'[Nextcloud DAV] All-media SEARCH status: ${streamed.statusCode}, bytes: ${responseBody.length}',
);
if (streamed.statusCode != 207 && streamed.statusCode != 200) {
throw Exception('Failed to load media. HTTP ${streamed.statusCode}');
}
final document = xml.XmlDocument.parse(responseBody);
final responses = document.findLocalDescendants('response');
final results = <NextcloudItem>[];
for (var res in responses) {
final hrefNode =
res.findLocalChildren('href').firstOrNull ??
res.findLocalDescendants('href').firstOrNull;
final href = hrefNode?.innerText ?? '';
if (href.isEmpty) continue;
final props = res.findLocalDescendants('prop');
if (props.isEmpty) continue;
String? sizeStr;
String? lastModStr;
String? createdStr;
String? mimeType;
bool isFav = false;
String? fileId;
String? mountType;
for (var prop in props) {
sizeStr ??= prop
.findLocalChildren('getcontentlength')
.firstOrNull
?.innerText;
lastModStr ??= prop
.findLocalChildren('getlastmodified')
.firstOrNull
?.innerText;
createdStr ??= prop
.findLocalChildren('creationdate')
.firstOrNull
?.innerText;
mimeType ??= prop
.findLocalChildren('getcontenttype')
.firstOrNull
?.innerText;
if (!isFav) {
isFav =
prop.findLocalChildren('favorite').firstOrNull?.innerText == '1';
}
fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText;
mountType ??= prop
.findLocalChildren('mount-type')
.firstOrNull
?.innerText;
}
var decodedHref = Uri.decodeFull(href);
if (decodedHref.endsWith('/') && decodedHref.length > 1) {
decodedHref = decodedHref.substring(0, decodedHref.length - 1);
}
final name = decodedHref.split('/').where((s) => s.isNotEmpty).last;
if (name.isEmpty) continue;
// Strip the DAV root prefix so `path` matches what fetchDirectory produces.
final marker = '/files/$username';
final markerIndex = decodedHref.indexOf(marker);
final itemPath = markerIndex >= 0
? decodedHref.substring(markerIndex + marker.length)
: decodedHref;
if (itemPath.isEmpty) continue;
final size = int.tryParse(sizeStr ?? '0') ?? 0;
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final created = _parseDavDate(createdStr);
final itemType = NextcloudItem.deduceType(name, false, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
results.add(
NextcloudItem(
id: validId,
name: name,
path: itemPath,
type: itemType,
size: size,
lastModified: lastMod,
dateCreated: created,
isFavorite: isFav,
mimeType: mimeType,
previewUrl:
'$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500',
mountType: mountType,
),
);
}
debugPrint(
'[Nextcloud DAV] All-media SEARCH found ${results.length} items',
);
return results;
}
/// Finds recently modified files across the whole account (folders
/// excluded), newest first — same SEARCH-REPORT mechanism as
/// [fetchAllMedia], just without the image/video mimetype filter.
Future<List<NextcloudItem>> fetchRecentFiles() async {
final url = '$_cleanServerUrl/remote.php/dav/';
debugPrint('[Nextcloud DAV] SEARCH for recent files from $url');
final body =
'''<?xml version="1.0" encoding="utf-8" ?>
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:basicsearch>
<d:select>
<d:prop>
<d:displayname/>
<d:getcontentlength/>
<d:getlastmodified/>
<d:creationdate/>
<d:getcontenttype/>
<d:resourcetype/>
<oc:favorite/>
<oc:fileid/>
</d:prop>
</d:select>
<d:from>
<d:scope>
<d:href>/files/$username</d:href>
<d:depth>infinity</d:depth>
</d:scope>
</d:from>
<d:where>
<d:like>
<d:prop><d:displayname/></d:prop>
<d:literal>%</d:literal>
</d:like>
</d:where>
<d:orderby>
<d:order>
<d:prop><d:getlastmodified/></d:prop>
<d:descending/>
</d:order>
</d:orderby>
<d:limit>
<d:nresults>200</d:nresults>
</d:limit>
</d:basicsearch>
</d:searchrequest>''';
final request = http.Request('SEARCH', Uri.parse(url))
..headers.addAll({..._headers, 'Content-Type': 'text/xml'})
..body = body;
final streamed = await http.Client().send(request);
final responseBody = await streamed.stream.bytesToString();
debugPrint(
'[Nextcloud DAV] Recent SEARCH status: ${streamed.statusCode}, bytes: ${responseBody.length}',
);
if (streamed.statusCode != 207 && streamed.statusCode != 200) {
throw Exception(
'Failed to load recent files. HTTP ${streamed.statusCode}',
);
}
final document = xml.XmlDocument.parse(responseBody);
final responses = document.findLocalDescendants('response');
final results = <NextcloudItem>[];
for (var res in responses) {
final hrefNode =
res.findLocalChildren('href').firstOrNull ??
res.findLocalDescendants('href').firstOrNull;
final href = hrefNode?.innerText ?? '';
if (href.isEmpty) continue;
final props = res.findLocalDescendants('prop');
if (props.isEmpty) continue;
bool isCollection = false;
String? sizeStr;
String? lastModStr;
String? createdStr;
String? mimeType;
bool isFav = false;
String? fileId;
for (var prop in props) {
if (!isCollection) {
final resTypeNode = prop
.findLocalChildren('resourcetype')
.firstOrNull;
if (resTypeNode != null &&
resTypeNode.findLocalChildren('collection').isNotEmpty) {
isCollection = true;
}
}
sizeStr ??= prop
.findLocalChildren('getcontentlength')
.firstOrNull
?.innerText;
lastModStr ??= prop
.findLocalChildren('getlastmodified')
.firstOrNull
?.innerText;
createdStr ??= prop
.findLocalChildren('creationdate')
.firstOrNull
?.innerText;
mimeType ??= prop
.findLocalChildren('getcontenttype')
.firstOrNull
?.innerText;
if (!isFav) {
isFav =
prop.findLocalChildren('favorite').firstOrNull?.innerText == '1';
}
fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText;
}
if (isCollection) continue; // folders aren't "recent files"
var decodedHref = Uri.decodeFull(href);
if (decodedHref.endsWith('/') && decodedHref.length > 1) {
decodedHref = decodedHref.substring(0, decodedHref.length - 1);
}
final name = decodedHref.split('/').where((s) => s.isNotEmpty).last;
if (name.isEmpty) continue;
// Strip the DAV root prefix so `path` matches what fetchDirectory produces.
final marker = '/files/$username';
final markerIndex = decodedHref.indexOf(marker);
final itemPath = markerIndex >= 0
? decodedHref.substring(markerIndex + marker.length)
: decodedHref;
if (itemPath.isEmpty) continue;
final size = int.tryParse(sizeStr ?? '0') ?? 0;
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final created = _parseDavDate(createdStr);
final itemType = NextcloudItem.deduceType(name, false, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
results.add(
NextcloudItem(
id: validId,
name: name,
path: itemPath,
type: itemType,
size: size,
lastModified: lastMod,
dateCreated: created,
isFavorite: isFav,
mimeType: mimeType,
previewUrl:
'$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500',
),
);
}
debugPrint('[Nextcloud DAV] Recent SEARCH found ${results.length} items');
return results;
}
Future<NextcloudUserQuota> fetchUserQuota() async {
final url = '$_cleanServerUrl/ocs/v1.php/cloud/user?format=json';
debugPrint('[Nextcloud OCS] Fetching user quota from $url');
@@ -665,6 +994,606 @@ class NextcloudService {
return response.statusCode == 201;
}
/// Lists the contents of the Nextcloud trash bin (flat — deleted folders
/// appear as a single entry, not expanded).
Future<List<NextcloudItem>> fetchTrash() async {
final url = '$_cleanServerUrl/remote.php/dav/trashbin/$username/trash/';
debugPrint('[Nextcloud DAV] Requesting PROPFIND for trash: $url');
final request = http.Request('PROPFIND', Uri.parse(url))
..headers.addAll({
..._headers,
'Depth': '1',
'Content-Type': 'application/xml',
});
const body = '''<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<d:getlastmodified/>
<d:getcontentlength/>
<d:getcontenttype/>
<d:resourcetype/>
<oc:fileid/>
<oc:size/>
<nc:trashbin-original-location/>
<nc:trashbin-deletion-time/>
</d:prop>
</d:propfind>''';
request.body = body;
final streamedResponse = await http.Client().send(request);
final responseBody = await streamedResponse.stream.bytesToString();
debugPrint(
'[Nextcloud DAV] Trash PROPFIND status: ${streamedResponse.statusCode}, bytes: ${responseBody.length}',
);
if (streamedResponse.statusCode != 207 &&
streamedResponse.statusCode != 200) {
throw Exception(
'Failed to load trash. HTTP ${streamedResponse.statusCode}',
);
}
final document = xml.XmlDocument.parse(responseBody);
final responses = document.findLocalDescendants('response');
final targetPath = _davPath(
'$_cleanServerUrl/remote.php/dav/trashbin/$username/trash/',
);
final items = <NextcloudItem>[];
for (var res in responses) {
final hrefNode =
res.findLocalChildren('href').firstOrNull ??
res.findLocalDescendants('href').firstOrNull;
final href = hrefNode?.innerText ?? '';
final hrefPath = _davPath(Uri.decodeFull(href));
if (hrefPath == targetPath) continue; // skip the trash root itself
final props = res.findLocalDescendants('prop');
if (props.isEmpty) continue;
bool isCollection = false;
String? sizeStr;
String? mimeType;
String? fileId;
String? originalLocation;
String? deletionTimeStr;
for (var prop in props) {
if (!isCollection) {
final resTypeNode = prop
.findLocalChildren('resourcetype')
.firstOrNull;
if (resTypeNode != null &&
resTypeNode.findLocalChildren('collection').isNotEmpty) {
isCollection = true;
}
}
sizeStr ??=
prop.findLocalChildren('size').firstOrNull?.innerText ??
prop.findLocalChildren('getcontentlength').firstOrNull?.innerText;
mimeType ??= prop
.findLocalChildren('getcontenttype')
.firstOrNull
?.innerText;
fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText;
originalLocation ??= prop
.findLocalChildren('trashbin-original-location')
.firstOrNull
?.innerText;
deletionTimeStr ??= prop
.findLocalChildren('trashbin-deletion-time')
.firstOrNull
?.innerText;
}
// The trash entry's own on-disk name, e.g. "photo.jpg.d1735689600" —
// this (not the display name) is what restore/delete-forever need.
final trashName = Uri.decodeFull(
href.split('/').where((s) => s.isNotEmpty).last,
);
if (trashName.isEmpty) continue;
final displayName =
(originalLocation != null && originalLocation.isNotEmpty)
? originalLocation.split('/').where((s) => s.isNotEmpty).last
: trashName;
final size = int.tryParse(sizeStr ?? '0') ?? 0;
final deletionTime = deletionTimeStr != null
? DateTime.fromMillisecondsSinceEpoch(
(int.tryParse(deletionTimeStr) ?? 0) * 1000,
)
: DateTime.now();
final itemType = NextcloudItem.deduceType(
displayName,
isCollection,
mimeType,
);
final validId = (fileId != null && fileId.isNotEmpty)
? fileId
: trashName;
items.add(
NextcloudItem(
id: validId,
name: displayName,
path: trashName,
type: itemType,
size: size,
lastModified: deletionTime,
originalLocation: originalLocation,
deletedAt: deletionTime,
),
);
}
debugPrint('[Nextcloud DAV] Loaded ${items.length} trash items');
return items;
}
/// Restores a trashed item (identified by its trash-relative name, i.e.
/// [NextcloudItem.path] for a trash item) back to its original location.
Future<bool> restoreTrashItem(String trashName) async {
final sourceUrl =
'$_cleanServerUrl/remote.php/dav/trashbin/$username/trash/$trashName';
final destUrl =
'$_cleanServerUrl/remote.php/dav/trashbin/$username/restore/$trashName';
debugPrint('[Nextcloud DAV] Restoring trash item $sourceUrl -> $destUrl');
final request = http.Request('MOVE', Uri.parse(sourceUrl))
..headers.addAll({..._headers, 'Destination': destUrl});
final response = await http.Client().send(request);
debugPrint('[Nextcloud DAV] Restore status: ${response.statusCode}');
return response.statusCode == 201 || response.statusCode == 204;
}
/// Permanently deletes a trashed item (identified by its trash-relative
/// name, i.e. [NextcloudItem.path] for a trash item). Cannot be undone.
Future<bool> deleteTrashItemForever(String trashName) async {
final url =
'$_cleanServerUrl/remote.php/dav/trashbin/$username/trash/$trashName';
debugPrint('[Nextcloud DAV] Permanently deleting trash item $url');
final response = await http.delete(Uri.parse(url), headers: _headers);
debugPrint(
'[Nextcloud DAV] Permanent delete status: ${response.statusCode}',
);
return response.statusCode == 204 || response.statusCode == 200;
}
ShareType _mapShareType(int value) {
switch (value) {
case 0:
return ShareType.user;
case 1:
return ShareType.group;
case 3:
return ShareType.publicLink;
case 4:
return ShareType.email;
case 6:
return ShareType.federated;
default:
return ShareType.other;
}
}
/// Lists shares: things the current user has shared with others by
/// default, or things others have shared with the current user when
/// [sharedWithMe] is true.
Future<List<NextcloudShare>> fetchShares({bool sharedWithMe = false}) async {
final url =
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares'
'?format=json${sharedWithMe ? '&shared_with_me=true' : ''}';
debugPrint(
'[Nextcloud OCS] Fetching shares (sharedWithMe=$sharedWithMe) from $url',
);
final response = await http.get(Uri.parse(url), headers: _headers);
debugPrint(
'[Nextcloud OCS] Shares response status: ${response.statusCode}',
);
if (response.statusCode != 200) {
throw Exception('Failed to load shares. HTTP ${response.statusCode}');
}
final data = jsonDecode(response.body);
final rawList = data['ocs']?['data'];
if (rawList is! List) return [];
return rawList.map<NextcloudShare>((raw) {
final path = (raw['path'] ?? '/').toString();
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
final name = segments.isNotEmpty ? segments.last : path;
final isFolder = (raw['item_type'] ?? '').toString() == 'folder';
final mimeType = raw['mimetype'] as String?;
return NextcloudShare(
id: (raw['id'] ?? '').toString(),
path: path,
name: name,
itemType: NextcloudItem.deduceType(name, isFolder, mimeType),
shareType: _mapShareType((raw['share_type'] as num?)?.toInt() ?? -1),
ownerDisplayName: (raw['displayname_owner'] ?? raw['uid_owner'] ?? '')
.toString(),
sharedWithDisplayName: raw['share_with_displayname'] as String?,
sharedAt: DateTime.fromMillisecondsSinceEpoch(
((raw['stime'] as num?)?.toInt() ?? 0) * 1000,
),
sharedWithMe: sharedWithMe,
);
}).toList();
}
/// Creates a public link share for [path] and returns its share URL.
Future<String> createPublicShareLink(String path) async {
final url =
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json';
debugPrint('[Nextcloud OCS] Creating public share link for $path');
final response = await http.post(
Uri.parse(url),
headers: _headers,
body: {'path': path, 'shareType': '3'},
);
debugPrint(
'[Nextcloud OCS] Create share response status: ${response.statusCode}',
);
if (response.statusCode != 200) {
throw Exception(
'Failed to create share link. HTTP ${response.statusCode}',
);
}
final data = jsonDecode(response.body);
final shareUrl = data['ocs']?['data']?['url'] as String?;
if (shareUrl == null) {
throw Exception('Server response did not include a share URL.');
}
return shareUrl;
}
/// Removes a share (un-shares an item you shared, or removes one shared
/// with you).
Future<bool> deleteShare(String shareId) async {
final url =
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares/$shareId?format=json';
debugPrint('[Nextcloud OCS] Deleting share $shareId');
final response = await http.delete(Uri.parse(url), headers: _headers);
debugPrint('[Nextcloud OCS] Delete share status: ${response.statusCode}');
return response.statusCode == 200;
}
NextcloudShare _shareFromJson(Map raw, {required bool sharedWithMe}) {
final path = (raw['path'] ?? '/').toString();
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
final name = segments.isNotEmpty ? segments.last : path;
final isFolder = (raw['item_type'] ?? '').toString() == 'folder';
final mimeType = raw['mimetype'] as String?;
final expiration = raw['expiration'] as String?;
return NextcloudShare(
id: (raw['id'] ?? '').toString(),
path: path,
name: name,
itemType: NextcloudItem.deduceType(name, isFolder, mimeType),
shareType: _mapShareType((raw['share_type'] as num?)?.toInt() ?? -1),
ownerDisplayName: (raw['displayname_owner'] ?? raw['uid_owner'] ?? '')
.toString(),
sharedWithDisplayName: raw['share_with_displayname'] as String?,
sharedAt: DateTime.fromMillisecondsSinceEpoch(
((raw['stime'] as num?)?.toInt() ?? 0) * 1000,
),
sharedWithMe: sharedWithMe,
url: raw['url'] as String?,
permissions: (raw['permissions'] as num?)?.toInt() ?? 1,
token: raw['token'] as String?,
expireDate: expiration != null ? DateTime.tryParse(expiration) : null,
);
}
/// The current user's own direct shares on a single file/folder (for the
/// Details sheet's Sharing tab) — unlike [fetchShares], scoped to one path.
Future<List<NextcloudShare>> fetchSharesForPath(String path) async {
final url =
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares'
'?format=json&path=${Uri.encodeQueryComponent(path)}&reshares=true';
debugPrint('[Nextcloud OCS] Fetching shares for path $path');
final response = await http.get(Uri.parse(url), headers: _headers);
if (response.statusCode != 200) {
throw Exception('Failed to load shares. HTTP ${response.statusCode}');
}
final data = jsonDecode(response.body);
final rawList = data['ocs']?['data'];
if (rawList is! List) return [];
return rawList
.map<NextcloudShare>((raw) => _shareFromJson(raw, sharedWithMe: false))
.toList();
}
/// Shares inherited from a parent folder that the current user doesn't
/// own directly ("Others with access" in the web UI). Returns an empty
/// list rather than throwing on servers too old to support this endpoint.
Future<List<NextcloudShare>> fetchInheritedShares(String path) async {
final url =
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares/inherited'
'?format=json&path=${Uri.encodeQueryComponent(path)}';
debugPrint('[Nextcloud OCS] Fetching inherited shares for path $path');
try {
final response = await http.get(Uri.parse(url), headers: _headers);
if (response.statusCode != 200) return [];
final data = jsonDecode(response.body);
final rawList = data['ocs']?['data'];
if (rawList is! List) return [];
return rawList
.map<NextcloudShare>(
(raw) => _shareFromJson(raw, sharedWithMe: false),
)
.toList();
} catch (e) {
debugPrint('[Nextcloud OCS] Inherited shares unavailable: $e');
return [];
}
}
/// Searches users/teams (groups) a file/folder can be shared with.
Future<List<NextcloudSharee>> searchSharees(String query) async {
final url =
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/sharees'
'?format=json&itemType=file&search=${Uri.encodeQueryComponent(query)}';
debugPrint('[Nextcloud OCS] Searching sharees for "$query"');
final response = await http.get(Uri.parse(url), headers: _headers);
if (response.statusCode != 200) {
throw Exception('Sharee search failed. HTTP ${response.statusCode}');
}
final data = jsonDecode(response.body)['ocs']?['data'];
if (data is! Map) return [];
NextcloudSharee? fromEntry(dynamic raw, ShareeType type) {
if (raw is! Map) return null;
final value = raw['value'];
final shareWith = value is Map ? value['shareWith'] as String? : null;
if (shareWith == null) return null;
return NextcloudSharee(
shareWith: shareWith,
label: (raw['label'] ?? shareWith).toString(),
type: type,
subtitle: value is Map
? value['shareWithAdditionalInfo'] as String?
: null,
);
}
final results = <NextcloudSharee>[];
final seen = <String>{};
void addAll(String bucket, ShareeType type) {
final list = data[bucket];
final exactList = (data['exact'] is Map) ? data['exact'][bucket] : null;
for (final raw in [
...(exactList is List ? exactList : []),
...(list is List ? list : []),
]) {
final sharee = fromEntry(raw, type);
if (sharee == null) continue;
final key = '${sharee.type}:${sharee.shareWith}';
if (seen.add(key)) results.add(sharee);
}
}
addAll('users', ShareeType.user);
addAll('groups', ShareeType.group);
return results;
}
String _formatDavDate(DateTime date) {
final y = date.year.toString().padLeft(4, '0');
final m = date.month.toString().padLeft(2, '0');
final d = date.day.toString().padLeft(2, '0');
return '$y-$m-$d';
}
/// General-purpose share creation, covering public links (shareType 3),
/// user/team shares (0/1), and email shares (4). [createPublicShareLink]
/// is left as the simpler dedicated entry point used by the swipe action.
Future<NextcloudShare> createShare({
required String path,
required int shareType,
String? shareWith,
String? password,
DateTime? expireDate,
}) async {
final url =
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json';
debugPrint('[Nextcloud OCS] Creating share (type $shareType) for $path');
final body = {
'path': path,
'shareType': '$shareType',
'shareWith': ?shareWith,
if (password != null && password.isNotEmpty) 'password': password,
if (expireDate != null) 'expireDate': _formatDavDate(expireDate),
};
final response = await http.post(
Uri.parse(url),
headers: _headers,
body: body,
);
if (response.statusCode != 200) {
throw Exception('Failed to create share. HTTP ${response.statusCode}');
}
final data = jsonDecode(response.body)['ocs']?['data'];
if (data is! Map) {
throw Exception('Server response did not include the new share.');
}
return _shareFromJson(data, sharedWithMe: false);
}
/// Per-file activity feed (Details sheet's Activity tab), unlike
/// [fetchActivities] which is the whole-account feed.
Future<List<NextcloudActivity>> fetchFileActivity(String fileId) async {
List<NextcloudActivity> parse(String body) {
final data = jsonDecode(body);
final rawData = data['ocs']?['data'];
List<dynamic> list = [];
if (rawData is List) {
list = rawData;
} else if (rawData is Map && rawData['activity'] is List) {
list = rawData['activity'];
}
return list.map((a) {
return NextcloudActivity(
id: (a['activity_id'] ?? '').toString(),
title: a['subject'] ?? 'Activity',
subject: a['message'] ?? (a['subject'] ?? ''),
timestamp: DateTime.fromMillisecondsSinceEpoch(
(a['timestamp'] as int? ?? 0) * 1000,
),
icon: Icons.cloud_outlined,
author: a['user'] ?? username,
);
}).toList();
}
// The activity app doesn't have one single documented way to scope the
// feed to a specific file across server versions - try the same base
// endpoint `fetchActivities` uses (proven to work) with object filter
// params first, then fall back to the `filter/files` route some
// versions expose, rather than betting on just one.
final candidates = [
'$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity'
'?format=json&object_type=files&object_id=${Uri.encodeQueryComponent(fileId)}',
'$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity/filter/files'
'?format=json&object_type=files&object_id=${Uri.encodeQueryComponent(fileId)}',
];
for (final url in candidates) {
try {
debugPrint('[Nextcloud OCS] Fetching activity for file $fileId: $url');
final response = await http.get(Uri.parse(url), headers: _headers);
debugPrint(
'[Nextcloud OCS] File activity response status: ${response.statusCode}',
);
if (response.statusCode != 200) continue;
final items = parse(response.body);
if (items.isNotEmpty) return items;
} catch (e) {
debugPrint('[Nextcloud OCS] File activity attempt failed: $e');
}
}
return [];
}
/// Lists a file's version history via the DAV versions endpoint.
Future<List<NextcloudFileVersion>> fetchFileVersions(String fileId) async {
final url =
'$_cleanServerUrl/remote.php/dav/versions/$username/versions/$fileId';
debugPrint('[Nextcloud DAV] Requesting PROPFIND for versions of $fileId');
const body = '''<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getlastmodified/>
<d:getcontentlength/>
</d:prop>
</d:propfind>''';
try {
final request = http.Request('PROPFIND', Uri.parse(url))
..headers.addAll({
..._headers,
'Depth': '1',
'Content-Type': 'application/xml',
})
..body = body;
final streamed = await http.Client().send(request);
final responseBody = await streamed.stream.bytesToString();
if (streamed.statusCode != 207 && streamed.statusCode != 200) {
debugPrint(
'[Nextcloud DAV] Versions PROPFIND failed: ${streamed.statusCode}',
);
return [];
}
final document = xml.XmlDocument.parse(responseBody);
final responses = document.findLocalDescendants('response');
final versions = <NextcloudFileVersion>[];
for (final res in responses) {
final href = res.findLocalChildren('href').firstOrNull?.innerText ?? '';
final hrefPath = _davPath(Uri.decodeFull(href));
// Skip the versions collection entry itself.
if (hrefPath.endsWith('/versions/$fileId')) continue;
final versionLabel = hrefPath.split('/').lastOrNull;
if (versionLabel == null || versionLabel.isEmpty) continue;
final props = res.findLocalDescendants('prop');
String? lastModStr;
String? sizeStr;
for (final prop in props) {
lastModStr ??= prop
.findLocalChildren('getlastmodified')
.firstOrNull
?.innerText;
sizeStr ??= prop
.findLocalChildren('getcontentlength')
.firstOrNull
?.innerText;
}
final timestamp = _parseDavDate(lastModStr) ?? DateTime.now();
versions.add(
NextcloudFileVersion(
versionLabel: versionLabel,
timestamp: timestamp,
size: int.tryParse(sizeStr ?? '') ?? 0,
),
);
}
versions.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return versions;
} catch (e) {
debugPrint('[Nextcloud DAV] Fetching versions failed: $e');
return [];
}
}
/// Restores [versionLabel] as the current version of [fileId].
Future<bool> restoreFileVersion(String fileId, String versionLabel) async {
final source =
'$_cleanServerUrl/remote.php/dav/versions/$username/versions/$fileId/$versionLabel';
final destination =
'$_cleanServerUrl/remote.php/dav/versions/$username/restore/$versionLabel';
debugPrint('[Nextcloud DAV] Restoring version $versionLabel of $fileId');
final request = http.Request('MOVE', Uri.parse(source))
..headers.addAll({..._headers, 'Destination': destination});
final response = await http.Client().send(request);
debugPrint(
'[Nextcloud DAV] Restore version status: ${response.statusCode}',
);
return response.statusCode == 201 || response.statusCode == 204;
}
/// Downloads a specific historical version's bytes to [savePath].
Future<void> downloadVersionToFile(
String fileId,
String versionLabel,
String savePath,
) async {
final url =
'$_cleanServerUrl/remote.php/dav/versions/$username/versions/$fileId/$versionLabel';
final dio = Dio();
await dio.download(url, savePath, options: Options(headers: _headers));
}
Future<bool> toggleFavorite(
String itemPath,
bool currentFavoriteState,