Add in-app copy/move, background downloads, and a Favorites tab
Build APK / build (push) Successful in 5m18s

- WebDAV MOVE/COPY-backed copy/move for files and folders, with a
  destination picker (independent nav state so it doesn't disturb the
  Files tab's browsing position) and a conflict-resolution sheet
  (overwrite all / keep both / decide per item).
- Downloads now use the same foreground-service + notification approach
  as uploads, surviving app closure.
- Favorites is now its own tab (account-wide, via a WebDAV SEARCH query)
  instead of a Files-tab filter toggle, which couldn't represent
  favorited items outside the currently browsed folder correctly.
- Share sheet restyled to match the app's design language, plus a
  "share file directly" action via the OS share sheet; details sheet
  tab icons resized to match the bottom nav.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 16:20:56 -04:00
co-authored by Claude Sonnet 5
parent 44a23c3632
commit b1400e399f
21 changed files with 2851 additions and 155 deletions
+5 -1
View File
@@ -4,7 +4,7 @@ import 'package:material_symbols_icons/symbols.dart';
/// Every destination the bottom nav bar can show. Order here is only the
/// fallback default — actual order/visibility/default-tab are user
/// configurable and persisted in [ServerProvider].
enum AppTab { files, photos, activity, trash, shares, recent }
enum AppTab { files, photos, favorites, activity, trash, shares, recent }
/// At most this many tabs may be visible in the bottom nav bar at once —
/// the rest are reachable through the "more" dropdown instead.
@@ -17,6 +17,8 @@ extension AppTabInfo on AppTab {
return 'Files';
case AppTab.photos:
return 'Photos';
case AppTab.favorites:
return 'Favorites';
case AppTab.activity:
return 'Activity';
case AppTab.trash:
@@ -34,6 +36,8 @@ extension AppTabInfo on AppTab {
return Icons.folder_rounded;
case AppTab.photos:
return Icons.photo_library_rounded;
case AppTab.favorites:
return Icons.favorite_rounded;
case AppTab.activity:
return Icons.electric_bolt_rounded;
case AppTab.trash:
+33
View File
@@ -0,0 +1,33 @@
import 'nextcloud_item.dart';
/// A resolution choice for one conflicting item in a Move/Copy batch -
/// keyed by `item.id` in [ConflictChoice] maps passed to
/// `ServerProvider.resolveConflicts`.
enum ConflictChoice { overwrite, keepBoth, skip }
/// One item that couldn't be moved/copied because something with the same
/// name already exists at the destination (a WebDAV 412 response).
class MoveCopyConflict {
final NextcloudItem item;
const MoveCopyConflict(this.item);
}
/// Outcome of a Move/Copy batch (an initial attempt via
/// `ServerProvider.moveItems`/`copyItems`, or a follow-up
/// `resolveConflicts` call). [blockedReason] is set instead of attempting
/// anything at all when the destination itself is invalid (e.g. moving a
/// folder into its own subfolder).
class MoveCopyResult {
final int succeeded;
final List<MoveCopyConflict> conflicts;
final int failed;
final String? blockedReason;
const MoveCopyResult({
this.succeeded = 0,
this.conflicts = const [],
this.failed = 0,
this.blockedReason,
});
}
+278 -22
View File
@@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_tab.dart';
import '../models/move_copy_result.dart';
import '../models/nextcloud_file_version.dart';
import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart';
@@ -75,7 +76,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
static const _prefBottomBarOpacity = 'ui_bottom_bar_opacity';
static const _prefBottomBarBlur = 'ui_bottom_bar_blur';
static const _prefGridView = 'ui_grid_view';
static const _prefShowFavoritesOnlyFiles = 'ui_show_favorites_only';
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
static const _prefStorageScope = 'ui_storage_scope';
static const _prefFilesTypeFilter = 'ui_files_type_filter';
@@ -153,7 +153,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
bool _isDownloadingForPick = false;
List<String> _pathStack = ['/'];
bool _isGridView = false;
bool _showFavoritesOnlyFiles = false;
bool _showFavoritesOnlyPhotos = false;
StorageScope _storageScope = StorageScope.cloud;
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
@@ -198,6 +197,26 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
bool _isMediaLoading = false;
String? _mediaErrorMessage;
// All-account favorites (the Favorites tab) - same idea as [_allMedia]:
// a strict filter (only favorited items) but an account-wide *scope*, so
// a favorited item several folders deep still shows up regardless of
// whether its parent folders are themselves favorited - which is exactly
// why this is its own tab (and its own fetch/loading state, kept
// separate from Files' own [_isLoading]/[_errorMessage] since both tabs
// are simultaneously mounted in MainShellView's IndexedStack and would
// otherwise bleed loading/error state into each other) rather than a
// filter toggle scoped to whatever folder the Files tab happens to be
// browsing.
List<NextcloudItem> _allFavorites = [];
bool _isFavoritesLoading = false;
String? _favoritesErrorMessage;
// True once fetchAllFavorites has run at least once (i.e. the Favorites
// tab has been visited) - delete/rename/move/copy re-sync [_allFavorites]
// afterward, but only when it's actually been loaded, so those actions
// don't pay for an extra network round-trip on every Files/Photos edit
// for an account that's never opened the Favorites tab this session.
bool _favoritesEverFetched = false;
// Trash state — kept separate from the regular folder-loading/error state
// above so a trash-fetch failure can't bleed a stale error into Files.
List<NextcloudItem> _trashItems = [];
@@ -393,7 +412,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
List<String> get pathStack => _pathStack;
bool get isGridView => _isGridView;
bool get showFavoritesOnlyFiles => _showFavoritesOnlyFiles;
bool get showFavoritesOnlyPhotos => _showFavoritesOnlyPhotos;
StorageScope get storageScope => _storageScope;
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
@@ -446,10 +464,11 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
.any((segment) => segment.startsWith('.'));
}
/// Applies the shared favorites-only/hidden-files/storage-scope toggles,
/// used by both [items] and [photoItems]. `showFavoritesOnly`/`showHidden`
/// are passed in separately since Files and Photos each have their own
/// independent favorites-only and hidden-files toggles.
/// Applies the shared favorites-only/hidden-files/storage-scope toggles.
/// `showFavoritesOnly` is effectively Photos-only now (`items`/
/// `favoriteItems` both always pass `false` - see their own doc
/// comments for why); kept as a parameter here since `photoItems` still
/// has its own independent favorites-only toggle.
List<NextcloudItem> _applyCommonFilters(
List<NextcloudItem> source, {
required bool showFavoritesOnly,
@@ -472,10 +491,31 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
return filtered;
}
List<NextcloudItem> get items {
List<NextcloudItem> get items => applyFilesDisplayPrefs(_items);
/// The Favorites tab's content - every favorited item account-wide (see
/// `fetchAllFavorites`), run through the exact same hidden/type-filter/
/// sort/storage-scope display prefs as the Files tab (deliberately
/// shared rather than a separate parallel settings dimension, the same
/// way the Move/Copy destination picker reuses them). No favorites
/// filter needed here - the source list is already all-favorites.
List<NextcloudItem> get favoriteItems =>
applyFilesDisplayPrefs(_allFavorites);
/// Applies the Files tab's current sort/filter display prefs (hidden,
/// type filter, storage scope, sort field/direction) to an arbitrary raw
/// item list - factored out of the [items] getter so both the Favorites
/// tab ([favoriteItems]) and the Move/Copy destination picker (which
/// fetches its own listings via [fetchFolderListing] rather than reading
/// [items] itself - see that method's doc comment) can render with the
/// exact same controls/behavior as the Files tab without duplicating
/// this logic.
List<NextcloudItem> applyFilesDisplayPrefs(List<NextcloudItem> rawItems) {
var filtered = _applyCommonFilters(
_items,
showFavoritesOnly: _showFavoritesOnlyFiles,
rawItems,
// Files itself no longer filters by favorite - that's the dedicated
// Favorites tab's job now (see `favoriteItems`/`fetchAllFavorites`).
showFavoritesOnly: false,
showHidden: _showHiddenFiles,
);
switch (_filesTypeFilter) {
@@ -511,6 +551,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
bool get isMediaLoading => _isMediaLoading;
String? get mediaErrorMessage => _mediaErrorMessage;
bool get isFavoritesLoading => _isFavoritesLoading;
String? get favoritesErrorMessage => _favoritesErrorMessage;
NextcloudUserQuota? get quota => _quota;
List<NextcloudActivity> get activities => _activities;
@@ -658,7 +700,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
if (accountId == null) {
_isGridView = false;
_showFavoritesOnlyFiles = false;
_showFavoritesOnlyPhotos = false;
_storageScope = StorageScope.cloud;
_filesTypeFilter = FilesTypeFilter.all;
@@ -674,8 +715,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
String k(String base) => _accountStore.accountPrefKey(accountId, base);
_isGridView = prefs.getBool(k(_prefGridView)) ?? false;
_showFavoritesOnlyFiles =
prefs.getBool(k(_prefShowFavoritesOnlyFiles)) ?? false;
_showFavoritesOnlyPhotos =
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
@@ -1196,6 +1235,48 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
}
}
/// Loads every favorited item across the whole account for the Favorites
/// tab - see [_allFavorites]'s doc comment for why this is a separate
/// account-wide fetch rather than a filter over the currently browsed
/// folder's [_items].
Future<void> fetchAllFavorites() async {
if (!_isLoggedIn || _service == null) return;
final gen = _sessionGeneration;
_favoritesEverFetched = true;
_isFavoritesLoading = true;
_favoritesErrorMessage = null;
notifyListeners();
try {
final favorites = await _service!.fetchFavorites();
if (gen != _sessionGeneration) return;
_allFavorites = favorites;
debugPrint('[ServerProvider] Loaded ${_allFavorites.length} favorites');
} catch (e) {
if (gen != _sessionGeneration) return;
debugPrint('[ServerProvider] Error fetching favorites: $e');
_favoritesErrorMessage = e.toString().replaceAll('Exception: ', '');
} finally {
if (gen == _sessionGeneration) {
_isFavoritesLoading = false;
notifyListeners();
}
}
}
/// Re-syncs [_allFavorites] after a delete/rename/move/copy elsewhere in
/// the app (Files, Photos, or Favorites itself) - those operations don't
/// know how to patch [_allFavorites] in place (a move changes an item's
/// path; WebDAV COPY's handling of custom properties like `oc:favorite`
/// isn't reliable enough to assume the copy is still favorited), so this
/// just refetches - but only if Favorites has actually been loaded this
/// session, so an account that never opens that tab doesn't pay for an
/// extra request on every edit.
Future<void> _syncFavoritesIfLoaded() {
return _favoritesEverFetched ? fetchAllFavorites() : Future.value();
}
Future<void> navigateToFolder(String path) async {
_pathStack.add(path);
await _navigateTo(path);
@@ -1284,15 +1365,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_persistAccountPref(_prefGridView, (p, key) => p.setBool(key, value));
}
void toggleFavoritesFilterFiles() {
_showFavoritesOnlyFiles = !_showFavoritesOnlyFiles;
notifyListeners();
_persistAccountPref(
_prefShowFavoritesOnlyFiles,
(p, key) => p.setBool(key, _showFavoritesOnlyFiles),
);
}
void toggleFavoritesFilterPhotos() {
_showFavoritesOnlyPhotos = !_showFavoritesOnlyPhotos;
notifyListeners();
@@ -1526,6 +1598,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
if (success) {
_allMedia = _allMedia.where((i) => i.path != itemPath).toList();
await refreshData();
await _syncFavoritesIfLoaded();
}
return success;
}
@@ -1535,10 +1608,178 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
final success = await _service!.renameItem(item.path, newName);
if (success) {
await refreshData();
await _syncFavoritesIfLoaded();
}
return success;
}
/// A destination-picker-only fetch: the current folder listing state
/// (`items`/`currentFolderPath`/`pathStack`/`_directoryCache`) belongs to
/// whichever tab is actively browsing (Files), so the Move/Copy
/// destination picker deliberately doesn't touch any of it - it fetches
/// listings for its own local navigation state through this instead,
/// mirroring `searchFiles`'s identical stateless-pass-through shape.
Future<List<NextcloudItem>> fetchFolderListing(String path) {
return _service?.fetchDirectory(path) ?? Future.value([]);
}
/// True if [destFolderPath] is [folderPath] itself or one of its own
/// descendants - moving/copying a folder into itself (or a subfolder of
/// itself) is nonsensical and rejected client-side rather than left to
/// the server to (maybe) reject.
bool _isSelfOrDescendant(String destFolderPath, String folderPath) {
final dest = destFolderPath.endsWith('/')
? destFolderPath
: '$destFolderPath/';
final folder = folderPath.endsWith('/') ? folderPath : '$folderPath/';
return dest == folder || dest.startsWith(folder);
}
Future<MoveCopyResult> _moveOrCopyItems(
List<NextcloudItem> items,
String destFolderPath, {
required bool copy,
}) async {
if (_service == null || items.isEmpty) return const MoveCopyResult();
for (final item in items) {
if (item.isFolder && _isSelfOrDescendant(destFolderPath, item.path)) {
return const MoveCopyResult(
blockedReason:
"Can't move a folder into itself or one of its own subfolders",
);
}
}
var succeeded = 0;
var failed = 0;
final conflicts = <MoveCopyConflict>[];
for (final item in items) {
final status = copy
? await _service!.copyItem(item.path, destFolderPath)
: await _service!.moveItem(item.path, destFolderPath);
if (status == 201 || status == 204) {
succeeded++;
} else if (status == 412) {
conflicts.add(MoveCopyConflict(item));
} else {
failed++;
}
}
if (succeeded > 0) {
_directoryCache.clear();
await refreshData();
await _syncFavoritesIfLoaded();
}
return MoveCopyResult(
succeeded: succeeded,
conflicts: conflicts,
failed: failed,
);
}
/// Moves every item in [items] into [destFolderPath], keeping each
/// item's own filename. Items that would collide with something already
/// there come back in [MoveCopyResult.conflicts] rather than failing
/// outright - resolve those with [resolveConflicts].
Future<MoveCopyResult> moveItems(
List<NextcloudItem> items,
String destFolderPath,
) {
return _moveOrCopyItems(items, destFolderPath, copy: false);
}
/// Same as [moveItems] but duplicates rather than relocates.
Future<MoveCopyResult> copyItems(
List<NextcloudItem> items,
String destFolderPath,
) {
return _moveOrCopyItems(items, destFolderPath, copy: true);
}
/// Finds the first `name (n).ext` not already in [existing] - `existing`
/// is mutated as names are claimed, so a batch of "keep both" resolutions
/// never picks the same generated name twice.
String _nextAvailableName(String name, Set<String> existing) {
if (!existing.contains(name)) return name;
final dotIndex = name.lastIndexOf('.');
final hasExtension = dotIndex > 0 && dotIndex < name.length - 1;
final base = hasExtension ? name.substring(0, dotIndex) : name;
final ext = hasExtension ? name.substring(dotIndex) : '';
var n = 2;
while (existing.contains('$base ($n)$ext')) {
n++;
}
return '$base ($n)$ext';
}
/// Resolves a previous [moveItems]/[copyItems] call's conflicts per
/// [choices] (keyed by `item.id`): overwrite the existing item, keep
/// both (renamed against a fresh listing of [destFolderPath] so two
/// "keep both" picks in the same batch can't collide with each other),
/// or skip (left untouched at the source).
Future<MoveCopyResult> resolveConflicts(
List<MoveCopyConflict> conflicts,
String destFolderPath, {
required bool copy,
required Map<String, ConflictChoice> choices,
}) async {
if (_service == null || conflicts.isEmpty) return const MoveCopyResult();
final needsKeepBoth = conflicts.any(
(c) => choices[c.item.id] == ConflictChoice.keepBoth,
);
final existingNames = needsKeepBoth
? (await fetchFolderListing(destFolderPath)).map((i) => i.name).toSet()
: <String>{};
var succeeded = 0;
var failed = 0;
for (final conflict in conflicts) {
final choice = choices[conflict.item.id] ?? ConflictChoice.skip;
if (choice == ConflictChoice.skip) continue;
final int status;
if (choice == ConflictChoice.overwrite) {
status = copy
? await _service!.copyItem(
conflict.item.path,
destFolderPath,
overwrite: true,
)
: await _service!.moveItem(
conflict.item.path,
destFolderPath,
overwrite: true,
);
} else {
final newName = _nextAvailableName(conflict.item.name, existingNames);
existingNames.add(newName);
status = copy
? await _service!.copyItem(
conflict.item.path,
destFolderPath,
newName: newName,
)
: await _service!.moveItem(
conflict.item.path,
destFolderPath,
newName: newName,
);
}
if (status == 201 || status == 204) {
succeeded++;
} else {
failed++;
}
}
if (succeeded > 0) {
_directoryCache.clear();
await refreshData();
await _syncFavoritesIfLoaded();
}
return MoveCopyResult(succeeded: succeeded, failed: failed);
}
Future<void> fetchTrash() async {
if (!_isLoggedIn || _service == null) return;
final gen = _sessionGeneration;
@@ -1673,6 +1914,21 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
if (index != -1) _items[index] = updated;
final mediaIndex = _allMedia.indexWhere((i) => i.id == item.id);
if (mediaIndex != -1) _allMedia[mediaIndex] = updated;
// Keeps the Favorites tab's own list in sync in place (cheap enough
// not to need a full refetch, unlike delete/rename/move/copy - see
// `_syncFavoritesIfLoaded`) - added if newly favorited, removed if
// un-favorited, since [_allFavorites] should only ever hold
// favorited items.
final favIndex = _allFavorites.indexWhere((i) => i.id == item.id);
if (updated.isFavorite) {
if (favIndex != -1) {
_allFavorites[favIndex] = updated;
} else {
_allFavorites = [..._allFavorites, updated];
}
} else if (favIndex != -1) {
_allFavorites = [..._allFavorites]..removeAt(favIndex);
}
notifyListeners();
}
}
-1
View File
@@ -27,7 +27,6 @@ class AccountStore {
/// by account switching.
static const perAccountPrefKeys = [
'ui_grid_view',
'ui_show_favorites_only',
'ui_show_favorites_only_photos',
'ui_storage_scope',
'ui_show_hidden',
+52
View File
@@ -0,0 +1,52 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
/// Hands a download off to `DownloadService.kt`'s Android foreground
/// service - the download counterpart of `UploadService`/
/// `ShareUploadService.kt` (see that pair's doc comments for why this
/// isn't done in Dart/Dio: only a real Android Service survives the
/// Flutter engine/Activity being gone, the same guarantee a real
/// file-manager app's download notification gives you). Saves straight
/// into the device's public Downloads folder with one cancellable
/// progress notification for the whole batch, instead of the old
/// download-to-app-cache-then-`file_saver`-prompt flow.
class DownloadService {
static const _channel = MethodChannel('dev.ayushya.noo/download_service');
/// Starts downloading [items]. Fire-and-forget from Dart's perspective -
/// once this returns, the service owns the rest and reports
/// progress/completion/cancellation through its own notification, not
/// back to the app.
static Future<void> startDownload(
ServerProvider provider,
List<NextcloudItem> items,
) async {
final service = provider.service;
final authHeader = service?.authHeaders['Authorization'];
if (service == null || authHeader == null) {
throw Exception('Not logged in.');
}
final filesJson = jsonEncode(
items
.map(
(i) => {
'path': i.path,
'name': i.name,
'mimeType': i.mimeType,
'size': i.size,
},
)
.toList(),
);
await _channel.invokeMethod('startDownload', {
'files': filesJson,
'serverUrl': provider.serverUrl,
'username': provider.username,
'authHeader': authHeader,
});
}
}
+269 -13
View File
@@ -699,6 +699,170 @@ class NextcloudService {
return results;
}
/// Finds every favorited file/folder anywhere in the account (not just
/// the currently browsed folder) via the same WebDAV SEARCH-REPORT
/// mechanism as [fetchAllMedia], filtered by `oc:favorite` instead of
/// mimetype. Used by the Files tab's favorites-only toggle: filtering
/// should still be strict (only favorited items), but the *scope* is the
/// whole account, not whatever folder happens to be open - otherwise a
/// favorited item nested a few folders down would never show up just
/// because its parent folders aren't favorited themselves.
Future<List<NextcloudItem>> fetchFavorites() async {
final url = '$_cleanServerUrl/remote.php/dav/';
debugPrint('[Nextcloud DAV] SEARCH for favorites 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:eq>
<d:prop><oc:favorite/></d:prop>
<d:literal>1</d:literal>
</d:eq>
</d:where>
<d:orderby>
<d:order>
<d:prop><d:displayname/></d:prop>
<d:ascending/>
</d:order>
</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] Favorites SEARCH status: ${streamed.statusCode}, bytes: ${responseBody.length}',
);
if (streamed.statusCode != 207 && streamed.statusCode != 200) {
throw Exception('Failed to load favorites. 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;
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('getcontentlength')
.firstOrNull
?.innerText;
lastModStr ??= prop
.findLocalChildren('getlastmodified')
.firstOrNull
?.innerText;
createdStr ??= prop
.findLocalChildren('creationdate')
.firstOrNull
?.innerText;
mimeType ??= prop
.findLocalChildren('getcontenttype')
.firstOrNull
?.innerText;
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, isCollection, 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: true,
mimeType: mimeType,
previewUrl:
'$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500',
mountType: mountType,
),
);
}
debugPrint(
'[Nextcloud DAV] Favorites 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.
@@ -989,6 +1153,43 @@ class NextcloudService {
return response.statusCode == 204 || response.statusCode == 200;
}
/// Shared MOVE/COPY request - WebDAV COPY is the same shape as the MOVE
/// [renameItem] uses, just a different verb and a caller-controlled
/// `Overwrite` rather than always forced off. Returns the raw HTTP status
/// rather than a bool so callers can tell a conflict (412 - only
/// possible when `overwrite` is false) apart from any other failure
/// without a separate existence-check request per item.
Future<int> _moveOrCopy(
String sourcePath,
String destPath, {
required bool copy,
required bool overwrite,
}) async {
var cleanSource = sourcePath.trim();
if (!cleanSource.startsWith('/')) cleanSource = '/$cleanSource';
var cleanDest = destPath.trim();
if (!cleanDest.startsWith('/')) cleanDest = '/$cleanDest';
final sourceUrl =
'$_cleanServerUrl/remote.php/dav/files/$username$cleanSource';
final destUrl = '$_cleanServerUrl/remote.php/dav/files/$username$cleanDest';
debugPrint(
'[Nextcloud DAV] ${copy ? 'Copying' : 'Moving'} $sourceUrl -> $destUrl',
);
final request = http.Request(copy ? 'COPY' : 'MOVE', Uri.parse(sourceUrl))
..headers.addAll({
..._headers,
'Destination': Uri.encodeFull(destUrl),
'Overwrite': overwrite ? 'T' : 'F',
});
final response = await http.Client().send(request);
debugPrint(
'[Nextcloud DAV] ${copy ? 'Copy' : 'Move'} status: ${response.statusCode}',
);
return response.statusCode;
}
/// Renames (or moves within the same folder) an item via WebDAV MOVE.
Future<bool> renameItem(String itemPath, String newName) async {
var cleanPath = itemPath.trim();
@@ -997,20 +1198,75 @@ class NextcloudService {
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 status = await _moveOrCopy(
cleanPath,
destPath,
copy: false,
overwrite: false,
);
return status == 201 || status == 204;
}
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<int> _moveOrCopyToFolder(
String itemPath,
String destFolderPath, {
required bool copy,
required bool overwrite,
String? newName,
}) {
var cleanPath = itemPath.trim();
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
final name = newName ?? cleanPath.split('/').last;
var cleanDestFolder = destFolderPath.trim();
if (!cleanDestFolder.startsWith('/')) cleanDestFolder = '/$cleanDestFolder';
if (cleanDestFolder.endsWith('/') && cleanDestFolder != '/') {
cleanDestFolder = cleanDestFolder.substring(
0,
cleanDestFolder.length - 1,
);
}
final destPath = cleanDestFolder == '/'
? '/$name'
: '$cleanDestFolder/$name';
return _moveOrCopy(cleanPath, destPath, copy: copy, overwrite: overwrite);
}
/// Moves [itemPath] into [destFolderPath], keeping its current filename
/// unless [newName] is given (used to resolve a "keep both" conflict
/// with a renamed destination). Returns the raw HTTP status - 201/204
/// success, 412 means an item with the same name already exists there
/// ([overwrite] was false).
Future<int> moveItem(
String itemPath,
String destFolderPath, {
bool overwrite = false,
String? newName,
}) {
return _moveOrCopyToFolder(
itemPath,
destFolderPath,
copy: false,
overwrite: overwrite,
newName: newName,
);
}
/// Same as [moveItem] but duplicates the item instead of relocating it -
/// WebDAV COPY, recursive for folders by default just like MOVE.
Future<int> copyItem(
String itemPath,
String destFolderPath, {
bool overwrite = false,
String? newName,
}) {
return _moveOrCopyToFolder(
itemPath,
destFolderPath,
copy: true,
overwrite: overwrite,
newName: newName,
);
}
Future<bool> createFolder(String parentPath, String folderName) async {
+804
View File
@@ -0,0 +1,804 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_item.dart';
import '../models/selection_action.dart';
import '../providers/server_provider.dart';
import '../services/download_service.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/item_icon.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/selectable_thumbnail.dart';
import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
/// Every favorited file/folder across the whole account, account-wide - a
/// real tab rather than a filter toggle scoped to whatever folder the
/// Files tab happens to be browsing (see `ServerProvider.fetchAllFavorites`
/// for why: a favorited item several folders deep needs to show up
/// regardless of whether its parent folders are themselves favorited,
/// which a current-folder-only filter can never do, and a flat account-
/// wide list also means the breadcrumb-vs-content mismatch a filter
/// toggle had - the trail showing wherever Files was last browsing while
/// the content showed something else entirely - simply can't happen).
/// Shares Files' own sort/hidden/storage-scope/grid-list display prefs
/// (via `ServerProvider.applyFilesDisplayPrefs`) rather than a separate
/// parallel settings dimension.
class FavoritesView extends StatefulWidget {
final ScrollController scrollController;
const FavoritesView({super.key, required this.scrollController});
@override
State<FavoritesView> createState() => _FavoritesViewState();
}
class _FavoritesViewState extends State<FavoritesView>
with SingleTickerProviderStateMixin {
bool _requested = false;
final Set<String> _selectedIds = {};
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty;
void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
});
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
}
void _clearSelection() {
setState(_selectedIds.clear);
}
/// Mirrors `FilesView`/`PhotosView`'s identical scroll-hint - see their
/// doc comment: nudges the selection actions row right and back, once,
/// the first time a selection starts. No-ops if there's nothing to
/// scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !scrollController.hasClients) return;
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
final double peak = maxExtent < 36 ? maxExtent : 36;
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_scrollHintControllers.add(controller);
final hint = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 0.0,
end: peak,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
TweenSequenceItem(
tween: Tween(
begin: peak,
end: 0.0,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
]).animate(controller);
void onTick() {
if (scrollController.hasClients) {
scrollController.jumpTo(hint.value);
}
}
hint.addListener(onTick);
await controller.forward();
hint.removeListener(onTick);
_scrollHintControllers.remove(controller);
controller.dispose();
}
@override
void dispose() {
for (final controller in _scrollHintControllers) {
controller.dispose();
}
_selectionActionsScrollController.dispose();
super.dispose();
}
/// A favorited folder switches to the Files tab, navigated there; a
/// favorited file just opens directly from here, like Files/Photos do -
/// no need to reposition Files first since dismissing the viewer lands
/// back on this tab either way.
void _openFavorite(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
if (item.isFolder) {
provider.navigateToAbsoluteFolder(item.path);
provider.requestTab(AppTab.files);
} else {
Navigator.push(
context,
FileViewerScreen.route(item: item, siblings: siblings),
);
}
}
/// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items - same set Files/Photos offer.
List<SelectionAction> _buildSelectionActions(
ServerProvider provider,
List<NextcloudItem> selected,
) {
return [
SelectionAction(
icon: Icons.favorite_border_rounded,
label: 'Remove from favorites',
onTap: () => _unfavoriteSelected(provider, selected),
),
SelectionAction(
icon: Icons.share_rounded,
label: 'Share',
onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single)
: _shareSelected(context, provider, selected),
),
SelectionAction(
icon: Icons.download_rounded,
label: 'Download',
onTap: () => _downloadSelected(context, provider, selected),
),
SelectionAction(
icon: Icons.delete_outline_rounded,
label: 'Delete',
onTap: () => _confirmDeleteSelected(context, provider, selected),
),
SelectionAction(
icon: Icons.copy_rounded,
label: 'Copy',
onTap: () => _moveOrCopySelected(provider, selected, copy: true),
),
SelectionAction(
icon: Icons.drive_file_move_rounded,
label: 'Move',
onTap: () => _moveOrCopySelected(provider, selected, copy: false),
),
if (selected.length == 1)
SelectionAction(
icon: Icons.info_outline_rounded,
label: 'Details',
onTap: () => DetailsSheet.show(context, selected.single),
),
];
}
Future<void> _unfavoriteSelected(
ServerProvider provider,
List<NextcloudItem> items,
) async {
for (final item in items) {
await provider.toggleItemFavorite(item);
}
_clearSelection();
}
Future<void> _shareSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(
content: Text('Creating share link(s)…'),
behavior: SnackBarBehavior.floating,
),
);
final lines = <String>[];
for (final item in items) {
final link = await provider.createShareLink(item);
if (link != null) {
lines.add(items.length > 1 ? '${item.name}: $link' : link);
}
}
if (!mounted) return;
messenger.hideCurrentSnackBar();
_clearSelection();
if (lines.isEmpty) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not create share link(s)'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
try {
await SharePlus.instance.share(ShareParams(text: lines.join('\n')));
} catch (_) {
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Text('Could not open the share sheet'),
behavior: SnackBarBehavior.floating,
),
);
}
}
/// Hands the whole batch off to `DownloadService.kt` - see
/// `FilesView._downloadSelected`'s identical doc comment for why.
Future<void> _downloadSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final files = items.where((i) => !i.isFolder).toList();
_clearSelection();
if (files.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
try {
await DownloadService.startDownload(provider, files);
messenger.showSnackBar(
SnackBar(
content: Text(
files.length == 1
? 'Downloading ${files.first.name} - see the notification for progress'
: 'Downloading ${files.length} files - see the notification for progress',
),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Could not start download: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _confirmDeleteSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Delete Items'),
content: Text(
'Delete ${items.length} item(s) from the server? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
var succeeded = 0;
for (final item in items) {
final success = await provider.deleteItem(item.path);
if (success) succeeded++;
}
_clearSelection();
messenger.showSnackBar(
SnackBar(
content: Text('Deleted $succeeded of ${items.length} item(s)'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _moveOrCopySelected(
ServerProvider provider,
List<NextcloudItem> items, {
required bool copy,
}) async {
final result = await MoveCopyDestinationPicker.show(
context,
items,
copy: copy,
);
if (!mounted || result == null) return;
_clearSelection();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${copy ? 'Copied' : 'Moved'} ${result.succeeded} of ${items.length} item(s)',
),
behavior: SnackBarBehavior.floating,
),
);
}
Widget _buildControlsRow(ServerProvider provider) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
icon: Icon(
provider.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: provider.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: provider.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: provider.filesSortField,
onChanged: provider.setFilesSortField,
),
),
ToggleIconButton(
icon: provider.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: provider.showHiddenFiles,
onTap: () => provider.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: provider.storageScope == StorageScope.cloud,
onTap: () => provider.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: provider.storageScope == StorageScope.external,
onTap: () => provider.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !provider.isGridView,
onTap: () => provider.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: provider.isGridView,
onTap: () => provider.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
),
),
);
}
Widget _buildListTile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
List<NextcloudItem> siblings,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isSelected = _selectedIds.contains(item.id);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.5)
: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, provider, item, siblings);
}
},
onLongPress: () => _toggleSelection(item),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
SelectableThumbnail(
isSelected: isSelected,
size: 44,
checkmarkSize: 24,
child: ItemThumbnail(
item: item,
provider: provider,
size: 44,
borderRadius: 12,
iconSize: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
item.isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
),
),
);
}
Widget _buildGridCard(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
List<NextcloudItem> siblings,
) {
final theme = Theme.of(context);
final isSelected = _selectedIds.contains(item.id);
final isMedia =
(item.type == NextcloudItemType.image ||
item.type == NextcloudItemType.video) &&
item.previewUrl != null;
final iconColor = getIconColor(context, item.type);
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Material(
color: theme.colorScheme.surfaceContainerLow,
child: InkWell(
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, provider, item, siblings);
}
},
onLongPress: () => _toggleSelection(item),
child: SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: isMedia
? Image.network(
item.previewUrl!,
headers: provider.service?.authHeaders,
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => Container(
color: theme.colorScheme.secondaryContainer,
child: Icon(
getItemIcon(item.type),
color: theme.colorScheme.onSecondaryContainer,
size: 32,
),
),
)
: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchAllFavorites(),
);
}
final favorites = provider.favoriteItems;
final siblings = favorites.where((i) => !i.isFolder).toList();
final selectedItems = favorites
.where((i) => _selectedIds.contains(i.id))
.toList();
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: !_isSelecting,
delegate: StickyHeaderDelegate(
height: 60,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: _buildControlsRow(provider),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (provider.isFavoritesLoading && favorites.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (provider.favoritesErrorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load favorites',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
provider.favoritesErrorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: provider.fetchAllFavorites,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
)
else if (favorites.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.favorite_border_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'No favorites yet',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
)
else if (provider.isGridView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(
context,
favorites[index],
provider,
siblings,
);
}, childCount: favorites.length),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(
context,
favorites[index],
provider,
siblings,
);
}, childCount: favorites.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return PopScope(
canPop: !_isSelecting,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: SyncedHeaderScaffold(
scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchAllFavorites,
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, provider, selectedItems)
: null,
contentSlivers: contentSlivers,
),
);
}
/// Replaces the top bar entirely while selecting (see
/// `SyncedHeaderScaffold.selectionBar`) - mirrors Files/Photos' identical
/// selection bar.
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
ServerProvider provider,
List<NextcloudItem> selectedItems,
) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
child: Align(
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
),
Expanded(
child: SingleChildScrollView(
controller: _selectionActionsScrollController,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in _buildSelectionActions(
provider,
selectedItems,
))
IconButton(
icon: Icon(action.icon, size: 20),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
],
),
),
),
],
),
);
}
}
+12 -13
View File
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:open_file/open_file.dart';
import 'package:path/path.dart' as p;
@@ -11,6 +10,7 @@ import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../services/download_service.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/frosted_glass_container.dart';
import '../widgets/marquee_title.dart';
@@ -189,21 +189,22 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
}
}
/// Hands off to `DownloadService.kt` (see its doc comment) rather than
/// downloading in Dart then prompting `file_saver` - same reasoning as
/// `UploadService`/`ShareUploadService.kt` on the upload side: a real
/// Android Service survives the app being closed mid-download, and one
/// notification covers progress/cancel instead of blocking this screen.
Future<void> _downloadToDevice(ServerProvider provider) async {
final item = _currentItem;
setState(() => _isBusy = true);
try {
final tempPath = await _downloadToTemp(provider, item);
final ext = p.extension(item.name).replaceFirst('.', '');
final baseName = p.basenameWithoutExtension(item.name);
await FileSaver.instance.saveFile(
name: baseName,
filePath: tempPath,
ext: ext,
);
await DownloadService.startDownload(provider, [item]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Saved ${item.name} to Downloads')),
SnackBar(
content: Text(
'Downloading ${item.name} - see the notification for progress',
),
),
);
}
} catch (e) {
@@ -212,8 +213,6 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
context,
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
}
} finally {
if (mounted) setState(() => _isBusy = false);
}
}
+64 -46
View File
@@ -1,16 +1,14 @@
import 'package:file_picker/file_picker.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
import '../models/selection_action.dart';
import '../providers/server_provider.dart';
import '../services/download_service.dart';
import '../widgets/breadcrumbs.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/item_icon.dart';
@@ -24,6 +22,7 @@ import '../widgets/sticky_header_delegate.dart';
import '../widgets/swipeable_item.dart';
import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
class FilesView extends StatefulWidget {
final ScrollController scrollController;
@@ -267,6 +266,16 @@ class _FilesViewState extends State<FilesView>
label: 'Delete',
onTap: () => _confirmDeleteSelected(context, provider, selected),
),
SelectionAction(
icon: Icons.copy_rounded,
label: 'Copy',
onTap: () => _moveOrCopySelected(provider, selected, copy: true),
),
SelectionAction(
icon: Icons.drive_file_move_rounded,
label: 'Move',
onTap: () => _moveOrCopySelected(provider, selected, copy: false),
),
if (selected.length == 1)
SelectionAction(
icon: Icons.drive_file_rename_outline_rounded,
@@ -440,15 +449,6 @@ class _FilesViewState extends State<FilesView>
onChanged: provider.setFilesSortField,
),
),
ToggleIconButton(
icon: provider.showFavoritesOnlyFiles
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
isSelected: provider.showFavoritesOnlyFiles,
onTap: provider.toggleFavoritesFilterFiles,
tooltip: 'Favorites only',
),
const SizedBox(width: 4),
ToggleIconButton(
icon: provider.showHiddenFiles
? Icons.visibility_rounded
@@ -785,46 +785,42 @@ class _FilesViewState extends State<FilesView>
_clearSelection();
}
/// Hands the whole batch off to `DownloadService.kt` (see its doc
/// comment) rather than downloading each item in Dart then prompting
/// `file_saver` per file - same reasoning as `UploadService`/
/// `ShareUploadService.kt` on the upload side: a real Android Service
/// survives the app being closed mid-download, with one cancellable
/// notification for the whole selection instead of blocking here.
Future<void> _downloadSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(
content: Text('Downloading ${items.length} item(s)...'),
behavior: SnackBarBehavior.floating,
),
);
var succeeded = 0;
for (final item in items) {
if (item.isFolder) continue;
try {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, item.name);
await provider.service!.downloadToFile(item.path, tempPath);
final ext = p.extension(item.name).replaceFirst('.', '');
final baseName = p.basenameWithoutExtension(item.name);
await FileSaver.instance.saveFile(
name: baseName,
filePath: tempPath,
ext: ext,
);
succeeded++;
} catch (_) {
// Reported in the summary snackbar below.
}
}
final files = items.where((i) => !i.isFolder).toList();
_clearSelection();
messenger.showSnackBar(
SnackBar(
content: Text('Downloaded $succeeded of ${items.length} item(s)'),
behavior: SnackBarBehavior.floating,
),
);
if (files.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
try {
await DownloadService.startDownload(provider, files);
messenger.showSnackBar(
SnackBar(
content: Text(
files.length == 1
? 'Downloading ${files.first.name} - see the notification for progress'
: 'Downloading ${files.length} files - see the notification for progress',
),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Could not start download: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _confirmDeleteSelected(
@@ -874,6 +870,28 @@ class _FilesViewState extends State<FilesView>
);
}
Future<void> _moveOrCopySelected(
ServerProvider provider,
List<NextcloudItem> items, {
required bool copy,
}) async {
final result = await MoveCopyDestinationPicker.show(
context,
items,
copy: copy,
);
if (!mounted || result == null) return;
_clearSelection();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${copy ? 'Copied' : 'Moved'} ${result.succeeded} of ${items.length} item(s)',
),
behavior: SnackBarBehavior.floating,
),
);
}
Widget _buildListTile(
BuildContext context,
NextcloudItem item,
+528
View File
@@ -0,0 +1,528 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../models/move_copy_result.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/move_copy_conflict_sheet.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
/// Destination-folder browser for moving/copying [items] (a multi-select
/// batch from Files or Photos). Visually mirrors `ShareUploadView`'s
/// browser (same chrome/controls/listing), but deliberately does **not**
/// reuse `ServerProvider`'s shared `currentFolderPath`/`pathStack`/`items`
/// navigation state the way that screen does - this is pushed mid-browsing
/// session (the user was already looking at a specific Files-tab folder
/// when they selected items and tapped Move/Copy), so clobbering that
/// shared state here would strand the Files tab in whatever folder this
/// picker last visited. Instead it owns its own local navigation state and
/// fetches through `ServerProvider.fetchFolderListing`, a stateless
/// pass-through that never touches shared fields - `ShareUploadView` gets
/// away with the shared state precisely because it always resets to root
/// and pops all the way to the app root afterward (a cold share-intent
/// launch has no prior browsing session to preserve); that doesn't hold
/// here.
class MoveCopyDestinationPicker extends StatefulWidget {
final List<NextcloudItem> items;
final bool copy;
const MoveCopyDestinationPicker({
super.key,
required this.items,
required this.copy,
});
/// Pushes the picker and returns the final [MoveCopyResult] (already
/// including any conflict resolution), or null if the user backed out
/// without confirming.
static Future<MoveCopyResult?> show(
BuildContext context,
List<NextcloudItem> items, {
required bool copy,
}) {
return Navigator.of(context).push<MoveCopyResult>(
MaterialPageRoute(
builder: (_) => MoveCopyDestinationPicker(items: items, copy: copy),
),
);
}
@override
State<MoveCopyDestinationPicker> createState() =>
_MoveCopyDestinationPickerState();
}
class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
final ScrollController _scrollController = ScrollController();
List<String> _pathStack = const ['/'];
List<NextcloudItem> _rawItems = [];
bool _isLoading = true;
bool _isSubmitting = false;
String get _currentPath => _pathStack.last;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _fetch(_currentPath));
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _fetch(String path) async {
setState(() => _isLoading = true);
final raw = await context.read<ServerProvider>().fetchFolderListing(path);
if (!mounted) return;
setState(() {
_rawItems = raw;
_isLoading = false;
});
}
void _navigateToFolder(String path) {
setState(() => _pathStack = [..._pathStack, path]);
_fetch(path);
}
void _navigateToPathIndex(int index) {
if (index < 0 || index >= _pathStack.length - 1) return;
setState(() => _pathStack = _pathStack.sublist(0, index + 1));
_fetch(_currentPath);
}
bool _navigateUp() {
if (_pathStack.length <= 1) return false;
setState(() => _pathStack = _pathStack.sublist(0, _pathStack.length - 1));
_fetch(_currentPath);
return true;
}
/// Client-side mirror of `ServerProvider`'s own authoritative check (see
/// `_isSelfOrDescendant`) - just for disabling the confirm button with an
/// explanation up front instead of letting the request round-trip and
/// fail.
bool _destinationIsInvalid() {
final dest = _currentPath.endsWith('/') ? _currentPath : '$_currentPath/';
for (final item in widget.items) {
if (!item.isFolder) continue;
final folder = item.path.endsWith('/') ? item.path : '${item.path}/';
if (dest == folder || dest.startsWith(folder)) return true;
}
return false;
}
Future<void> _confirm(ServerProvider provider) async {
setState(() => _isSubmitting = true);
final result = widget.copy
? await provider.copyItems(widget.items, _currentPath)
: await provider.moveItems(widget.items, _currentPath);
if (!mounted) return;
if (result.blockedReason != null) {
setState(() => _isSubmitting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.blockedReason!),
behavior: SnackBarBehavior.floating,
),
);
return;
}
var succeeded = result.succeeded;
var failed = result.failed;
if (result.conflicts.isNotEmpty) {
final choices = await MoveCopyConflictSheet.show(
context,
result.conflicts,
);
if (!mounted) return;
if (choices != null) {
final resolved = await provider.resolveConflicts(
result.conflicts,
_currentPath,
copy: widget.copy,
choices: choices,
);
succeeded += resolved.succeeded;
failed += resolved.failed;
}
// A dismissed sheet (choices == null) leaves those conflicts
// untouched at the source - same outcome as explicitly skipping
// every one, just without a network round-trip to get there.
}
if (!mounted) return;
Navigator.of(
context,
).pop(MoveCopyResult(succeeded: succeeded, failed: failed));
}
Widget _buildControlsRow(ServerProvider provider) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
icon: Icon(
provider.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: provider.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: provider.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: provider.filesSortField,
onChanged: provider.setFilesSortField,
),
),
ToggleIconButton(
icon: provider.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: provider.showHiddenFiles,
onTap: () => provider.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: provider.storageScope == StorageScope.cloud,
onTap: () => provider.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: provider.storageScope == StorageScope.external,
onTap: () => provider.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !provider.isGridView,
onTap: () => provider.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: provider.isGridView,
onTap: () => provider.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
),
),
);
}
// Only folders are valid Move/Copy destinations - files still show (so
// the listing matches what the Files tab itself would show for this
// folder) but are visually dimmed and inert, same treatment as
// ShareUploadView's own destination browser.
Widget _buildListTile(NextcloudItem item, ServerProvider provider) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isFolder = item.isFolder;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
ItemThumbnail(
item: item,
provider: provider,
size: 44,
borderRadius: 12,
iconSize: 22,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isFolder) const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
),
),
);
}
Widget _buildGridCard(NextcloudItem item, ServerProvider provider) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isFolder = item.isFolder;
final iconColor = getIconColor(context, item.type);
return Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 2),
Text(
isFolder ? 'Folder' : formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
],
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final hasBreadcrumbs = _pathStack.length > 1;
final currentLabel = _currentPath == '/'
? 'Home'
: _currentPath.split('/').where((s) => s.isNotEmpty).last;
final items = provider.applyFilesDisplayPrefs(_rawItems);
final invalidDestination = _destinationIsInvalid();
final controlsColumn = Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildControlsRow(provider),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: _pathStack,
onTap: _navigateToPathIndex,
),
),
],
],
),
);
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
height: hasBreadcrumbs ? 114 : 72,
child: controlsColumn,
),
),
if (_isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
)
else if (provider.isGridView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(items[index], provider);
}, childCount: items.length),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(items[index], provider);
}, childCount: items.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
];
return PopScope(
canPop: _pathStack.length <= 1,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) _navigateUp();
},
child: Scaffold(
body: SyncedHeaderScaffold(
scrollController: _scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers,
),
bottomNavigationBar: Material(
color: colorScheme.surfaceContainerHigh,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (invalidDestination) ...[
Text(
"Can't ${widget.copy ? 'copy' : 'move'} a folder into "
"itself or one of its own subfolders",
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
const SizedBox(height: 8),
],
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: invalidDestination || _isSubmitting
? null
: () => _confirm(provider),
icon: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
widget.copy
? Icons.copy_rounded
: Icons.drive_file_move_rounded,
),
label: Text(
'${widget.copy ? 'Copy' : 'Move'} ${widget.items.length} '
'item(s) to $currentLabel',
),
),
),
],
),
),
),
),
),
);
}
}
+57 -36
View File
@@ -1,14 +1,12 @@
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
import '../models/selection_action.dart';
import '../providers/server_provider.dart';
import '../services/download_service.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
@@ -19,6 +17,7 @@ import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
class PhotosView extends StatefulWidget {
final ScrollController scrollController;
@@ -173,6 +172,16 @@ class _PhotosViewState extends State<PhotosView>
label: 'Delete',
onTap: () => _confirmDeleteSelected(context, provider, selected),
),
SelectionAction(
icon: Icons.copy_rounded,
label: 'Copy',
onTap: () => _moveOrCopySelected(provider, selected, copy: true),
),
SelectionAction(
icon: Icons.drive_file_move_rounded,
label: 'Move',
onTap: () => _moveOrCopySelected(provider, selected, copy: false),
),
if (selected.length == 1)
SelectionAction(
icon: Icons.info_outline_rounded,
@@ -633,45 +642,35 @@ class _PhotosViewState extends State<PhotosView>
}
}
/// Hands the whole batch off to `DownloadService.kt` - see
/// `FilesView._downloadSelected`'s identical doc comment for why.
Future<void> _downloadSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(
content: Text('Downloading ${items.length} item(s)...'),
behavior: SnackBarBehavior.floating,
),
);
var succeeded = 0;
for (final item in items) {
try {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, item.name);
await provider.service!.downloadToFile(item.path, tempPath);
final ext = p.extension(item.name).replaceFirst('.', '');
final baseName = p.basenameWithoutExtension(item.name);
await FileSaver.instance.saveFile(
name: baseName,
filePath: tempPath,
ext: ext,
);
succeeded++;
} catch (_) {
// Reported in the summary snackbar below.
}
}
_clearSelection();
messenger.showSnackBar(
SnackBar(
content: Text('Downloaded $succeeded of ${items.length} item(s)'),
behavior: SnackBarBehavior.floating,
),
);
final messenger = ScaffoldMessenger.of(context);
try {
await DownloadService.startDownload(provider, items);
messenger.showSnackBar(
SnackBar(
content: Text(
items.length == 1
? 'Downloading ${items.first.name} - see the notification for progress'
: 'Downloading ${items.length} files - see the notification for progress',
),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Could not start download: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _confirmDeleteSelected(
@@ -720,4 +719,26 @@ class _PhotosViewState extends State<PhotosView>
),
);
}
Future<void> _moveOrCopySelected(
ServerProvider provider,
List<NextcloudItem> items, {
required bool copy,
}) async {
final result = await MoveCopyDestinationPicker.show(
context,
items,
copy: copy,
);
if (!mounted || result == null) return;
_clearSelection();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${copy ? 'Copied' : 'Moved'} ${result.succeeded} of ${items.length} item(s)',
),
behavior: SnackBarBehavior.floating,
),
);
}
}
-9
View File
@@ -108,15 +108,6 @@ class _ShareUploadViewState extends State<ShareUploadView> {
onChanged: provider.setFilesSortField,
),
),
ToggleIconButton(
icon: provider.showFavoritesOnlyFiles
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
isSelected: provider.showFavoritesOnlyFiles,
onTap: provider.toggleFavoritesFilterFiles,
tooltip: 'Favorites only',
),
const SizedBox(width: 4),
ToggleIconButton(
icon: provider.showHiddenFiles
? Icons.visibility_rounded
+3
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../models/app_tab.dart';
import '../views/activity_view.dart';
import '../views/favorites_view.dart';
import '../views/files_view.dart';
import '../views/photos_view.dart';
import '../views/recent_view.dart';
@@ -16,6 +17,8 @@ Widget buildAppTabView(AppTab tab, ScrollController controller) {
return FilesView(scrollController: controller);
case AppTab.photos:
return PhotosView(scrollController: controller);
case AppTab.favorites:
return FavoritesView(scrollController: controller);
case AppTab.activity:
return ActivityView(scrollController: controller);
case AppTab.trash:
+16 -3
View File
@@ -51,11 +51,24 @@ class DetailsSheet extends StatelessWidget {
child: Column(
children: [
DetailsHeader(item: item),
// Icon size matches the bottom nav bar's own tab icons
// (FloatingBottomNavBar) rather than TabBar's oversized default,
// so a "tabs" affordance reads the same size everywhere in the
// app.
const TabBar(
tabs: [
Tab(icon: Icon(Icons.info_outline_rounded), text: 'Info'),
Tab(icon: Icon(Icons.history_rounded), text: 'Versions'),
Tab(icon: Icon(Icons.electric_bolt_rounded), text: 'Activity'),
Tab(
icon: Icon(Icons.info_outline_rounded, size: 22),
text: 'Info',
),
Tab(
icon: Icon(Icons.history_rounded, size: 22),
text: 'Versions',
),
Tab(
icon: Icon(Icons.electric_bolt_rounded, size: 22),
text: 'Activity',
),
],
),
Expanded(
+172
View File
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import '../models/move_copy_result.dart';
import 'gradual_bottom_sheet.dart';
/// Shown after a Move/Copy batch comes back with `MoveCopyResult.conflicts`
/// non-empty (an item with the same name already exists at the
/// destination) - summarizes every conflict at once rather than prompting
/// per item as they're hit, and lets the user resolve them all the same
/// way (overwrite/keep both) or open a per-item breakdown. Returns the
/// `ConflictChoice` map `ServerProvider.resolveConflicts` expects, or null
/// if dismissed without choosing.
class MoveCopyConflictSheet extends StatefulWidget {
final List<MoveCopyConflict> conflicts;
final ScrollController? scrollController;
const MoveCopyConflictSheet({
super.key,
required this.conflicts,
this.scrollController,
});
static Future<Map<String, ConflictChoice>?> show(
BuildContext context,
List<MoveCopyConflict> conflicts,
) {
return showGradualBottomSheet<Map<String, ConflictChoice>>(
context,
builder: (context, scrollController) => MoveCopyConflictSheet(
conflicts: conflicts,
scrollController: scrollController,
),
);
}
@override
State<MoveCopyConflictSheet> createState() => _MoveCopyConflictSheetState();
}
class _MoveCopyConflictSheetState extends State<MoveCopyConflictSheet> {
bool _decidingPerItem = false;
late final Map<String, ConflictChoice> _perItemChoices = {
for (final conflict in widget.conflicts)
conflict.item.id: ConflictChoice.keepBoth,
};
void _resolveAll(ConflictChoice choice) {
Navigator.pop(context, {
for (final conflict in widget.conflicts) conflict.item.id: choice,
});
}
void _confirmPerItem() {
Navigator.pop(context, _perItemChoices);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final count = widget.conflicts.length;
return ListView(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
children: [
Text(
count == 1
? '1 item already exists at the destination'
: '$count items already exist at the destination',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Choose how to handle the conflicts.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
if (!_decidingPerItem) ...[
FilledButton(
onPressed: () => _resolveAll(ConflictChoice.overwrite),
child: const Text('Overwrite all'),
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () => _resolveAll(ConflictChoice.keepBoth),
child: const Text('Keep both (rename)'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => setState(() => _decidingPerItem = true),
child: const Text('Decide per item'),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
],
for (final conflict in widget.conflicts)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
conflict.item.isFolder
? Icons.folder_rounded
: Icons.insert_drive_file_rounded,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
conflict.item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
if (_decidingPerItem) ...[
const SizedBox(height: 6),
SizedBox(
width: double.infinity,
child: SegmentedButton<ConflictChoice>(
segments: const [
ButtonSegment(
value: ConflictChoice.overwrite,
label: Text('Overwrite'),
),
ButtonSegment(
value: ConflictChoice.keepBoth,
label: Text('Keep both'),
),
ButtonSegment(
value: ConflictChoice.skip,
label: Text('Skip'),
),
],
selected: {_perItemChoices[conflict.item.id]!},
showSelectedIcon: false,
onSelectionChanged: (selection) => setState(() {
_perItemChoices[conflict.item.id] = selection.first;
}),
),
),
],
],
),
),
if (_decidingPerItem) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _confirmPerItem,
child: const Text('Confirm'),
),
),
],
],
);
}
}
+87 -2
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart';
@@ -67,6 +70,8 @@ class _ShareSheetState extends State<ShareSheet> {
List<NextcloudShare> _inherited = [];
List<NextcloudSharee> _searchResults = [];
bool _isSearching = false;
bool _isAddingEmail = false;
bool _isSharingFile = false;
final _searchController = TextEditingController();
final _emailController = TextEditingController();
@@ -141,6 +146,7 @@ class _ShareSheetState extends State<ShareSheet> {
Future<void> _addEmailShare() async {
final email = _emailController.text.trim();
if (email.isEmpty) return;
setState(() => _isAddingEmail = true);
final provider = context.read<ServerProvider>();
final messenger = ScaffoldMessenger.of(context);
final share = await provider.createShare(
@@ -150,6 +156,7 @@ class _ShareSheetState extends State<ShareSheet> {
);
if (!mounted) return;
if (share == null) {
setState(() => _isAddingEmail = false);
messenger.showSnackBar(
const SnackBar(
content: Text('Could not create email share'),
@@ -161,6 +168,7 @@ class _ShareSheetState extends State<ShareSheet> {
setState(() {
_shares = [..._shares, share];
_emailController.clear();
_isAddingEmail = false;
});
messenger.showSnackBar(
SnackBar(
@@ -217,6 +225,41 @@ class _ShareSheetState extends State<ShareSheet> {
);
}
/// Downloads the item to a scratch file and hands it to Android's native
/// "Share to..." sheet (`share_plus`) - a completely different action
/// from the rest of this sheet (which shares *within* Nextcloud, via
/// users/links) and the only one here that reads the file's actual
/// bytes.
Future<void> _shareFileDirectly() async {
final provider = context.read<ServerProvider>();
if (provider.service == null) return;
setState(() => _isSharingFile = true);
final messenger = ScaffoldMessenger.of(context);
try {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, widget.item.name);
await provider.service!.downloadToFile(widget.item.path, tempPath);
if (!mounted) return;
await SharePlus.instance.share(
ShareParams(
files: [XFile(tempPath)],
fileNameOverrides: [widget.item.name],
),
);
} catch (_) {
if (mounted) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not share the file'),
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _isSharingFile = false);
}
}
Widget _sectionHeader(String label) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
@@ -314,17 +357,43 @@ class _ShareSheetState extends State<ShareSheet> {
item: widget.item,
padding: const EdgeInsets.only(top: 12, bottom: 8),
),
const SizedBox(height: 8),
if (!widget.item.isFolder) ...[
const SizedBox(height: 4),
ListTile(
contentPadding: EdgeInsets.zero,
leading: _isSharingFile
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.ios_share_rounded),
title: const Text('Share file directly'),
subtitle: const Text('Send the file itself via another app'),
onTap: _isSharingFile ? null : _shareFileDirectly,
),
const SizedBox(height: 12),
const Divider(),
],
const SizedBox(height: 12),
_sectionHeader('Internal shares'),
const SizedBox(height: 8),
TextField(
controller: _searchController,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
hintText: 'Type names or teams',
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: _isSearching
? const Padding(
padding: EdgeInsets.all(14),
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
@@ -381,9 +450,25 @@ class _ShareSheetState extends State<ShareSheet> {
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
enabled: !_isAddingEmail,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
hintText: 'Type an email',
prefixIcon: const Icon(Icons.email_rounded),
suffixIcon: _isAddingEmail
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
filled: true,
fillColor: colorScheme.surfaceContainerLow,