Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eee2730fbe | ||
|
|
be38f24f32 | ||
|
|
bcc18a7060 | ||
|
|
453aff983a | ||
|
|
f78ceb7e8b |
@@ -109,9 +109,11 @@ and rendered through `buildAppTabView` (`widgets/app_tab_view_builder.dart`).
|
||||
Each tab keeps its own `ScrollController` (survives tab switches via
|
||||
`IndexedStack`'s built-but-hidden trees) and tapping the already-active tab
|
||||
scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also
|
||||
owns the app's share-intent listener (`receive_sharing_intent`): both
|
||||
`getInitialMedia()` (cold start via another app's "Share to...") and
|
||||
`getMediaStream()` (already running) push `ShareUploadView`.
|
||||
owns the app's share-intent listener (`ShareIntentService`, backed by
|
||||
hand-rolled native handling in `MainActivity.kt` - see `server.md` for why
|
||||
this isn't the `receive_sharing_intent` plugin): both `getInitialShare()`
|
||||
(cold start via another app's "Share to...") and `onNewShare` (already
|
||||
running) push `ShareUploadView`.
|
||||
|
||||
Five of the six tabs (all but Files) plus each tab's own controls share
|
||||
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a
|
||||
|
||||
+56
-13
@@ -9,12 +9,23 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart):
|
||||
1. `LoginFlowService.initiate(serverUrl)` POSTs to
|
||||
`{server}/index.php/login/v2`, gets back a browser login URL + a poll
|
||||
endpoint/token.
|
||||
2. The app opens the login URL in a Chrome Custom Tab (`url_launcher`,
|
||||
`LaunchMode.inAppBrowserView` — real Chrome, so saved passwords/autofill
|
||||
work, unlike Flutter's own embedded web view); the user authenticates and
|
||||
authorizes there. There's no way to close the tab automatically on
|
||||
success (Login Flow v2 never redirects back into the app, and a Custom
|
||||
Tab belongs to Chrome's own task) — the user switches back manually.
|
||||
2. The app opens the login URL for the user to authenticate/authorize in
|
||||
[`LoginWebViewView`](../../lib/views/login_webview_view.dart), a normal
|
||||
screen this app owns (`package:webview_flutter`) - pushed by `LoginView`
|
||||
the moment `loginFlowStatus` flips to `awaitingBrowser`, for every login
|
||||
(first account or an additional one), not just add-account. This used
|
||||
to be split: first login went through a Chrome Custom Tab
|
||||
(`url_launcher`, `LaunchMode.inAppBrowserView`) for Chrome's own
|
||||
autofill, and only add-account used the embedded WebView, specifically
|
||||
to avoid a Custom Tab silently reusing Chrome's existing session for a
|
||||
different account. But a Custom Tab has real costs even for the first
|
||||
login - no way to close it automatically on success (the user has to
|
||||
switch back manually), and it's a separate task outside this app's own
|
||||
navigation entirely - so both paths now use the same owned screen.
|
||||
`url_launcher` is no longer a dependency. The cost is no
|
||||
Chrome-autofill (Android's own system Autofill framework, e.g. a
|
||||
password manager, may still work in the WebView; Chrome's own
|
||||
saved-password autofill specifically cannot, since that's Chrome-only).
|
||||
3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every
|
||||
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in
|
||||
`server_provider.dart`) until it gets a 200 with `server`/`loginName`/
|
||||
@@ -68,13 +79,45 @@ rather than needing a rewrite for multi-account support.
|
||||
`ServerProvider` wrapper always uploads into `_currentFolderPath` — the
|
||||
share-to-upload flow (`ShareUploadView`) gets a caller-chosen destination
|
||||
by navigating there first (`navigateToAbsoluteFolder`), then uploading.
|
||||
- **Receiving a shared file from another app**: `receive_sharing_intent`
|
||||
(Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`, `android:launchMode`
|
||||
`singleTask` in the manifest so a second share while running hits
|
||||
`onNewIntent` instead of spawning a new instance). `MainShellView` listens
|
||||
via `getInitialMedia()`/`getMediaStream()` and pushes `ShareUploadView`,
|
||||
which reuses the same `uploadFileFromPath` path after the user picks a
|
||||
destination folder.
|
||||
- **Receiving a shared file from another app**: hand-rolled in
|
||||
`MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`,
|
||||
`android:launchMode` `singleTask` in the manifest so a second share while
|
||||
running hits `onNewIntent` instead of spawning a new instance) plus
|
||||
[`ShareIntentService`](../../lib/services/share_intent_service.dart) on
|
||||
the Dart side - not the `receive_sharing_intent` plugin, which this used
|
||||
to be. That plugin resolves a shared `content://` Uri by synchronously
|
||||
copying the *entire* file into the cache dir on the main thread during
|
||||
activity startup; for a large file that blocks long enough that Android
|
||||
kills the newly-launched activity for failing to draw a first frame,
|
||||
dropping the user straight back to the home screen with no error and no
|
||||
Dart code ever running. `MainActivity.kt`'s doc comment has the full
|
||||
story. The fix: `getInitialShare`/`onNewShare` only ever query cheap Uri
|
||||
metadata (name/size/mime, not content) so `ShareUploadView`'s destination
|
||||
picker - which mirrors the Files tab's own controls/filters/listing,
|
||||
reusing the same `ServerProvider` fields and `widgets/item_icon.dart` -
|
||||
always appears instantly regardless of file size.
|
||||
- **Uploading a shared file**: once the user picks a destination in
|
||||
`ShareUploadView`, [`UploadService`](../../lib/services/upload_service.dart)
|
||||
hands the whole batch off to `ShareUploadService.kt`, an Android
|
||||
foreground service, rather than uploading from Dart in that screen. This
|
||||
is deliberate, not just an implementation detail: the point is that
|
||||
closing the app right after confirming a destination doesn't interrupt
|
||||
the upload, the same guarantee a real file-manager app's upload
|
||||
notification gives you - a plain Dart `Future` (even one kept alive by a
|
||||
singleton service class) stops running once the Flutter engine/Activity
|
||||
are gone, only an actual Android `Service` survives that. The service
|
||||
re-implements the WebDAV PUT itself in Kotlin (`HttpURLConnection`, no
|
||||
new HTTP dependency) since it can't reach the Dart-side `NextcloudService`/
|
||||
Dio from a separate process lifecycle - `UploadService.startUpload` passes
|
||||
everything the Kotlin side needs (the pre-built `Authorization` header
|
||||
from `NextcloudService.authHeaders`, not the raw password) as Intent
|
||||
extras, a one-way handoff with no channel back to Dart afterward. Keep
|
||||
the two upload implementations in sync manually if upload semantics
|
||||
change. Progress/cancellation is entirely notification-driven (one
|
||||
ongoing, updatable notification for the whole batch; its Cancel action
|
||||
re-delivers an Intent to the same running service instance, which an
|
||||
`AtomicBoolean` the copy/upload loops poll) - there's no plumbing back to
|
||||
the Dart UI, by design, since the app may not even be running.
|
||||
|
||||
## Multi-account storage & session persistence
|
||||
|
||||
|
||||
@@ -36,6 +36,12 @@ widgets. Key points:
|
||||
|
||||
## Reusable chrome
|
||||
|
||||
- [`getItemIcon`/`getIconColor`/`ItemThumbnail`](../../lib/widgets/item_icon.dart)
|
||||
— the icon/color/thumbnail treatment for a file or folder, shared by any
|
||||
screen that lists `NextcloudItem`s the way the Files tab does (currently
|
||||
`files_view.dart` and `share_upload_view.dart`'s destination picker).
|
||||
Extend this rather than re-deriving per-type icons/colors at a new call
|
||||
site.
|
||||
- [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) —
|
||||
the blurred/translucent pill background shared by all floating chrome
|
||||
(bottom nav bar, media-viewer top/bottom bars and video transport
|
||||
@@ -81,3 +87,10 @@ widgets. Key points:
|
||||
exception, since those colors are content-identity cues, not theme).
|
||||
- Use `colorScheme.surfaceContainer*`/`onSurfaceVariant` tokens for
|
||||
elevation/secondary text rather than manual opacity on black/white.
|
||||
- **Anywhere the app shows its own icon in-app** (splash, lock screen,
|
||||
login screen) uses `assets/icon/app_icon_monochrome.png` — a plain white
|
||||
silhouette on transparent, tinted via `ColorFiltered(colorFilter:
|
||||
ColorFilter.mode(colorScheme.onSurface, BlendMode.srcIn), ...)` so it
|
||||
reads correctly in both light and dark mode. Never the full-color
|
||||
`app_icon.png`/adaptive-icon assets for in-app UI — those are for the
|
||||
launcher icon only (`flutter_launcher_icons` in `pubspec.yaml`).
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- For local_auth's biometric app-lock (Settings > Security). -->
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||
<!-- For ShareUploadService, which keeps a "Share to Noo" upload going
|
||||
(and cancellable from its notification) even if the app is fully
|
||||
closed - see the service's own doc comment. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:label="Noo"
|
||||
android:name="${applicationName}"
|
||||
@@ -31,7 +37,8 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Lets other apps' share sheets ("Share to...") send a file here
|
||||
to be uploaded, via receive_sharing_intent. -->
|
||||
to be uploaded - handled by MainActivity.kt directly, not a
|
||||
plugin (see its doc comment for why). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
@@ -43,6 +50,10 @@
|
||||
<data android:mimeType="*/*"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".ShareUploadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"/>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
@@ -59,12 +70,6 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- Needed so url_launcher can find a browser for Nextcloud Login Flow v2. -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
<!-- Needed so open_file can find apps to open downloaded files. -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
|
||||
@@ -1,8 +1,183 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
// FlutterFragmentActivity (not the default FlutterActivity) is required by
|
||||
// local_auth's Android implementation, which hosts its biometric/device
|
||||
// credential prompt via a Fragment.
|
||||
class MainActivity : FlutterFragmentActivity()
|
||||
//
|
||||
// Also hand-rolls "Share to Noo" intent handling instead of using the
|
||||
// receive_sharing_intent plugin: that plugin resolves a shared content:// URI
|
||||
// by synchronously copying the *entire* file into the app's cache dir on the
|
||||
// main thread, during activity startup, before Flutter has even drawn its
|
||||
// first frame - fine for a photo, but for a large video/archive it blocks
|
||||
// long enough that Android decides this newly-launched activity failed to
|
||||
// render and kills it (silently back to the home screen - no crash, no
|
||||
// Dart-side error, since Dart never got a chance to run). Other file-manager
|
||||
// apps show their destination picker instantly regardless of file size
|
||||
// because they defer touching the file's actual bytes until the user picks
|
||||
// a destination - `getInitialShare`/`onNewShare` only ever query cheap Uri
|
||||
// metadata (name/size/mime, not content). The actual copy+upload then runs
|
||||
// in ShareUploadService (see its own doc comment), not here, so it survives
|
||||
// the app being closed right after the user confirms a destination.
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
private val shareIntentChannelName = "dev.ayushya.noo/share_intent"
|
||||
private val newShareChannelName = "dev.ayushya.noo/share_intent/new"
|
||||
private val uploadServiceChannelName = "dev.ayushya.noo/upload_service"
|
||||
private val notificationPermissionRequestCode = 4202
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var newShareSink: EventChannel.EventSink? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, shareIntentChannelName)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getInitialShare" -> result.success(extractShareMetadata(intent))
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, newShareChannelName)
|
||||
.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
newShareSink = events
|
||||
}
|
||||
override fun onCancel(arguments: Any?) {
|
||||
newShareSink = null
|
||||
}
|
||||
})
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, uploadServiceChannelName)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"startUpload" -> {
|
||||
startUploadService(call, result)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
val shared = extractShareMetadata(intent)
|
||||
if (shared.isNotEmpty()) {
|
||||
mainHandler.post { newShareSink?.success(shared) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap Uri metadata only (display name / size / mime type) via a
|
||||
/// single ContentResolver query - never reads the file's actual bytes,
|
||||
/// so this is fast regardless of how large the shared file is.
|
||||
private fun extractShareMetadata(intent: Intent): List<Map<String, Any?>> {
|
||||
val uris: List<Uri> = when (intent.action) {
|
||||
Intent.ACTION_SEND -> listOfNotNull(intentStreamUri(intent))
|
||||
Intent.ACTION_SEND_MULTIPLE -> intentStreamUris(intent)
|
||||
else -> emptyList()
|
||||
}
|
||||
return uris.map { uriMetadata(it) }
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun intentStreamUri(intent: Intent): Uri? {
|
||||
return if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun intentStreamUris(intent: Intent): List<Uri> {
|
||||
val list = if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
}
|
||||
return list ?: emptyList()
|
||||
}
|
||||
|
||||
private fun uriMetadata(uri: Uri): Map<String, Any?> {
|
||||
var name = uri.lastPathSegment ?: "shared_file"
|
||||
var size: Long? = null
|
||||
try {
|
||||
contentResolver.query(
|
||||
uri,
|
||||
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIdx >= 0) cursor.getString(nameIdx)?.let { name = it }
|
||||
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) size = cursor.getLong(sizeIdx)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Fall back to the Uri-derived name / unknown size below.
|
||||
}
|
||||
val mimeType = contentResolver.getType(uri)
|
||||
return mapOf("uri" to uri.toString(), "name" to name, "mimeType" to mimeType, "size" to size)
|
||||
}
|
||||
|
||||
/// Starts ShareUploadService with everything it needs to run entirely on
|
||||
/// its own (see its doc comment) - the Intent extras are the full
|
||||
/// contract between this call and that service, kept in sync manually
|
||||
/// since they cross the Kotlin/Dart boundary as loosely-typed args.
|
||||
private fun startUploadService(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")
|
||||
val remoteFolder = call.argument<String>("remoteFolder")
|
||||
if (filesJson == null || serverUrl == null || username == null ||
|
||||
authHeader == null || remoteFolder == null
|
||||
) {
|
||||
result.error("bad_args", "Missing required upload 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, ShareUploadService::class.java).apply {
|
||||
putExtra(ShareUploadService.EXTRA_FILES, filesJson)
|
||||
putExtra(ShareUploadService.EXTRA_SERVER_URL, serverUrl)
|
||||
putExtra(ShareUploadService.EXTRA_USERNAME, username)
|
||||
putExtra(ShareUploadService.EXTRA_AUTH_HEADER, authHeader)
|
||||
putExtra(ShareUploadService.EXTRA_REMOTE_FOLDER, remoteFolder)
|
||||
}
|
||||
ContextCompat.startForegroundService(this, serviceIntent)
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
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.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Foreground service that prepares (copies from a content:// Uri) and
|
||||
* uploads one or more "Share to Noo" files over WebDAV, independent of
|
||||
* MainActivity/the Flutter engine being alive - the whole point is that
|
||||
* closing the app right after confirming a destination in ShareUploadView
|
||||
* doesn't interrupt the upload, the same way a file-manager app's own
|
||||
* upload notification survives the app being closed.
|
||||
*
|
||||
* Re-implements a plain WebDAV PUT here in Kotlin (HttpURLConnection, no
|
||||
* new HTTP dependency) rather than reusing NextcloudService/Dio from Dart,
|
||||
* since a Dart isolate doesn't keep running once the Flutter engine/
|
||||
* Activity are gone - only a real Android Service does. This does mean the
|
||||
* PUT request itself is duplicated logic (see NextcloudService.
|
||||
* uploadFileFromPath); keep both in sync if the upload semantics change.
|
||||
*
|
||||
* Started via the `dev.ayushya.noo/upload_service` MethodChannel
|
||||
* (MainActivity.kt) with credentials/destination passed as Intent extras -
|
||||
* never has an Activity in the loop after that. Shows one persistent,
|
||||
* cancellable notification for the whole batch; the Cancel action re-enters
|
||||
* this same running service instance with [ACTION_CANCEL], which the
|
||||
* copy/upload loops poll.
|
||||
*/
|
||||
class ShareUploadService : Service() {
|
||||
companion object {
|
||||
const val ACTION_CANCEL = "dev.ayushya.noo.action.CANCEL_UPLOAD"
|
||||
const val EXTRA_FILES = "files" // JSON array of {uri, name, size}
|
||||
const val EXTRA_SERVER_URL = "serverUrl"
|
||||
const val EXTRA_USERNAME = "username"
|
||||
const val EXTRA_AUTH_HEADER = "authHeader"
|
||||
const val EXTRA_REMOTE_FOLDER = "remoteFolder"
|
||||
|
||||
private const val CHANNEL_ID = "share_upload"
|
||||
private const val NOTIFICATION_ID = 4201
|
||||
}
|
||||
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
private var uploadThread: Thread? = null
|
||||
|
||||
private data class ShareFile(val uri: String, val name: 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)
|
||||
val remoteFolder = intent?.getStringExtra(EXTRA_REMOTE_FOLDER)
|
||||
if (filesJson == null || serverUrl == null || username == null ||
|
||||
authHeader == null || remoteFolder == null
|
||||
) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
createNotificationChannel()
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
buildProgressNotification("Preparing to upload…", null, indeterminate = true),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
|
||||
// A second share arriving mid-upload is dropped rather than queued -
|
||||
// rare in practice (sharing again before the first batch finishes),
|
||||
// not worth a real queue for.
|
||||
if (uploadThread == null) {
|
||||
val files = parseFiles(filesJson)
|
||||
uploadThread = Thread {
|
||||
runUploads(files, serverUrl, username, authHeader, remoteFolder)
|
||||
}.also { it.start() }
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun parseFiles(json: String): List<ShareFile> {
|
||||
val arr = JSONArray(json)
|
||||
return (0 until arr.length()).map { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
ShareFile(
|
||||
uri = obj.getString("uri"),
|
||||
name = obj.getString("name"),
|
||||
size = if (obj.isNull("size")) null else obj.getLong("size"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runUploads(
|
||||
files: List<ShareFile>,
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
remoteFolder: String,
|
||||
) {
|
||||
var succeeded = 0
|
||||
var failed = 0
|
||||
val cleanServer = serverUrl.trimEnd('/')
|
||||
var cleanFolder = remoteFolder.trim()
|
||||
if (!cleanFolder.startsWith("/")) cleanFolder = "/$cleanFolder"
|
||||
if (!cleanFolder.endsWith("/")) cleanFolder = "$cleanFolder/"
|
||||
|
||||
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})"
|
||||
var tempFile: File? = null
|
||||
try {
|
||||
notify(buildProgressNotification("Preparing $label…", null, indeterminate = true))
|
||||
tempFile = materialize(file) { sent, total ->
|
||||
notify(
|
||||
buildProgressNotification(
|
||||
"Preparing $label…",
|
||||
progressFraction(sent, total),
|
||||
indeterminate = total == null,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (cancelled.get()) break
|
||||
|
||||
val encodedName = Uri.encode(file.name)
|
||||
val url = URL("$cleanServer/remote.php/dav/files/$username$cleanFolder$encodedName")
|
||||
val ok = uploadFile(tempFile, url, authHeader) { sent, total ->
|
||||
notify(
|
||||
buildProgressNotification(
|
||||
"Uploading $label…",
|
||||
progressFraction(sent, total),
|
||||
indeterminate = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (ok) succeeded++ else failed++
|
||||
} catch (e: Exception) {
|
||||
failed++
|
||||
} finally {
|
||||
tempFile?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val finalText = when {
|
||||
cancelled.get() -> "Upload cancelled"
|
||||
failed == 0 && files.size == 1 -> "Uploaded ${files.first().name}"
|
||||
failed == 0 -> "Uploaded $succeeded of ${files.size} files"
|
||||
else -> "Uploaded $succeeded of ${files.size} files - $failed failed"
|
||||
}
|
||||
manager.notify(NOTIFICATION_ID, buildFinalNotification(finalText))
|
||||
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun progressFraction(sent: Long, total: Long?): Float? {
|
||||
if (total == null || total <= 0) return null
|
||||
return (sent.toFloat() / total.toFloat()).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private fun materialize(file: ShareFile, onProgress: (Long, Long?) -> Unit): File {
|
||||
val uri = Uri.parse(file.uri)
|
||||
val target = File(cacheDir, "share_upload_${System.currentTimeMillis()}_${file.name}")
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(target).use { output ->
|
||||
val buffer = ByteArray(256 * 1024)
|
||||
var sent = 0L
|
||||
var lastEmit = 0L
|
||||
while (!cancelled.get()) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
output.write(buffer, 0, read)
|
||||
sent += read
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastEmit >= 200) {
|
||||
lastEmit = now
|
||||
onProgress(sent, file.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: throw IllegalStateException("Could not open ${file.uri}")
|
||||
return target
|
||||
}
|
||||
|
||||
private fun uploadFile(
|
||||
file: File,
|
||||
url: URL,
|
||||
authHeader: String,
|
||||
onProgress: (Long, Long) -> Unit,
|
||||
): Boolean {
|
||||
val length = file.length()
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.requestMethod = "PUT"
|
||||
connection.doOutput = true
|
||||
connection.setFixedLengthStreamingMode(length)
|
||||
connection.setRequestProperty("Authorization", authHeader)
|
||||
connection.setRequestProperty("OCS-APIRequest", "true")
|
||||
connection.setRequestProperty("Content-Type", "application/octet-stream")
|
||||
connection.connectTimeout = 15000
|
||||
connection.readTimeout = 30000
|
||||
|
||||
connection.outputStream.use { output ->
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(256 * 1024)
|
||||
var sent = 0L
|
||||
var lastEmit = 0L
|
||||
while (!cancelled.get()) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
output.write(buffer, 0, read)
|
||||
sent += read
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastEmit >= 200) {
|
||||
lastEmit = now
|
||||
onProgress(sent, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cancelled.get()) {
|
||||
false
|
||||
} else {
|
||||
val code = connection.responseCode
|
||||
code == 201 || code == 204 || code == 200
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
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 uploads",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { description = "Progress for files shared to 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, ShareUploadService::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_upload)
|
||||
.setContentTitle("Uploading to 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_upload_done)
|
||||
.setContentTitle("Noo")
|
||||
.setContentText(text)
|
||||
.setOngoing(false)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cancelled.set(true)
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -19,21 +19,6 @@ subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
// receive_sharing_intent declares Java 11 for its Java sources but leaves
|
||||
// its Kotlin compilation to default to whatever JDK runs the build - with
|
||||
// JDK 17 that mismatch (Java 11 vs Kotlin 17) fails with "Inconsistent JVM
|
||||
// Target Compatibility". Pin just this plugin's Kotlin target down to match
|
||||
// its own declared Java target, rather than touching every subproject.
|
||||
project(":receive_sharing_intent") {
|
||||
afterEvaluate {
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
||||
+14
-21
@@ -2,9 +2,9 @@ import 'dart:async';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
import 'models/app_tab.dart';
|
||||
import 'providers/server_provider.dart';
|
||||
import 'services/share_intent_service.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'views/lock_screen_view.dart';
|
||||
import 'views/login_view.dart';
|
||||
@@ -112,7 +112,7 @@ class MainShellView extends StatefulWidget {
|
||||
class _MainShellViewState extends State<MainShellView> {
|
||||
late AppTab _currentTab;
|
||||
late final Map<AppTab, ScrollController> _scrollControllers;
|
||||
StreamSubscription<List<SharedMediaFile>>? _shareSub;
|
||||
StreamSubscription<List<SharedFileRef>>? _shareSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -126,29 +126,22 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
_currentTab = context.read<ServerProvider>().defaultTab;
|
||||
|
||||
// Handles both a cold start via another app's "Share to..." sheet
|
||||
// (getInitialMedia) and a share arriving while the app is already
|
||||
// running (getMediaStream) - the plugin guarantees the stream doesn't
|
||||
// re-emit whatever getInitialMedia already returned.
|
||||
ReceiveSharingIntent.instance.getInitialMedia().then(_handleSharedFiles);
|
||||
_shareSub = ReceiveSharingIntent.instance.getMediaStream().listen(
|
||||
_handleSharedFiles,
|
||||
);
|
||||
// (getInitialShare) and a share arriving while the app is already
|
||||
// running (onNewShare). See MainActivity.kt's doc comment for why this
|
||||
// is hand-rolled instead of the receive_sharing_intent plugin - in
|
||||
// short, this only ever fetches cheap Uri metadata here, never a
|
||||
// file's actual bytes, so a large shared file can't block startup.
|
||||
ShareIntentService.getInitialShare().then(_handleSharedFiles);
|
||||
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
|
||||
}
|
||||
|
||||
void _handleSharedFiles(List<SharedMediaFile> files) {
|
||||
final uploadable = files
|
||||
.where(
|
||||
(f) =>
|
||||
f.type != SharedMediaType.text && f.type != SharedMediaType.url,
|
||||
)
|
||||
.toList();
|
||||
if (uploadable.isEmpty) return;
|
||||
ReceiveSharingIntent.instance.reset();
|
||||
void _handleSharedFiles(List<SharedFileRef> files) {
|
||||
if (files.isEmpty) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => ShareUploadView(files: uploadable)),
|
||||
);
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => ShareUploadView(files: files)));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../models/app_tab.dart';
|
||||
import '../models/nextcloud_file_version.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
@@ -28,6 +27,10 @@ enum StorageScope { cloud, external }
|
||||
/// in Settings.
|
||||
enum SwipeAction { none, favorite, delete, share }
|
||||
|
||||
/// The Files tab's files/folders/both filter — independent of and applied
|
||||
/// after [StorageScope]/favorites/hidden filtering.
|
||||
enum FilesTypeFilter { all, filesOnly, foldersOnly }
|
||||
|
||||
/// How aggressively the Files tab's folder listings (not thumbnails/file
|
||||
/// content - just the list of names/sizes/dates) are reused across
|
||||
/// navigation instead of refetched from the server every time.
|
||||
@@ -70,6 +73,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
static const _prefShowFavoritesOnlyFiles = 'ui_show_favorites_only';
|
||||
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
||||
static const _prefStorageScope = 'ui_storage_scope';
|
||||
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
||||
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
||||
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||
static const _prefSortField = 'ui_sort_field';
|
||||
@@ -141,6 +145,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
bool _showFavoritesOnlyFiles = false;
|
||||
bool _showFavoritesOnlyPhotos = false;
|
||||
StorageScope _storageScope = StorageScope.cloud;
|
||||
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
||||
bool _showHiddenFiles = false;
|
||||
bool _showHiddenPhotos = false;
|
||||
// Photos tab sort - a single global setting (Photos has no folder concept,
|
||||
@@ -309,6 +314,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
bool get showFavoritesOnlyFiles => _showFavoritesOnlyFiles;
|
||||
bool get showFavoritesOnlyPhotos => _showFavoritesOnlyPhotos;
|
||||
StorageScope get storageScope => _storageScope;
|
||||
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
||||
bool get showHiddenFiles => _showHiddenFiles;
|
||||
bool get showHiddenPhotos => _showHiddenPhotos;
|
||||
FileSortField get photosSortField => _photosSortField;
|
||||
@@ -385,11 +391,19 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
List<NextcloudItem> get items {
|
||||
final filtered = _applyCommonFilters(
|
||||
var filtered = _applyCommonFilters(
|
||||
_items,
|
||||
showFavoritesOnly: _showFavoritesOnlyFiles,
|
||||
showHidden: _showHiddenFiles,
|
||||
);
|
||||
switch (_filesTypeFilter) {
|
||||
case FilesTypeFilter.all:
|
||||
break;
|
||||
case FilesTypeFilter.filesOnly:
|
||||
filtered = filtered.where((i) => !i.isFolder).toList();
|
||||
case FilesTypeFilter.foldersOnly:
|
||||
filtered = filtered.where((i) => i.isFolder).toList();
|
||||
}
|
||||
|
||||
final field = filesSortField;
|
||||
final folders = filtered.where((i) => i.isFolder).toList()
|
||||
@@ -565,6 +579,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_showFavoritesOnlyFiles = false;
|
||||
_showFavoritesOnlyPhotos = false;
|
||||
_storageScope = StorageScope.cloud;
|
||||
_filesTypeFilter = FilesTypeFilter.all;
|
||||
_showHiddenFiles = false;
|
||||
_showHiddenPhotos = false;
|
||||
_photosSortField = FileSortField.name;
|
||||
@@ -588,6 +603,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
orElse: () => StorageScope.cloud,
|
||||
);
|
||||
|
||||
final filesTypeFilterName = prefs.getString(k(_prefFilesTypeFilter));
|
||||
_filesTypeFilter = FilesTypeFilter.values.firstWhere(
|
||||
(f) => f.name == filesTypeFilterName,
|
||||
orElse: () => FilesTypeFilter.all,
|
||||
);
|
||||
|
||||
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
||||
_showHiddenPhotos = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
||||
|
||||
@@ -683,13 +704,26 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
/// Starts Nextcloud Login Flow v2: asks the server for a one-time login
|
||||
/// URL, opens it in the system browser, then polls until the user
|
||||
/// authorizes and the server hands back a scoped app password. The app
|
||||
/// never sees the user's real password. [addAccount] only marks the
|
||||
/// flow as "add another account" for [isAddAccountFlow] - the pushed Add
|
||||
/// Account screen reads that to decide when to pop itself; this method's
|
||||
/// own persistence behavior on success ([_completeLoginFlow]) is the same
|
||||
/// either way (create-or-refresh the resulting account, then activate it).
|
||||
/// URL, then polls until the user authorizes (in LoginWebViewView, pushed
|
||||
/// by LoginView once loginFlowStatus flips to awaitingBrowser below) and
|
||||
/// the server hands back a scoped app password. The app never sees the
|
||||
/// user's real password. [addAccount] only marks the flow as "add
|
||||
/// another account" for [isAddAccountFlow] - the pushed Add Account
|
||||
/// screen reads that to decide when to pop itself; this method's own
|
||||
/// persistence behavior on success ([_completeLoginFlow]) is the same
|
||||
/// either way (create-or-refresh the resulting account, then activate
|
||||
/// it).
|
||||
///
|
||||
/// Every login - first account or an additional one - goes through
|
||||
/// LoginWebViewView (`package:webview_flutter`), a screen this app owns,
|
||||
/// rather than a Chrome Custom Tab. That used to only be true for
|
||||
/// add-account, specifically to avoid a Custom Tab silently reusing
|
||||
/// Chrome's existing session for a different account; but a Custom Tab
|
||||
/// has real costs even for the first login - no way to close it
|
||||
/// automatically on success (the user has to switch back manually), and
|
||||
/// it's a separate task outside this app's own navigation entirely. The
|
||||
/// only thing it bought over a plain WebView was Chrome's own
|
||||
/// autofill/saved-password support, which isn't worth those tradeoffs.
|
||||
Future<void> startLoginFlow(
|
||||
String serverUrl, {
|
||||
bool addAccount = false,
|
||||
@@ -702,23 +736,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
try {
|
||||
final init = await LoginFlowService.initiate(serverUrl);
|
||||
_pendingLoginUrl = init.loginUrl;
|
||||
|
||||
// Chrome Custom Tabs (inAppBrowserView): real Chrome, so the saved
|
||||
// passwords/autofill service works normally, unlike Flutter's own
|
||||
// embedded web view. The tradeoff is that - like Login Flow v2's
|
||||
// grant page itself, which never redirects back into the app on its
|
||||
// own (unlike the old nc:// Flow v1) - a Custom Tab belongs to
|
||||
// Chrome's own task, not ours, so we have no way to close it
|
||||
// automatically once polling below detects success; the user has to
|
||||
// switch back manually, same as with the external browser.
|
||||
final opened = await launchUrl(
|
||||
init.loginUrl,
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
);
|
||||
if (!opened) {
|
||||
throw Exception('Could not open the browser for login.');
|
||||
}
|
||||
|
||||
_loginFlowStatus = LoginFlowStatus.awaitingBrowser;
|
||||
notifyListeners();
|
||||
|
||||
@@ -768,12 +785,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reopenLoginBrowser() async {
|
||||
if (_pendingLoginUrl != null) {
|
||||
await launchUrl(_pendingLoginUrl!, mode: LaunchMode.inAppBrowserView);
|
||||
}
|
||||
}
|
||||
|
||||
void cancelLoginFlow({String? errorMessage}) {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimeoutTimer?.cancel();
|
||||
@@ -1202,6 +1213,16 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
void setFilesTypeFilter(FilesTypeFilter filter) {
|
||||
if (_filesTypeFilter == filter) return;
|
||||
_filesTypeFilter = filter;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefFilesTypeFilter,
|
||||
(p, key) => p.setString(key, filter.name),
|
||||
);
|
||||
}
|
||||
|
||||
/// Only *enabling* hidden-files visibility is gated - hiding them again
|
||||
/// never exposes anything, so that direction is always allowed instantly.
|
||||
Future<void> toggleShowHiddenFiles() async {
|
||||
|
||||
@@ -35,6 +35,7 @@ class AccountStore {
|
||||
'ui_sort_field',
|
||||
'ui_sort_ascending',
|
||||
'ui_folder_sort',
|
||||
'ui_files_type_filter',
|
||||
'ui_cache_policy',
|
||||
'ui_cache_interval_minutes',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// A file shared to Noo from another app's "Share to..." sheet, before its
|
||||
/// bytes have been touched - just the `content://` Uri and whatever cheap
|
||||
/// metadata Android will hand over without reading the file itself.
|
||||
class SharedFileRef {
|
||||
final String uri;
|
||||
final String name;
|
||||
final String? mimeType;
|
||||
final int? size;
|
||||
|
||||
const SharedFileRef({
|
||||
required this.uri,
|
||||
required this.name,
|
||||
this.mimeType,
|
||||
this.size,
|
||||
});
|
||||
|
||||
factory SharedFileRef.fromMap(Map<dynamic, dynamic> map) {
|
||||
return SharedFileRef(
|
||||
uri: map['uri'] as String,
|
||||
name: map['name'] as String? ?? 'shared_file',
|
||||
mimeType: map['mimeType'] as String?,
|
||||
size: (map['size'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Talks to `MainActivity.kt`'s hand-rolled share-intent handling (see its
|
||||
/// doc comment for why this isn't the receive_sharing_intent plugin): it
|
||||
/// only ever exposes cheap Uri metadata up front. Actually reading a shared
|
||||
/// file's bytes - preparing and uploading it - is [UploadService]'s job
|
||||
/// (`ShareUploadService.kt`'s Android foreground service), not this one.
|
||||
class ShareIntentService {
|
||||
static const _methodChannel = MethodChannel('dev.ayushya.noo/share_intent');
|
||||
static const _newShareChannel = EventChannel(
|
||||
'dev.ayushya.noo/share_intent/new',
|
||||
);
|
||||
|
||||
/// Whatever was shared to launch the app cold (empty if it was launched
|
||||
/// normally, not via a share).
|
||||
static Future<List<SharedFileRef>> getInitialShare() async {
|
||||
final result = await _methodChannel.invokeMethod<List<dynamic>>(
|
||||
'getInitialShare',
|
||||
);
|
||||
return (result ?? [])
|
||||
.cast<Map<dynamic, dynamic>>()
|
||||
.map(SharedFileRef.fromMap)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Emits whenever another share arrives while the app is already running.
|
||||
static Stream<List<SharedFileRef>> get onNewShare {
|
||||
return _newShareChannel.receiveBroadcastStream().map((event) {
|
||||
return (event as List<dynamic>)
|
||||
.cast<Map<dynamic, dynamic>>()
|
||||
.map(SharedFileRef.fromMap)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
import 'share_intent_service.dart';
|
||||
|
||||
/// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android
|
||||
/// foreground service, which does the actual prepare+upload entirely on its
|
||||
/// own from there - including surviving the app being closed - with a
|
||||
/// single cancellable progress notification for the whole batch. See that
|
||||
/// service's doc comment for why this isn't done in Dart/Dio: a Dart
|
||||
/// isolate doesn't keep running once the Flutter engine is gone, only a
|
||||
/// real Android Service does.
|
||||
class UploadService {
|
||||
static const _channel = MethodChannel('dev.ayushya.noo/upload_service');
|
||||
|
||||
/// Starts uploading [files] into the active account's current folder.
|
||||
/// 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> startUpload(
|
||||
ServerProvider provider,
|
||||
List<SharedFileRef> files,
|
||||
) async {
|
||||
final service = provider.service;
|
||||
final authHeader = service?.authHeaders['Authorization'];
|
||||
if (service == null || authHeader == null) {
|
||||
throw Exception('Not logged in.');
|
||||
}
|
||||
|
||||
final filesJson = jsonEncode(
|
||||
files.map((f) => {'uri': f.uri, 'name': f.name, 'size': f.size}).toList(),
|
||||
);
|
||||
|
||||
await _channel.invokeMethod('startUpload', {
|
||||
'files': filesJson,
|
||||
'serverUrl': provider.serverUrl,
|
||||
'username': provider.username,
|
||||
'authHeader': authHeader,
|
||||
'remoteFolder': provider.currentFolderPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
+240
-199
@@ -13,6 +13,7 @@ import '../models/selection_action.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
import '../widgets/breadcrumbs.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';
|
||||
@@ -33,141 +34,116 @@ class FilesView extends StatefulWidget {
|
||||
State<FilesView> createState() => _FilesViewState();
|
||||
}
|
||||
|
||||
IconData _getItemIcon(NextcloudItemType type) {
|
||||
switch (type) {
|
||||
case NextcloudItemType.folder:
|
||||
return Icons.folder_rounded;
|
||||
case NextcloudItemType.image:
|
||||
return Icons.image_rounded;
|
||||
case NextcloudItemType.video:
|
||||
return Icons.movie_rounded;
|
||||
case NextcloudItemType.audio:
|
||||
return Icons.audiotrack_rounded;
|
||||
case NextcloudItemType.document:
|
||||
return Icons.description_rounded;
|
||||
case NextcloudItemType.archive:
|
||||
return Icons.folder_zip_rounded;
|
||||
case NextcloudItemType.file:
|
||||
return Icons.insert_drive_file_rounded;
|
||||
}
|
||||
}
|
||||
/// Slides+fades its child in on first build. Give it a [Key] that changes
|
||||
/// whenever the folder changes (folder path + item id) so Flutter discards
|
||||
/// and remounts the Element instead of just updating it in place - that's
|
||||
/// what makes the whole visible list replay the animation on every folder
|
||||
/// navigation, not just newly-appearing rows.
|
||||
class _FolderEnterAnimation extends StatefulWidget {
|
||||
final Widget child;
|
||||
final bool fromRight;
|
||||
final int index;
|
||||
|
||||
Color _getIconColor(BuildContext context, NextcloudItemType type) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
switch (type) {
|
||||
case NextcloudItemType.folder:
|
||||
return colorScheme.primary;
|
||||
case NextcloudItemType.image:
|
||||
return Colors.amber.shade700;
|
||||
case NextcloudItemType.video:
|
||||
return Colors.deepOrange.shade600;
|
||||
case NextcloudItemType.audio:
|
||||
return Colors.purple.shade600;
|
||||
case NextcloudItemType.document:
|
||||
return Colors.blue.shade700;
|
||||
case NextcloudItemType.archive:
|
||||
return Colors.teal.shade700;
|
||||
case NextcloudItemType.file:
|
||||
return colorScheme.outline;
|
||||
}
|
||||
}
|
||||
|
||||
/// A file/folder's icon badge — for images and videos this shows an actual
|
||||
/// thumbnail (falling back to the plain icon on error or if there's no
|
||||
/// preview URL yet), matching the real-content treatment already used in
|
||||
/// the Photos tab.
|
||||
class _ItemThumbnail extends StatelessWidget {
|
||||
final NextcloudItem item;
|
||||
final ServerProvider provider;
|
||||
final double size;
|
||||
final double borderRadius;
|
||||
final double iconSize;
|
||||
|
||||
const _ItemThumbnail({
|
||||
required this.item,
|
||||
required this.provider,
|
||||
required this.size,
|
||||
required this.borderRadius,
|
||||
required this.iconSize,
|
||||
const _FolderEnterAnimation({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.fromRight,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconColor = _getIconColor(context, item.type);
|
||||
final isMedia =
|
||||
item.type == NextcloudItemType.image ||
|
||||
item.type == NextcloudItemType.video;
|
||||
// Decode straight to the size this thumbnail is actually painted at —
|
||||
// the server hands back a 500x500 preview regardless, and decoding that
|
||||
// in full for a ~40dp tile (times however many are on screen while
|
||||
// scrolling) is a real source of jank.
|
||||
final cachePixels = (size * MediaQuery.of(context).devicePixelRatio)
|
||||
.round();
|
||||
State<_FolderEnterAnimation> createState() => _FolderEnterAnimationState();
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: isMedia && item.previewUrl != null
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.network(
|
||||
item.previewUrl!,
|
||||
headers: provider.service?.authHeaders,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cachePixels,
|
||||
cacheHeight: cachePixels,
|
||||
filterQuality: FilterQuality.low,
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return Center(
|
||||
child: Icon(
|
||||
_getItemIcon(item.type),
|
||||
color: iconColor,
|
||||
size: iconSize,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stack) => Center(
|
||||
child: Icon(
|
||||
_getItemIcon(item.type),
|
||||
color: iconColor,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.type == NextcloudItemType.video)
|
||||
const Center(
|
||||
child: Icon(
|
||||
Icons.play_circle_fill_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
_getItemIcon(item.type),
|
||||
color: iconColor,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
class _FolderEnterAnimationState extends State<_FolderEnterAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _opacity;
|
||||
late final Animation<Offset> _offset;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
);
|
||||
_opacity = CurvedAnimation(parent: _controller, curve: Curves.easeOut);
|
||||
_offset = Tween<Offset>(
|
||||
begin: Offset(widget.fromRight ? 0.12 : -0.12, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
|
||||
// Staggered slightly by position so the list reads as sliding in as a
|
||||
// group rather than every row popping in simultaneously; capped so a
|
||||
// long list doesn't visibly trickle in for seconds.
|
||||
final delay = Duration(milliseconds: (widget.index * 12).clamp(0, 150));
|
||||
Future.delayed(delay, () {
|
||||
if (mounted) _controller.forward();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: _opacity,
|
||||
child: SlideTransition(position: _offset, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilesViewState extends State<FilesView> {
|
||||
final Set<String> _selectedIds = {};
|
||||
int _lastPathDepth = 1;
|
||||
final ScrollController _controlsScrollController = ScrollController();
|
||||
|
||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _playControlsScrollHint(),
|
||||
);
|
||||
}
|
||||
|
||||
/// A one-shot hint that the controls row scrolls: nudges it a little to
|
||||
/// the right and back, once, right after it first appears - a motion cue
|
||||
/// instead of a persistent widget (a chevron badge, an edge fade) sitting
|
||||
/// on top of the actual controls the whole time. No-ops if there's
|
||||
/// nothing to scroll (row already fits).
|
||||
Future<void> _playControlsScrollHint() async {
|
||||
// The delay lets the row's first frame (and its actual layout/max
|
||||
// scroll extent) settle before nudging it, and reads more like a
|
||||
// deliberate hint than something that happens to fire on load.
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (!mounted || !_controlsScrollController.hasClients) return;
|
||||
final maxExtent = _controlsScrollController.position.maxScrollExtent;
|
||||
if (maxExtent <= 0) return;
|
||||
await _controlsScrollController.animateTo(
|
||||
maxExtent < 36 ? maxExtent : 36,
|
||||
duration: const Duration(milliseconds: 320),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
if (!mounted || !_controlsScrollController.hasClients) return;
|
||||
await _controlsScrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 320),
|
||||
curve: Curves.easeIn,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controlsScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleSelection(NextcloudItem item) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
@@ -331,6 +307,10 @@ class _FilesViewState extends State<FilesView> {
|
||||
final colorScheme = theme.colorScheme;
|
||||
final provider = context.watch<ServerProvider>();
|
||||
|
||||
final pathDepth = provider.pathStack.length;
|
||||
final navigatingDeeper = pathDepth > _lastPathDepth;
|
||||
_lastPathDepth = pathDepth;
|
||||
|
||||
final hasBreadcrumbs = provider.pathStack.length > 1;
|
||||
final selectedItems = provider.items
|
||||
.where((i) => _selectedIds.contains(i.id))
|
||||
@@ -346,81 +326,132 @@ class _FilesViewState extends State<FilesView> {
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
provider.filesSortAscending
|
||||
? Icons.arrow_upward_rounded
|
||||
: Icons.arrow_downward_rounded,
|
||||
size: 20,
|
||||
// No persistent hint widget here - instead, a one-shot nudge
|
||||
// (see _playControlsScrollHint, triggered from initState) just
|
||||
// scrolls this row a little to the right and back, once, right
|
||||
// after it first appears. Two static attempts before this -
|
||||
// fading the controls' own opacity via ShaderMask, then a
|
||||
// chevron badge overlaid on the edge - both add a
|
||||
// permanent element competing with the actual controls; a
|
||||
// motion cue that plays once and gets out of the way reads
|
||||
// just as clearly without that cost.
|
||||
child: SingleChildScrollView(
|
||||
controller: _controlsScrollController,
|
||||
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,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: provider.filesSortAscending
|
||||
? 'Ascending'
|
||||
: 'Descending',
|
||||
onPressed: provider.toggleFilesSortOrder,
|
||||
),
|
||||
Expanded(
|
||||
child: SortMenuButton(
|
||||
field: provider.filesSortField,
|
||||
onChanged: provider.setFilesSortField,
|
||||
// A plain width, not Expanded - this row scrolls
|
||||
// horizontally now, which gives every control unbounded
|
||||
// width to lay out in, so a flex child would throw.
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: SortMenuButton(
|
||||
field: provider.filesSortField,
|
||||
onChanged: provider.setFilesSortField,
|
||||
),
|
||||
),
|
||||
),
|
||||
ToggleIconButton(
|
||||
icon: provider.showFavoritesOnlyFiles
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
isSelected: provider.showFavoritesOnlyFiles,
|
||||
onTap: provider.toggleFavoritesFilterFiles,
|
||||
tooltip: 'Favorites only',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ToggleIconButton(
|
||||
icon: provider.showHiddenFiles
|
||||
? Icons.visibility_rounded
|
||||
: 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
ToggleIconButton(
|
||||
icon: provider.showFavoritesOnlyFiles
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
isSelected: provider.showFavoritesOnlyFiles,
|
||||
onTap: provider.toggleFavoritesFilterFiles,
|
||||
tooltip: 'Favorites only',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ToggleIconButton(
|
||||
icon: provider.showHiddenFiles
|
||||
? Icons.visibility_rounded
|
||||
: 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.select_all_rounded,
|
||||
isSelected:
|
||||
provider.filesTypeFilter == FilesTypeFilter.all,
|
||||
onTap: () =>
|
||||
provider.setFilesTypeFilter(FilesTypeFilter.all),
|
||||
tooltip: 'Files & folders',
|
||||
),
|
||||
ToggleIconButton(
|
||||
icon: Icons.insert_drive_file_outlined,
|
||||
isSelected:
|
||||
provider.filesTypeFilter ==
|
||||
FilesTypeFilter.filesOnly,
|
||||
onTap: () => provider.setFilesTypeFilter(
|
||||
FilesTypeFilter.filesOnly,
|
||||
),
|
||||
tooltip: 'Files only',
|
||||
),
|
||||
ToggleIconButton(
|
||||
icon: Icons.folder_outlined,
|
||||
isSelected:
|
||||
provider.filesTypeFilter ==
|
||||
FilesTypeFilter.foldersOnly,
|
||||
onTap: () => provider.setFilesTypeFilter(
|
||||
FilesTypeFilter.foldersOnly,
|
||||
),
|
||||
tooltip: 'Folders only',
|
||||
),
|
||||
],
|
||||
),
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasBreadcrumbs) ...[
|
||||
@@ -580,7 +611,12 @@ class _FilesViewState extends State<FilesView> {
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = provider.items[index];
|
||||
return _buildGridCard(context, item, provider);
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${provider.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildGridCard(context, item, provider),
|
||||
);
|
||||
}, childCount: provider.items.length),
|
||||
),
|
||||
)
|
||||
@@ -591,7 +627,12 @@ class _FilesViewState extends State<FilesView> {
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = provider.items[index];
|
||||
return _buildListTile(context, item, provider);
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${provider.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildListTile(context, item, provider),
|
||||
);
|
||||
}, childCount: provider.items.length),
|
||||
),
|
||||
),
|
||||
@@ -771,7 +812,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
isSelected: isSelected,
|
||||
size: 44,
|
||||
checkmarkSize: 24,
|
||||
child: _ItemThumbnail(
|
||||
child: ItemThumbnail(
|
||||
item: item,
|
||||
provider: provider,
|
||||
size: 44,
|
||||
@@ -966,7 +1007,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
Widget _buildPlainGridContent(BuildContext context, NextcloudItem item) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final iconColor = _getIconColor(context, item.type);
|
||||
final iconColor = getIconColor(context, item.type);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
@@ -981,7 +1022,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
color: iconColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(_getItemIcon(item.type), color: iconColor, size: 24),
|
||||
child: Icon(getItemIcon(item.type), color: iconColor, size: 24),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
+75
-100
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/saved_account.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
import 'login_webview_view.dart';
|
||||
|
||||
class LoginView extends StatefulWidget {
|
||||
/// True when this is pushed from Settings ("Add account") on top of an
|
||||
@@ -21,6 +22,7 @@ class _LoginViewState extends State<LoginView> {
|
||||
final _urlController = TextEditingController();
|
||||
String? _originalActiveAccountId;
|
||||
bool _popped = false;
|
||||
bool _webViewPushed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -67,6 +69,25 @@ class _LoginViewState extends State<LoginView> {
|
||||
});
|
||||
}
|
||||
|
||||
// Every login (first account or an additional one) shows its login
|
||||
// page in LoginWebViewView, a real screen this app owns - see
|
||||
// ServerProvider.startLoginFlow's doc comment for why. Push it the
|
||||
// moment there's a URL to show; _webViewPushed resets once that route
|
||||
// pops (cancelled or done) so a retry after cancelling pushes it again.
|
||||
if (!_webViewPushed &&
|
||||
isAwaitingBrowser &&
|
||||
provider.pendingLoginUrl != null) {
|
||||
_webViewPushed = true;
|
||||
final url = provider.pendingLoginUrl!;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!mounted) return;
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => LoginWebViewView(url: url)));
|
||||
_webViewPushed = false;
|
||||
});
|
||||
}
|
||||
|
||||
final body = Scaffold(
|
||||
appBar: widget.isAddingAccount
|
||||
? AppBar(title: const Text('Add Account'))
|
||||
@@ -78,59 +99,52 @@ class _LoginViewState extends State<LoginView> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: isAwaitingBrowser
|
||||
? _WaitingForBrowser(
|
||||
onCancel: () => provider.cancelLoginFlow(),
|
||||
onReopenBrowser: provider.reopenLoginBrowser,
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// A logout keeps the account saved rather than
|
||||
// deleting it, specifically so it can be resumed
|
||||
// from here with one tap - no need to repeat
|
||||
// Login Flow v2.
|
||||
if (!widget.isAddingAccount &&
|
||||
provider.accounts.isNotEmpty) ...[
|
||||
_SavedAccountsSection(
|
||||
accounts: provider.accounts,
|
||||
onSelect: (account) => provider.switchAccount(account.id),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Row(
|
||||
children: [
|
||||
// A logout keeps the account saved rather than
|
||||
// deleting it, specifically so it can be resumed
|
||||
// from here with one tap - no need to repeat
|
||||
// Login Flow v2.
|
||||
if (!widget.isAddingAccount &&
|
||||
provider.accounts.isNotEmpty) ...[
|
||||
_SavedAccountsSection(
|
||||
accounts: provider.accounts,
|
||||
onSelect: (account) =>
|
||||
provider.switchAccount(account.id),
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'or',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
child: Text(
|
||||
'or',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
_ServerForm(
|
||||
formKey: _formKey,
|
||||
urlController: _urlController,
|
||||
isLoading: isInitiating || provider.isLoading,
|
||||
errorMessage:
|
||||
provider.loginFlowStatus == LoginFlowStatus.error
|
||||
? provider.errorMessage
|
||||
: null,
|
||||
onContinue: _handleContinue,
|
||||
theme: theme,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
_ServerForm(
|
||||
formKey: _formKey,
|
||||
urlController: _urlController,
|
||||
isLoading:
|
||||
isInitiating || provider.isLoading || isAwaitingBrowser,
|
||||
errorMessage:
|
||||
provider.loginFlowStatus == LoginFlowStatus.error
|
||||
? provider.errorMessage
|
||||
: null,
|
||||
onContinue: _handleContinue,
|
||||
theme: theme,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -140,7 +154,7 @@ class _LoginViewState extends State<LoginView> {
|
||||
if (!widget.isAddingAccount) return body;
|
||||
|
||||
// Backing out mid-flow (system back/swipe-back, not just the explicit
|
||||
// Cancel button in _WaitingForBrowser) should cancel the pending login
|
||||
// Cancel button in LoginWebViewView) should cancel the pending login
|
||||
// flow rather than leaving its poll timer running after this screen is
|
||||
// gone - this view could never be popped before "add account" existed,
|
||||
// so that case wasn't reachable until now.
|
||||
@@ -182,10 +196,18 @@ class _ServerForm extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
// Same monochrome-tinted treatment as the splash/lock screens
|
||||
// (see main.dart's _SplashView) - the asset is a plain white
|
||||
// silhouette on transparent, meant to be recolored rather than
|
||||
// shown as-is. Used everywhere the app shows its own icon
|
||||
// in-app, rather than the full-color launcher icon.
|
||||
child: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/icon/app_icon.png',
|
||||
'assets/icon/app_icon_monochrome.png',
|
||||
width: 80,
|
||||
height: 80,
|
||||
),
|
||||
@@ -289,7 +311,7 @@ class _ServerForm extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
"You'll finish signing in through your browser. This app never sees your password.",
|
||||
"You'll finish signing in on the page that opens. This app never sees your password.",
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -380,50 +402,3 @@ class _SavedAccountRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WaitingForBrowser extends StatelessWidget {
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onReopenBrowser;
|
||||
|
||||
const _WaitingForBrowser({
|
||||
required this.onCancel,
|
||||
required this.onReopenBrowser,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(color: colorScheme.primary),
|
||||
const SizedBox(height: 28),
|
||||
Text(
|
||||
'Waiting for you to sign in',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Complete the login in the browser window that just opened, then come back here.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onReopenBrowser,
|
||||
icon: const Icon(Icons.open_in_browser_rounded),
|
||||
label: const Text('Reopen browser'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(onPressed: onCancel, child: const Text('Cancel')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
|
||||
/// Every Login Flow v2 page - first login or an additional account -
|
||||
/// rendered in Flutter's own WebView rather than a Chrome Custom Tab or
|
||||
/// url_launcher's `LaunchMode.inAppWebView`. The latter hosts a bare native
|
||||
/// WebView Activity with no chrome of its own: no close button, and on at
|
||||
/// least some devices it draws edge-to-edge and hides the status bar with
|
||||
/// no way back short of the OS back gesture. A normal Scaffold/AppBar here
|
||||
/// gets the status bar/safe-area handling and a close button for free,
|
||||
/// exactly like every other screen in the app - and unlike a Custom Tab,
|
||||
/// this screen can close itself automatically once login succeeds.
|
||||
///
|
||||
/// See ServerProvider.startLoginFlow's doc comment for the full rationale.
|
||||
class LoginWebViewView extends StatefulWidget {
|
||||
final Uri url;
|
||||
|
||||
const LoginWebViewView({super.key, required this.url});
|
||||
|
||||
@override
|
||||
State<LoginWebViewView> createState() => _LoginWebViewViewState();
|
||||
}
|
||||
|
||||
class _LoginWebViewViewState extends State<LoginWebViewView> {
|
||||
late final WebViewController _controller;
|
||||
bool _pageLoading = true;
|
||||
bool _popped = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (_) {
|
||||
if (mounted) setState(() => _pageLoading = true);
|
||||
},
|
||||
onPageFinished: (_) {
|
||||
if (mounted) setState(() => _pageLoading = false);
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(widget.url);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
context.read<ServerProvider>().cancelLoginFlow();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<ServerProvider>();
|
||||
|
||||
// The login flow finished (success or error) or was cancelled
|
||||
// elsewhere - close this screen automatically rather than leaving it
|
||||
// sitting open over a flow that's no longer awaiting the browser.
|
||||
if (!_popped &&
|
||||
provider.loginFlowStatus != LoginFlowStatus.awaitingBrowser) {
|
||||
_popped = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
});
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _cancel();
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Sign in'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: 'Cancel',
|
||||
onPressed: _cancel,
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_pageLoading) const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+345
-115
@@ -1,17 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
import '../models/app_tab.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
import '../services/share_intent_service.dart';
|
||||
import '../services/upload_service.dart';
|
||||
import '../widgets/breadcrumbs.dart';
|
||||
import '../widgets/item_icon.dart';
|
||||
import '../widgets/segmented_icon_toggle.dart';
|
||||
import '../widgets/sort_menu_button.dart';
|
||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||
|
||||
/// Shown when another app shares one or more files to Noo (Android's
|
||||
/// "Share to..." sheet). Lets the user browse to a destination folder, then
|
||||
/// uploads every shared file into it via the same WebDAV upload path as the
|
||||
/// Files tab's own "Upload File" action.
|
||||
/// "Share to..." sheet). Lets the user browse to a destination folder,
|
||||
/// mirroring the Files tab's own controls/filters/listing so this feels
|
||||
/// like the same browser rather than a stripped-down picker, then hands the
|
||||
/// actual prepare+upload off to [UploadService] - a real Android foreground
|
||||
/// service (see `ShareUploadService.kt`'s doc comment), not something this
|
||||
/// screen or even the app needs to stay open for. [files] only ever carry
|
||||
/// cheap Uri metadata (see [ShareIntentService]'s doc comment); that
|
||||
/// service is the only thing that ever reads their actual bytes.
|
||||
class ShareUploadView extends StatefulWidget {
|
||||
final List<SharedMediaFile> files;
|
||||
final List<SharedFileRef> files;
|
||||
|
||||
const ShareUploadView({super.key, required this.files});
|
||||
|
||||
@@ -20,10 +32,6 @@ class ShareUploadView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ShareUploadViewState extends State<ShareUploadView> {
|
||||
bool _uploading = false;
|
||||
int _currentFileIndex = 0;
|
||||
double? _currentFileProgress;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -35,46 +43,283 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
||||
}
|
||||
|
||||
Future<void> _uploadHere(ServerProvider provider) async {
|
||||
setState(() => _uploading = true);
|
||||
|
||||
var anyFailed = false;
|
||||
for (var i = 0; i < widget.files.length; i++) {
|
||||
final file = widget.files[i];
|
||||
setState(() {
|
||||
_currentFileIndex = i;
|
||||
_currentFileProgress = 0;
|
||||
});
|
||||
final name = p.basename(file.path);
|
||||
final success = await provider.uploadFileFromPath(
|
||||
name,
|
||||
file.path,
|
||||
onProgress: (sent, total) {
|
||||
if (total > 0 && mounted) {
|
||||
setState(() => _currentFileProgress = sent / total);
|
||||
}
|
||||
},
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
await UploadService.startUpload(provider, widget.files);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Could not start upload: $e'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
if (!success) anyFailed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
provider.requestTab(AppTab.files);
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
anyFailed
|
||||
? 'Some files failed to upload'
|
||||
: widget.files.length == 1
|
||||
? 'Uploaded ${p.basename(widget.files.first.path)}'
|
||||
: 'Uploaded ${widget.files.length} files',
|
||||
widget.files.length == 1
|
||||
? 'Uploading ${widget.files.first.name} - see the notification for progress'
|
||||
: 'Uploading ${widget.files.length} files - see the notification for progress',
|
||||
),
|
||||
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.showFavoritesOnlyFiles
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
isSelected: provider.showFavoritesOnlyFiles,
|
||||
onTap: provider.toggleFavoritesFilterFiles,
|
||||
tooltip: 'Favorites only',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ToggleIconButton(
|
||||
icon: provider.showHiddenFiles
|
||||
? Icons.visibility_rounded
|
||||
: 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.select_all_rounded,
|
||||
isSelected: provider.filesTypeFilter == FilesTypeFilter.all,
|
||||
onTap: () => provider.setFilesTypeFilter(FilesTypeFilter.all),
|
||||
tooltip: 'Files & folders',
|
||||
),
|
||||
ToggleIconButton(
|
||||
icon: Icons.insert_drive_file_outlined,
|
||||
isSelected:
|
||||
provider.filesTypeFilter == FilesTypeFilter.filesOnly,
|
||||
onTap: () =>
|
||||
provider.setFilesTypeFilter(FilesTypeFilter.filesOnly),
|
||||
tooltip: 'Files only',
|
||||
),
|
||||
ToggleIconButton(
|
||||
icon: Icons.folder_outlined,
|
||||
isSelected:
|
||||
provider.filesTypeFilter == FilesTypeFilter.foldersOnly,
|
||||
onTap: () =>
|
||||
provider.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
|
||||
tooltip: 'Folders only',
|
||||
),
|
||||
],
|
||||
),
|
||||
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 upload destinations - files still show (so the
|
||||
// listing matches what the Files tab itself would show for this folder)
|
||||
// but are visually dimmed and inert rather than hidden outright.
|
||||
Widget _buildListTile(
|
||||
BuildContext context,
|
||||
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
|
||||
? () => provider.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(
|
||||
BuildContext context,
|
||||
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 ? () => provider.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);
|
||||
@@ -85,94 +330,79 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
||||
final currentLabel = currentPath == '/'
|
||||
? 'Home'
|
||||
: currentPath.split('/').where((s) => s.isNotEmpty).last;
|
||||
final folders = provider.items.where((i) => i.isFolder).toList();
|
||||
final items = provider.items;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.files.length == 1
|
||||
? 'Upload ${p.basename(widget.files.first.path)}'
|
||||
? 'Upload ${widget.files.first.name}'
|
||||
: 'Upload ${widget.files.length} files',
|
||||
),
|
||||
),
|
||||
body: _uploading
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: _currentFileProgress,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
widget.files.length == 1
|
||||
? 'Uploading ${p.basename(widget.files[_currentFileIndex].path)}…'
|
||||
: 'Uploading ${_currentFileIndex + 1} of ${widget.files.length}…',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: _buildControlsRow(provider),
|
||||
),
|
||||
if (hasBreadcrumbs)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Breadcrumbs(
|
||||
pathStack: provider.pathStack,
|
||||
onTap: (index) => provider.navigateToPathIndex(index),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
if (hasBreadcrumbs)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Breadcrumbs(
|
||||
pathStack: provider.pathStack,
|
||||
onTap: (index) => provider.navigateToPathIndex(index),
|
||||
),
|
||||
Expanded(
|
||||
child: provider.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Folder is empty',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: provider.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: folders.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No subfolders here',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: folders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final folder = folders[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.folder_rounded,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
title: Text(folder.name),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () =>
|
||||
provider.navigateToFolder(folder.path),
|
||||
);
|
||||
},
|
||||
)
|
||||
: provider.isGridView
|
||||
? GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 8,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 1.1,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _uploading
|
||||
? null
|
||||
: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _uploadHere(provider),
|
||||
icon: const Icon(Icons.upload_rounded),
|
||||
label: Text('Upload to $currentLabel'),
|
||||
),
|
||||
),
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildGridCard(context, items[index], provider),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 8,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildListTile(context, items[index], provider),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _uploadHere(provider),
|
||||
icon: const Icon(Icons.upload_rounded),
|
||||
label: Text('Upload to $currentLabel'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
|
||||
/// The icon/color/thumbnail treatment for a file or folder, shared by any
|
||||
/// screen that lists [NextcloudItem]s the same way the Files tab does (see
|
||||
/// `files_view.dart`, and `share_upload_view.dart`'s destination picker).
|
||||
|
||||
IconData getItemIcon(NextcloudItemType type) {
|
||||
switch (type) {
|
||||
case NextcloudItemType.folder:
|
||||
return Icons.folder_rounded;
|
||||
case NextcloudItemType.image:
|
||||
return Icons.image_rounded;
|
||||
case NextcloudItemType.video:
|
||||
return Icons.movie_rounded;
|
||||
case NextcloudItemType.audio:
|
||||
return Icons.audiotrack_rounded;
|
||||
case NextcloudItemType.document:
|
||||
return Icons.description_rounded;
|
||||
case NextcloudItemType.archive:
|
||||
return Icons.folder_zip_rounded;
|
||||
case NextcloudItemType.file:
|
||||
return Icons.insert_drive_file_rounded;
|
||||
}
|
||||
}
|
||||
|
||||
Color getIconColor(BuildContext context, NextcloudItemType type) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
switch (type) {
|
||||
case NextcloudItemType.folder:
|
||||
return colorScheme.primary;
|
||||
case NextcloudItemType.image:
|
||||
return Colors.amber.shade700;
|
||||
case NextcloudItemType.video:
|
||||
return Colors.deepOrange.shade600;
|
||||
case NextcloudItemType.audio:
|
||||
return Colors.purple.shade600;
|
||||
case NextcloudItemType.document:
|
||||
return Colors.blue.shade700;
|
||||
case NextcloudItemType.archive:
|
||||
return Colors.teal.shade700;
|
||||
case NextcloudItemType.file:
|
||||
return colorScheme.outline;
|
||||
}
|
||||
}
|
||||
|
||||
/// A file/folder's icon badge — for images and videos this shows an actual
|
||||
/// thumbnail (falling back to the plain icon on error or if there's no
|
||||
/// preview URL yet), matching the real-content treatment already used in
|
||||
/// the Photos tab.
|
||||
class ItemThumbnail extends StatelessWidget {
|
||||
final NextcloudItem item;
|
||||
final ServerProvider provider;
|
||||
final double size;
|
||||
final double borderRadius;
|
||||
final double iconSize;
|
||||
|
||||
const ItemThumbnail({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.provider,
|
||||
required this.size,
|
||||
required this.borderRadius,
|
||||
required this.iconSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconColor = getIconColor(context, item.type);
|
||||
final isMedia =
|
||||
item.type == NextcloudItemType.image ||
|
||||
item.type == NextcloudItemType.video;
|
||||
// Decode straight to the size this thumbnail is actually painted at —
|
||||
// the server hands back a 500x500 preview regardless, and decoding that
|
||||
// in full for a ~40dp tile (times however many are on screen while
|
||||
// scrolling) is a real source of jank.
|
||||
final cachePixels = (size * MediaQuery.of(context).devicePixelRatio)
|
||||
.round();
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: isMedia && item.previewUrl != null
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.network(
|
||||
item.previewUrl!,
|
||||
headers: provider.service?.authHeaders,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cachePixels,
|
||||
cacheHeight: cachePixels,
|
||||
filterQuality: FilterQuality.low,
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return Center(
|
||||
child: Icon(
|
||||
getItemIcon(item.type),
|
||||
color: iconColor,
|
||||
size: iconSize,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stack) => Center(
|
||||
child: Icon(
|
||||
getItemIcon(item.type),
|
||||
color: iconColor,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.type == NextcloudItemType.video)
|
||||
const Center(
|
||||
child: Icon(
|
||||
Icons.play_circle_fill_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
getItemIcon(item.type),
|
||||
color: iconColor,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
-40
@@ -768,14 +768,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
receive_sharing_intent:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: receive_sharing_intent
|
||||
sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.8.1"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -933,30 +925,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.33"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.2"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -965,14 +933,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.3"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.6"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1069,6 +1029,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter
|
||||
sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.14.1"
|
||||
webview_flutter_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.14.1"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.15.1"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.26.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-2
@@ -42,7 +42,6 @@ dependencies:
|
||||
intl: ^0.19.0
|
||||
path: ^1.9.0
|
||||
flutter_secure_storage: ^11.1.1
|
||||
url_launcher: ^6.3.1
|
||||
dio: ^5.11.1
|
||||
path_provider: ^2.1.5
|
||||
open_file: ^3.5.10
|
||||
@@ -53,9 +52,9 @@ dependencies:
|
||||
share_plus: ^13.3.0
|
||||
material_symbols_icons: ^4.2960.0
|
||||
shared_preferences: ^2.5.5
|
||||
receive_sharing_intent: ^1.8.1
|
||||
marquee: ^2.3.0
|
||||
local_auth: ^2.3.0
|
||||
webview_flutter: ^4.14.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user