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:
@@ -32,7 +32,7 @@ class LoginFlowResult {
|
||||
/// back a scoped app password.
|
||||
/// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||
class LoginFlowService {
|
||||
static const _userAgent = 'Nextcloud-Flutter-Client/1.0';
|
||||
static const _userAgent = 'Noo/1.0';
|
||||
|
||||
static String normalizeServerUrl(String input) {
|
||||
var url = input.trim();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user