Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eee2730fbe | ||
|
|
be38f24f32 | ||
|
|
bcc18a7060 | ||
|
|
453aff983a | ||
|
|
f78ceb7e8b | ||
|
|
bb1688ce3a | ||
|
|
3f156cbc13 | ||
|
|
06bc25ae1e |
@@ -10,12 +10,12 @@ lib/
|
||||
# SavedAccount, AppTab, ...)
|
||||
providers/ # ServerProvider — the single app-wide ChangeNotifier
|
||||
services/ # network/IO: NextcloudService, LoginFlowService,
|
||||
# AccountStore
|
||||
# AccountStore, AppLockService
|
||||
theme/ # AppTheme (Material 3 ThemeData)
|
||||
views/ # one screen each (FilesView, PhotosView, TrashView,
|
||||
# SharesView, RecentView, ActivityView, SearchView,
|
||||
# AccountView, LoginView, FileViewerScreen,
|
||||
# ShareUploadView)
|
||||
# ShareUploadView, LockScreenView)
|
||||
widgets/ # reusable pieces shared across views
|
||||
details/ # the file-details bottom sheet and its tabs
|
||||
```
|
||||
@@ -54,9 +54,23 @@ There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/se
|
||||
- UI settings that persist across launches — split into **global** (theme
|
||||
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
|
||||
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
|
||||
actions) and **per-account** (grid/list view, favorites-only, show-hidden,
|
||||
storage scope, Photos sort, Files' per-folder sort map, cache policy) — see
|
||||
`server.md` for exactly which is which and why
|
||||
actions, login lock — see below) and **per-account** (grid/list view,
|
||||
favorites-only, show-hidden, storage scope, Photos sort, Files'
|
||||
per-folder sort map, cache policy) — see `server.md` for exactly which is
|
||||
which and why
|
||||
- **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/
|
||||
`lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via
|
||||
`AppLockService` (a thin wrapper over `local_auth` — this app never
|
||||
stores or hashes a PIN itself, it delegates entirely to whatever
|
||||
credential the OS already has configured). `needsUnlock` is
|
||||
`loginLockEnabled && !_isUnlocked`, where `_isUnlocked` is transient
|
||||
(never persisted) and reset to `false` on every backgrounding
|
||||
(`didChangeAppLifecycleState`, `AppLifecycleState.paused`) so the lock has
|
||||
real value rather than only firing once per cold start. `switchAccount`/
|
||||
`cycleToNextAccount`/`cycleToPreviousAccount` and *enabling* (not
|
||||
disabling) `showHiddenFiles`/`showHiddenPhotos` each call the shared
|
||||
`_passGate` helper, which no-ops unless both `loginLockEnabled` and the
|
||||
relevant per-feature toggle are on.
|
||||
|
||||
New **global** state belongs on `ServerProvider` as a private field + getter
|
||||
+ a method that mutates it, calls `notifyListeners()`, and persists via
|
||||
@@ -70,7 +84,7 @@ split.
|
||||
|
||||
There's no separate repository/data layer: views call `ServerProvider`
|
||||
methods directly, which call `NextcloudService`/`LoginFlowService`/
|
||||
`AccountStore`.
|
||||
`AccountStore`/`AppLockService`.
|
||||
|
||||
## Navigation / screen flow
|
||||
|
||||
@@ -85,6 +99,7 @@ state, no named routes:
|
||||
Flow v2) — `isLoggedIn` is only ever false here or after the last saved
|
||||
account is removed; switching between multiple saved accounts never
|
||||
routes through this screen (see `server.md`)
|
||||
- else `provider.needsUnlock` → `LockScreenView` (login lock — see above)
|
||||
- else `MainShellView`
|
||||
|
||||
`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files,
|
||||
@@ -94,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,9 +164,53 @@ account is *currently* live (see `architecture.md`).
|
||||
its credentials and refetch everything. This is a full teardown-and-reload
|
||||
every time — there is deliberately no simultaneous multi-account state or
|
||||
background sync; only one account's content is ever live.
|
||||
- `logout()` is just `removeAccount(activeAccountId)` — with other accounts
|
||||
saved it falls back to one of them instead of ending the session;
|
||||
`isLoggedIn` only ever becomes `false` when the *last* account is removed.
|
||||
- **`logout()` vs `removeAccount()`** are deliberately different actions,
|
||||
both funneling into a shared `_deactivateSession()` helper for the
|
||||
teardown/pointer-clearing part:
|
||||
- `logout()` ends the active session but keeps the account itself fully
|
||||
intact (password, prefs, its entry in `accounts` all untouched) —
|
||||
always lands on `LoginView` even if other accounts are saved (it does
|
||||
*not* fall back to one of them the way `removeAccount` does). This
|
||||
exists so `LoginView` can offer a "Continue as ..." one-tap resume list
|
||||
(`_SavedAccountsSection` in `login_view.dart`) with no Login Flow v2
|
||||
needed - logging out must never be mistaken for forgetting an account.
|
||||
- `removeAccount(id)` deletes everything for that account (secure-storage
|
||||
password, namespaced prefs, its `accounts` entry) and, only if it was
|
||||
the active one, falls back to another saved account or - if none
|
||||
remain - calls the same `_deactivateSession()`. This is the only path
|
||||
(besides `logout()`) that can set `isLoggedIn` false, and the only one
|
||||
that's actually destructive/irreversible - UI call sites (`AccountView`)
|
||||
gate it behind a confirmation dialog; `logout()` doesn't need one.
|
||||
- Any UI code that calls either and might have ended the session should
|
||||
check `!provider.isLoggedIn` afterward and `Navigator.popUntil((r) =>
|
||||
r.isFirst)` if so — otherwise a screen pushed on top (Settings) is left
|
||||
stranded over a root route that's silently swapped to `LoginView`
|
||||
underneath it. Don't pop unconditionally — removing a *non-active*
|
||||
account, or one that fell back to another, keeps the user logged in and
|
||||
Settings should just stay open.
|
||||
|
||||
## App lock (login lock)
|
||||
|
||||
An orthogonal, app-wide security layer on top of the Nextcloud
|
||||
login/session above — not account credentials, just a gate on *using* the
|
||||
app. [`AppLockService`](../../lib/services/app_lock_service.dart) wraps
|
||||
`local_auth`; this app never implements its own PIN entry/storage/hashing —
|
||||
`authenticate()` always delegates to whatever the OS already has configured
|
||||
(biometric, or device PIN/pattern/password as fallback, via
|
||||
`biometricOnly: false`). Never build a custom in-app PIN screen for this —
|
||||
extend `AppLockService`/the `ServerProvider` gates described in
|
||||
`architecture.md` instead.
|
||||
|
||||
**Android native requirements** (both already done, keep them if you touch
|
||||
these files): `MainActivity.kt` must extend `FlutterFragmentActivity`, not
|
||||
the default `FlutterActivity` — `local_auth`'s Android implementation hosts
|
||||
its prompt via a Fragment and silently fails to build/crashes without it.
|
||||
`AndroidManifest.xml` needs `<uses-permission
|
||||
android:name="android.permission.USE_BIOMETRIC"/>` (also declared by the
|
||||
plugin's own manifest via merge, but kept explicit here too).
|
||||
`android/app/build.gradle.kts` floors `minSdk` at 24 (`local_auth_android`'s
|
||||
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
|
||||
Flutter's own default to already be high enough.
|
||||
|
||||
`isRestoringSession` still gates the splash screen until the above resolves
|
||||
— see `standards.md` for why widget tests must mock both storage channels
|
||||
|
||||
@@ -66,6 +66,32 @@ class/method already makes obvious.
|
||||
of failing fast. See `test/widget_test.dart` for the reference setup.
|
||||
- Run with `flutter test`.
|
||||
|
||||
## Local install/deploy
|
||||
|
||||
Never use `flutter install` to push a build to a test device — it always
|
||||
does a full **uninstall-then-install** (prints "Uninstalling old
|
||||
version..."), and Android deletes all app data (SharedPreferences, secure
|
||||
storage — every saved account/preference) on uninstall. This wipes the app
|
||||
clean on every single deploy, which looks like an account/settings-loss bug
|
||||
but is actually just the install method.
|
||||
|
||||
Instead, build then install with `adb`'s replace flag, which updates the
|
||||
APK in place and preserves app data:
|
||||
|
||||
```bash
|
||||
flutter build apk --release
|
||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
This only preserves data if the new APK's signature matches what's already
|
||||
on the device — a local build's release variant currently reuses the debug
|
||||
signing config (`android/app/build.gradle.kts`), so consecutive local
|
||||
builds share a key and `adb install -r` works cleanly. Installing a build
|
||||
signed with a different key (e.g. a CI-signed release APK from the Gitea
|
||||
release pipeline) over a differently-signed local build forces Android to
|
||||
require a full uninstall regardless of the install method used — there's no
|
||||
way around that from the tooling side.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Networking is deliberately split: `package:http` for simple JSON/XML
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -18,7 +18,10 @@ android {
|
||||
applicationId = "dev.ayushya.noo"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
// local_auth (app-lock/biometric) requires API 24+; Flutter's own
|
||||
// default may be lower, so floor it here rather than relying on
|
||||
// whatever flutter.minSdkVersion currently resolves to.
|
||||
minSdk = maxOf(24, flutter.minSdkVersion)
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<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}"
|
||||
@@ -29,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"/>
|
||||
@@ -41,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
|
||||
@@ -57,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,5 +1,183 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
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
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
// FlutterFragmentActivity (not the default FlutterActivity) is required by
|
||||
// local_auth's Android implementation, which hosts its biometric/device
|
||||
// credential prompt via a Fragment.
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 34 KiB |
@@ -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)
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 33 KiB |
@@ -2,10 +2,11 @@ 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';
|
||||
import 'views/search_view.dart';
|
||||
import 'views/share_upload_view.dart';
|
||||
@@ -48,9 +49,11 @@ class NextcloudApp extends StatelessWidget {
|
||||
themeMode: provider.themeMode,
|
||||
home: provider.isRestoringSession
|
||||
? const _SplashView()
|
||||
: (provider.isLoggedIn
|
||||
? const MainShellView()
|
||||
: const LoginView()),
|
||||
: (!provider.isLoggedIn
|
||||
? const LoginView()
|
||||
: (provider.needsUnlock
|
||||
? const LockScreenView()
|
||||
: const MainShellView())),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -109,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() {
|
||||
@@ -123,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)));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ class NextcloudUserQuota {
|
||||
final String userName;
|
||||
final String email;
|
||||
final String serverVersion;
|
||||
final List<String> groups;
|
||||
|
||||
const NextcloudUserQuota({
|
||||
required this.usedBytes,
|
||||
@@ -147,6 +148,7 @@ class NextcloudUserQuota {
|
||||
required this.userName,
|
||||
required this.email,
|
||||
required this.serverVersion,
|
||||
this.groups = const [],
|
||||
});
|
||||
|
||||
factory NextcloudUserQuota.demo() {
|
||||
@@ -157,6 +159,7 @@ class NextcloudUserQuota {
|
||||
userName: 'Ayush Admin',
|
||||
email: 'ayush@cloud.internal',
|
||||
serverVersion: 'Nextcloud 29.0.4 Hub 8',
|
||||
groups: ['Family', 'Friends'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -11,6 +10,7 @@ import '../models/nextcloud_share.dart';
|
||||
import '../models/nextcloud_sharee.dart';
|
||||
import '../models/saved_account.dart';
|
||||
import '../services/account_store.dart';
|
||||
import '../services/app_lock_service.dart';
|
||||
import '../services/login_flow_service.dart';
|
||||
import '../services/nextcloud_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
@@ -27,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.
|
||||
@@ -69,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';
|
||||
@@ -84,6 +89,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
static const _prefCachePolicy = 'ui_cache_policy';
|
||||
static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
|
||||
static const _prefLoginLockEnabled = 'ui_login_lock_enabled';
|
||||
static const _prefLockAccountSwitching = 'ui_lock_account_switching';
|
||||
static const _prefLockHiddenFiles = 'ui_lock_hidden_files';
|
||||
|
||||
final Future<SharedPreferences> _prefsFuture =
|
||||
SharedPreferences.getInstance();
|
||||
@@ -114,6 +122,17 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
double _bottomBarBlur = 28;
|
||||
bool _tapTabToScrollTop = true;
|
||||
|
||||
// App lock (PIN/biometric via the device's own credential, not our own
|
||||
// storage - see AppLockService). Global, not per-account: it guards
|
||||
// access to the app/its accounts, not any one account's content.
|
||||
bool _loginLockEnabled = false;
|
||||
bool _lockAccountSwitching = false;
|
||||
bool _lockHiddenFiles = false;
|
||||
// Transient (never persisted) - starts locked whenever the app process
|
||||
// starts, and re-locks on every backgrounding if a lock is configured;
|
||||
// see didChangeAppLifecycleState.
|
||||
bool _isUnlocked = false;
|
||||
|
||||
// Navigation state
|
||||
String _currentFolderPath = '/';
|
||||
|
||||
@@ -126,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,
|
||||
@@ -217,12 +237,20 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
|
||||
/// Starts/stops the periodic folder-listing refresh as the app leaves and
|
||||
/// returns to the foreground - only relevant under [CachePolicy.interval].
|
||||
/// Also re-locks the app on backgrounding when login lock is set up - a
|
||||
/// one-time unlock at cold start would give the feature no real security
|
||||
/// value, since the realistic threat is someone else picking up an
|
||||
/// already-running, unlocked phone.
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_startCacheRefreshTimerIfNeeded();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
_cacheRefreshTimer?.cancel();
|
||||
if (_loginLockEnabled && _isUnlocked) {
|
||||
_isUnlocked = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +288,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
double get bottomBarOpacity => _bottomBarOpacity;
|
||||
double get bottomBarBlur => _bottomBarBlur;
|
||||
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
||||
bool get loginLockEnabled => _loginLockEnabled;
|
||||
bool get lockAccountSwitching => _lockAccountSwitching;
|
||||
bool get lockHiddenFiles => _lockHiddenFiles;
|
||||
bool get needsUnlock => _loginLockEnabled && !_isUnlocked;
|
||||
|
||||
String get currentFolderPath => _currentFolderPath;
|
||||
AppTab? get requestedTab => _requestedTab;
|
||||
@@ -282,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;
|
||||
@@ -358,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()
|
||||
@@ -450,6 +491,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
|
||||
_tapTabToScrollTop =
|
||||
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
|
||||
_loginLockEnabled =
|
||||
prefs.getBool(_prefLoginLockEnabled) ?? _loginLockEnabled;
|
||||
_lockAccountSwitching =
|
||||
prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching;
|
||||
_lockHiddenFiles =
|
||||
prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles;
|
||||
_applyAccountPrefs(prefs, _activeAccountId);
|
||||
|
||||
final savedOrderNames = prefs.getStringList(_prefTabOrder);
|
||||
@@ -532,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;
|
||||
@@ -555,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;
|
||||
|
||||
@@ -650,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,
|
||||
@@ -669,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();
|
||||
|
||||
@@ -735,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();
|
||||
@@ -865,15 +909,21 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
/// Switches to an already-saved account. No-op if it's already active or
|
||||
/// unknown.
|
||||
/// unknown. Gated behind login lock when [lockAccountSwitching] is on.
|
||||
Future<void> switchAccount(String accountId) async {
|
||||
if (accountId == _activeAccountId) return;
|
||||
if (!_accounts.any((a) => a.id == accountId)) return;
|
||||
if (!await _passGate(_lockAccountSwitching, 'Unlock to switch accounts')) {
|
||||
return;
|
||||
}
|
||||
await _activateAccount(accountId);
|
||||
}
|
||||
|
||||
SavedAccount? _cycleAccount(int direction) {
|
||||
Future<SavedAccount?> _cycleAccount(int direction) async {
|
||||
if (_accounts.length < 2) return null;
|
||||
if (!await _passGate(_lockAccountSwitching, 'Unlock to switch accounts')) {
|
||||
return null;
|
||||
}
|
||||
final currentIndex = _accounts.indexWhere((a) => a.id == _activeAccountId);
|
||||
final targetIndex = currentIndex == -1
|
||||
? 0
|
||||
@@ -886,17 +936,18 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
|
||||
/// Cycles to the next/previous saved account (by the order they were
|
||||
/// added) - used by the avatar's swipe-up/down quick-switch gesture.
|
||||
/// Returns the account it's switching to (synchronously, before the
|
||||
/// switch's own network verification completes) so the caller can show
|
||||
/// immediate feedback, or null if there's fewer than 2 saved accounts.
|
||||
SavedAccount? cycleToNextAccount() => _cycleAccount(1);
|
||||
SavedAccount? cycleToPreviousAccount() => _cycleAccount(-1);
|
||||
/// Returns the account it's switching to once any login-lock gate has
|
||||
/// passed (the switch's own network verification is still fire-and-forget
|
||||
/// after that, same as before), or null if there's fewer than 2 saved
|
||||
/// accounts or the gate was not passed.
|
||||
Future<SavedAccount?> cycleToNextAccount() => _cycleAccount(1);
|
||||
Future<SavedAccount?> cycleToPreviousAccount() => _cycleAccount(-1);
|
||||
|
||||
/// Removes a saved account entirely: its stored password, its
|
||||
/// namespaced prefs, and its entry in the saved-accounts list. If it was
|
||||
/// the active account, falls back to another saved account, or - if none
|
||||
/// remain - performs a full logout (the only path that sets
|
||||
/// [isLoggedIn] false).
|
||||
/// remain - deactivates the session (the only path here that sets
|
||||
/// [isLoggedIn] false; unlike [logout], there's nothing left to keep).
|
||||
Future<void> removeAccount(String accountId) async {
|
||||
final index = _accounts.indexWhere((a) => a.id == accountId);
|
||||
if (index == -1) return;
|
||||
@@ -916,9 +967,21 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
if (_accounts.isEmpty) {
|
||||
await _deactivateSession();
|
||||
return;
|
||||
}
|
||||
|
||||
await _activateAccount(_accounts.first.id);
|
||||
}
|
||||
|
||||
/// Clears the active session pointer and tears down its content, without
|
||||
/// touching any saved account's own data - shared by [logout] (which
|
||||
/// deliberately keeps the account around to resume later, no
|
||||
/// re-authentication needed) and [removeAccount]'s "nothing left to fall
|
||||
/// back to" branch (where the account's data has already been deleted by
|
||||
/// the time this runs).
|
||||
Future<void> _deactivateSession() async {
|
||||
_sessionGeneration++;
|
||||
_activeAccountId = null;
|
||||
await _accountStore.saveActiveAccountId(prefs, null);
|
||||
_cacheRefreshTimer?.cancel();
|
||||
_directoryCache.clear();
|
||||
_clearActiveContent();
|
||||
@@ -926,19 +989,24 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_serverUrl = '';
|
||||
_username = '';
|
||||
_password = '';
|
||||
_activeAccountId = null;
|
||||
final prefs = await _prefsFuture;
|
||||
await _accountStore.saveActiveAccountId(prefs, null);
|
||||
_applyAccountPrefs(prefs, null);
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
await _activateAccount(_accounts.first.id);
|
||||
/// Ends the active session but keeps this account saved - unlike
|
||||
/// [removeAccount], nothing is deleted (password, prefs, its entry in
|
||||
/// [accounts] all remain), so it's available to resume with a single tap
|
||||
/// from the login screen's saved-accounts list, no Login Flow v2 needed.
|
||||
/// Always lands on the login screen even if other accounts are saved -
|
||||
/// deliberately not the same as switching to one of them.
|
||||
Future<void> logout() async {
|
||||
if (_activeAccountId == null) return;
|
||||
await _deactivateSession();
|
||||
}
|
||||
|
||||
/// Removes the active account. Kept as the app's one "Log Out" action -
|
||||
/// with multiple accounts saved this falls back to another one instead
|
||||
/// of ending the session, exactly like removing any other account would.
|
||||
Future<void> logout() => removeAccount(_activeAccountId ?? '');
|
||||
|
||||
Future<void> refreshData() async {
|
||||
if (!_isLoggedIn || _service == null) {
|
||||
debugPrint(
|
||||
@@ -1145,7 +1213,24 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
void toggleShowHiddenFiles() {
|
||||
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 {
|
||||
if (!_showHiddenFiles) {
|
||||
if (!await _passGate(_lockHiddenFiles, 'Unlock to show hidden files')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHiddenFiles = !_showHiddenFiles;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
@@ -1154,7 +1239,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
void toggleShowHiddenPhotos() {
|
||||
Future<void> toggleShowHiddenPhotos() async {
|
||||
if (!_showHiddenPhotos) {
|
||||
if (!await _passGate(_lockHiddenFiles, 'Unlock to show hidden files')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHiddenPhotos = !_showHiddenPhotos;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
@@ -1643,6 +1733,78 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_prefsFuture.then((p) => p.setBool(_prefTapTabToScrollTop, value));
|
||||
}
|
||||
|
||||
/// Confirms the device can do local auth and prompts once to enable login
|
||||
/// lock. Returns false (leaving lock disabled) if the device has no
|
||||
/// biometric/PIN capability or the user cancels/fails the confirmation.
|
||||
Future<bool> setupLoginLock() async {
|
||||
if (!await AppLockService.isDeviceSupported()) return false;
|
||||
final confirmed = await AppLockService.authenticate(
|
||||
'Confirm to turn on login lock',
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
_loginLockEnabled = true;
|
||||
// Already just authenticated - don't immediately re-prompt behind it.
|
||||
_isUnlocked = true;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, true));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Turns login lock off, along with both of its sub-toggles (meaningless
|
||||
/// once the base lock is gone). Requires a successful auth first, same as
|
||||
/// turning it on - otherwise anyone with momentary access to an unlocked
|
||||
/// phone could just switch it off.
|
||||
Future<bool> disableLoginLock() async {
|
||||
if (!_loginLockEnabled) return true;
|
||||
final confirmed = await AppLockService.authenticate(
|
||||
'Confirm to turn off login lock',
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
_loginLockEnabled = false;
|
||||
_lockAccountSwitching = false;
|
||||
_lockHiddenFiles = false;
|
||||
_isUnlocked = false;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) {
|
||||
p.setBool(_prefLoginLockEnabled, false);
|
||||
p.setBool(_prefLockAccountSwitching, false);
|
||||
p.setBool(_prefLockHiddenFiles, false);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void setLockAccountSwitching(bool value) {
|
||||
if (!_loginLockEnabled) return;
|
||||
_lockAccountSwitching = value;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
|
||||
}
|
||||
|
||||
void setLockHiddenFiles(bool value) {
|
||||
if (!_loginLockEnabled) return;
|
||||
_lockHiddenFiles = value;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value));
|
||||
}
|
||||
|
||||
/// Called by the lock screen. Returns whether it actually unlocked.
|
||||
Future<bool> attemptUnlock() async {
|
||||
final success = await AppLockService.authenticate('Unlock Noo');
|
||||
if (success) {
|
||||
_isUnlocked = true;
|
||||
notifyListeners();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Prompts for auth if [gate] is on and login lock is configured;
|
||||
/// returns true immediately (no prompt) otherwise. Shared by the
|
||||
/// account-switching and hidden-files gates below.
|
||||
Future<bool> _passGate(bool gate, String reason) async {
|
||||
if (!_loginLockEnabled || !gate) return true;
|
||||
return AppLockService.authenticate(reason);
|
||||
}
|
||||
|
||||
void setThemeMode(ThemeMode mode) {
|
||||
_themeMode = mode;
|
||||
notifyListeners();
|
||||
|
||||
@@ -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,48 @@
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
|
||||
/// PIN storage/hashing - `authenticate` delegates to whatever the OS
|
||||
/// already has configured (fingerprint/face, or the device's own PIN/
|
||||
/// pattern/password as a fallback when `biometricOnly` is false), which is
|
||||
/// both simpler and more secure than reimplementing credential storage.
|
||||
class AppLockService {
|
||||
static final LocalAuthentication _auth = LocalAuthentication();
|
||||
|
||||
/// Whether this device can do *some* form of local auth - biometric
|
||||
/// enrolled, or at minimum a device PIN/pattern/password set up.
|
||||
static Future<bool> isDeviceSupported() async {
|
||||
try {
|
||||
final canCheckBiometrics = await _auth.canCheckBiometrics;
|
||||
if (canCheckBiometrics) return true;
|
||||
return await _auth.isDeviceSupported();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompts for biometric or device-credential auth. Returns false (never
|
||||
/// throws) on cancellation, failure, or any platform error, so callers
|
||||
/// can treat every non-true result the same way: stay locked/blocked.
|
||||
static Future<bool> authenticate(String reason) async {
|
||||
try {
|
||||
return await _auth.authenticate(
|
||||
localizedReason: reason,
|
||||
options: const AuthenticationOptions(
|
||||
biometricOnly: false,
|
||||
stickyAuth: true,
|
||||
// Default (true) requires an extra manual confirmation tap after
|
||||
// a *passive* modality like face/iris recognizes you - Android's
|
||||
// own guidance reserves that for confirming risky actions
|
||||
// (a purchase); unlocking the app is exactly the "lower-risk"
|
||||
// case they say to pass false for, and on some devices/Android
|
||||
// versions it also determines whether face is offered as an
|
||||
// option in the prompt at all, not just whether it needs a
|
||||
// second tap to accept.
|
||||
sensitiveTransaction: false,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,15 @@ DateTime? _parseDavDate(String? raw) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The OCS Activity API reports each event's time as an ISO 8601 string in
|
||||
/// a `datetime` field (e.g. "2025-09-15T12:34:56+00:00") - there is no
|
||||
/// numeric `timestamp` field despite that being a very easy name to guess.
|
||||
/// Falls back to now() only if the field is missing/unparseable, so a
|
||||
/// broken response reads as "just now" rather than the Unix epoch.
|
||||
DateTime _parseActivityDateTime(dynamic raw) {
|
||||
return DateTime.tryParse(raw?.toString() ?? '') ?? DateTime.now();
|
||||
}
|
||||
|
||||
/// Normalizes a WebDAV `href` (which servers may return as either a bare
|
||||
/// path or a full absolute URL) down to just its path, trailing slash
|
||||
/// stripped, so hrefs from either form can be compared directly.
|
||||
@@ -872,6 +881,9 @@ class NextcloudService {
|
||||
final pct = total > 0 ? (used / total).clamp(0.0, 1.0) : 0.0;
|
||||
final display = ocsData['displayname'] ?? username;
|
||||
final email = ocsData['email'] ?? '$username@$serverUrl';
|
||||
final groups = ((ocsData['groups'] as List?) ?? [])
|
||||
.map((g) => g.toString())
|
||||
.toList();
|
||||
|
||||
return NextcloudUserQuota(
|
||||
usedBytes: used,
|
||||
@@ -880,6 +892,7 @@ class NextcloudService {
|
||||
userName: display,
|
||||
email: email,
|
||||
serverVersion: 'Nextcloud Server',
|
||||
groups: groups,
|
||||
);
|
||||
} else {
|
||||
throw Exception(
|
||||
@@ -914,9 +927,7 @@ class NextcloudService {
|
||||
id: (a['activity_id'] ?? '').toString(),
|
||||
title: a['subject'] ?? 'Server Activity',
|
||||
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(a['timestamp'] as int? ?? 0) * 1000,
|
||||
),
|
||||
timestamp: _parseActivityDateTime(a['datetime']),
|
||||
icon: Icons.cloud_outlined,
|
||||
author: a['user'] ?? username,
|
||||
);
|
||||
@@ -1500,9 +1511,7 @@ class NextcloudService {
|
||||
id: (a['activity_id'] ?? '').toString(),
|
||||
title: a['subject'] ?? 'Activity',
|
||||
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(a['timestamp'] as int? ?? 0) * 1000,
|
||||
),
|
||||
timestamp: _parseActivityDateTime(a['datetime']),
|
||||
icon: Icons.cloud_outlined,
|
||||
author: a['user'] ?? username,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,17 +6,12 @@ import '../providers/server_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/frosted_glass_container.dart';
|
||||
import '../widgets/seek_bar_painter.dart';
|
||||
import '../widgets/synced_header_scaffold.dart' show formatQuota;
|
||||
import 'login_view.dart';
|
||||
|
||||
class AccountView extends StatelessWidget {
|
||||
const AccountView({super.key});
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 GB';
|
||||
final gb = bytes / (1024 * 1024 * 1024);
|
||||
return '${gb.toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -80,16 +75,33 @@ class AccountView extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
_showLogoutConfirmation(context, provider),
|
||||
icon: const Icon(Icons.logout_rounded),
|
||||
tooltip: 'Log Out',
|
||||
color: colorScheme.error,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.public_rounded,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
Uri.tryParse(provider.serverUrl)?.host ??
|
||||
provider.serverUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
@@ -102,11 +114,7 @@ class AccountView extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
quota != null
|
||||
? (quota.totalBytes > 0
|
||||
? '${_formatBytes(quota.usedBytes)} of ${_formatBytes(quota.totalBytes)}'
|
||||
: '${_formatBytes(quota.usedBytes)} (Unlimited)')
|
||||
: 'Live Server Storage',
|
||||
quota != null ? formatQuota(quota) : 'Loading…',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -129,52 +137,111 @@ class AccountView extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
quota != null
|
||||
? (quota.totalBytes > 0
|
||||
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
|
||||
: 'Unlimited Storage Plan')
|
||||
: 'WebDAV connection active',
|
||||
: 'Unlimited storage')
|
||||
: '',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (quota != null && quota.groups.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Groups',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final group in quota.groups)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: provider.isLoggedIn
|
||||
? Colors.green.withValues(alpha: 0.2)
|
||||
: Colors.red.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
provider.isLoggedIn ? 'ONLINE' : 'OFFLINE',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: provider.isLoggedIn
|
||||
? Colors.green.shade800
|
||||
: Colors.red.shade800,
|
||||
group,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
await provider.refreshData();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Refreshed WebDAV data'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'Refresh Cache',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Connected to ${provider.serverUrl}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: FilledButton(
|
||||
onPressed: () => _handleLogout(context, provider),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
foregroundColor: colorScheme.onErrorContainer,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'Logout',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () =>
|
||||
_confirmRemoveActive(context, provider),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.error,
|
||||
foregroundColor: colorScheme.onError,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'Remove',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -196,47 +263,15 @@ class AccountView extends StatelessWidget {
|
||||
const _AccountsCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Server Credentials Section
|
||||
// Security Section
|
||||
Text(
|
||||
'Server Connection Info',
|
||||
'Security',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.dns_rounded),
|
||||
title: const Text('Nextcloud Host'),
|
||||
subtitle: Text(provider.serverUrl),
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_rounded),
|
||||
title: const Text('Logged In User'),
|
||||
subtitle: Text(provider.username),
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh_rounded),
|
||||
title: const Text('Refresh WebDAV Cache'),
|
||||
onTap: () async {
|
||||
await provider.refreshData();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Refreshed WebDAV directory data'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const _SecurityCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Material You Design Settings
|
||||
@@ -452,34 +487,62 @@ class AccountView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showLogoutConfirmation(BuildContext context, ServerProvider provider) {
|
||||
showDialog(
|
||||
/// Logout keeps the account saved (see [ServerProvider.logout]) so it's
|
||||
/// harmless/reversible from a one-tap resume on the login screen -
|
||||
/// doesn't need a confirmation dialog the way [_confirmRemoveActive] does.
|
||||
Future<void> _handleLogout(
|
||||
BuildContext context,
|
||||
ServerProvider provider,
|
||||
) async {
|
||||
final navigator = Navigator.of(context);
|
||||
await provider.logout();
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
Future<void> _confirmRemoveActive(
|
||||
BuildContext context,
|
||||
ServerProvider provider,
|
||||
) async {
|
||||
final host = Uri.tryParse(provider.serverUrl)?.host ?? provider.serverUrl;
|
||||
final hasOtherAccounts = provider.accounts.length > 1;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Log Out'),
|
||||
title: const Text('Remove Account'),
|
||||
content: Text(
|
||||
'Are you sure you want to disconnect from ${provider.serverUrl}?',
|
||||
hasOtherAccounts
|
||||
? 'Remove ${provider.username} ($host)? Another saved account will become active.'
|
||||
: 'Remove ${provider.username} ($host)? You can add it again later.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
provider.logout();
|
||||
},
|
||||
child: const Text('Log Out'),
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final id = provider.activeAccountId;
|
||||
if (id == null) return;
|
||||
if (!context.mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
await provider.removeAccount(id);
|
||||
// Only pop back to the root route if that was the last account and the
|
||||
// session actually ended - if it fell back to another saved account,
|
||||
// Settings just keeps showing (now for that account) instead.
|
||||
if (!provider.isLoggedIn) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +586,17 @@ class _AccountsCard extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
if (confirmed == true) {
|
||||
if (!context.mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
await provider.removeAccount(account.id);
|
||||
// Only when this was the last saved account does isLoggedIn drop to
|
||||
// false and main.dart swap the root route to LoginView underneath -
|
||||
// pop back to it then, rather than leaving Settings stranded on top.
|
||||
// Removing a non-active account, or falling back to another one,
|
||||
// both keep the user logged in, so Settings should just stay put.
|
||||
if (!provider.isLoggedIn) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,6 +688,77 @@ class _AccountRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Login lock: gates opening the app, switching accounts, and revealing
|
||||
/// hidden files behind the device's own PIN/biometric credential (see
|
||||
/// AppLockService - this app never stores or handles a PIN itself).
|
||||
class _SecurityCard extends StatelessWidget {
|
||||
const _SecurityCard();
|
||||
|
||||
Future<void> _handleLoginLockChanged(
|
||||
BuildContext context,
|
||||
ServerProvider provider,
|
||||
bool value,
|
||||
) async {
|
||||
final success = value
|
||||
? await provider.setupLoginLock()
|
||||
: await provider.disableLoginLock();
|
||||
if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
value
|
||||
? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured"
|
||||
: 'Could not turn off login lock',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<ServerProvider>();
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.lock_outline_rounded),
|
||||
title: const Text('Login Lock'),
|
||||
subtitle: const Text(
|
||||
"Require this device's PIN or biometric to open Noo",
|
||||
),
|
||||
value: provider.loginLockEnabled,
|
||||
onChanged: (value) =>
|
||||
_handleLoginLockChanged(context, provider, value),
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.swap_horiz_rounded),
|
||||
title: const Text('Lock account switching'),
|
||||
subtitle: const Text('Unlock to switch between saved accounts'),
|
||||
value: provider.lockAccountSwitching,
|
||||
onChanged: provider.loginLockEnabled
|
||||
? provider.setLockAccountSwitching
|
||||
: null,
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.visibility_off_rounded),
|
||||
title: const Text('Lock hidden files'),
|
||||
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
||||
value: provider.lockHiddenFiles,
|
||||
onChanged: provider.loginLockEnabled
|
||||
? provider.setLockHiddenFiles
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomBarAppearanceCard extends StatefulWidget {
|
||||
const _BottomBarAppearanceCard();
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
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),
|
||||
);
|
||||
},
|
||||
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,
|
||||
),
|
||||
),
|
||||
_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,6 +326,18 @@ class _FilesViewState extends State<FilesView> {
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 44,
|
||||
// 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(
|
||||
@@ -361,7 +353,11 @@ class _FilesViewState extends State<FilesView> {
|
||||
: 'Descending',
|
||||
onPressed: provider.toggleFilesSortOrder,
|
||||
),
|
||||
Expanded(
|
||||
// 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,
|
||||
@@ -381,7 +377,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
? Icons.visibility_rounded
|
||||
: Icons.visibility_off_rounded,
|
||||
isSelected: provider.showHiddenFiles,
|
||||
onTap: provider.toggleShowHiddenFiles,
|
||||
onTap: () => provider.toggleShowHiddenFiles(),
|
||||
tooltip: 'Show hidden files',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
@@ -390,7 +386,8 @@ class _FilesViewState extends State<FilesView> {
|
||||
ToggleIconButton(
|
||||
icon: Symbols.circles_rounded,
|
||||
isSelected: provider.storageScope == StorageScope.cloud,
|
||||
onTap: () => provider.setStorageScope(StorageScope.cloud),
|
||||
onTap: () =>
|
||||
provider.setStorageScope(StorageScope.cloud),
|
||||
tooltip: 'Cloud storage',
|
||||
),
|
||||
ToggleIconButton(
|
||||
@@ -404,6 +401,39 @@ class _FilesViewState extends State<FilesView> {
|
||||
],
|
||||
),
|
||||
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(
|
||||
@@ -423,6 +453,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasBreadcrumbs) ...[
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
|
||||
/// Shown in place of the main shell whenever [ServerProvider.needsUnlock] is
|
||||
/// true - a fresh app launch with login lock configured, or returning to the
|
||||
/// foreground after being backgrounded. Prompts automatically on first show
|
||||
/// and again whenever the app resumes while still locked (e.g. the user
|
||||
/// switched away to enter their device PIN in the system UI and came back).
|
||||
class LockScreenView extends StatefulWidget {
|
||||
const LockScreenView({super.key});
|
||||
|
||||
@override
|
||||
State<LockScreenView> createState() => _LockScreenViewState();
|
||||
}
|
||||
|
||||
class _LockScreenViewState extends State<LockScreenView>
|
||||
with WidgetsBindingObserver {
|
||||
bool _authenticating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _attemptUnlock());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed || !mounted || _authenticating) {
|
||||
return;
|
||||
}
|
||||
if (context.read<ServerProvider>().needsUnlock) _attemptUnlock();
|
||||
}
|
||||
|
||||
Future<void> _attemptUnlock() async {
|
||||
if (_authenticating || !mounted) return;
|
||||
setState(() => _authenticating = true);
|
||||
await context.read<ServerProvider>().attemptUnlock();
|
||||
if (mounted) setState(() => _authenticating = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Same monochrome-tinted treatment as the splash screen (see
|
||||
// main.dart's _SplashView) - the asset is a plain white
|
||||
// silhouette on transparent, meant to be recolored rather
|
||||
// than shown as-is.
|
||||
ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
colorScheme.onSurface,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/icon/app_icon_monochrome.png',
|
||||
width: 80,
|
||||
height: 80,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Noo is locked',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Unlock with your PIN or biometrics to continue',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
height: 54,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _authenticating ? null : _attemptUnlock,
|
||||
icon: _authenticating
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.lock_open_rounded),
|
||||
label: Text(_authenticating ? 'Unlocking…' : 'Unlock'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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
|
||||
@@ -20,6 +22,7 @@ class _LoginViewState extends State<LoginView> {
|
||||
final _urlController = TextEditingController();
|
||||
String? _originalActiveAccountId;
|
||||
bool _popped = false;
|
||||
bool _webViewPushed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -66,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'))
|
||||
@@ -77,15 +99,42 @@ 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,
|
||||
)
|
||||
: _ServerForm(
|
||||
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: [
|
||||
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,
|
||||
isLoading:
|
||||
isInitiating || provider.isLoading || isAwaitingBrowser,
|
||||
errorMessage:
|
||||
provider.loginFlowStatus == LoginFlowStatus.error
|
||||
? provider.errorMessage
|
||||
@@ -94,6 +143,8 @@ class _LoginViewState extends State<LoginView> {
|
||||
theme: theme,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -103,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.
|
||||
@@ -145,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,
|
||||
),
|
||||
@@ -252,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,
|
||||
@@ -265,14 +324,13 @@ class _ServerForm extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _WaitingForBrowser extends StatelessWidget {
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onReopenBrowser;
|
||||
/// Saved accounts a logout left recoverable - tapping one resumes it via
|
||||
/// [ServerProvider.switchAccount] instead of repeating Login Flow v2.
|
||||
class _SavedAccountsSection extends StatelessWidget {
|
||||
final List<SavedAccount> accounts;
|
||||
final ValueChanged<SavedAccount> onSelect;
|
||||
|
||||
const _WaitingForBrowser({
|
||||
required this.onCancel,
|
||||
required this.onReopenBrowser,
|
||||
});
|
||||
const _SavedAccountsSection({required this.accounts, required this.onSelect});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -280,34 +338,67 @@ class _WaitingForBrowser extends StatelessWidget {
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text(
|
||||
'Continue as',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
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')),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (final account in accounts) ...[
|
||||
_SavedAccountRow(
|
||||
account: account,
|
||||
onTap: () => onSelect(account),
|
||||
),
|
||||
if (account != accounts.last)
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SavedAccountRow extends StatelessWidget {
|
||||
final SavedAccount account;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SavedAccountRow({required this.account, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
|
||||
final initial = account.username.isNotEmpty
|
||||
? account.username[0].toUpperCase()
|
||||
: '?';
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: colorScheme.primary,
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(account.username),
|
||||
subtitle: Text(host),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
? Icons.visibility_rounded
|
||||
: Icons.visibility_off_rounded,
|
||||
isSelected: provider.showHiddenPhotos,
|
||||
onTap: provider.toggleShowHiddenPhotos,
|
||||
onTap: () => provider.toggleShowHiddenPhotos(),
|
||||
tooltip: 'Show hidden files',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
@@ -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,46 +330,25 @@ 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,
|
||||
body: Column(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
if (hasBreadcrumbs)
|
||||
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),
|
||||
@@ -133,37 +357,43 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
||||
Expanded(
|
||||
child: provider.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: folders.isEmpty
|
||||
: items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No subfolders here',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
'Folder is empty',
|
||||
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,
|
||||
: provider.isGridView
|
||||
? GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 8,
|
||||
),
|
||||
title: Text(folder.name),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () =>
|
||||
provider.navigateToFolder(folder.path),
|
||||
);
|
||||
},
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 1.1,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
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: _uploading
|
||||
? null
|
||||
: SafeArea(
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,18 @@ import '../views/account_view.dart';
|
||||
class ProfileAvatarButton extends StatelessWidget {
|
||||
const ProfileAvatarButton({super.key});
|
||||
|
||||
void _handleVerticalSwipe(BuildContext context, DragEndDetails details) {
|
||||
Future<void> _handleVerticalSwipe(
|
||||
BuildContext context,
|
||||
DragEndDetails details,
|
||||
) async {
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
if (velocity.abs() < 250) return;
|
||||
|
||||
final provider = context.read<ServerProvider>();
|
||||
final target = velocity < 0
|
||||
? provider.cycleToNextAccount()
|
||||
: provider.cycleToPreviousAccount();
|
||||
if (target == null) return;
|
||||
? await provider.cycleToNextAccount()
|
||||
: await provider.cycleToPreviousAccount();
|
||||
if (target == null || !context.mounted) return;
|
||||
|
||||
HapticFeedback.selectionClick();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -278,6 +278,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.35"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -456,6 +464,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
local_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: local_auth
|
||||
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
local_auth_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_android
|
||||
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.56"
|
||||
local_auth_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_darwin
|
||||
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.1"
|
||||
local_auth_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_platform_interface
|
||||
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
local_auth_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_windows
|
||||
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.11"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -720,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:
|
||||
@@ -885,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:
|
||||
@@ -917,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:
|
||||
@@ -1021,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:
|
||||
|
||||
@@ -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,8 +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:
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
||||
#include <file_saver/file_saver_plugin.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <local_auth_windows/local_auth_plugin.h>
|
||||
#include <pdfx/pdfx_plugin.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
@@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("FileSaverPlugin"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
LocalAuthPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||
PdfxPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PdfxPlugin"));
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
|
||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
dynamic_color
|
||||
file_saver
|
||||
flutter_secure_storage_windows
|
||||
local_auth_windows
|
||||
pdfx
|
||||
share_plus
|
||||
url_launcher_windows
|
||||
|
||||