Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb1688ce3a | ||
|
|
3f156cbc13 | ||
|
|
06bc25ae1e |
@@ -10,12 +10,12 @@ lib/
|
|||||||
# SavedAccount, AppTab, ...)
|
# SavedAccount, AppTab, ...)
|
||||||
providers/ # ServerProvider — the single app-wide ChangeNotifier
|
providers/ # ServerProvider — the single app-wide ChangeNotifier
|
||||||
services/ # network/IO: NextcloudService, LoginFlowService,
|
services/ # network/IO: NextcloudService, LoginFlowService,
|
||||||
# AccountStore
|
# AccountStore, AppLockService
|
||||||
theme/ # AppTheme (Material 3 ThemeData)
|
theme/ # AppTheme (Material 3 ThemeData)
|
||||||
views/ # one screen each (FilesView, PhotosView, TrashView,
|
views/ # one screen each (FilesView, PhotosView, TrashView,
|
||||||
# SharesView, RecentView, ActivityView, SearchView,
|
# SharesView, RecentView, ActivityView, SearchView,
|
||||||
# AccountView, LoginView, FileViewerScreen,
|
# AccountView, LoginView, FileViewerScreen,
|
||||||
# ShareUploadView)
|
# ShareUploadView, LockScreenView)
|
||||||
widgets/ # reusable pieces shared across views
|
widgets/ # reusable pieces shared across views
|
||||||
details/ # the file-details bottom sheet and its tabs
|
details/ # the file-details bottom sheet and its tabs
|
||||||
```
|
```
|
||||||
@@ -54,9 +54,23 @@ There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/se
|
|||||||
- UI settings that persist across launches — split into **global** (theme
|
- UI settings that persist across launches — split into **global** (theme
|
||||||
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
|
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
|
||||||
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
|
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
|
||||||
actions) and **per-account** (grid/list view, favorites-only, show-hidden,
|
actions, login lock — see below) and **per-account** (grid/list view,
|
||||||
storage scope, Photos sort, Files' per-folder sort map, cache policy) — see
|
favorites-only, show-hidden, storage scope, Photos sort, Files'
|
||||||
`server.md` for exactly which is which and why
|
per-folder sort map, cache policy) — see `server.md` for exactly which is
|
||||||
|
which and why
|
||||||
|
- **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/
|
||||||
|
`lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via
|
||||||
|
`AppLockService` (a thin wrapper over `local_auth` — this app never
|
||||||
|
stores or hashes a PIN itself, it delegates entirely to whatever
|
||||||
|
credential the OS already has configured). `needsUnlock` is
|
||||||
|
`loginLockEnabled && !_isUnlocked`, where `_isUnlocked` is transient
|
||||||
|
(never persisted) and reset to `false` on every backgrounding
|
||||||
|
(`didChangeAppLifecycleState`, `AppLifecycleState.paused`) so the lock has
|
||||||
|
real value rather than only firing once per cold start. `switchAccount`/
|
||||||
|
`cycleToNextAccount`/`cycleToPreviousAccount` and *enabling* (not
|
||||||
|
disabling) `showHiddenFiles`/`showHiddenPhotos` each call the shared
|
||||||
|
`_passGate` helper, which no-ops unless both `loginLockEnabled` and the
|
||||||
|
relevant per-feature toggle are on.
|
||||||
|
|
||||||
New **global** state belongs on `ServerProvider` as a private field + getter
|
New **global** state belongs on `ServerProvider` as a private field + getter
|
||||||
+ a method that mutates it, calls `notifyListeners()`, and persists via
|
+ a method that mutates it, calls `notifyListeners()`, and persists via
|
||||||
@@ -70,7 +84,7 @@ split.
|
|||||||
|
|
||||||
There's no separate repository/data layer: views call `ServerProvider`
|
There's no separate repository/data layer: views call `ServerProvider`
|
||||||
methods directly, which call `NextcloudService`/`LoginFlowService`/
|
methods directly, which call `NextcloudService`/`LoginFlowService`/
|
||||||
`AccountStore`.
|
`AccountStore`/`AppLockService`.
|
||||||
|
|
||||||
## Navigation / screen flow
|
## Navigation / screen flow
|
||||||
|
|
||||||
@@ -85,6 +99,7 @@ state, no named routes:
|
|||||||
Flow v2) — `isLoggedIn` is only ever false here or after the last saved
|
Flow v2) — `isLoggedIn` is only ever false here or after the last saved
|
||||||
account is removed; switching between multiple saved accounts never
|
account is removed; switching between multiple saved accounts never
|
||||||
routes through this screen (see `server.md`)
|
routes through this screen (see `server.md`)
|
||||||
|
- else `provider.needsUnlock` → `LockScreenView` (login lock — see above)
|
||||||
- else `MainShellView`
|
- else `MainShellView`
|
||||||
|
|
||||||
`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files,
|
`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files,
|
||||||
|
|||||||
@@ -121,9 +121,53 @@ account is *currently* live (see `architecture.md`).
|
|||||||
its credentials and refetch everything. This is a full teardown-and-reload
|
its credentials and refetch everything. This is a full teardown-and-reload
|
||||||
every time — there is deliberately no simultaneous multi-account state or
|
every time — there is deliberately no simultaneous multi-account state or
|
||||||
background sync; only one account's content is ever live.
|
background sync; only one account's content is ever live.
|
||||||
- `logout()` is just `removeAccount(activeAccountId)` — with other accounts
|
- **`logout()` vs `removeAccount()`** are deliberately different actions,
|
||||||
saved it falls back to one of them instead of ending the session;
|
both funneling into a shared `_deactivateSession()` helper for the
|
||||||
`isLoggedIn` only ever becomes `false` when the *last* account is removed.
|
teardown/pointer-clearing part:
|
||||||
|
- `logout()` ends the active session but keeps the account itself fully
|
||||||
|
intact (password, prefs, its entry in `accounts` all untouched) —
|
||||||
|
always lands on `LoginView` even if other accounts are saved (it does
|
||||||
|
*not* fall back to one of them the way `removeAccount` does). This
|
||||||
|
exists so `LoginView` can offer a "Continue as ..." one-tap resume list
|
||||||
|
(`_SavedAccountsSection` in `login_view.dart`) with no Login Flow v2
|
||||||
|
needed - logging out must never be mistaken for forgetting an account.
|
||||||
|
- `removeAccount(id)` deletes everything for that account (secure-storage
|
||||||
|
password, namespaced prefs, its `accounts` entry) and, only if it was
|
||||||
|
the active one, falls back to another saved account or - if none
|
||||||
|
remain - calls the same `_deactivateSession()`. This is the only path
|
||||||
|
(besides `logout()`) that can set `isLoggedIn` false, and the only one
|
||||||
|
that's actually destructive/irreversible - UI call sites (`AccountView`)
|
||||||
|
gate it behind a confirmation dialog; `logout()` doesn't need one.
|
||||||
|
- Any UI code that calls either and might have ended the session should
|
||||||
|
check `!provider.isLoggedIn` afterward and `Navigator.popUntil((r) =>
|
||||||
|
r.isFirst)` if so — otherwise a screen pushed on top (Settings) is left
|
||||||
|
stranded over a root route that's silently swapped to `LoginView`
|
||||||
|
underneath it. Don't pop unconditionally — removing a *non-active*
|
||||||
|
account, or one that fell back to another, keeps the user logged in and
|
||||||
|
Settings should just stay open.
|
||||||
|
|
||||||
|
## App lock (login lock)
|
||||||
|
|
||||||
|
An orthogonal, app-wide security layer on top of the Nextcloud
|
||||||
|
login/session above — not account credentials, just a gate on *using* the
|
||||||
|
app. [`AppLockService`](../../lib/services/app_lock_service.dart) wraps
|
||||||
|
`local_auth`; this app never implements its own PIN entry/storage/hashing —
|
||||||
|
`authenticate()` always delegates to whatever the OS already has configured
|
||||||
|
(biometric, or device PIN/pattern/password as fallback, via
|
||||||
|
`biometricOnly: false`). Never build a custom in-app PIN screen for this —
|
||||||
|
extend `AppLockService`/the `ServerProvider` gates described in
|
||||||
|
`architecture.md` instead.
|
||||||
|
|
||||||
|
**Android native requirements** (both already done, keep them if you touch
|
||||||
|
these files): `MainActivity.kt` must extend `FlutterFragmentActivity`, not
|
||||||
|
the default `FlutterActivity` — `local_auth`'s Android implementation hosts
|
||||||
|
its prompt via a Fragment and silently fails to build/crashes without it.
|
||||||
|
`AndroidManifest.xml` needs `<uses-permission
|
||||||
|
android:name="android.permission.USE_BIOMETRIC"/>` (also declared by the
|
||||||
|
plugin's own manifest via merge, but kept explicit here too).
|
||||||
|
`android/app/build.gradle.kts` floors `minSdk` at 24 (`local_auth_android`'s
|
||||||
|
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
|
||||||
|
Flutter's own default to already be high enough.
|
||||||
|
|
||||||
`isRestoringSession` still gates the splash screen until the above resolves
|
`isRestoringSession` still gates the splash screen until the above resolves
|
||||||
— see `standards.md` for why widget tests must mock both storage channels
|
— see `standards.md` for why widget tests must mock both storage channels
|
||||||
|
|||||||
@@ -66,6 +66,32 @@ class/method already makes obvious.
|
|||||||
of failing fast. See `test/widget_test.dart` for the reference setup.
|
of failing fast. See `test/widget_test.dart` for the reference setup.
|
||||||
- Run with `flutter test`.
|
- Run with `flutter test`.
|
||||||
|
|
||||||
|
## Local install/deploy
|
||||||
|
|
||||||
|
Never use `flutter install` to push a build to a test device — it always
|
||||||
|
does a full **uninstall-then-install** (prints "Uninstalling old
|
||||||
|
version..."), and Android deletes all app data (SharedPreferences, secure
|
||||||
|
storage — every saved account/preference) on uninstall. This wipes the app
|
||||||
|
clean on every single deploy, which looks like an account/settings-loss bug
|
||||||
|
but is actually just the install method.
|
||||||
|
|
||||||
|
Instead, build then install with `adb`'s replace flag, which updates the
|
||||||
|
APK in place and preserves app data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter build apk --release
|
||||||
|
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
This only preserves data if the new APK's signature matches what's already
|
||||||
|
on the device — a local build's release variant currently reuses the debug
|
||||||
|
signing config (`android/app/build.gradle.kts`), so consecutive local
|
||||||
|
builds share a key and `adb install -r` works cleanly. Installing a build
|
||||||
|
signed with a different key (e.g. a CI-signed release APK from the Gitea
|
||||||
|
release pipeline) over a differently-signed local build forces Android to
|
||||||
|
require a full uninstall regardless of the install method used — there's no
|
||||||
|
way around that from the tooling side.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
Networking is deliberately split: `package:http` for simple JSON/XML
|
Networking is deliberately split: `package:http` for simple JSON/XML
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ android {
|
|||||||
applicationId = "dev.ayushya.noo"
|
applicationId = "dev.ayushya.noo"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
// local_auth (app-lock/biometric) requires API 24+; Flutter's own
|
||||||
|
// default may be lower, so floor it here rather than relying on
|
||||||
|
// whatever flutter.minSdkVersion currently resolves to.
|
||||||
|
minSdk = maxOf(24, flutter.minSdkVersion)
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<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"/>
|
||||||
<application
|
<application
|
||||||
android:label="Noo"
|
android:label="Noo"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package dev.ayushya.noo
|
package dev.ayushya.noo
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
|
|
||||||
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.
|
||||||
|
class MainActivity : FlutterFragmentActivity()
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 33 KiB |
@@ -6,6 +6,7 @@ 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 '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())),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ class NextcloudUserQuota {
|
|||||||
final String userName;
|
final String userName;
|
||||||
final String email;
|
final String email;
|
||||||
final String serverVersion;
|
final String serverVersion;
|
||||||
|
final List<String> groups;
|
||||||
|
|
||||||
const NextcloudUserQuota({
|
const NextcloudUserQuota({
|
||||||
required this.usedBytes,
|
required this.usedBytes,
|
||||||
@@ -147,6 +148,7 @@ class NextcloudUserQuota {
|
|||||||
required this.userName,
|
required this.userName,
|
||||||
required this.email,
|
required this.email,
|
||||||
required this.serverVersion,
|
required this.serverVersion,
|
||||||
|
this.groups = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory NextcloudUserQuota.demo() {
|
factory NextcloudUserQuota.demo() {
|
||||||
@@ -157,6 +159,7 @@ class NextcloudUserQuota {
|
|||||||
userName: 'Ayush Admin',
|
userName: 'Ayush Admin',
|
||||||
email: 'ayush@cloud.internal',
|
email: 'ayush@cloud.internal',
|
||||||
serverVersion: 'Nextcloud 29.0.4 Hub 8',
|
serverVersion: 'Nextcloud 29.0.4 Hub 8',
|
||||||
|
groups: ['Family', 'Friends'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../models/nextcloud_share.dart';
|
|||||||
import '../models/nextcloud_sharee.dart';
|
import '../models/nextcloud_sharee.dart';
|
||||||
import '../models/saved_account.dart';
|
import '../models/saved_account.dart';
|
||||||
import '../services/account_store.dart';
|
import '../services/account_store.dart';
|
||||||
|
import '../services/app_lock_service.dart';
|
||||||
import '../services/login_flow_service.dart';
|
import '../services/login_flow_service.dart';
|
||||||
import '../services/nextcloud_service.dart';
|
import '../services/nextcloud_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
@@ -84,6 +85,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 +118,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 = '/';
|
||||||
|
|
||||||
@@ -217,12 +232,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 +283,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;
|
||||||
@@ -450,6 +477,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);
|
||||||
@@ -865,15 +898,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 +925,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 +956,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 +978,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 +1202,14 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleShowHiddenFiles() {
|
/// 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 +1218,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 +1712,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();
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:local_auth/local_auth.dart';
|
||||||
|
|
||||||
|
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
|
||||||
|
/// PIN storage/hashing - `authenticate` delegates to whatever the OS
|
||||||
|
/// already has configured (fingerprint/face, or the device's own PIN/
|
||||||
|
/// pattern/password as a fallback when `biometricOnly` is false), which is
|
||||||
|
/// both simpler and more secure than reimplementing credential storage.
|
||||||
|
class AppLockService {
|
||||||
|
static final LocalAuthentication _auth = LocalAuthentication();
|
||||||
|
|
||||||
|
/// Whether this device can do *some* form of local auth - biometric
|
||||||
|
/// enrolled, or at minimum a device PIN/pattern/password set up.
|
||||||
|
static Future<bool> isDeviceSupported() async {
|
||||||
|
try {
|
||||||
|
final canCheckBiometrics = await _auth.canCheckBiometrics;
|
||||||
|
if (canCheckBiometrics) return true;
|
||||||
|
return await _auth.isDeviceSupported();
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompts for biometric or device-credential auth. Returns false (never
|
||||||
|
/// throws) on cancellation, failure, or any platform error, so callers
|
||||||
|
/// can treat every non-true result the same way: stay locked/blocked.
|
||||||
|
static Future<bool> authenticate(String reason) async {
|
||||||
|
try {
|
||||||
|
return await _auth.authenticate(
|
||||||
|
localizedReason: reason,
|
||||||
|
options: const AuthenticationOptions(
|
||||||
|
biometricOnly: false,
|
||||||
|
stickyAuth: true,
|
||||||
|
// Default (true) requires an extra manual confirmation tap after
|
||||||
|
// a *passive* modality like face/iris recognizes you - Android's
|
||||||
|
// own guidance reserves that for confirming risky actions
|
||||||
|
// (a purchase); unlocking the app is exactly the "lower-risk"
|
||||||
|
// case they say to pass false for, and on some devices/Android
|
||||||
|
// versions it also determines whether face is offered as an
|
||||||
|
// option in the prompt at all, not just whether it needs a
|
||||||
|
// second tap to accept.
|
||||||
|
sensitiveTransaction: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,15 @@ DateTime? _parseDavDate(String? raw) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The OCS Activity API reports each event's time as an ISO 8601 string in
|
||||||
|
/// a `datetime` field (e.g. "2025-09-15T12:34:56+00:00") - there is no
|
||||||
|
/// numeric `timestamp` field despite that being a very easy name to guess.
|
||||||
|
/// Falls back to now() only if the field is missing/unparseable, so a
|
||||||
|
/// broken response reads as "just now" rather than the Unix epoch.
|
||||||
|
DateTime _parseActivityDateTime(dynamic raw) {
|
||||||
|
return DateTime.tryParse(raw?.toString() ?? '') ?? DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
/// Normalizes a WebDAV `href` (which servers may return as either a bare
|
/// Normalizes a WebDAV `href` (which servers may return as either a bare
|
||||||
/// path or a full absolute URL) down to just its path, trailing slash
|
/// path or a full absolute URL) down to just its path, trailing slash
|
||||||
/// stripped, so hrefs from either form can be compared directly.
|
/// stripped, so hrefs from either form can be compared directly.
|
||||||
@@ -872,6 +881,9 @@ class NextcloudService {
|
|||||||
final pct = total > 0 ? (used / total).clamp(0.0, 1.0) : 0.0;
|
final pct = total > 0 ? (used / total).clamp(0.0, 1.0) : 0.0;
|
||||||
final display = ocsData['displayname'] ?? username;
|
final display = ocsData['displayname'] ?? username;
|
||||||
final email = ocsData['email'] ?? '$username@$serverUrl';
|
final email = ocsData['email'] ?? '$username@$serverUrl';
|
||||||
|
final groups = ((ocsData['groups'] as List?) ?? [])
|
||||||
|
.map((g) => g.toString())
|
||||||
|
.toList();
|
||||||
|
|
||||||
return NextcloudUserQuota(
|
return NextcloudUserQuota(
|
||||||
usedBytes: used,
|
usedBytes: used,
|
||||||
@@ -880,6 +892,7 @@ class NextcloudService {
|
|||||||
userName: display,
|
userName: display,
|
||||||
email: email,
|
email: email,
|
||||||
serverVersion: 'Nextcloud Server',
|
serverVersion: 'Nextcloud Server',
|
||||||
|
groups: groups,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
@@ -914,9 +927,7 @@ class NextcloudService {
|
|||||||
id: (a['activity_id'] ?? '').toString(),
|
id: (a['activity_id'] ?? '').toString(),
|
||||||
title: a['subject'] ?? 'Server Activity',
|
title: a['subject'] ?? 'Server Activity',
|
||||||
subject: a['message'] ?? (a['subject'] ?? ''),
|
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
timestamp: _parseActivityDateTime(a['datetime']),
|
||||||
(a['timestamp'] as int? ?? 0) * 1000,
|
|
||||||
),
|
|
||||||
icon: Icons.cloud_outlined,
|
icon: Icons.cloud_outlined,
|
||||||
author: a['user'] ?? username,
|
author: a['user'] ?? username,
|
||||||
);
|
);
|
||||||
@@ -1500,9 +1511,7 @@ class NextcloudService {
|
|||||||
id: (a['activity_id'] ?? '').toString(),
|
id: (a['activity_id'] ?? '').toString(),
|
||||||
title: a['subject'] ?? 'Activity',
|
title: a['subject'] ?? 'Activity',
|
||||||
subject: a['message'] ?? (a['subject'] ?? ''),
|
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
timestamp: _parseActivityDateTime(a['datetime']),
|
||||||
(a['timestamp'] as int? ?? 0) * 1000,
|
|
||||||
),
|
|
||||||
icon: Icons.cloud_outlined,
|
icon: Icons.cloud_outlined,
|
||||||
author: a['user'] ?? username,
|
author: a['user'] ?? username,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -381,7 +381,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),
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/server_provider.dart';
|
||||||
|
|
||||||
|
/// Shown in place of the main shell whenever [ServerProvider.needsUnlock] is
|
||||||
|
/// true - a fresh app launch with login lock configured, or returning to the
|
||||||
|
/// foreground after being backgrounded. Prompts automatically on first show
|
||||||
|
/// and again whenever the app resumes while still locked (e.g. the user
|
||||||
|
/// switched away to enter their device PIN in the system UI and came back).
|
||||||
|
class LockScreenView extends StatefulWidget {
|
||||||
|
const LockScreenView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LockScreenView> createState() => _LockScreenViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LockScreenViewState extends State<LockScreenView>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
bool _authenticating = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _attemptUnlock());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state != AppLifecycleState.resumed || !mounted || _authenticating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context.read<ServerProvider>().needsUnlock) _attemptUnlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _attemptUnlock() async {
|
||||||
|
if (_authenticating || !mounted) return;
|
||||||
|
setState(() => _authenticating = true);
|
||||||
|
await context.read<ServerProvider>().attemptUnlock();
|
||||||
|
if (mounted) setState(() => _authenticating = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Same monochrome-tinted treatment as the splash screen (see
|
||||||
|
// main.dart's _SplashView) - the asset is a plain white
|
||||||
|
// silhouette on transparent, meant to be recolored rather
|
||||||
|
// than shown as-is.
|
||||||
|
ColorFiltered(
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
colorScheme.onSurface,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/icon/app_icon_monochrome.png',
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Noo is locked',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Unlock with your PIN or biometrics to continue',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
SizedBox(
|
||||||
|
height: 54,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _authenticating ? null : _attemptUnlock,
|
||||||
|
icon: _authenticating
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.lock_open_rounded),
|
||||||
|
label: Text(_authenticating ? 'Unlocking…' : 'Unlock'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
class LoginView extends StatefulWidget {
|
class LoginView extends StatefulWidget {
|
||||||
@@ -82,7 +83,41 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
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,
|
||||||
@@ -94,6 +129,8 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
theme: theme,
|
theme: theme,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -265,6 +302,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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ import '../views/account_view.dart';
|
|||||||
class ProfileAvatarButton extends StatelessWidget {
|
class ProfileAvatarButton extends StatelessWidget {
|
||||||
const ProfileAvatarButton({super.key});
|
const ProfileAvatarButton({super.key});
|
||||||
|
|
||||||
void _handleVerticalSwipe(BuildContext context, DragEndDetails details) {
|
Future<void> _handleVerticalSwipe(
|
||||||
|
BuildContext context,
|
||||||
|
DragEndDetails details,
|
||||||
|
) async {
|
||||||
final velocity = details.primaryVelocity ?? 0;
|
final velocity = details.primaryVelocity ?? 0;
|
||||||
if (velocity.abs() < 250) return;
|
if (velocity.abs() < 250) return;
|
||||||
|
|
||||||
final provider = context.read<ServerProvider>();
|
final provider = context.read<ServerProvider>();
|
||||||
final target = velocity < 0
|
final target = velocity < 0
|
||||||
? provider.cycleToNextAccount()
|
? await provider.cycleToNextAccount()
|
||||||
: provider.cycleToPreviousAccount();
|
: await provider.cycleToPreviousAccount();
|
||||||
if (target == null) return;
|
if (target == null || !context.mounted) return;
|
||||||
|
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -278,6 +278,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.35"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -456,6 +464,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.1.0"
|
||||||
|
local_auth:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: local_auth
|
||||||
|
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
local_auth_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_android
|
||||||
|
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.56"
|
||||||
|
local_auth_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_darwin
|
||||||
|
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.1"
|
||||||
|
local_auth_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_platform_interface
|
||||||
|
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
local_auth_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_windows
|
||||||
|
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.11"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
receive_sharing_intent: ^1.8.1
|
receive_sharing_intent: ^1.8.1
|
||||||
marquee: ^2.3.0
|
marquee: ^2.3.0
|
||||||
|
local_auth: ^2.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
||||||
#include <file_saver/file_saver_plugin.h>
|
#include <file_saver/file_saver_plugin.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
#include <local_auth_windows/local_auth_plugin.h>
|
||||||
#include <pdfx/pdfx_plugin.h>
|
#include <pdfx/pdfx_plugin.h>
|
||||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
@@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FileSaverPlugin"));
|
registry->GetRegistrarForPlugin("FileSaverPlugin"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
LocalAuthPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||||
PdfxPluginRegisterWithRegistrar(
|
PdfxPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PdfxPlugin"));
|
registry->GetRegistrarForPlugin("PdfxPlugin"));
|
||||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
dynamic_color
|
dynamic_color
|
||||||
file_saver
|
file_saver
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
local_auth_windows
|
||||||
pdfx
|
pdfx
|
||||||
share_plus
|
share_plus
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
|
|||||||