Files
noo/lib/providers/item_operations.dart
T
ayushyaandClaude Sonnet 5 ff5f04e9a1
Build APK / build (push) Successful in 5m20s
Dedupe native transfer/notification code and split ServerProvider into focused controllers
Consolidates duplicated GET/PUT/notification-channel logic across
DownloadService/ShareUploadService/SyncEngine into shared Kotlin helpers,
gives upload/download real batch queueing instead of dropping a second
concurrent batch, and dedupes repeated Dart channel-argument boilerplate.

Replaces the 2300+ line ServerProvider god object with ten focused
ChangeNotifiers (SessionController, SettingsController, FilesController,
PhotosController, FavoritesController, TrashController, SharesController,
RecentController, SyncStatusController, PickController) plus ItemOperations,
a plain coordinator for cross-domain item mutations - fixing the coupling
where device-sync status, per-tab data, and global UI prefs all lived in
one object. Updates every view/widget call site accordingly and refreshes
the architecture/server/standards/styling docs to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:04:12 -04:00

359 lines
12 KiB
Dart

import 'package:flutter/foundation.dart';
import '../models/move_copy_result.dart';
import '../models/nextcloud_file_version.dart';
import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart';
import 'favorites_controller.dart';
import 'files_controller.dart';
import 'photos_controller.dart';
import 'session_controller.dart';
/// The one place that knows how an item mutation (delete/rename/move/copy/
/// favorite-toggle/create-folder) ripples across Files/Photos/Favorites -
/// those three each own their own item list, but a mutation triggered from
/// *any* of them (see each call site in the views) needs to reconcile all
/// three, since e.g. a delete removes an item Photos might also be
/// showing. Deliberately holds direct references to the sibling
/// controllers (constructed once in `main.dart`, after they already exist)
/// rather than a generic event bus, so callers can still `await` a
/// mutation and know every affected list has already been reconciled by
/// the time it returns - exactly the same guarantee the single
/// `ServerProvider` god object gave for free before this was split out.
///
/// Also home to the file-details sheet's per-item pass-throughs (shares/
/// activity/versions) - stateless, no stored data, so they don't need a
/// controller of their own.
class ItemOperations {
final SessionController session;
final FilesController files;
final PhotosController photos;
final FavoritesController favorites;
const ItemOperations({
required this.session,
required this.files,
required this.photos,
required this.favorites,
});
Future<bool> deleteItem(String itemPath) async {
final service = session.service;
if (service == null) return false;
final success = await service.deleteItem(itemPath);
if (success) {
photos.applyDelete(itemPath);
await files.refreshData();
await favorites.syncIfLoaded();
}
return success;
}
Future<bool> renameItem(NextcloudItem item, String newName) async {
final service = session.service;
if (service == null) return false;
final success = await service.renameItem(item.path, newName);
if (success) {
await files.refreshData();
await favorites.syncIfLoaded();
}
return success;
}
/// 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 {
final service = session.service;
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) {
files.invalidateCache();
await files.refreshData();
await favorites.syncIfLoaded();
}
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 {
final service = session.service;
if (service == null || conflicts.isEmpty) return const MoveCopyResult();
final needsKeepBoth = conflicts.any(
(c) => choices[c.item.id] == ConflictChoice.keepBoth,
);
final existingNames = needsKeepBoth
? (await files.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) {
files.invalidateCache();
await files.refreshData();
await favorites.syncIfLoaded();
}
return MoveCopyResult(succeeded: succeeded, failed: failed);
}
Future<void> toggleItemFavorite(NextcloudItem item) async {
final service = session.service;
if (service == null) return;
final success = await service.toggleFavorite(item.path, item.isFavorite);
if (success) {
final updated = item.copyWith(isFavorite: !item.isFavorite);
files.applyFavoriteToggle(updated);
photos.applyFavoriteToggle(updated);
favorites.applyFavoriteToggle(updated);
}
}
/// Creates a public link share for [item] and returns its URL, or null on
/// failure (server error, unsupported item, etc).
Future<String?> createShareLink(NextcloudItem item) async {
final service = session.service;
if (service == null) return null;
try {
return await service.createPublicShareLink(item.path);
} catch (e) {
debugPrint('[ItemOperations] createShareLink failed: $e');
return null;
}
}
// --- Details sheet: per-item sharing/activity/versions ---
//
// Thin pass-throughs (no stored state) - the data here is transient and
// scoped to whichever item's Details sheet happens to be open, so it
// lives in that sheet's own local widget state rather than a controller.
Future<List<NextcloudShare>> fetchItemShares(NextcloudItem item) async {
final service = session.service;
if (service == null) return [];
try {
return await service.fetchSharesForPath(item.path);
} catch (e) {
debugPrint('[ItemOperations] fetchItemShares failed: $e');
return [];
}
}
Future<List<NextcloudShare>> fetchInheritedShares(NextcloudItem item) async {
final service = session.service;
if (service == null) return [];
return service.fetchInheritedShares(item.path);
}
Future<List<NextcloudSharee>> searchSharees(String query) async {
final service = session.service;
if (service == null) return [];
try {
return await service.searchSharees(query);
} catch (e) {
debugPrint('[ItemOperations] searchSharees failed: $e');
return [];
}
}
Future<NextcloudShare?> createShare({
required String path,
required int shareType,
String? shareWith,
String? password,
DateTime? expireDate,
}) async {
final service = session.service;
if (service == null) return null;
try {
return await service.createShare(
path: path,
shareType: shareType,
shareWith: shareWith,
password: password,
expireDate: expireDate,
);
} catch (e) {
debugPrint('[ItemOperations] createShare failed: $e');
return null;
}
}
/// Deletes a share (from the file-details/Share sheet, not the Shares
/// tab itself - see `SharesController.deleteShare` for that one, which
/// additionally patches the tab's own list).
Future<bool> deleteShare(NextcloudShare share) async {
final service = session.service;
if (service == null) return false;
return service.deleteShare(share.id);
}
Future<List<NextcloudActivity>> fetchFileActivity(NextcloudItem item) async {
final service = session.service;
if (service == null) return [];
return service.fetchFileActivity(item.id);
}
Future<List<NextcloudFileVersion>> fetchFileVersions(
NextcloudItem item,
) async {
final service = session.service;
if (service == null) return [];
return service.fetchFileVersions(item.id);
}
Future<bool> restoreFileVersion(
NextcloudItem item,
String versionLabel,
) async {
final service = session.service;
if (service == null) return false;
try {
return await service.restoreFileVersion(item.id, versionLabel);
} catch (e) {
debugPrint('[ItemOperations] restoreFileVersion failed: $e');
return false;
}
}
Future<bool> downloadVersion(
NextcloudItem item,
String versionLabel,
String savePath,
) async {
final service = session.service;
if (service == null) return false;
try {
await service.downloadVersionToFile(item.id, versionLabel, savePath);
return true;
} catch (e) {
debugPrint('[ItemOperations] downloadVersion failed: $e');
return false;
}
}
}