7 Commits
Author SHA1 Message Date
ayushyaandClaude Sonnet 5 be38f24f32 Update docs for share/upload pipeline rework and login WebView fix
Build APK / build (push) Successful in 5m10s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:33:58 -04:00
ayushyaandClaude Sonnet 5 bcc18a7060 Replace receive_sharing_intent; add background upload service; fix add-account login page
Sharing a large file to Noo used to black-screen and silently kick back
to the home screen: receive_sharing_intent copies the entire shared file
into the cache dir synchronously on the main thread before Flutter even
renders, which Android's watchdog eventually kills. Replaced with
hand-rolled handling in MainActivity.kt that only ever reads cheap Uri
metadata up front (ShareIntentService), so the destination picker always
appears instantly regardless of file size.

The destination picker (ShareUploadView) now mirrors the Files tab's own
controls/filters/listing instead of a bare folder list, and the actual
prepare+upload is handed off to ShareUploadService.kt, a real Android
foreground service with a single cancellable progress notification for
the whole batch - this survives the app being closed right after the
user confirms a destination, the same guarantee a file-manager app's own
upload notification gives.

Also fixes the "add another account" login page: it used to open in
url_launcher's bare inAppWebView, which has no close button and can hide
the status bar. LoginWebViewView is a real screen this app owns instead
(package:webview_flutter), with a normal AppBar/close button/safe area,
and can now auto-close itself on successful login.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:33:55 -04:00
ayushyaandClaude Sonnet 5 453aff983a Extract item icon helpers; simplify controls-bar scroll hint to a nudge
getItemIcon/getIconColor/ItemThumbnail move out of files_view.dart into
widgets/item_icon.dart so other screens (the share-upload destination
picker, in a follow-up commit) can reuse the same file/folder icon
treatment instead of re-deriving it.

The Files controls bar's "this scrolls" hint is now a one-shot nudge
(scrolls right then back once on first appearance) instead of a
persistent chevron badge or edge fade - both prior attempts blended into
the row's sparse icon content too easily to actually register.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:33:43 -04:00
ayushyaandClaude Sonnet 5 f78ceb7e8b Add files/folders/both filter and folder-navigation slide animation
The Files controls bar now scrolls horizontally to fit a new segmented
toggle (files & folders / files only / folders only), persisted per
account like the other browsing prefs.

Navigating into or out of a folder now slides the listing in (from the
right when going deeper, from the left when going back), staggered
slightly per row. This is a per-item animation rather than a real page
transition, since folder navigation swaps ServerProvider's path stack in
place rather than pushing Navigator routes - true OS-level predictive
back would need that to change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:28:51 -04:00
ayushyaandClaude Sonnet 5 bb1688ce3a Update docs for app lock, multi-account, and local install guidance
Build APK / build (push) Successful in 5m17s
Documents the app-lock layer and multi-account storage/session model in
architecture.md/server.md, and adds a standards.md note that `flutter
install` wipes app data (it uninstalls before installing) — use `adb
install -r` for local test deploys instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:09:32 -04:00
ayushyaandClaude Sonnet 5 3f156cbc13 Update app icon assets
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:09:28 -04:00
ayushyaandClaude Sonnet 5 06bc25ae1e Add app lock, multi-account support, and redesigned account settings
App lock (local_auth) gates the app behind the OS's biometric/device
credential prompt via a new lock screen, layered on top of the existing
Nextcloud session rather than replacing it.

Multi-account support lets users save several Nextcloud logins and switch
between them (AccountStore, per-account browsing prefs, avatar swipe-to-
cycle); logout() now keeps an account's saved credentials/prefs intact so
it can be resumed from a "Continue as..." list, while removeAccount()
stays the only destructive path.

The account settings screen is redesigned around a single profile card
(avatar/name/email, server host, storage quota with groups) with
Refresh Cache/Logout/Remove actions, replacing the old separate "Server
Connection Info" section.

Also fixes activity-tab timestamps: the OCS Activity API reports time as
an ISO 8601 `datetime` string, not the numeric `timestamp` field the code
incorrectly assumed, which made every activity render as Jan 1 1970.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:09:22 -04:00
42 changed files with 2552 additions and 559 deletions
+26 -9
View File
@@ -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
+114 -16
View File
@@ -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
+26
View File
@@ -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
+13
View File
@@ -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`).
+4 -1
View File
@@ -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
+14 -1
View File
@@ -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()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 34 KiB

-15
View File
@@ -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)
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+20 -24
View File
@@ -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)));
}); });
} }
+3
View File
@@ -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'],
); );
} }
} }
+224 -29
View File
@@ -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();
+1
View File
@@ -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',
]; ];
+48
View File
@@ -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;
}
}
}
+15 -6
View File
@@ -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,
); );
+61
View File
@@ -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();
});
}
}
+42
View File
@@ -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,
});
}
}
+233 -89
View File
@@ -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();
+169 -128
View File
@@ -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,
+116
View File
@@ -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'),
),
),
],
),
),
),
);
}
}
+156 -6
View File
@@ -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;
+92
View File
@@ -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(),
],
),
),
);
}
}
+1 -1
View File
@@ -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),
+319 -89
View File
@@ -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(
+138
View File
@@ -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,
),
),
);
}
}
+7 -4
View File
@@ -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(
+80 -8
View File
@@ -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:
+2 -1
View File
@@ -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(
+1
View File
@@ -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