Rebuild Noo as a full Nextcloud client: auth, media, and Material You UI
Replaces the starter template with Login Flow v2 auth, WebDAV-backed Files/Photos/Activity tabs, a swipeable media viewer with PDF/text/video preview, Material You dynamic theming, a frosted-glass floating nav bar, and a rebranded app icon/identity (com.example -> family.nasa). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class LoginFlowInit {
|
||||
final Uri loginUrl;
|
||||
final Uri pollEndpoint;
|
||||
final String pollToken;
|
||||
|
||||
const LoginFlowInit({
|
||||
required this.loginUrl,
|
||||
required this.pollEndpoint,
|
||||
required this.pollToken,
|
||||
});
|
||||
}
|
||||
|
||||
class LoginFlowResult {
|
||||
final String serverUrl;
|
||||
final String loginName;
|
||||
final String appPassword;
|
||||
|
||||
const LoginFlowResult({
|
||||
required this.serverUrl,
|
||||
required this.loginName,
|
||||
required this.appPassword,
|
||||
});
|
||||
}
|
||||
|
||||
/// Implements Nextcloud's "Login Flow v2": the app never sees the user's
|
||||
/// real password. It asks the server for a one-time login URL, the user
|
||||
/// authorizes in their browser, and the app polls until the server hands
|
||||
/// 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 String normalizeServerUrl(String input) {
|
||||
var url = input.trim();
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'https://$url';
|
||||
}
|
||||
if (url.endsWith('/')) {
|
||||
url = url.substring(0, url.length - 1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
static Future<LoginFlowInit> initiate(String serverUrl) async {
|
||||
final cleanUrl = normalizeServerUrl(serverUrl);
|
||||
final endpoint = Uri.parse('$cleanUrl/index.php/login/v2');
|
||||
debugPrint('[LoginFlow] Initiating login flow at $endpoint');
|
||||
|
||||
final response = await http.post(
|
||||
endpoint,
|
||||
headers: {'User-Agent': _userAgent, 'OCS-APIRequest': 'true'},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
'Could not start login flow (HTTP ${response.statusCode}). '
|
||||
'Check the server address and that it is a reachable Nextcloud instance.',
|
||||
);
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
final loginUrl = data['login'] as String?;
|
||||
final poll = data['poll'] as Map?;
|
||||
final pollToken = poll?['token'] as String?;
|
||||
final pollEndpoint = poll?['endpoint'] as String?;
|
||||
|
||||
if (loginUrl == null || pollToken == null || pollEndpoint == null) {
|
||||
throw Exception('Server response did not include login flow details.');
|
||||
}
|
||||
|
||||
return LoginFlowInit(
|
||||
loginUrl: Uri.parse(loginUrl),
|
||||
pollEndpoint: Uri.parse(pollEndpoint),
|
||||
pollToken: pollToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// Performs a single poll attempt. Returns null while the user has not
|
||||
/// finished authorizing yet (server responds 404 during that window).
|
||||
static Future<LoginFlowResult?> poll(Uri pollEndpoint, String token) async {
|
||||
final response = await http.post(
|
||||
pollEndpoint,
|
||||
headers: {
|
||||
'User-Agent': _userAgent,
|
||||
'OCS-APIRequest': 'true',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: {'token': token},
|
||||
);
|
||||
|
||||
if (response.statusCode == 404) {
|
||||
return null;
|
||||
}
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Login poll failed (HTTP ${response.statusCode}).');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
final server = data['server'] as String?;
|
||||
final loginName = data['loginName'] as String?;
|
||||
final appPassword = data['appPassword'] as String?;
|
||||
|
||||
if (server == null || loginName == null || appPassword == null) {
|
||||
throw Exception('Server response did not include app credentials.');
|
||||
}
|
||||
|
||||
return LoginFlowResult(
|
||||
serverUrl: server,
|
||||
loginName: loginName,
|
||||
appPassword: appPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:xml/xml.dart' as xml;
|
||||
@@ -8,24 +9,49 @@ import '../models/nextcloud_item.dart';
|
||||
extension XmlElementHelper on xml.XmlNode {
|
||||
Iterable<xml.XmlElement> findLocalChildren(String localName) {
|
||||
return children.whereType<xml.XmlElement>().where(
|
||||
(e) => e.name.local.toLowerCase() == localName.toLowerCase(),
|
||||
);
|
||||
(e) => e.name.local.toLowerCase() == localName.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<xml.XmlElement> findLocalDescendants(String localName) {
|
||||
if (this is xml.XmlDocument) {
|
||||
return (this as xml.XmlDocument).descendants.whereType<xml.XmlElement>().where(
|
||||
(e) => e.name.local.toLowerCase() == localName.toLowerCase(),
|
||||
);
|
||||
return (this as xml.XmlDocument).descendants
|
||||
.whereType<xml.XmlElement>()
|
||||
.where((e) => e.name.local.toLowerCase() == localName.toLowerCase());
|
||||
} else if (this is xml.XmlElement) {
|
||||
return (this as xml.XmlElement).descendants.whereType<xml.XmlElement>().where(
|
||||
(e) => e.name.local.toLowerCase() == localName.toLowerCase(),
|
||||
);
|
||||
return (this as xml.XmlElement).descendants
|
||||
.whereType<xml.XmlElement>()
|
||||
.where((e) => e.name.local.toLowerCase() == localName.toLowerCase());
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// WebDAV date props (`getlastmodified`, `creationdate`) come back as RFC 1123
|
||||
/// dates (e.g. "Mon, 01 Jan 2024 00:00:00 GMT"), which [DateTime.tryParse]
|
||||
/// can't read since it only understands ISO 8601. Fall back to [HttpDate].
|
||||
DateTime? _parseDavDate(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
final iso = DateTime.tryParse(raw);
|
||||
if (iso != null) return iso;
|
||||
try {
|
||||
return HttpDate.parse(raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes a WebDAV `href` (which servers may return as either a bare
|
||||
/// path or a full absolute URL) down to just its path, trailing slash
|
||||
/// stripped, so hrefs from either form can be compared directly.
|
||||
String _davPath(String value) {
|
||||
var path = Uri.parse(value.trim()).path;
|
||||
if (path.endsWith('/') && path.length > 1) {
|
||||
path = path.substring(0, path.length - 1);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
class NextcloudService {
|
||||
final String serverUrl;
|
||||
final String username;
|
||||
@@ -58,6 +84,44 @@ class NextcloudService {
|
||||
};
|
||||
}
|
||||
|
||||
/// Auth headers usable directly by widgets that fetch content themselves
|
||||
/// (e.g. `Image.network(url, headers: service.authHeaders)`).
|
||||
Map<String, String> get authHeaders => _headers;
|
||||
|
||||
/// The direct WebDAV download URL for a file at [itemPath].
|
||||
String fileUrl(String itemPath) {
|
||||
var cleanPath = itemPath.trim();
|
||||
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
|
||||
return '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath';
|
||||
}
|
||||
|
||||
/// Downloads the file at [itemPath] to [savePath], reporting progress.
|
||||
Future<void> downloadToFile(
|
||||
String itemPath,
|
||||
String savePath, {
|
||||
void Function(int received, int total)? onProgress,
|
||||
}) async {
|
||||
final dio = Dio();
|
||||
await dio.download(
|
||||
fileUrl(itemPath),
|
||||
savePath,
|
||||
options: Options(headers: _headers),
|
||||
onReceiveProgress: onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetches the raw bytes of a file (used for in-app text/PDF previews).
|
||||
Future<List<int>> fetchBytes(String itemPath) async {
|
||||
final response = await http.get(
|
||||
Uri.parse(fileUrl(itemPath)),
|
||||
headers: _headers,
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to download file. HTTP ${response.statusCode}');
|
||||
}
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
Future<bool> testConnection() async {
|
||||
try {
|
||||
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
|
||||
@@ -75,7 +139,9 @@ class NextcloudService {
|
||||
</d:prop>
|
||||
</d:propfind>''';
|
||||
final streamed = await http.Client().send(request);
|
||||
debugPrint('[Nextcloud Test] Connection response HTTP status: ${streamed.statusCode}');
|
||||
debugPrint(
|
||||
'[Nextcloud Test] Connection response HTTP status: ${streamed.statusCode}',
|
||||
);
|
||||
|
||||
if (streamed.statusCode == 207 || streamed.statusCode == 200) {
|
||||
return true;
|
||||
@@ -83,7 +149,9 @@ class NextcloudService {
|
||||
|
||||
// Try fallback endpoint /remote.php/webdav/
|
||||
final legacyPath = '$_cleanServerUrl/remote.php/webdav/';
|
||||
debugPrint('[Nextcloud Test] Testing legacy WebDAV endpoint: $legacyPath');
|
||||
debugPrint(
|
||||
'[Nextcloud Test] Testing legacy WebDAV endpoint: $legacyPath',
|
||||
);
|
||||
final req2 = http.Request('PROPFIND', Uri.parse(legacyPath))
|
||||
..headers.addAll({
|
||||
..._headers,
|
||||
@@ -91,7 +159,9 @@ class NextcloudService {
|
||||
'Content-Type': 'application/xml',
|
||||
});
|
||||
final st2 = await http.Client().send(req2);
|
||||
debugPrint('[Nextcloud Test] Legacy WebDAV response HTTP status: ${st2.statusCode}');
|
||||
debugPrint(
|
||||
'[Nextcloud Test] Legacy WebDAV response HTTP status: ${st2.statusCode}',
|
||||
);
|
||||
if (st2.statusCode == 207 || st2.statusCode == 200) {
|
||||
return true;
|
||||
}
|
||||
@@ -106,7 +176,9 @@ class NextcloudService {
|
||||
} catch (e) {
|
||||
debugPrint('[Nextcloud Test] Error: $e');
|
||||
final str = e.toString();
|
||||
if (str.contains('XMLHttpRequest') || str.contains('ClientException') || str.contains('Failed to fetch')) {
|
||||
if (str.contains('XMLHttpRequest') ||
|
||||
str.contains('ClientException') ||
|
||||
str.contains('Failed to fetch')) {
|
||||
throw Exception(
|
||||
'Browser CORS Policy Blocked: The web browser blocked the connection preflight request to $_cleanServerUrl. '
|
||||
'To bypass browser CORS, run the application as a native Windows app (flutter run -d windows) '
|
||||
@@ -134,15 +206,17 @@ class NextcloudService {
|
||||
});
|
||||
|
||||
const body = '''<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:prop>
|
||||
<d:getlastmodified/>
|
||||
<d:creationdate/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:resourcetype/>
|
||||
<oc:favorite/>
|
||||
<oc:fileid/>
|
||||
<oc:size/>
|
||||
<nc:mount-type/>
|
||||
</d:prop>
|
||||
</d:propfind>''';
|
||||
|
||||
@@ -151,11 +225,16 @@ class NextcloudService {
|
||||
var streamedResponse = await http.Client().send(request);
|
||||
var responseBody = await streamedResponse.stream.bytesToString();
|
||||
|
||||
debugPrint('[Nextcloud DAV] PROPFIND primary URL status: ${streamedResponse.statusCode}, bytes: ${responseBody.length}');
|
||||
debugPrint(
|
||||
'[Nextcloud DAV] PROPFIND primary URL status: ${streamedResponse.statusCode}, bytes: ${responseBody.length}',
|
||||
);
|
||||
|
||||
if (streamedResponse.statusCode != 207 && streamedResponse.statusCode != 200) {
|
||||
if (streamedResponse.statusCode != 207 &&
|
||||
streamedResponse.statusCode != 200) {
|
||||
final fallbackUrl = '$_cleanServerUrl/remote.php/webdav$cleanPath';
|
||||
debugPrint('[Nextcloud DAV] Primary URL failed (${streamedResponse.statusCode}). Trying fallback: $fallbackUrl');
|
||||
debugPrint(
|
||||
'[Nextcloud DAV] Primary URL failed (${streamedResponse.statusCode}). Trying fallback: $fallbackUrl',
|
||||
);
|
||||
final req2 = http.Request('PROPFIND', Uri.parse(fallbackUrl))
|
||||
..headers.addAll({
|
||||
..._headers,
|
||||
@@ -165,51 +244,207 @@ class NextcloudService {
|
||||
..body = body;
|
||||
final res2 = await http.Client().send(req2);
|
||||
final body2 = await res2.stream.bytesToString();
|
||||
debugPrint('[Nextcloud DAV] Fallback URL response status: ${res2.statusCode}, bytes: ${body2.length}');
|
||||
debugPrint(
|
||||
'[Nextcloud DAV] Fallback URL response status: ${res2.statusCode}, bytes: ${body2.length}',
|
||||
);
|
||||
|
||||
if (res2.statusCode == 207 || res2.statusCode == 200) {
|
||||
streamedResponse = res2;
|
||||
responseBody = body2;
|
||||
} else {
|
||||
throw Exception('Failed to load directory $cleanPath. HTTP ${streamedResponse.statusCode}');
|
||||
throw Exception(
|
||||
'Failed to load directory $cleanPath. HTTP ${streamedResponse.statusCode}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final document = xml.XmlDocument.parse(responseBody);
|
||||
final responses = document.findLocalDescendants('response');
|
||||
debugPrint('[Nextcloud DAV] Found ${responses.length} response nodes in XML');
|
||||
debugPrint(
|
||||
'[Nextcloud DAV] Found ${responses.length} response nodes in XML',
|
||||
);
|
||||
|
||||
List<NextcloudItem> items = [];
|
||||
for (var res in responses) {
|
||||
final hrefNode = res.findLocalChildren('href').firstOrNull ?? res.findLocalDescendants('href').firstOrNull;
|
||||
final hrefNode =
|
||||
res.findLocalChildren('href').firstOrNull ??
|
||||
res.findLocalDescendants('href').firstOrNull;
|
||||
final href = hrefNode?.innerText ?? '';
|
||||
|
||||
var normalizedHref = Uri.decodeFull(href).trim();
|
||||
if (normalizedHref.endsWith('/') && normalizedHref.length > 1) {
|
||||
normalizedHref = normalizedHref.substring(0, normalizedHref.length - 1);
|
||||
}
|
||||
|
||||
var targetDav = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath'.trim();
|
||||
if (targetDav.endsWith('/') && targetDav.length > 1) {
|
||||
targetDav = targetDav.substring(0, targetDav.length - 1);
|
||||
}
|
||||
|
||||
var targetLegacy = '$_cleanServerUrl/remote.php/webdav$cleanPath'.trim();
|
||||
if (targetLegacy.endsWith('/') && targetLegacy.length > 1) {
|
||||
targetLegacy = targetLegacy.substring(0, targetLegacy.length - 1);
|
||||
}
|
||||
// Servers may return `href` as either a server-relative path or a full
|
||||
// absolute URL; compare on path only so both forms line up.
|
||||
final hrefPath = _davPath(Uri.decodeFull(href));
|
||||
final targetDavPath = _davPath(
|
||||
'$_cleanServerUrl/remote.php/dav/files/$username$cleanPath',
|
||||
);
|
||||
final targetLegacyPath = _davPath(
|
||||
'$_cleanServerUrl/remote.php/webdav$cleanPath',
|
||||
);
|
||||
|
||||
// Skip current root directory entry itself
|
||||
if (normalizedHref == targetDav ||
|
||||
normalizedHref == targetLegacy ||
|
||||
normalizedHref.endsWith('/remote.php/dav/files/$username') ||
|
||||
normalizedHref.endsWith('/remote.php/webdav')) {
|
||||
if (hrefPath == targetDavPath ||
|
||||
hrefPath == targetLegacyPath ||
|
||||
hrefPath.endsWith('/remote.php/dav/files/$username') ||
|
||||
hrefPath.endsWith('/remote.php/webdav')) {
|
||||
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;
|
||||
String? mountType;
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
final name = Uri.decodeFull(
|
||||
href.split('/').where((s) => s.isNotEmpty).last,
|
||||
);
|
||||
if (name.isEmpty) continue;
|
||||
|
||||
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
||||
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
||||
final created = _parseDavDate(createdStr);
|
||||
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
|
||||
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
||||
|
||||
items.add(
|
||||
NextcloudItem(
|
||||
id: validId,
|
||||
name: name,
|
||||
path: '$cleanPath$name',
|
||||
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] Successfully parsed ${items.length} items from directory $cleanPath',
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
/// Searches the whole file tree by name using the WebDAV SEARCH-REPORT
|
||||
/// extension (supported by Nextcloud/ownCloud), rather than just the
|
||||
/// currently open folder.
|
||||
Future<List<NextcloudItem>> searchFiles(String query) async {
|
||||
final term = query.trim();
|
||||
if (term.isEmpty) return [];
|
||||
|
||||
final url = '$_cleanServerUrl/remote.php/dav/';
|
||||
debugPrint('[Nextcloud DAV] SEARCH "$term" from $url');
|
||||
|
||||
final escaped = term
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
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: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>%$escaped%</d:literal>
|
||||
</d:like>
|
||||
</d:where>
|
||||
<d:orderby/>
|
||||
</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] SEARCH status: ${streamed.statusCode}, bytes: ${responseBody.length}',
|
||||
);
|
||||
|
||||
if (streamed.statusCode != 207 && streamed.statusCode != 200) {
|
||||
throw Exception('Search failed. 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;
|
||||
@@ -219,44 +454,73 @@ class NextcloudService {
|
||||
|
||||
for (var prop in props) {
|
||||
if (!isCollection) {
|
||||
final resTypeNode = prop.findLocalChildren('resourcetype').firstOrNull;
|
||||
if (resTypeNode != null && resTypeNode.findLocalChildren('collection').isNotEmpty) {
|
||||
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;
|
||||
lastModStr ??= prop.findLocalChildren('getlastmodified').firstOrNull?.innerText;
|
||||
mimeType ??= prop.findLocalChildren('getcontenttype').firstOrNull?.innerText;
|
||||
sizeStr ??= prop
|
||||
.findLocalChildren('getcontentlength')
|
||||
.firstOrNull
|
||||
?.innerText;
|
||||
lastModStr ??= prop
|
||||
.findLocalChildren('getlastmodified')
|
||||
.firstOrNull
|
||||
?.innerText;
|
||||
mimeType ??= prop
|
||||
.findLocalChildren('getcontenttype')
|
||||
.firstOrNull
|
||||
?.innerText;
|
||||
if (!isFav) {
|
||||
isFav = prop.findLocalChildren('favorite').firstOrNull?.innerText == '1';
|
||||
isFav =
|
||||
prop.findLocalChildren('favorite').firstOrNull?.innerText == '1';
|
||||
}
|
||||
fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText;
|
||||
}
|
||||
|
||||
final name = Uri.decodeFull(href.split('/').where((s) => s.isNotEmpty).last);
|
||||
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 = lastModStr != null ? DateTime.tryParse(lastModStr) ?? DateTime.now() : DateTime.now();
|
||||
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
||||
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
|
||||
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
||||
|
||||
items.add(NextcloudItem(
|
||||
id: validId,
|
||||
name: name,
|
||||
path: '$cleanPath$name',
|
||||
type: itemType,
|
||||
size: size,
|
||||
lastModified: lastMod,
|
||||
isFavorite: isFav,
|
||||
mimeType: mimeType,
|
||||
previewUrl: '$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500',
|
||||
));
|
||||
results.add(
|
||||
NextcloudItem(
|
||||
id: validId,
|
||||
name: name,
|
||||
path: itemPath,
|
||||
type: itemType,
|
||||
size: size,
|
||||
lastModified: lastMod,
|
||||
isFavorite: isFav,
|
||||
mimeType: mimeType,
|
||||
previewUrl:
|
||||
'$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('[Nextcloud DAV] Successfully parsed ${items.length} items from directory $cleanPath');
|
||||
return items;
|
||||
debugPrint(
|
||||
'[Nextcloud DAV] Search returned ${results.length} results for "$term"',
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<NextcloudUserQuota> fetchUserQuota() async {
|
||||
@@ -264,7 +528,9 @@ class NextcloudService {
|
||||
debugPrint('[Nextcloud OCS] Fetching user quota from $url');
|
||||
|
||||
final response = await http.get(Uri.parse(url), headers: _headers);
|
||||
debugPrint('[Nextcloud OCS] User quota response status: ${response.statusCode}');
|
||||
debugPrint(
|
||||
'[Nextcloud OCS] User quota response status: ${response.statusCode}',
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
@@ -287,17 +553,22 @@ class NextcloudService {
|
||||
serverVersion: 'Nextcloud Server',
|
||||
);
|
||||
} else {
|
||||
throw Exception('Failed to fetch user quota. HTTP ${response.statusCode}');
|
||||
throw Exception(
|
||||
'Failed to fetch user quota. HTTP ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<NextcloudActivity>> fetchActivities() async {
|
||||
try {
|
||||
final url = '$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity?format=json';
|
||||
final url =
|
||||
'$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity?format=json';
|
||||
debugPrint('[Nextcloud OCS] Fetching activity feed from $url');
|
||||
|
||||
final response = await http.get(Uri.parse(url), headers: _headers);
|
||||
debugPrint('[Nextcloud OCS] Activity response status: ${response.statusCode}');
|
||||
debugPrint(
|
||||
'[Nextcloud OCS] Activity response status: ${response.statusCode}',
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
@@ -314,7 +585,9 @@ class NextcloudService {
|
||||
id: (a['activity_id'] ?? '').toString(),
|
||||
title: a['subject'] ?? 'Server Activity',
|
||||
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch((a['timestamp'] as int? ?? 0) * 1000),
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(a['timestamp'] as int? ?? 0) * 1000,
|
||||
),
|
||||
icon: Icons.cloud_outlined,
|
||||
author: a['user'] ?? username,
|
||||
);
|
||||
@@ -326,24 +599,42 @@ class NextcloudService {
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<bool> uploadFile(String folderPath, String fileName, Uint8List fileBytes) async {
|
||||
/// Uploads a file from disk, streaming it so large files don't need to be
|
||||
/// buffered fully in memory, with progress reporting.
|
||||
Future<bool> uploadFileFromPath(
|
||||
String folderPath,
|
||||
String fileName,
|
||||
String localFilePath, {
|
||||
void Function(int sent, int total)? onProgress,
|
||||
}) async {
|
||||
var cleanPath = folderPath.trim();
|
||||
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
|
||||
if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/';
|
||||
|
||||
final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$fileName';
|
||||
debugPrint('[Nextcloud DAV] Uploading file to $url');
|
||||
final url =
|
||||
'$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$fileName';
|
||||
debugPrint('[Nextcloud DAV] Streaming upload to $url');
|
||||
|
||||
final response = await http.put(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
..._headers,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: fileBytes,
|
||||
final file = File(localFilePath);
|
||||
final length = await file.length();
|
||||
final dio = Dio();
|
||||
|
||||
final response = await dio.put<void>(
|
||||
url,
|
||||
data: file.openRead(),
|
||||
options: Options(
|
||||
headers: {..._headers, Headers.contentLengthHeader: length},
|
||||
contentType: 'application/octet-stream',
|
||||
),
|
||||
onSendProgress: onProgress,
|
||||
);
|
||||
debugPrint('[Nextcloud DAV] Upload status: ${response.statusCode}');
|
||||
return response.statusCode == 201 || response.statusCode == 204 || response.statusCode == 200;
|
||||
|
||||
debugPrint(
|
||||
'[Nextcloud DAV] Streaming upload status: ${response.statusCode}',
|
||||
);
|
||||
return response.statusCode == 201 ||
|
||||
response.statusCode == 204 ||
|
||||
response.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<bool> deleteItem(String itemPath) async {
|
||||
@@ -363,16 +654,21 @@ class NextcloudService {
|
||||
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
|
||||
if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/';
|
||||
|
||||
final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$folderName';
|
||||
final url =
|
||||
'$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$folderName';
|
||||
debugPrint('[Nextcloud DAV] Creating folder at $url');
|
||||
|
||||
final request = http.Request('MKCOL', Uri.parse(url))..headers.addAll(_headers);
|
||||
final request = http.Request('MKCOL', Uri.parse(url))
|
||||
..headers.addAll(_headers);
|
||||
final response = await http.Client().send(request);
|
||||
debugPrint('[Nextcloud DAV] Create folder status: ${response.statusCode}');
|
||||
return response.statusCode == 201;
|
||||
}
|
||||
|
||||
Future<bool> toggleFavorite(String itemPath, bool currentFavoriteState) async {
|
||||
Future<bool> toggleFavorite(
|
||||
String itemPath,
|
||||
bool currentFavoriteState,
|
||||
) async {
|
||||
var cleanPath = itemPath.trim();
|
||||
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
|
||||
|
||||
@@ -380,7 +676,8 @@ class NextcloudService {
|
||||
final newFavVal = currentFavoriteState ? '0' : '1';
|
||||
debugPrint('[Nextcloud DAV] Toggling favorite ($newFavVal) for $url');
|
||||
|
||||
final body = '''<?xml version="1.0" encoding="utf-8" ?>
|
||||
final body =
|
||||
'''<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
@@ -390,14 +687,13 @@ class NextcloudService {
|
||||
</d:propertyupdate>''';
|
||||
|
||||
final request = http.Request('PROPPATCH', Uri.parse(url))
|
||||
..headers.addAll({
|
||||
..._headers,
|
||||
'Content-Type': 'application/xml',
|
||||
})
|
||||
..headers.addAll({..._headers, 'Content-Type': 'application/xml'})
|
||||
..body = body;
|
||||
|
||||
final response = await http.Client().send(request);
|
||||
debugPrint('[Nextcloud DAV] Toggle favorite status: ${response.statusCode}');
|
||||
debugPrint(
|
||||
'[Nextcloud DAV] Toggle favorite status: ${response.statusCode}',
|
||||
);
|
||||
return response.statusCode == 207 || response.statusCode == 200;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user