Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be38f24f32 | ||
|
|
bcc18a7060 | ||
|
|
453aff983a | ||
|
|
f78ceb7e8b | ||
|
|
bb1688ce3a | ||
|
|
3f156cbc13 | ||
|
|
06bc25ae1e |
@@ -10,12 +10,12 @@ lib/
|
|||||||
# SavedAccount, AppTab, ...)
|
# SavedAccount, AppTab, ...)
|
||||||
providers/ # ServerProvider — the single app-wide ChangeNotifier
|
providers/ # ServerProvider — the single app-wide ChangeNotifier
|
||||||
services/ # network/IO: NextcloudService, LoginFlowService,
|
services/ # network/IO: NextcloudService, LoginFlowService,
|
||||||
# AccountStore
|
# AccountStore, AppLockService
|
||||||
theme/ # AppTheme (Material 3 ThemeData)
|
theme/ # AppTheme (Material 3 ThemeData)
|
||||||
views/ # one screen each (FilesView, PhotosView, TrashView,
|
views/ # one screen each (FilesView, PhotosView, TrashView,
|
||||||
# SharesView, RecentView, ActivityView, SearchView,
|
# SharesView, RecentView, ActivityView, SearchView,
|
||||||
# AccountView, LoginView, FileViewerScreen,
|
# AccountView, LoginView, FileViewerScreen,
|
||||||
# ShareUploadView)
|
# ShareUploadView, LockScreenView)
|
||||||
widgets/ # reusable pieces shared across views
|
widgets/ # reusable pieces shared across views
|
||||||
details/ # the file-details bottom sheet and its tabs
|
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
|
- UI settings that persist across launches — split into **global** (theme
|
||||||
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
|
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
|
||||||
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
|
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
|
||||||
actions) and **per-account** (grid/list view, favorites-only, show-hidden,
|
actions, login lock — see below) and **per-account** (grid/list view,
|
||||||
storage scope, Photos sort, Files' per-folder sort map, cache policy) — see
|
favorites-only, show-hidden, storage scope, Photos sort, Files'
|
||||||
`server.md` for exactly which is which and why
|
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
|
New **global** state belongs on `ServerProvider` as a private field + getter
|
||||||
+ a method that mutates it, calls `notifyListeners()`, and persists via
|
+ 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`
|
There's no separate repository/data layer: views call `ServerProvider`
|
||||||
methods directly, which call `NextcloudService`/`LoginFlowService`/
|
methods directly, which call `NextcloudService`/`LoginFlowService`/
|
||||||
`AccountStore`.
|
`AccountStore`/`AppLockService`.
|
||||||
|
|
||||||
## Navigation / screen flow
|
## Navigation / screen flow
|
||||||
|
|
||||||
@@ -85,6 +99,7 @@ state, no named routes:
|
|||||||
Flow v2) — `isLoggedIn` is only ever false here or after the last saved
|
Flow v2) — `isLoggedIn` is only ever false here or after the last saved
|
||||||
account is removed; switching between multiple saved accounts never
|
account is removed; switching between multiple saved accounts never
|
||||||
routes through this screen (see `server.md`)
|
routes through this screen (see `server.md`)
|
||||||
|
- else `provider.needsUnlock` → `LockScreenView` (login lock — see above)
|
||||||
- else `MainShellView`
|
- else `MainShellView`
|
||||||
|
|
||||||
`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files,
|
`MainShellView` is a bottom-nav `IndexedStack` over up to 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
|
Each tab keeps its own `ScrollController` (survives tab switches via
|
||||||
`IndexedStack`'s built-but-hidden trees) and tapping the already-active tab
|
`IndexedStack`'s built-but-hidden trees) and tapping the already-active tab
|
||||||
scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also
|
scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also
|
||||||
owns the app's share-intent listener (`receive_sharing_intent`): both
|
owns the app's share-intent listener (`ShareIntentService`, backed by
|
||||||
`getInitialMedia()` (cold start via another app's "Share to...") and
|
hand-rolled native handling in `MainActivity.kt` - see `server.md` for why
|
||||||
`getMediaStream()` (already running) push `ShareUploadView`.
|
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
|
Five of the six tabs (all but Files) plus each tab's own controls share
|
||||||
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a
|
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a
|
||||||
|
|||||||
@@ -9,12 +9,34 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart):
|
|||||||
1. `LoginFlowService.initiate(serverUrl)` POSTs to
|
1. `LoginFlowService.initiate(serverUrl)` POSTs to
|
||||||
`{server}/index.php/login/v2`, gets back a browser login URL + a poll
|
`{server}/index.php/login/v2`, gets back a browser login URL + a poll
|
||||||
endpoint/token.
|
endpoint/token.
|
||||||
2. The app opens the login URL in a Chrome Custom Tab (`url_launcher`,
|
2. The app opens the login URL for the user to authenticate/authorize.
|
||||||
`LaunchMode.inAppBrowserView` — real Chrome, so saved passwords/autofill
|
Which browser depends on whether this is the first login or an
|
||||||
work, unlike Flutter's own embedded web view); the user authenticates and
|
add-account flow (`addAccount`, see below):
|
||||||
authorizes there. There's no way to close the tab automatically on
|
- First/only login: a Chrome Custom Tab (`url_launcher`,
|
||||||
success (Login Flow v2 never redirects back into the app, and a Custom
|
`LaunchMode.inAppBrowserView`) — real Chrome, so saved
|
||||||
Tab belongs to Chrome's own task) — the user switches back manually.
|
passwords/autofill work, unlike Flutter's own embedded web view.
|
||||||
|
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.
|
||||||
|
- Adding another account: [`LoginWebViewView`](../../lib/views/login_webview_view.dart),
|
||||||
|
a normal screen this app owns, backed by `package:webview_flutter`
|
||||||
|
rather than a Custom Tab - pushed by `LoginView` the moment
|
||||||
|
`loginFlowStatus` flips to `awaitingBrowser`. Two reasons this isn't
|
||||||
|
a Custom Tab: (1) a Custom Tab shares Chrome's actual browser
|
||||||
|
profile/cookie jar - if the user is still logged into the first
|
||||||
|
account on the Nextcloud web UI in Chrome, it would silently reuse
|
||||||
|
that session and authorize the wrong account instead of prompting
|
||||||
|
fresh credentials; (2) `url_launcher`'s own `LaunchMode.inAppWebView`
|
||||||
|
(tried first) is 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. Owning the screen fixes both -
|
||||||
|
isolated cookies, a normal `AppBar`/close button/safe area - and lets
|
||||||
|
it close itself automatically on success (it watches
|
||||||
|
`loginFlowStatus` itself), unlike the Custom Tab case. The cost is no
|
||||||
|
Chrome-autofill for this one flow (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
|
3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every
|
||||||
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in
|
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in
|
||||||
`server_provider.dart`) until it gets a 200 with `server`/`loginName`/
|
`server_provider.dart`) until it gets a 200 with `server`/`loginName`/
|
||||||
@@ -68,13 +90,45 @@ rather than needing a rewrite for multi-account support.
|
|||||||
`ServerProvider` wrapper always uploads into `_currentFolderPath` — the
|
`ServerProvider` wrapper always uploads into `_currentFolderPath` — the
|
||||||
share-to-upload flow (`ShareUploadView`) gets a caller-chosen destination
|
share-to-upload flow (`ShareUploadView`) gets a caller-chosen destination
|
||||||
by navigating there first (`navigateToAbsoluteFolder`), then uploading.
|
by navigating there first (`navigateToAbsoluteFolder`), then uploading.
|
||||||
- **Receiving a shared file from another app**: `receive_sharing_intent`
|
- **Receiving a shared file from another app**: hand-rolled in
|
||||||
(Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`, `android:launchMode`
|
`MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`,
|
||||||
`singleTask` in the manifest so a second share while running hits
|
`android:launchMode` `singleTask` in the manifest so a second share while
|
||||||
`onNewIntent` instead of spawning a new instance). `MainShellView` listens
|
running hits `onNewIntent` instead of spawning a new instance) plus
|
||||||
via `getInitialMedia()`/`getMediaStream()` and pushes `ShareUploadView`,
|
[`ShareIntentService`](../../lib/services/share_intent_service.dart) on
|
||||||
which reuses the same `uploadFileFromPath` path after the user picks a
|
the Dart side - not the `receive_sharing_intent` plugin, which this used
|
||||||
destination folder.
|
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
|
## Multi-account storage & session persistence
|
||||||
|
|
||||||
@@ -121,9 +175,53 @@ account is *currently* live (see `architecture.md`).
|
|||||||
its credentials and refetch everything. This is a full teardown-and-reload
|
its credentials and refetch everything. This is a full teardown-and-reload
|
||||||
every time — there is deliberately no simultaneous multi-account state or
|
every time — there is deliberately no simultaneous multi-account state or
|
||||||
background sync; only one account's content is ever live.
|
background sync; only one account's content is ever live.
|
||||||
- `logout()` is just `removeAccount(activeAccountId)` — with other accounts
|
- **`logout()` vs `removeAccount()`** are deliberately different actions,
|
||||||
saved it falls back to one of them instead of ending the session;
|
both funneling into a shared `_deactivateSession()` helper for the
|
||||||
`isLoggedIn` only ever becomes `false` when the *last* account is removed.
|
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
|
`isRestoringSession` still gates the splash screen until the above resolves
|
||||||
— see `standards.md` for why widget tests must mock both storage channels
|
— 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.
|
of failing fast. See `test/widget_test.dart` for the reference setup.
|
||||||
- Run with `flutter test`.
|
- 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
|
## Dependencies
|
||||||
|
|
||||||
Networking is deliberately split: `package:http` for simple JSON/XML
|
Networking is deliberately split: `package:http` for simple JSON/XML
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ widgets. Key points:
|
|||||||
|
|
||||||
## Reusable chrome
|
## 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) —
|
- [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) —
|
||||||
the blurred/translucent pill background shared by all floating chrome
|
the blurred/translucent pill background shared by all floating chrome
|
||||||
(bottom nav bar, media-viewer top/bottom bars and video transport
|
(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).
|
exception, since those colors are content-identity cues, not theme).
|
||||||
- Use `colorScheme.surfaceContainer*`/`onSurfaceVariant` tokens for
|
- Use `colorScheme.surfaceContainer*`/`onSurfaceVariant` tokens for
|
||||||
elevation/secondary text rather than manual opacity on black/white.
|
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"
|
applicationId = "dev.ayushya.noo"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// 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
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<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
|
<application
|
||||||
android:label="Noo"
|
android:label="Noo"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -29,7 +37,8 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Lets other apps' share sheets ("Share to...") send a file here
|
<!-- 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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND"/>
|
<action android:name="android.intent.action.SEND"/>
|
||||||
<category android:name="android.intent.category.DEFAULT"/>
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
@@ -41,6 +50,10 @@
|
|||||||
<data android:mimeType="*/*"/>
|
<data android:mimeType="*/*"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".ShareUploadService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync"/>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -1,5 +1,183 @@
|
|||||||
package dev.ayushya.noo
|
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")
|
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") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
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:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
|
||||||
import 'models/app_tab.dart';
|
import 'models/app_tab.dart';
|
||||||
import 'providers/server_provider.dart';
|
import 'providers/server_provider.dart';
|
||||||
|
import 'services/share_intent_service.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
import 'views/lock_screen_view.dart';
|
||||||
import 'views/login_view.dart';
|
import 'views/login_view.dart';
|
||||||
import 'views/search_view.dart';
|
import 'views/search_view.dart';
|
||||||
import 'views/share_upload_view.dart';
|
import 'views/share_upload_view.dart';
|
||||||
@@ -48,9 +49,11 @@ class NextcloudApp extends StatelessWidget {
|
|||||||
themeMode: provider.themeMode,
|
themeMode: provider.themeMode,
|
||||||
home: provider.isRestoringSession
|
home: provider.isRestoringSession
|
||||||
? const _SplashView()
|
? const _SplashView()
|
||||||
: (provider.isLoggedIn
|
: (!provider.isLoggedIn
|
||||||
? const MainShellView()
|
? const LoginView()
|
||||||
: const LoginView()),
|
: (provider.needsUnlock
|
||||||
|
? const LockScreenView()
|
||||||
|
: const MainShellView())),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -109,7 +112,7 @@ class MainShellView extends StatefulWidget {
|
|||||||
class _MainShellViewState extends State<MainShellView> {
|
class _MainShellViewState extends State<MainShellView> {
|
||||||
late AppTab _currentTab;
|
late AppTab _currentTab;
|
||||||
late final Map<AppTab, ScrollController> _scrollControllers;
|
late final Map<AppTab, ScrollController> _scrollControllers;
|
||||||
StreamSubscription<List<SharedMediaFile>>? _shareSub;
|
StreamSubscription<List<SharedFileRef>>? _shareSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -123,29 +126,22 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
_currentTab = context.read<ServerProvider>().defaultTab;
|
_currentTab = context.read<ServerProvider>().defaultTab;
|
||||||
|
|
||||||
// Handles both a cold start via another app's "Share to..." sheet
|
// Handles both a cold start via another app's "Share to..." sheet
|
||||||
// (getInitialMedia) and a share arriving while the app is already
|
// (getInitialShare) and a share arriving while the app is already
|
||||||
// running (getMediaStream) - the plugin guarantees the stream doesn't
|
// running (onNewShare). See MainActivity.kt's doc comment for why this
|
||||||
// re-emit whatever getInitialMedia already returned.
|
// is hand-rolled instead of the receive_sharing_intent plugin - in
|
||||||
ReceiveSharingIntent.instance.getInitialMedia().then(_handleSharedFiles);
|
// short, this only ever fetches cheap Uri metadata here, never a
|
||||||
_shareSub = ReceiveSharingIntent.instance.getMediaStream().listen(
|
// file's actual bytes, so a large shared file can't block startup.
|
||||||
_handleSharedFiles,
|
ShareIntentService.getInitialShare().then(_handleSharedFiles);
|
||||||
);
|
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSharedFiles(List<SharedMediaFile> files) {
|
void _handleSharedFiles(List<SharedFileRef> files) {
|
||||||
final uploadable = files
|
if (files.isEmpty) return;
|
||||||
.where(
|
|
||||||
(f) =>
|
|
||||||
f.type != SharedMediaType.text && f.type != SharedMediaType.url,
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
if (uploadable.isEmpty) return;
|
|
||||||
ReceiveSharingIntent.instance.reset();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).push(
|
Navigator.of(
|
||||||
MaterialPageRoute(builder: (_) => ShareUploadView(files: uploadable)),
|
context,
|
||||||
);
|
).push(MaterialPageRoute(builder: (_) => ShareUploadView(files: files)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ class NextcloudUserQuota {
|
|||||||
final String userName;
|
final String userName;
|
||||||
final String email;
|
final String email;
|
||||||
final String serverVersion;
|
final String serverVersion;
|
||||||
|
final List<String> groups;
|
||||||
|
|
||||||
const NextcloudUserQuota({
|
const NextcloudUserQuota({
|
||||||
required this.usedBytes,
|
required this.usedBytes,
|
||||||
@@ -147,6 +148,7 @@ class NextcloudUserQuota {
|
|||||||
required this.userName,
|
required this.userName,
|
||||||
required this.email,
|
required this.email,
|
||||||
required this.serverVersion,
|
required this.serverVersion,
|
||||||
|
this.groups = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory NextcloudUserQuota.demo() {
|
factory NextcloudUserQuota.demo() {
|
||||||
@@ -157,6 +159,7 @@ class NextcloudUserQuota {
|
|||||||
userName: 'Ayush Admin',
|
userName: 'Ayush Admin',
|
||||||
email: 'ayush@cloud.internal',
|
email: 'ayush@cloud.internal',
|
||||||
serverVersion: 'Nextcloud 29.0.4 Hub 8',
|
serverVersion: 'Nextcloud 29.0.4 Hub 8',
|
||||||
|
groups: ['Family', 'Friends'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../models/nextcloud_share.dart';
|
|||||||
import '../models/nextcloud_sharee.dart';
|
import '../models/nextcloud_sharee.dart';
|
||||||
import '../models/saved_account.dart';
|
import '../models/saved_account.dart';
|
||||||
import '../services/account_store.dart';
|
import '../services/account_store.dart';
|
||||||
|
import '../services/app_lock_service.dart';
|
||||||
import '../services/login_flow_service.dart';
|
import '../services/login_flow_service.dart';
|
||||||
import '../services/nextcloud_service.dart';
|
import '../services/nextcloud_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
@@ -27,6 +28,10 @@ enum StorageScope { cloud, external }
|
|||||||
/// in Settings.
|
/// in Settings.
|
||||||
enum SwipeAction { none, favorite, delete, share }
|
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
|
/// How aggressively the Files tab's folder listings (not thumbnails/file
|
||||||
/// content - just the list of names/sizes/dates) are reused across
|
/// content - just the list of names/sizes/dates) are reused across
|
||||||
/// navigation instead of refetched from the server every time.
|
/// navigation instead of refetched from the server every time.
|
||||||
@@ -69,6 +74,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
static const _prefShowFavoritesOnlyFiles = 'ui_show_favorites_only';
|
static const _prefShowFavoritesOnlyFiles = 'ui_show_favorites_only';
|
||||||
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
||||||
static const _prefStorageScope = 'ui_storage_scope';
|
static const _prefStorageScope = 'ui_storage_scope';
|
||||||
|
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
||||||
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
||||||
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||||
static const _prefSortField = 'ui_sort_field';
|
static const _prefSortField = 'ui_sort_field';
|
||||||
@@ -84,6 +90,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
static const _prefCachePolicy = 'ui_cache_policy';
|
static const _prefCachePolicy = 'ui_cache_policy';
|
||||||
static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||||
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
|
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 =
|
final Future<SharedPreferences> _prefsFuture =
|
||||||
SharedPreferences.getInstance();
|
SharedPreferences.getInstance();
|
||||||
@@ -114,6 +123,17 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
double _bottomBarBlur = 28;
|
double _bottomBarBlur = 28;
|
||||||
bool _tapTabToScrollTop = true;
|
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
|
// Navigation state
|
||||||
String _currentFolderPath = '/';
|
String _currentFolderPath = '/';
|
||||||
|
|
||||||
@@ -126,6 +146,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
bool _showFavoritesOnlyFiles = false;
|
bool _showFavoritesOnlyFiles = false;
|
||||||
bool _showFavoritesOnlyPhotos = false;
|
bool _showFavoritesOnlyPhotos = false;
|
||||||
StorageScope _storageScope = StorageScope.cloud;
|
StorageScope _storageScope = StorageScope.cloud;
|
||||||
|
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
||||||
bool _showHiddenFiles = false;
|
bool _showHiddenFiles = false;
|
||||||
bool _showHiddenPhotos = false;
|
bool _showHiddenPhotos = false;
|
||||||
// Photos tab sort - a single global setting (Photos has no folder concept,
|
// Photos tab sort - a single global setting (Photos has no folder concept,
|
||||||
@@ -217,12 +238,20 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
/// Starts/stops the periodic folder-listing refresh as the app leaves and
|
/// Starts/stops the periodic folder-listing refresh as the app leaves and
|
||||||
/// returns to the foreground - only relevant under [CachePolicy.interval].
|
/// 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
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
_startCacheRefreshTimerIfNeeded();
|
_startCacheRefreshTimerIfNeeded();
|
||||||
} else if (state == AppLifecycleState.paused) {
|
} else if (state == AppLifecycleState.paused) {
|
||||||
_cacheRefreshTimer?.cancel();
|
_cacheRefreshTimer?.cancel();
|
||||||
|
if (_loginLockEnabled && _isUnlocked) {
|
||||||
|
_isUnlocked = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +289,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
double get bottomBarOpacity => _bottomBarOpacity;
|
double get bottomBarOpacity => _bottomBarOpacity;
|
||||||
double get bottomBarBlur => _bottomBarBlur;
|
double get bottomBarBlur => _bottomBarBlur;
|
||||||
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
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;
|
String get currentFolderPath => _currentFolderPath;
|
||||||
AppTab? get requestedTab => _requestedTab;
|
AppTab? get requestedTab => _requestedTab;
|
||||||
@@ -282,6 +315,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
bool get showFavoritesOnlyFiles => _showFavoritesOnlyFiles;
|
bool get showFavoritesOnlyFiles => _showFavoritesOnlyFiles;
|
||||||
bool get showFavoritesOnlyPhotos => _showFavoritesOnlyPhotos;
|
bool get showFavoritesOnlyPhotos => _showFavoritesOnlyPhotos;
|
||||||
StorageScope get storageScope => _storageScope;
|
StorageScope get storageScope => _storageScope;
|
||||||
|
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
||||||
bool get showHiddenFiles => _showHiddenFiles;
|
bool get showHiddenFiles => _showHiddenFiles;
|
||||||
bool get showHiddenPhotos => _showHiddenPhotos;
|
bool get showHiddenPhotos => _showHiddenPhotos;
|
||||||
FileSortField get photosSortField => _photosSortField;
|
FileSortField get photosSortField => _photosSortField;
|
||||||
@@ -358,11 +392,19 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<NextcloudItem> get items {
|
List<NextcloudItem> get items {
|
||||||
final filtered = _applyCommonFilters(
|
var filtered = _applyCommonFilters(
|
||||||
_items,
|
_items,
|
||||||
showFavoritesOnly: _showFavoritesOnlyFiles,
|
showFavoritesOnly: _showFavoritesOnlyFiles,
|
||||||
showHidden: _showHiddenFiles,
|
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 field = filesSortField;
|
||||||
final folders = filtered.where((i) => i.isFolder).toList()
|
final folders = filtered.where((i) => i.isFolder).toList()
|
||||||
@@ -450,6 +492,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
|
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
|
||||||
_tapTabToScrollTop =
|
_tapTabToScrollTop =
|
||||||
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
|
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
|
||||||
|
_loginLockEnabled =
|
||||||
|
prefs.getBool(_prefLoginLockEnabled) ?? _loginLockEnabled;
|
||||||
|
_lockAccountSwitching =
|
||||||
|
prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching;
|
||||||
|
_lockHiddenFiles =
|
||||||
|
prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles;
|
||||||
_applyAccountPrefs(prefs, _activeAccountId);
|
_applyAccountPrefs(prefs, _activeAccountId);
|
||||||
|
|
||||||
final savedOrderNames = prefs.getStringList(_prefTabOrder);
|
final savedOrderNames = prefs.getStringList(_prefTabOrder);
|
||||||
@@ -532,6 +580,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_showFavoritesOnlyFiles = false;
|
_showFavoritesOnlyFiles = false;
|
||||||
_showFavoritesOnlyPhotos = false;
|
_showFavoritesOnlyPhotos = false;
|
||||||
_storageScope = StorageScope.cloud;
|
_storageScope = StorageScope.cloud;
|
||||||
|
_filesTypeFilter = FilesTypeFilter.all;
|
||||||
_showHiddenFiles = false;
|
_showHiddenFiles = false;
|
||||||
_showHiddenPhotos = false;
|
_showHiddenPhotos = false;
|
||||||
_photosSortField = FileSortField.name;
|
_photosSortField = FileSortField.name;
|
||||||
@@ -555,6 +604,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
orElse: () => StorageScope.cloud,
|
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;
|
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
||||||
_showHiddenPhotos = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
_showHiddenPhotos = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
||||||
|
|
||||||
@@ -670,14 +725,32 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
final init = await LoginFlowService.initiate(serverUrl);
|
final init = await LoginFlowService.initiate(serverUrl);
|
||||||
_pendingLoginUrl = init.loginUrl;
|
_pendingLoginUrl = init.loginUrl;
|
||||||
|
|
||||||
// Chrome Custom Tabs (inAppBrowserView): real Chrome, so the saved
|
// Chrome Custom Tabs (inAppBrowserView) for the first/only login:
|
||||||
// passwords/autofill service works normally, unlike Flutter's own
|
// real Chrome, so the saved passwords/autofill service works
|
||||||
// embedded web view. The tradeoff is that - like Login Flow v2's
|
// normally, unlike Flutter's own embedded web view. The tradeoff is
|
||||||
// grant page itself, which never redirects back into the app on its
|
// that - like Login Flow v2's grant page itself, which never
|
||||||
// own (unlike the old nc:// Flow v1) - a Custom Tab belongs to
|
// redirects back into the app on its own (unlike the old nc://
|
||||||
// Chrome's own task, not ours, so we have no way to close it
|
// Flow v1) - a Custom Tab belongs to Chrome's own task, not ours, so
|
||||||
// automatically once polling below detects success; the user has to
|
// we have no way to close it automatically once polling below
|
||||||
// switch back manually, same as with the external browser.
|
// detects success; the user has to switch back manually, same as
|
||||||
|
// with the external browser.
|
||||||
|
//
|
||||||
|
// Adding another account instead shows the login page in
|
||||||
|
// LoginWebViewView, a real screen this app owns (pushed by LoginView
|
||||||
|
// once loginFlowStatus flips to awaitingBrowser below), backed by
|
||||||
|
// Flutter's own WebView rather than a Custom Tab. Two reasons, not
|
||||||
|
// just one: (1) a Custom Tab shares Chrome's actual browser
|
||||||
|
// profile/cookie jar, so if the user is still logged into the first
|
||||||
|
// account on the Nextcloud web UI in Chrome, it would silently reuse
|
||||||
|
// that session and authorize the wrong account instead of prompting
|
||||||
|
// fresh credentials; (2) url_launcher's own LaunchMode.inAppWebView
|
||||||
|
// is 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. Owning the screen ourselves fixes both:
|
||||||
|
// isolated cookies, plus a normal AppBar/close button/safe area, and
|
||||||
|
// as a bonus we CAN close it automatically on success (LoginWebView
|
||||||
|
// View watches loginFlowStatus itself), unlike the Custom Tab case.
|
||||||
|
if (!addAccount) {
|
||||||
final opened = await launchUrl(
|
final opened = await launchUrl(
|
||||||
init.loginUrl,
|
init.loginUrl,
|
||||||
mode: LaunchMode.inAppBrowserView,
|
mode: LaunchMode.inAppBrowserView,
|
||||||
@@ -685,6 +758,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
if (!opened) {
|
if (!opened) {
|
||||||
throw Exception('Could not open the browser for login.');
|
throw Exception('Could not open the browser for login.');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_loginFlowStatus = LoginFlowStatus.awaitingBrowser;
|
_loginFlowStatus = LoginFlowStatus.awaitingBrowser;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -735,6 +809,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only relevant to the first/only-login Chrome Custom Tab path - the
|
||||||
|
// add-account LoginWebViewView is a normal pushed screen the user can't
|
||||||
|
// lose track of, so it has no equivalent "reopen" need.
|
||||||
Future<void> reopenLoginBrowser() async {
|
Future<void> reopenLoginBrowser() async {
|
||||||
if (_pendingLoginUrl != null) {
|
if (_pendingLoginUrl != null) {
|
||||||
await launchUrl(_pendingLoginUrl!, mode: LaunchMode.inAppBrowserView);
|
await launchUrl(_pendingLoginUrl!, mode: LaunchMode.inAppBrowserView);
|
||||||
@@ -865,15 +942,21 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Switches to an already-saved account. No-op if it's already active or
|
/// 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 {
|
Future<void> switchAccount(String accountId) async {
|
||||||
if (accountId == _activeAccountId) return;
|
if (accountId == _activeAccountId) return;
|
||||||
if (!_accounts.any((a) => a.id == accountId)) return;
|
if (!_accounts.any((a) => a.id == accountId)) return;
|
||||||
|
if (!await _passGate(_lockAccountSwitching, 'Unlock to switch accounts')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await _activateAccount(accountId);
|
await _activateAccount(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
SavedAccount? _cycleAccount(int direction) {
|
Future<SavedAccount?> _cycleAccount(int direction) async {
|
||||||
if (_accounts.length < 2) return null;
|
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 currentIndex = _accounts.indexWhere((a) => a.id == _activeAccountId);
|
||||||
final targetIndex = currentIndex == -1
|
final targetIndex = currentIndex == -1
|
||||||
? 0
|
? 0
|
||||||
@@ -886,17 +969,18 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
/// Cycles to the next/previous saved account (by the order they were
|
/// Cycles to the next/previous saved account (by the order they were
|
||||||
/// added) - used by the avatar's swipe-up/down quick-switch gesture.
|
/// added) - used by the avatar's swipe-up/down quick-switch gesture.
|
||||||
/// Returns the account it's switching to (synchronously, before the
|
/// Returns the account it's switching to once any login-lock gate has
|
||||||
/// switch's own network verification completes) so the caller can show
|
/// passed (the switch's own network verification is still fire-and-forget
|
||||||
/// immediate feedback, or null if there's fewer than 2 saved accounts.
|
/// after that, same as before), or null if there's fewer than 2 saved
|
||||||
SavedAccount? cycleToNextAccount() => _cycleAccount(1);
|
/// accounts or the gate was not passed.
|
||||||
SavedAccount? cycleToPreviousAccount() => _cycleAccount(-1);
|
Future<SavedAccount?> cycleToNextAccount() => _cycleAccount(1);
|
||||||
|
Future<SavedAccount?> cycleToPreviousAccount() => _cycleAccount(-1);
|
||||||
|
|
||||||
/// Removes a saved account entirely: its stored password, its
|
/// Removes a saved account entirely: its stored password, its
|
||||||
/// namespaced prefs, and its entry in the saved-accounts list. If it was
|
/// 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
|
/// the active account, falls back to another saved account, or - if none
|
||||||
/// remain - performs a full logout (the only path that sets
|
/// remain - deactivates the session (the only path here that sets
|
||||||
/// [isLoggedIn] false).
|
/// [isLoggedIn] false; unlike [logout], there's nothing left to keep).
|
||||||
Future<void> removeAccount(String accountId) async {
|
Future<void> removeAccount(String accountId) async {
|
||||||
final index = _accounts.indexWhere((a) => a.id == accountId);
|
final index = _accounts.indexWhere((a) => a.id == accountId);
|
||||||
if (index == -1) return;
|
if (index == -1) return;
|
||||||
@@ -916,9 +1000,21 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_accounts.isEmpty) {
|
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++;
|
_sessionGeneration++;
|
||||||
_activeAccountId = null;
|
|
||||||
await _accountStore.saveActiveAccountId(prefs, null);
|
|
||||||
_cacheRefreshTimer?.cancel();
|
_cacheRefreshTimer?.cancel();
|
||||||
_directoryCache.clear();
|
_directoryCache.clear();
|
||||||
_clearActiveContent();
|
_clearActiveContent();
|
||||||
@@ -926,19 +1022,24 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_serverUrl = '';
|
_serverUrl = '';
|
||||||
_username = '';
|
_username = '';
|
||||||
_password = '';
|
_password = '';
|
||||||
|
_activeAccountId = null;
|
||||||
|
final prefs = await _prefsFuture;
|
||||||
|
await _accountStore.saveActiveAccountId(prefs, null);
|
||||||
_applyAccountPrefs(prefs, null);
|
_applyAccountPrefs(prefs, null);
|
||||||
notifyListeners();
|
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 {
|
Future<void> refreshData() async {
|
||||||
if (!_isLoggedIn || _service == null) {
|
if (!_isLoggedIn || _service == null) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1145,7 +1246,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;
|
_showHiddenFiles = !_showHiddenFiles;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistAccountPref(
|
_persistAccountPref(
|
||||||
@@ -1154,7 +1272,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;
|
_showHiddenPhotos = !_showHiddenPhotos;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistAccountPref(
|
_persistAccountPref(
|
||||||
@@ -1643,6 +1766,78 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_prefsFuture.then((p) => p.setBool(_prefTapTabToScrollTop, value));
|
_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) {
|
void setThemeMode(ThemeMode mode) {
|
||||||
_themeMode = mode;
|
_themeMode = mode;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class AccountStore {
|
|||||||
'ui_sort_field',
|
'ui_sort_field',
|
||||||
'ui_sort_ascending',
|
'ui_sort_ascending',
|
||||||
'ui_folder_sort',
|
'ui_folder_sort',
|
||||||
|
'ui_files_type_filter',
|
||||||
'ui_cache_policy',
|
'ui_cache_policy',
|
||||||
'ui_cache_interval_minutes',
|
'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
|
/// 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
|
/// path or a full absolute URL) down to just its path, trailing slash
|
||||||
/// stripped, so hrefs from either form can be compared directly.
|
/// 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 pct = total > 0 ? (used / total).clamp(0.0, 1.0) : 0.0;
|
||||||
final display = ocsData['displayname'] ?? username;
|
final display = ocsData['displayname'] ?? username;
|
||||||
final email = ocsData['email'] ?? '$username@$serverUrl';
|
final email = ocsData['email'] ?? '$username@$serverUrl';
|
||||||
|
final groups = ((ocsData['groups'] as List?) ?? [])
|
||||||
|
.map((g) => g.toString())
|
||||||
|
.toList();
|
||||||
|
|
||||||
return NextcloudUserQuota(
|
return NextcloudUserQuota(
|
||||||
usedBytes: used,
|
usedBytes: used,
|
||||||
@@ -880,6 +892,7 @@ class NextcloudService {
|
|||||||
userName: display,
|
userName: display,
|
||||||
email: email,
|
email: email,
|
||||||
serverVersion: 'Nextcloud Server',
|
serverVersion: 'Nextcloud Server',
|
||||||
|
groups: groups,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
@@ -914,9 +927,7 @@ class NextcloudService {
|
|||||||
id: (a['activity_id'] ?? '').toString(),
|
id: (a['activity_id'] ?? '').toString(),
|
||||||
title: a['subject'] ?? 'Server Activity',
|
title: a['subject'] ?? 'Server Activity',
|
||||||
subject: a['message'] ?? (a['subject'] ?? ''),
|
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
timestamp: _parseActivityDateTime(a['datetime']),
|
||||||
(a['timestamp'] as int? ?? 0) * 1000,
|
|
||||||
),
|
|
||||||
icon: Icons.cloud_outlined,
|
icon: Icons.cloud_outlined,
|
||||||
author: a['user'] ?? username,
|
author: a['user'] ?? username,
|
||||||
);
|
);
|
||||||
@@ -1500,9 +1511,7 @@ class NextcloudService {
|
|||||||
id: (a['activity_id'] ?? '').toString(),
|
id: (a['activity_id'] ?? '').toString(),
|
||||||
title: a['subject'] ?? 'Activity',
|
title: a['subject'] ?? 'Activity',
|
||||||
subject: a['message'] ?? (a['subject'] ?? ''),
|
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
timestamp: _parseActivityDateTime(a['datetime']),
|
||||||
(a['timestamp'] as int? ?? 0) * 1000,
|
|
||||||
),
|
|
||||||
icon: Icons.cloud_outlined,
|
icon: Icons.cloud_outlined,
|
||||||
author: a['user'] ?? username,
|
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 '../theme/app_theme.dart';
|
||||||
import '../widgets/frosted_glass_container.dart';
|
import '../widgets/frosted_glass_container.dart';
|
||||||
import '../widgets/seek_bar_painter.dart';
|
import '../widgets/seek_bar_painter.dart';
|
||||||
|
import '../widgets/synced_header_scaffold.dart' show formatQuota;
|
||||||
import 'login_view.dart';
|
import 'login_view.dart';
|
||||||
|
|
||||||
class AccountView extends StatelessWidget {
|
class AccountView extends StatelessWidget {
|
||||||
const AccountView({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -80,16 +75,33 @@ class AccountView extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
],
|
||||||
onPressed: () =>
|
),
|
||||||
_showLogoutConfirmation(context, provider),
|
const SizedBox(height: 16),
|
||||||
icon: const Icon(Icons.logout_rounded),
|
const Divider(height: 1),
|
||||||
tooltip: 'Log Out',
|
const SizedBox(height: 16),
|
||||||
color: colorScheme.error,
|
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 Divider(height: 1),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
@@ -102,11 +114,7 @@ class AccountView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
quota != null
|
quota != null ? formatQuota(quota) : 'Loading…',
|
||||||
? (quota.totalBytes > 0
|
|
||||||
? '${_formatBytes(quota.usedBytes)} of ${_formatBytes(quota.totalBytes)}'
|
|
||||||
: '${_formatBytes(quota.usedBytes)} (Unlimited)')
|
|
||||||
: 'Live Server Storage',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -129,52 +137,111 @@ class AccountView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Text(
|
||||||
quota != null
|
quota != null
|
||||||
? (quota.totalBytes > 0
|
? (quota.totalBytes > 0
|
||||||
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
|
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
|
||||||
: 'Unlimited Storage Plan')
|
: 'Unlimited storage')
|
||||||
: 'WebDAV connection active',
|
: '',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (quota != null && quota.groups.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Text(
|
||||||
|
'Groups',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
|
for (final group in quota.groups)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: 14,
|
||||||
vertical: 4,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: provider.isLoggedIn
|
color: colorScheme.surfaceContainerHigh,
|
||||||
? Colors.green.withValues(alpha: 0.2)
|
borderRadius: BorderRadius.circular(20),
|
||||||
: Colors.red.withValues(alpha: 0.2),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
provider.isLoggedIn ? 'ONLINE' : 'OFFLINE',
|
group,
|
||||||
style: TextStyle(
|
style: theme.textTheme.bodyMedium,
|
||||||
fontSize: 11,
|
),
|
||||||
fontWeight: FontWeight.bold,
|
),
|
||||||
color: provider.isLoggedIn
|
],
|
||||||
? Colors.green.shade800
|
),
|
||||||
: Colors.red.shade800,
|
],
|
||||||
|
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),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: FilledButton(
|
||||||
'Connected to ${provider.serverUrl}',
|
onPressed: () => _handleLogout(context, provider),
|
||||||
maxLines: 1,
|
style: FilledButton.styleFrom(
|
||||||
overflow: TextOverflow.ellipsis,
|
backgroundColor: colorScheme.errorContainer,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
foregroundColor: colorScheme.onErrorContainer,
|
||||||
color: colorScheme.onSurfaceVariant,
|
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 _AccountsCard(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Server Credentials Section
|
// Security Section
|
||||||
Text(
|
Text(
|
||||||
'Server Connection Info',
|
'Security',
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Card(
|
const _SecurityCard(),
|
||||||
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 SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Material You Design Settings
|
// Material You Design Settings
|
||||||
@@ -452,34 +487,62 @@ class AccountView extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showLogoutConfirmation(BuildContext context, ServerProvider provider) {
|
/// Logout keeps the account saved (see [ServerProvider.logout]) so it's
|
||||||
showDialog(
|
/// 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,
|
context: context,
|
||||||
builder: (context) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Log Out'),
|
title: const Text('Remove Account'),
|
||||||
content: Text(
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
child: const Text('Remove'),
|
||||||
provider.logout();
|
|
||||||
},
|
|
||||||
child: const Text('Log Out'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
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 (confirmed == true) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
await provider.removeAccount(account.id);
|
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 {
|
class _BottomBarAppearanceCard extends StatefulWidget {
|
||||||
const _BottomBarAppearanceCard();
|
const _BottomBarAppearanceCard();
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../models/selection_action.dart';
|
|||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
import '../widgets/breadcrumbs.dart';
|
import '../widgets/breadcrumbs.dart';
|
||||||
import '../widgets/details/details_sheet.dart';
|
import '../widgets/details/details_sheet.dart';
|
||||||
|
import '../widgets/item_icon.dart';
|
||||||
import '../widgets/more_tabs_button.dart';
|
import '../widgets/more_tabs_button.dart';
|
||||||
import '../widgets/profile_avatar_button.dart';
|
import '../widgets/profile_avatar_button.dart';
|
||||||
import '../widgets/segmented_icon_toggle.dart';
|
import '../widgets/segmented_icon_toggle.dart';
|
||||||
@@ -33,141 +34,116 @@ class FilesView extends StatefulWidget {
|
|||||||
State<FilesView> createState() => _FilesViewState();
|
State<FilesView> createState() => _FilesViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _getItemIcon(NextcloudItemType type) {
|
/// Slides+fades its child in on first build. Give it a [Key] that changes
|
||||||
switch (type) {
|
/// whenever the folder changes (folder path + item id) so Flutter discards
|
||||||
case NextcloudItemType.folder:
|
/// and remounts the Element instead of just updating it in place - that's
|
||||||
return Icons.folder_rounded;
|
/// what makes the whole visible list replay the animation on every folder
|
||||||
case NextcloudItemType.image:
|
/// navigation, not just newly-appearing rows.
|
||||||
return Icons.image_rounded;
|
class _FolderEnterAnimation extends StatefulWidget {
|
||||||
case NextcloudItemType.video:
|
final Widget child;
|
||||||
return Icons.movie_rounded;
|
final bool fromRight;
|
||||||
case NextcloudItemType.audio:
|
final int index;
|
||||||
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) {
|
const _FolderEnterAnimation({
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
super.key,
|
||||||
switch (type) {
|
required this.child,
|
||||||
case NextcloudItemType.folder:
|
required this.fromRight,
|
||||||
return colorScheme.primary;
|
required this.index,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<_FolderEnterAnimation> createState() => _FolderEnterAnimationState();
|
||||||
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(
|
class _FolderEnterAnimationState extends State<_FolderEnterAnimation>
|
||||||
width: size,
|
with SingleTickerProviderStateMixin {
|
||||||
height: size,
|
late final AnimationController _controller;
|
||||||
clipBehavior: Clip.antiAlias,
|
late final Animation<double> _opacity;
|
||||||
decoration: BoxDecoration(
|
late final Animation<Offset> _offset;
|
||||||
color: iconColor.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
@override
|
||||||
),
|
void initState() {
|
||||||
child: isMedia && item.previewUrl != null
|
super.initState();
|
||||||
? Stack(
|
_controller = AnimationController(
|
||||||
fit: StackFit.expand,
|
vsync: this,
|
||||||
children: [
|
duration: const Duration(milliseconds: 260),
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
_opacity = CurvedAnimation(parent: _controller, curve: Curves.easeOut);
|
||||||
errorBuilder: (context, error, stack) => Center(
|
_offset = Tween<Offset>(
|
||||||
child: Icon(
|
begin: Offset(widget.fromRight ? 0.12 : -0.12, 0),
|
||||||
_getItemIcon(item.type),
|
end: Offset.zero,
|
||||||
color: iconColor,
|
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
|
||||||
size: iconSize,
|
// 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));
|
||||||
if (item.type == NextcloudItemType.video)
|
Future.delayed(delay, () {
|
||||||
const Center(
|
if (mounted) _controller.forward();
|
||||||
child: Icon(
|
});
|
||||||
Icons.play_circle_fill_rounded,
|
}
|
||||||
color: Colors.white,
|
|
||||||
size: 18,
|
@override
|
||||||
),
|
void dispose() {
|
||||||
),
|
_controller.dispose();
|
||||||
],
|
super.dispose();
|
||||||
)
|
}
|
||||||
: Center(
|
|
||||||
child: Icon(
|
@override
|
||||||
_getItemIcon(item.type),
|
Widget build(BuildContext context) {
|
||||||
color: iconColor,
|
return FadeTransition(
|
||||||
size: iconSize,
|
opacity: _opacity,
|
||||||
),
|
child: SlideTransition(position: _offset, child: widget.child),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FilesViewState extends State<FilesView> {
|
class _FilesViewState extends State<FilesView> {
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
|
int _lastPathDepth = 1;
|
||||||
|
final ScrollController _controlsScrollController = ScrollController();
|
||||||
|
|
||||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
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) {
|
void _toggleSelection(NextcloudItem item) {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -331,6 +307,10 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final provider = context.watch<ServerProvider>();
|
final provider = context.watch<ServerProvider>();
|
||||||
|
|
||||||
|
final pathDepth = provider.pathStack.length;
|
||||||
|
final navigatingDeeper = pathDepth > _lastPathDepth;
|
||||||
|
_lastPathDepth = pathDepth;
|
||||||
|
|
||||||
final hasBreadcrumbs = provider.pathStack.length > 1;
|
final hasBreadcrumbs = provider.pathStack.length > 1;
|
||||||
final selectedItems = provider.items
|
final selectedItems = provider.items
|
||||||
.where((i) => _selectedIds.contains(i.id))
|
.where((i) => _selectedIds.contains(i.id))
|
||||||
@@ -346,6 +326,18 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 44,
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -361,7 +353,11 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
: 'Descending',
|
: 'Descending',
|
||||||
onPressed: provider.toggleFilesSortOrder,
|
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(
|
child: SortMenuButton(
|
||||||
field: provider.filesSortField,
|
field: provider.filesSortField,
|
||||||
onChanged: provider.setFilesSortField,
|
onChanged: provider.setFilesSortField,
|
||||||
@@ -381,7 +377,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
? Icons.visibility_rounded
|
? Icons.visibility_rounded
|
||||||
: Icons.visibility_off_rounded,
|
: Icons.visibility_off_rounded,
|
||||||
isSelected: provider.showHiddenFiles,
|
isSelected: provider.showHiddenFiles,
|
||||||
onTap: provider.toggleShowHiddenFiles,
|
onTap: () => provider.toggleShowHiddenFiles(),
|
||||||
tooltip: 'Show hidden files',
|
tooltip: 'Show hidden files',
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -390,7 +386,8 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
ToggleIconButton(
|
ToggleIconButton(
|
||||||
icon: Symbols.circles_rounded,
|
icon: Symbols.circles_rounded,
|
||||||
isSelected: provider.storageScope == StorageScope.cloud,
|
isSelected: provider.storageScope == StorageScope.cloud,
|
||||||
onTap: () => provider.setStorageScope(StorageScope.cloud),
|
onTap: () =>
|
||||||
|
provider.setStorageScope(StorageScope.cloud),
|
||||||
tooltip: 'Cloud storage',
|
tooltip: 'Cloud storage',
|
||||||
),
|
),
|
||||||
ToggleIconButton(
|
ToggleIconButton(
|
||||||
@@ -404,6 +401,39 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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(
|
SegmentedIconGroup(
|
||||||
children: [
|
children: [
|
||||||
ToggleIconButton(
|
ToggleIconButton(
|
||||||
@@ -423,6 +453,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (hasBreadcrumbs) ...[
|
if (hasBreadcrumbs) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -580,7 +611,12 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
final item = provider.items[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),
|
}, childCount: provider.items.length),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -591,7 +627,12 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
final item = provider.items[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),
|
}, childCount: provider.items.length),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -771,7 +812,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
isSelected: isSelected,
|
isSelected: isSelected,
|
||||||
size: 44,
|
size: 44,
|
||||||
checkmarkSize: 24,
|
checkmarkSize: 24,
|
||||||
child: _ItemThumbnail(
|
child: ItemThumbnail(
|
||||||
item: item,
|
item: item,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
size: 44,
|
size: 44,
|
||||||
@@ -966,7 +1007,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
Widget _buildPlainGridContent(BuildContext context, NextcloudItem item) {
|
Widget _buildPlainGridContent(BuildContext context, NextcloudItem item) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final iconColor = _getIconColor(context, item.type);
|
final iconColor = getIconColor(context, item.type);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
@@ -981,7 +1022,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
color: iconColor.withValues(alpha: 0.12),
|
color: iconColor.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(_getItemIcon(item.type), color: iconColor, size: 24),
|
child: Icon(getItemIcon(item.type), color: iconColor, size: 24),
|
||||||
),
|
),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/saved_account.dart';
|
||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
|
import 'login_webview_view.dart';
|
||||||
|
|
||||||
class LoginView extends StatefulWidget {
|
class LoginView extends StatefulWidget {
|
||||||
/// True when this is pushed from Settings ("Add account") on top of an
|
/// 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();
|
final _urlController = TextEditingController();
|
||||||
String? _originalActiveAccountId;
|
String? _originalActiveAccountId;
|
||||||
bool _popped = false;
|
bool _popped = false;
|
||||||
|
bool _webViewPushed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -66,6 +69,27 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The add-account flow shows its login page in LoginWebViewView (a
|
||||||
|
// real pushed screen, not a Custom Tab - see ServerProvider.
|
||||||
|
// startLoginFlow's doc comment for why) rather than this view's own
|
||||||
|
// _WaitingForBrowser, so 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 (widget.isAddingAccount &&
|
||||||
|
!_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(
|
final body = Scaffold(
|
||||||
appBar: widget.isAddingAccount
|
appBar: widget.isAddingAccount
|
||||||
? AppBar(title: const Text('Add Account'))
|
? AppBar(title: const Text('Add Account'))
|
||||||
@@ -77,15 +101,52 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 440),
|
constraints: const BoxConstraints(maxWidth: 440),
|
||||||
child: isAwaitingBrowser
|
child: isAwaitingBrowser && !widget.isAddingAccount
|
||||||
? _WaitingForBrowser(
|
? _WaitingForBrowser(
|
||||||
onCancel: () => provider.cancelLoginFlow(),
|
onCancel: () => provider.cancelLoginFlow(),
|
||||||
onReopenBrowser: provider.reopenLoginBrowser,
|
onReopenBrowser: provider.reopenLoginBrowser,
|
||||||
)
|
)
|
||||||
: _ServerForm(
|
: 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,
|
formKey: _formKey,
|
||||||
urlController: _urlController,
|
urlController: _urlController,
|
||||||
isLoading: isInitiating || provider.isLoading,
|
isLoading:
|
||||||
|
isInitiating ||
|
||||||
|
provider.isLoading ||
|
||||||
|
(widget.isAddingAccount && isAwaitingBrowser),
|
||||||
errorMessage:
|
errorMessage:
|
||||||
provider.loginFlowStatus == LoginFlowStatus.error
|
provider.loginFlowStatus == LoginFlowStatus.error
|
||||||
? provider.errorMessage
|
? provider.errorMessage
|
||||||
@@ -94,6 +155,8 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
theme: theme,
|
theme: theme,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -145,10 +208,18 @@ class _ServerForm extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Center(
|
Center(
|
||||||
child: ClipRRect(
|
// Same monochrome-tinted treatment as the splash/lock screens
|
||||||
borderRadius: BorderRadius.circular(20),
|
// (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(
|
child: Image.asset(
|
||||||
'assets/icon/app_icon.png',
|
'assets/icon/app_icon_monochrome.png',
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
),
|
),
|
||||||
@@ -265,6 +336,85 @@ class _ServerForm extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 _SavedAccountsSection({required this.accounts, required this.onSelect});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
'Continue as',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _WaitingForBrowser extends StatelessWidget {
|
class _WaitingForBrowser extends StatelessWidget {
|
||||||
final VoidCallback onCancel;
|
final VoidCallback onCancel;
|
||||||
final VoidCallback onReopenBrowser;
|
final VoidCallback onReopenBrowser;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
/// The "add another account" Login Flow v2 page, rendered in Flutter's own
|
||||||
|
/// WebView rather than url_launcher's `LaunchMode.inAppWebView` - that mode
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// See ServerProvider.startLoginFlow's doc comment for why the add-account
|
||||||
|
/// flow uses an embedded WebView (isolated cookies) instead of a Chrome
|
||||||
|
/// Custom Tab at all.
|
||||||
|
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('Add Account'),
|
||||||
|
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_rounded
|
||||||
: Icons.visibility_off_rounded,
|
: Icons.visibility_off_rounded,
|
||||||
isSelected: provider.showHiddenPhotos,
|
isSelected: provider.showHiddenPhotos,
|
||||||
onTap: provider.toggleShowHiddenPhotos,
|
onTap: () => provider.toggleShowHiddenPhotos(),
|
||||||
tooltip: 'Show hidden files',
|
tooltip: 'Show hidden files',
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import 'package:flutter/material.dart';
|
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:provider/provider.dart';
|
||||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
|
import '../models/nextcloud_item.dart';
|
||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
|
import '../services/share_intent_service.dart';
|
||||||
|
import '../services/upload_service.dart';
|
||||||
import '../widgets/breadcrumbs.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
|
/// 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
|
/// "Share to..." sheet). Lets the user browse to a destination folder,
|
||||||
/// uploads every shared file into it via the same WebDAV upload path as the
|
/// mirroring the Files tab's own controls/filters/listing so this feels
|
||||||
/// Files tab's own "Upload File" action.
|
/// 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 {
|
class ShareUploadView extends StatefulWidget {
|
||||||
final List<SharedMediaFile> files;
|
final List<SharedFileRef> files;
|
||||||
|
|
||||||
const ShareUploadView({super.key, required this.files});
|
const ShareUploadView({super.key, required this.files});
|
||||||
|
|
||||||
@@ -20,10 +32,6 @@ class ShareUploadView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ShareUploadViewState extends State<ShareUploadView> {
|
class _ShareUploadViewState extends State<ShareUploadView> {
|
||||||
bool _uploading = false;
|
|
||||||
int _currentFileIndex = 0;
|
|
||||||
double? _currentFileProgress;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -35,46 +43,283 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _uploadHere(ServerProvider provider) async {
|
Future<void> _uploadHere(ServerProvider provider) async {
|
||||||
setState(() => _uploading = true);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
var anyFailed = false;
|
try {
|
||||||
for (var i = 0; i < widget.files.length; i++) {
|
await UploadService.startUpload(provider, widget.files);
|
||||||
final file = widget.files[i];
|
} catch (e) {
|
||||||
setState(() {
|
messenger.showSnackBar(
|
||||||
_currentFileIndex = i;
|
SnackBar(
|
||||||
_currentFileProgress = 0;
|
content: Text('Could not start upload: $e'),
|
||||||
});
|
behavior: SnackBarBehavior.floating,
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if (!success) anyFailed = true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
provider.requestTab(AppTab.files);
|
provider.requestTab(AppTab.files);
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
navigator.popUntil((route) => route.isFirst);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
anyFailed
|
widget.files.length == 1
|
||||||
? 'Some files failed to upload'
|
? 'Uploading ${widget.files.first.name} - see the notification for progress'
|
||||||
: widget.files.length == 1
|
: 'Uploading ${widget.files.length} files - see the notification for progress',
|
||||||
? 'Uploaded ${p.basename(widget.files.first.path)}'
|
|
||||||
: 'Uploaded ${widget.files.length} files',
|
|
||||||
),
|
),
|
||||||
behavior: SnackBarBehavior.floating,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -85,46 +330,25 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
|||||||
final currentLabel = currentPath == '/'
|
final currentLabel = currentPath == '/'
|
||||||
? 'Home'
|
? 'Home'
|
||||||
: currentPath.split('/').where((s) => s.isNotEmpty).last;
|
: currentPath.split('/').where((s) => s.isNotEmpty).last;
|
||||||
final folders = provider.items.where((i) => i.isFolder).toList();
|
final items = provider.items;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
widget.files.length == 1
|
widget.files.length == 1
|
||||||
? 'Upload ${p.basename(widget.files.first.path)}'
|
? 'Upload ${widget.files.first.name}'
|
||||||
: 'Upload ${widget.files.length} files',
|
: 'Upload ${widget.files.length} files',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: _uploading
|
body: Column(
|
||||||
? Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
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(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: _buildControlsRow(provider),
|
||||||
|
),
|
||||||
|
if (hasBreadcrumbs)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||||
child: Breadcrumbs(
|
child: Breadcrumbs(
|
||||||
pathStack: provider.pathStack,
|
pathStack: provider.pathStack,
|
||||||
onTap: (index) => provider.navigateToPathIndex(index),
|
onTap: (index) => provider.navigateToPathIndex(index),
|
||||||
@@ -133,37 +357,43 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: provider.isLoading
|
child: provider.isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: folders.isEmpty
|
: items.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No subfolders here',
|
'Folder is empty',
|
||||||
style: TextStyle(
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: provider.isGridView
|
||||||
itemCount: folders.length,
|
? GridView.builder(
|
||||||
itemBuilder: (context, index) {
|
padding: const EdgeInsets.symmetric(
|
||||||
final folder = folders[index];
|
horizontal: 20,
|
||||||
return ListTile(
|
vertical: 8,
|
||||||
leading: Icon(
|
|
||||||
Icons.folder_rounded,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
),
|
||||||
title: Text(folder.name),
|
gridDelegate:
|
||||||
trailing: const Icon(Icons.chevron_right_rounded),
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
onTap: () =>
|
crossAxisCount: 2,
|
||||||
provider.navigateToFolder(folder.path),
|
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
|
bottomNavigationBar: SafeArea(
|
||||||
? null
|
|
||||||
: SafeArea(
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: FilledButton.icon(
|
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 {
|
class ProfileAvatarButton extends StatelessWidget {
|
||||||
const ProfileAvatarButton({super.key});
|
const ProfileAvatarButton({super.key});
|
||||||
|
|
||||||
void _handleVerticalSwipe(BuildContext context, DragEndDetails details) {
|
Future<void> _handleVerticalSwipe(
|
||||||
|
BuildContext context,
|
||||||
|
DragEndDetails details,
|
||||||
|
) async {
|
||||||
final velocity = details.primaryVelocity ?? 0;
|
final velocity = details.primaryVelocity ?? 0;
|
||||||
if (velocity.abs() < 250) return;
|
if (velocity.abs() < 250) return;
|
||||||
|
|
||||||
final provider = context.read<ServerProvider>();
|
final provider = context.read<ServerProvider>();
|
||||||
final target = velocity < 0
|
final target = velocity < 0
|
||||||
? provider.cycleToNextAccount()
|
? await provider.cycleToNextAccount()
|
||||||
: provider.cycleToPreviousAccount();
|
: await provider.cycleToPreviousAccount();
|
||||||
if (target == null) return;
|
if (target == null || !context.mounted) return;
|
||||||
|
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -278,6 +278,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
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:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -456,6 +464,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
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:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -720,14 +768,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1"
|
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:
|
record_use:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1021,6 +1061,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -53,8 +53,9 @@ dependencies:
|
|||||||
share_plus: ^13.3.0
|
share_plus: ^13.3.0
|
||||||
material_symbols_icons: ^4.2960.0
|
material_symbols_icons: ^4.2960.0
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
receive_sharing_intent: ^1.8.1
|
|
||||||
marquee: ^2.3.0
|
marquee: ^2.3.0
|
||||||
|
local_auth: ^2.3.0
|
||||||
|
webview_flutter: ^4.14.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
||||||
#include <file_saver/file_saver_plugin.h>
|
#include <file_saver/file_saver_plugin.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_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 <pdfx/pdfx_plugin.h>
|
||||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
@@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FileSaverPlugin"));
|
registry->GetRegistrarForPlugin("FileSaverPlugin"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
LocalAuthPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||||
PdfxPluginRegisterWithRegistrar(
|
PdfxPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PdfxPlugin"));
|
registry->GetRegistrarForPlugin("PdfxPlugin"));
|
||||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
dynamic_color
|
dynamic_color
|
||||||
file_saver
|
file_saver
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
local_auth_windows
|
||||||
pdfx
|
pdfx
|
||||||
share_plus
|
share_plus
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
|
|||||||