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

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

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