From b1400e399ff7ef3c50ec8830562e8111b3676cc1 Mon Sep 17 00:00:00 2001 From: ayushya Date: Fri, 18 Sep 2026 16:20:51 -0400 Subject: [PATCH] Add in-app copy/move, background downloads, and a Favorites tab - 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 --- .claude/context/architecture.md | 30 +- .claude/context/server.md | 79 +- android/app/src/main/AndroidManifest.xml | 17 +- .../kotlin/dev/ayushya/noo/DownloadService.kt | 307 +++++++ .../kotlin/dev/ayushya/noo/MainActivity.kt | 47 + lib/models/app_tab.dart | 6 +- lib/models/move_copy_result.dart | 33 + lib/providers/server_provider.dart | 300 ++++++- lib/services/account_store.dart | 1 - lib/services/download_service.dart | 52 ++ lib/services/nextcloud_service.dart | 282 +++++- lib/views/favorites_view.dart | 804 ++++++++++++++++++ lib/views/file_viewer_screen.dart | 25 +- lib/views/files_view.dart | 110 ++- lib/views/move_copy_destination_picker.dart | 528 ++++++++++++ lib/views/photos_view.dart | 93 +- lib/views/share_upload_view.dart | 9 - lib/widgets/app_tab_view_builder.dart | 3 + lib/widgets/details/details_sheet.dart | 19 +- lib/widgets/move_copy_conflict_sheet.dart | 172 ++++ lib/widgets/share_sheet.dart | 89 +- 21 files changed, 2851 insertions(+), 155 deletions(-) create mode 100644 android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt create mode 100644 lib/models/move_copy_result.dart create mode 100644 lib/services/download_service.dart create mode 100644 lib/views/favorites_view.dart create mode 100644 lib/views/move_copy_destination_picker.dart create mode 100644 lib/widgets/move_copy_conflict_sheet.dart diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index ddf4def..5f43268 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -102,10 +102,15 @@ state, no named routes: - else `provider.needsUnlock` → `LockScreenView` (login lock — see above) - else `MainShellView` -`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files, -Photos, Activity, Trash, Shares, Recent — user-configurable (order, -visibility up to `maxVisibleTabs`, default tab) via `AppTab`/`ServerProvider` -and rendered through `buildAppTabView` (`widgets/app_tab_view_builder.dart`). +`MainShellView` is a bottom-nav `IndexedStack` over up to 7 tabs — Files, +Photos, Favorites, Activity, Trash, Shares, Recent — user-configurable +(order, visibility up to `maxVisibleTabs`, default tab) via +`AppTab`/`ServerProvider` and rendered through `buildAppTabView` +(`widgets/app_tab_view_builder.dart`). `maxVisibleTabs` (5) is less than the +total tab count, and `ServerProvider._enforceMaxVisibleTabs` already +auto-hides overflow on load (fresh install, or - as when Favorites was +added - an existing saved tab order from before a new tab existed), so +adding a tab to the `AppTab` enum needs no extra migration. Each tab keeps its own `ScrollController` (survives tab switches via `IndexedStack`'s built-but-hidden trees) and tapping the already-active tab scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also @@ -140,6 +145,23 @@ sheet peeking up from the bottom edge rather than a plain flat bar. [`MarqueeTitle`](../../lib/widgets/marquee_title.dart) (`package:marquee`) is shared with `FileViewerScreen`'s title - falls back to a plain ellipsized `Text` when the content already fits, so short text never marquees. + +The Move/Copy destination picker +([`MoveCopyDestinationPicker`](../../lib/views/move_copy_destination_picker.dart), +pushed from Files/Photos' selection toolbar - see `server.md` for the +backend side) visually mirrors `ShareUploadView`'s browser the same way, +but is a deliberately different case for state: it does **not** reuse +`ServerProvider`'s shared `currentFolderPath`/`pathStack`/`items`, and +owns its own local navigation state instead, fetching through the +stateless `ServerProvider.fetchFolderListing`/`applyFilesDisplayPrefs` +pair. `ShareUploadView` can get away with hijacking the shared state +because it always resets to root on entry and pops all the way to the +app's root route on completion - fine for a cold share-intent launch with +no prior browsing session to preserve. The Move/Copy picker is pushed +*while the user is actively browsing a specific Files-tab folder*, so +reusing the shared state would strand that folder's `pathStack` under it; +its local state means popping back always lands the user exactly where +they were, untouched. `SearchView`, `AccountView` (Settings), the file-details sheet, and the share sheet are pushed on top via `Navigator`/`showModalBottomSheet`/`showGradualBottomSheet` rather than diff --git a/.claude/context/server.md b/.claude/context/server.md index b4909cc..07ecec7 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -76,12 +76,87 @@ rather than needing a rewrite for multi-account support. - **Everything else** (shares, activity, trash, favorites, quota, user info, file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON in, with the `OCS-APIRequest: true` header required on every OCS call. + *Toggling* a favorite is OCS; *listing* every favorite (`fetchFavorites`, + for the Favorites tab) is WebDAV instead - the same `SEARCH` mechanism + `fetchAllMedia`/`fetchRecentFiles` use, filtered by `oc:favorite` instead + of mimetype/date. Favorites is a real tab + (`views/favorites_view.dart`/`ServerProvider.favoriteItems`/ + `fetchAllFavorites`/`_allFavorites`), not a filter toggle scoped to + whatever folder the Files tab happens to be browsing (that's what it + used to be - see the note below on why that didn't work). It shares + Files' own sort/hidden/storage-scope/grid-list display prefs + (`applyFilesDisplayPrefs`, also used by the Move/Copy destination + picker) rather than a separate parallel settings dimension. Tapping a + favorited folder switches to the Files tab, navigated there + (`navigateToAbsoluteFolder` + `requestTab`); a favorited file opens + directly from the Favorites tab itself. `deleteItem`/`renameItem`/move/ + copy all re-sync `_allFavorites` afterward via `_syncFavoritesIfLoaded` + (only once Favorites has actually been opened this session, tracked by + `_favoritesEverFetched`, so those actions don't pay for an extra request + on every edit for an account that's never visited the tab) since none of + them know how to patch `_allFavorites` in place the way + `toggleItemFavorite` does (added/removed/updated by id, right inline). + **Note**: this used to be a "favorites-only" filter toggle on the Files + tab's controls row instead of its own tab, filtering the currently + browsed folder's `_items`. That had two real bugs in sequence: first, + the filter dropped every non-favorited item *including folders*, so a + non-favorited folder (containing a favorited item nested inside) + vanished from the listing entirely, with no way to navigate into it; + fixing that by exempting folders from the filter was itself wrong, + because the actual intent was for favorites-only to show every + favorited item *account-wide*, not just direct children of whatever + folder was open - a strict filter over the wrong scope. Converting it to + a real tab, backed by an account-wide fetch, was the actual fix; keep + favorites account-wide rather than reintroducing a current-folder-scoped + filter. - Auth header is HTTP Basic (`username:appPassword`, base64), built in `_headers`/exposed as `authHeaders` for widgets that need to hit URLs directly (e.g. `Image.network(url, headers: service.authHeaders)` for thumbnails/previews). -- Downloads stream through `Dio` (`downloadToFile`) for progress callbacks; - small in-app previews (text/PDF) use `fetchBytes` via `package:http`. +- `NextcloudService.downloadToFile` (`Dio`, progress callbacks) is still + used for small in-app-only downloads: text/PDF previews (`fetchBytes` via + `package:http`) and "open externally" (`FileViewerScreen._downloadToTemp` + streams to the app's own cache dir so `open_file` can hand it to another + app). Explicitly saving a file **to the device** - the "Download" action + in Files/Photos' selection toolbar and the media viewer's download button + - instead hands off to `DownloadService.kt`, an Android foreground + service, the same way "Share to Noo" hands its upload off to + `ShareUploadService.kt` (see that section below) rather than downloading + in Dart and prompting `file_saver` per file: a real Service survives the + app being closed mid-download, with one cancellable notification for the + whole batch. `DownloadService.kt` re-implements a plain WebDAV GET in + Kotlin for the same reason `ShareUploadService.kt`'s PUT does - keep + `NextcloudService.downloadToFile` in sync manually if download semantics + change - and writes straight into the device's public Downloads + collection via `MediaStore.Downloads` (API 29+; a background Service + can't prompt `file_saver`'s SAF picker the way the Flutter/Activity side + can, so this is the direct equivalent) with a legacy + `Environment.DIRECTORY_DOWNLOADS` file-write fallback pre-Android-10. + Folders are filtered out client-side before handing off (no recursive/ + zip download support); `DetailsVersionsTab`'s version-restore download + and `ShareSheet`'s "Share file directly" (native OS share, not saved to + Downloads) keep using `downloadToFile` directly instead, since both need + the bytes in-app rather than saved to Downloads. +- **Move/Copy** (`moveItem`/`copyItem(itemPath, destFolderPath, {overwrite, + newName})`) share one private `_moveOrCopy` helper with `renameItem` + (itself just a same-folder MOVE) - WebDAV `MOVE`/`COPY` are the same + request shape, just a different verb, and both are recursive by default + for a folder ("collection"), so no extra `Depth` header is needed. They + return the raw HTTP status rather than a bool: `412 Precondition Failed` + is WebDAV's standard signal for "something's already there" when + `Overwrite: F`, which is exactly the conflict `ServerProvider.moveItems`/ + `copyItems` need to detect without a separate existence-check request + per item. `ServerProvider` attempts every item in the batch first, + collects conflicts into `MoveCopyResult.conflicts` + (`models/move_copy_result.dart`), and only then shows one summary + (`MoveCopyConflictSheet`) instead of prompting per conflict as they're + hit; resolving picks overwrite/keep-both (auto-renamed via + `_nextAvailableName` against a fresh listing of the destination, fetched + once up front, not per item)/skip per item via `resolveConflicts`. The + destination-picker screen behind this + (`views/move_copy_destination_picker.dart`) is covered in + `architecture.md`, including why it can't reuse the Files tab's shared + navigation state the way `ShareUploadView` does. - **Uploads** (`uploadFileFromPath(folderPath, fileName, localFilePath, {onProgress})`) stream the local file via `Dio().put()` with an explicit `Content-Length` and `onSendProgress`, mirroring the download path. The diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5869d8f..9d781c6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,12 +2,19 @@ - + + + + diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt b/android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt new file mode 100644 index 0000000..611f016 --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt @@ -0,0 +1,307 @@ +package dev.ayushya.noo + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.IBinder +import android.provider.MediaStore +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import org.json.JSONArray +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Foreground service that downloads one or more files over WebDAV straight + * into the device's public Downloads folder, independent of MainActivity/ + * the Flutter engine being alive - the download counterpart of + * ShareUploadService.kt (see its doc comment for the full rationale: only + * a real Android Service survives the app being closed mid-transfer, the + * same guarantee a real file-manager app's download notification gives + * you). Re-implements a plain WebDAV GET here in Kotlin for the same + * reason uploads do - keep NextcloudService.downloadToFile in sync if + * download semantics change. + * + * Started via the `dev.ayushya.noo/download_service` MethodChannel + * (MainActivity.kt). Shows one persistent, cancellable notification for + * the whole batch, same UX as ShareUploadService's. + */ +class DownloadService : Service() { + companion object { + const val ACTION_CANCEL = "dev.ayushya.noo.action.CANCEL_DOWNLOAD" + const val EXTRA_FILES = "files" // JSON array of {path, name, mimeType, size} + const val EXTRA_SERVER_URL = "serverUrl" + const val EXTRA_USERNAME = "username" + const val EXTRA_AUTH_HEADER = "authHeader" + + private const val CHANNEL_ID = "file_downloads" + private const val NOTIFICATION_ID = 4301 + } + + private val cancelled = AtomicBoolean(false) + private var downloadThread: Thread? = null + + private data class DownloadFile( + val path: String, + val name: String, + val mimeType: String?, + val size: Long?, + ) + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_CANCEL) { + cancelled.set(true) + return START_NOT_STICKY + } + + val filesJson = intent?.getStringExtra(EXTRA_FILES) + val serverUrl = intent?.getStringExtra(EXTRA_SERVER_URL) + val username = intent?.getStringExtra(EXTRA_USERNAME) + val authHeader = intent?.getStringExtra(EXTRA_AUTH_HEADER) + if (filesJson == null || serverUrl == null || username == null || authHeader == null) { + stopSelf() + return START_NOT_STICKY + } + + createNotificationChannel() + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + buildProgressNotification("Preparing to download…", null, indeterminate = true), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + }, + ) + + // A second batch arriving mid-download is dropped rather than + // queued - same tradeoff ShareUploadService makes, rare in + // practice and not worth a real queue for. + if (downloadThread == null) { + val files = parseFiles(filesJson) + downloadThread = Thread { + runDownloads(files, serverUrl, username, authHeader) + }.also { it.start() } + } + + return START_NOT_STICKY + } + + private fun parseFiles(json: String): List { + val arr = JSONArray(json) + return (0 until arr.length()).map { i -> + val obj = arr.getJSONObject(i) + DownloadFile( + path = obj.getString("path"), + name = obj.getString("name"), + mimeType = if (obj.isNull("mimeType")) null else obj.getString("mimeType"), + size = if (obj.isNull("size")) null else obj.getLong("size"), + ) + } + } + + private fun runDownloads( + files: List, + serverUrl: String, + username: String, + authHeader: String, + ) { + var succeeded = 0 + var failed = 0 + val cleanServer = serverUrl.trimEnd('/') + + for ((index, file) in files.withIndex()) { + if (cancelled.get()) break + val label = if (files.size == 1) file.name else "${file.name} (${index + 1}/${files.size})" + try { + var cleanPath = file.path.trim() + if (!cleanPath.startsWith("/")) cleanPath = "/$cleanPath" + val encodedPath = cleanPath.split("/").joinToString("/") { Uri.encode(it) } + val url = URL("$cleanServer/remote.php/dav/files/$username$encodedPath") + val ok = downloadFile(file, url, authHeader) { received, total -> + notify( + buildProgressNotification( + "Downloading $label…", + progressFraction(received, total), + indeterminate = total == null, + ), + ) + } + if (ok) succeeded++ else failed++ + } catch (e: Exception) { + failed++ + } + } + + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val finalText = when { + cancelled.get() -> "Download cancelled" + failed == 0 && files.size == 1 -> "Downloaded ${files.first().name}" + failed == 0 -> "Downloaded $succeeded of ${files.size} files" + else -> "Downloaded $succeeded of ${files.size} files - $failed failed" + } + manager.notify(NOTIFICATION_ID, buildFinalNotification(finalText)) + + stopForeground(STOP_FOREGROUND_DETACH) + stopSelf() + } + + private fun progressFraction(received: Long, total: Long?): Float? { + if (total == null || total <= 0) return null + return (received.toFloat() / total.toFloat()).coerceIn(0f, 1f) + } + + /// Streams the GET response straight into the public Downloads + /// collection via MediaStore (API 29+) - scoped storage means a + /// background Service can't prompt a SAF picker the way `file_saver` + /// does from the Flutter/Activity side, so this writes directly to the + /// Downloads collection instead, same place a browser download lands. + /// Pre-Q devices fall back to the legacy public Downloads directory. + private fun downloadFile( + file: DownloadFile, + url: URL, + authHeader: String, + onProgress: (Long, Long?) -> Unit, + ): Boolean { + val connection = url.openConnection() as HttpURLConnection + return try { + connection.requestMethod = "GET" + connection.setRequestProperty("Authorization", authHeader) + connection.setRequestProperty("OCS-APIRequest", "true") + connection.connectTimeout = 15000 + connection.readTimeout = 30000 + connection.connect() + + if (connection.responseCode !in 200..299) return false + + val total = file.size ?: connection.contentLengthLong.takeIf { it > 0 } + val mimeType = file.mimeType ?: "application/octet-stream" + val outputUri = createDownloadsEntry(file.name, mimeType) ?: return false + + val stream = contentResolver.openOutputStream(outputUri) ?: return false + stream.use { output -> + connection.inputStream.use { input -> + val buffer = ByteArray(256 * 1024) + var received = 0L + var lastEmit = 0L + while (!cancelled.get()) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + received += read + val now = System.currentTimeMillis() + if (now - lastEmit >= 200) { + lastEmit = now + onProgress(received, total) + } + } + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val values = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) } + contentResolver.update(outputUri, values, null, null) + } + + if (cancelled.get()) { + contentResolver.delete(outputUri, null, null) + false + } else { + true + } + } catch (e: Exception) { + false + } finally { + connection.disconnect() + } + } + + private fun createDownloadsEntry(name: String, mimeType: String): Uri? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, name) + put(MediaStore.Downloads.MIME_TYPE, mimeType) + put(MediaStore.Downloads.IS_PENDING, 1) + } + contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + } else { + @Suppress("DEPRECATION") + val downloadsDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!downloadsDir.exists()) downloadsDir.mkdirs() + Uri.fromFile(File(downloadsDir, name)) + } + } + + private fun notify(notification: Notification) { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, notification) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "File downloads", + NotificationManager.IMPORTANCE_LOW, + ).apply { description = "Progress for files downloaded from Noo" } + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + } + + private fun buildProgressNotification( + text: String, + progress: Float?, + indeterminate: Boolean, + ): Notification { + val cancelIntent = Intent(this, DownloadService::class.java).apply { action = ACTION_CANCEL } + val cancelPendingIntent = PendingIntent.getService( + this, + 0, + cancelIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle("Downloading from Noo") + .setContentText(text) + .setOnlyAlertOnce(true) + .setOngoing(true) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent) + when { + indeterminate -> builder.setProgress(100, 0, true) + progress != null -> builder.setProgress(100, (progress * 100).toInt(), false) + } + return builder.build() + } + + private fun buildFinalNotification(text: String): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle("Noo") + .setContentText(text) + .setOngoing(false) + .setAutoCancel(true) + .build() + } + + override fun onDestroy() { + cancelled.set(true) + super.onDestroy() + } +} diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt b/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt index 0fcf36e..41caf57 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt @@ -41,6 +41,7 @@ class MainActivity : FlutterFragmentActivity() { private val shareIntentChannelName = "dev.ayushya.noo/share_intent" private val newShareChannelName = "dev.ayushya.noo/share_intent/new" private val uploadServiceChannelName = "dev.ayushya.noo/upload_service" + private val downloadServiceChannelName = "dev.ayushya.noo/download_service" private val pickIntentChannelName = "dev.ayushya.noo/pick_intent" private val newPickChannelName = "dev.ayushya.noo/pick_intent/new" private val notificationPermissionRequestCode = 4202 @@ -84,6 +85,16 @@ class MainActivity : FlutterFragmentActivity() { } } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, downloadServiceChannelName) + .setMethodCallHandler { call, result -> + when (call.method) { + "startDownload" -> { + startDownloadService(call, result) + } + else -> result.notImplemented() + } + } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, pickIntentChannelName) .setMethodCallHandler { call, result -> when (call.method) { @@ -215,6 +226,42 @@ class MainActivity : FlutterFragmentActivity() { result.success(null) } + /// Starts DownloadService with everything it needs to run entirely on + /// its own (see its doc comment) - same contract shape as + /// [startUploadService], kept in sync manually for the same reason. + private fun startDownloadService(call: MethodCall, result: MethodChannel.Result) { + val filesJson = call.argument("files") + val serverUrl = call.argument("serverUrl") + val username = call.argument("username") + val authHeader = call.argument("authHeader") + if (filesJson == null || serverUrl == null || username == null || authHeader == null) { + result.error("bad_args", "Missing required download arguments", null) + return + } + + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + // Fire-and-forget: the service works fine even if this is denied, + // it just won't be able to show progress/cancel in a notification. + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + notificationPermissionRequestCode, + ) + } + + val serviceIntent = Intent(this, DownloadService::class.java).apply { + putExtra(DownloadService.EXTRA_FILES, filesJson) + putExtra(DownloadService.EXTRA_SERVER_URL, serverUrl) + putExtra(DownloadService.EXTRA_USERNAME, username) + putExtra(DownloadService.EXTRA_AUTH_HEADER, authHeader) + } + ContextCompat.startForegroundService(this, serviceIntent) + result.success(null) + } + /// Non-null only when this Activity was launched (or re-delivered a new /// Intent) as another app's GET_CONTENT picker - the mimeType filter and /// multi-select flag the caller asked for, plus a best-effort display diff --git a/lib/models/app_tab.dart b/lib/models/app_tab.dart index 77c9f8e..c93cd3f 100644 --- a/lib/models/app_tab.dart +++ b/lib/models/app_tab.dart @@ -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: diff --git a/lib/models/move_copy_result.dart b/lib/models/move_copy_result.dart new file mode 100644 index 0000000..6a34b5c --- /dev/null +++ b/lib/models/move_copy_result.dart @@ -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 conflicts; + final int failed; + final String? blockedReason; + + const MoveCopyResult({ + this.succeeded = 0, + this.conflicts = const [], + this.failed = 0, + this.blockedReason, + }); +} diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index 744af87..5e67740 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -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 _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 _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 _trashItems = []; @@ -393,7 +412,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { List 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 _applyCommonFilters( List source, { required bool showFavoritesOnly, @@ -472,10 +491,31 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { return filtered; } - List get items { + List 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 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 applyFilesDisplayPrefs(List 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 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 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 _syncFavoritesIfLoaded() { + return _favoritesEverFetched ? fetchAllFavorites() : Future.value(); + } + Future 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> 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 _moveOrCopyItems( + List 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 = []; + 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 moveItems( + List items, + String destFolderPath, + ) { + return _moveOrCopyItems(items, destFolderPath, copy: false); + } + + /// Same as [moveItems] but duplicates rather than relocates. + Future copyItems( + List 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 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 resolveConflicts( + List conflicts, + String destFolderPath, { + required bool copy, + required Map 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() + : {}; + + 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 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(); } } diff --git a/lib/services/account_store.dart b/lib/services/account_store.dart index abe8a4d..4582886 100644 --- a/lib/services/account_store.dart +++ b/lib/services/account_store.dart @@ -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', diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart new file mode 100644 index 0000000..88bdb63 --- /dev/null +++ b/lib/services/download_service.dart @@ -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 startDownload( + ServerProvider provider, + List 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, + }); + } +} diff --git a/lib/services/nextcloud_service.dart b/lib/services/nextcloud_service.dart index 6d656fb..efd01ea 100644 --- a/lib/services/nextcloud_service.dart +++ b/lib/services/nextcloud_service.dart @@ -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> fetchFavorites() async { + final url = '$_cleanServerUrl/remote.php/dav/'; + debugPrint('[Nextcloud DAV] SEARCH for favorites from $url'); + + final body = + ''' + + + + + + + + + + + + + + + + + + /files/$username + infinity + + + + + + 1 + + + + + + + + + +'''; + + 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 = []; + 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 _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 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 _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 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 copyItem( + String itemPath, + String destFolderPath, { + bool overwrite = false, + String? newName, + }) { + return _moveOrCopyToFolder( + itemPath, + destFolderPath, + copy: true, + overwrite: overwrite, + newName: newName, + ); } Future createFolder(String parentPath, String folderName) async { diff --git a/lib/views/favorites_view.dart b/lib/views/favorites_view.dart new file mode 100644 index 0000000..0d88575 --- /dev/null +++ b/lib/views/favorites_view.dart @@ -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 createState() => _FavoritesViewState(); +} + +class _FavoritesViewState extends State + with SingleTickerProviderStateMixin { + bool _requested = false; + final Set _selectedIds = {}; + final ScrollController _selectionActionsScrollController = ScrollController(); + final List _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 _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([ + 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 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 _buildSelectionActions( + ServerProvider provider, + List 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 _unfavoriteSelected( + ServerProvider provider, + List items, + ) async { + for (final item in items) { + await provider.toggleItemFavorite(item); + } + _clearSelection(); + } + + Future _shareSelected( + BuildContext context, + ServerProvider provider, + List items, + ) async { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar( + content: Text('Creating share link(s)…'), + behavior: SnackBarBehavior.floating, + ), + ); + + final lines = []; + 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 _downloadSelected( + BuildContext context, + ServerProvider provider, + List 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 _confirmDeleteSelected( + BuildContext context, + ServerProvider provider, + List items, + ) async { + final confirmed = await showDialog( + 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 _moveOrCopySelected( + ServerProvider provider, + List 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 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 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(); + + 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 = [ + 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 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, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 13a36c3..cc5e192 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -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 { } } + /// 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 _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 { context, ).showSnackBar(SnackBar(content: Text('Download failed: $e'))); } - } finally { - if (mounted) setState(() => _isBusy = false); } } diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 5825245..6b66c06 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -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 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 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 _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 _downloadSelected( BuildContext context, ServerProvider provider, List 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 _confirmDeleteSelected( @@ -874,6 +870,28 @@ class _FilesViewState extends State ); } + Future _moveOrCopySelected( + ServerProvider provider, + List 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, diff --git a/lib/views/move_copy_destination_picker.dart b/lib/views/move_copy_destination_picker.dart new file mode 100644 index 0000000..436c994 --- /dev/null +++ b/lib/views/move_copy_destination_picker.dart @@ -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 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 show( + BuildContext context, + List items, { + required bool copy, + }) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MoveCopyDestinationPicker(items: items, copy: copy), + ), + ); + } + + @override + State createState() => + _MoveCopyDestinationPickerState(); +} + +class _MoveCopyDestinationPickerState extends State { + final ScrollController _scrollController = ScrollController(); + List _pathStack = const ['/']; + List _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 _fetch(String path) async { + setState(() => _isLoading = true); + final raw = await context.read().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 _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(); + 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 = [ + 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', + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index cf3ba73..d1fff10 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -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 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 } } + /// Hands the whole batch off to `DownloadService.kt` - see + /// `FilesView._downloadSelected`'s identical doc comment for why. Future _downloadSelected( BuildContext context, ServerProvider provider, List 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 _confirmDeleteSelected( @@ -720,4 +719,26 @@ class _PhotosViewState extends State ), ); } + + Future _moveOrCopySelected( + ServerProvider provider, + List 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, + ), + ); + } } diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart index f35b0c6..6eb470e 100644 --- a/lib/views/share_upload_view.dart +++ b/lib/views/share_upload_view.dart @@ -108,15 +108,6 @@ class _ShareUploadViewState extends State { 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 diff --git a/lib/widgets/app_tab_view_builder.dart b/lib/widgets/app_tab_view_builder.dart index a736078..eebfc6a 100644 --- a/lib/widgets/app_tab_view_builder.dart +++ b/lib/widgets/app_tab_view_builder.dart @@ -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: diff --git a/lib/widgets/details/details_sheet.dart b/lib/widgets/details/details_sheet.dart index 6170887..8d39827 100644 --- a/lib/widgets/details/details_sheet.dart +++ b/lib/widgets/details/details_sheet.dart @@ -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( diff --git a/lib/widgets/move_copy_conflict_sheet.dart b/lib/widgets/move_copy_conflict_sheet.dart new file mode 100644 index 0000000..366cd94 --- /dev/null +++ b/lib/widgets/move_copy_conflict_sheet.dart @@ -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 conflicts; + final ScrollController? scrollController; + + const MoveCopyConflictSheet({ + super.key, + required this.conflicts, + this.scrollController, + }); + + static Future?> show( + BuildContext context, + List conflicts, + ) { + return showGradualBottomSheet>( + context, + builder: (context, scrollController) => MoveCopyConflictSheet( + conflicts: conflicts, + scrollController: scrollController, + ), + ); + } + + @override + State createState() => _MoveCopyConflictSheetState(); +} + +class _MoveCopyConflictSheetState extends State { + bool _decidingPerItem = false; + late final Map _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( + 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'), + ), + ), + ], + ], + ); + } +} diff --git a/lib/widgets/share_sheet.dart b/lib/widgets/share_sheet.dart index 4dcb67c..ca414e1 100644 --- a/lib/widgets/share_sheet.dart +++ b/lib/widgets/share_sheet.dart @@ -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 { List _inherited = []; List _searchResults = []; bool _isSearching = false; + bool _isAddingEmail = false; + bool _isSharingFile = false; final _searchController = TextEditingController(); final _emailController = TextEditingController(); @@ -141,6 +146,7 @@ class _ShareSheetState extends State { Future _addEmailShare() async { final email = _emailController.text.trim(); if (email.isEmpty) return; + setState(() => _isAddingEmail = true); final provider = context.read(); final messenger = ScaffoldMessenger.of(context); final share = await provider.createShare( @@ -150,6 +156,7 @@ class _ShareSheetState extends State { ); 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 { setState(() { _shares = [..._shares, share]; _emailController.clear(); + _isAddingEmail = false; }); messenger.showSnackBar( SnackBar( @@ -217,6 +225,41 @@ class _ShareSheetState extends State { ); } + /// 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 _shareFileDirectly() async { + final provider = context.read(); + 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 { 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 { 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,