Polish media viewer, pull-to-sync, search nav, and add seek bar presets

- Remove peek details sheet from media viewer; scope Activity tab to
  the current file via client-side filtering instead of unreliable
  server-side query params
- Add file rename action (WebDAV MOVE); fix search result taps to
  switch to Files tab and rebuild breadcrumbs from an absolute path
- Add swipe-to-delete confirmation dialog; fix a crash from disposing
  the rename dialog's TextEditingController too early
- Rework pull-to-sync gesture so sync and lock-open thresholds fire
  independently within one gesture, raise thresholds, and add a
  classic Material refresh spinner
- Add "tap active tab to scroll to top" behavior with a settings toggle
- Align selection toolbar padding with the sort/filter controls row
- Replace app icon assets (adaptive icon + splash screen) with
  updated IconKitchen export
- Replace the seek bar style dropdown with a Default/Wavy/Slim/Squiggly
  preset picker, each rendered with a live, perpetually-animated
  preview via a new shared SeekBarPainter/SeekBarPreview

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 10:25:52 -04:00
co-authored by Claude Sonnet 5
parent 1e42fcc0e6
commit d5675d853c
37 changed files with 951 additions and 376 deletions
+62 -41
View File
@@ -978,6 +978,30 @@ class NextcloudService {
return response.statusCode == 204 || response.statusCode == 200;
}
/// Renames (or moves within the same folder) an item via WebDAV MOVE.
Future<bool> renameItem(String itemPath, String newName) async {
var cleanPath = itemPath.trim();
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
final segments = cleanPath.split('/')..removeLast();
final destPath = '${segments.join('/')}/$newName';
final sourceUrl =
'$_cleanServerUrl/remote.php/dav/files/$username$cleanPath';
final destUrl = '$_cleanServerUrl/remote.php/dav/files/$username$destPath';
debugPrint('[Nextcloud DAV] Renaming $sourceUrl -> $destUrl');
final request = http.Request('MOVE', Uri.parse(sourceUrl))
..headers.addAll({
..._headers,
'Destination': Uri.encodeFull(destUrl),
'Overwrite': 'F',
});
final response = await http.Client().send(request);
debugPrint('[Nextcloud DAV] Rename status: ${response.statusCode}');
return response.statusCode == 201 || response.statusCode == 204;
}
Future<bool> createFolder(String parentPath, String folderName) async {
var cleanPath = parentPath.trim();
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
@@ -1441,8 +1465,27 @@ class NextcloudService {
/// 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);
// The server-side `object_type`/`object_id` filter params on the
// activity endpoints turned out to be silently ignored (still
// returning the whole account feed) - instead, fetch the same
// proven-working global feed `fetchActivities` uses (with a larger
// page so older file-specific entries aren't cut off) and filter to
// this file ourselves using each entry's own `object_id`, which the
// server always includes for rich-subject substitution regardless of
// whether the query-param filter works.
final url =
'$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity'
'?format=json&limit=200';
debugPrint('[Nextcloud OCS] Fetching activity feed to filter for $fileId');
try {
final response = await http.get(Uri.parse(url), headers: _headers);
debugPrint(
'[Nextcloud OCS] File activity response status: ${response.statusCode}',
);
if (response.statusCode != 200) return [];
final data = jsonDecode(response.body);
final rawData = data['ocs']?['data'];
List<dynamic> list = [];
if (rawData is List) {
@@ -1450,47 +1493,25 @@ class NextcloudService {
} 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 list.where((a) => (a['object_id'] ?? '').toString() == fileId).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();
} catch (e) {
debugPrint('[Nextcloud OCS] File activity unavailable: $e');
return [];
}
return [];
}
/// Lists a file's version history via the DAV versions endpoint.