3 Commits
Author SHA1 Message Date
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
33 changed files with 894 additions and 165 deletions
+21 -6
View File
@@ -10,12 +10,12 @@ lib/
# SavedAccount, AppTab, ...)
providers/ # ServerProvider — the single app-wide ChangeNotifier
services/ # network/IO: NextcloudService, LoginFlowService,
# AccountStore
# AccountStore, AppLockService
theme/ # AppTheme (Material 3 ThemeData)
views/ # one screen each (FilesView, PhotosView, TrashView,
# SharesView, RecentView, ActivityView, SearchView,
# AccountView, LoginView, FileViewerScreen,
# ShareUploadView)
# ShareUploadView, LockScreenView)
widgets/ # reusable pieces shared across views
details/ # the file-details bottom sheet and its tabs
```
@@ -54,9 +54,23 @@ There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/se
- UI settings that persist across launches — split into **global** (theme
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
actions) and **per-account** (grid/list view, favorites-only, show-hidden,
storage scope, Photos sort, Files' per-folder sort map, cache policy) — see
`server.md` for exactly which is which and why
actions, login lock — see below) and **per-account** (grid/list view,
favorites-only, show-hidden, storage scope, Photos sort, Files'
per-folder sort map, cache policy) — see `server.md` for exactly which is
which and why
- **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/
`lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via
`AppLockService` (a thin wrapper over `local_auth` — this app never
stores or hashes a PIN itself, it delegates entirely to whatever
credential the OS already has configured). `needsUnlock` is
`loginLockEnabled && !_isUnlocked`, where `_isUnlocked` is transient
(never persisted) and reset to `false` on every backgrounding
(`didChangeAppLifecycleState`, `AppLifecycleState.paused`) so the lock has
real value rather than only firing once per cold start. `switchAccount`/
`cycleToNextAccount`/`cycleToPreviousAccount` and *enabling* (not
disabling) `showHiddenFiles`/`showHiddenPhotos` each call the shared
`_passGate` helper, which no-ops unless both `loginLockEnabled` and the
relevant per-feature toggle are on.
New **global** state belongs on `ServerProvider` as a private field + getter
+ a method that mutates it, calls `notifyListeners()`, and persists via
@@ -70,7 +84,7 @@ split.
There's no separate repository/data layer: views call `ServerProvider`
methods directly, which call `NextcloudService`/`LoginFlowService`/
`AccountStore`.
`AccountStore`/`AppLockService`.
## Navigation / screen flow
@@ -85,6 +99,7 @@ state, no named routes:
Flow v2) — `isLoggedIn` is only ever false here or after the last saved
account is removed; switching between multiple saved accounts never
routes through this screen (see `server.md`)
- else `provider.needsUnlock` → `LockScreenView` (login lock — see above)
- else `MainShellView`
`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files,
+47 -3
View File
@@ -121,9 +121,53 @@ account is *currently* live (see `architecture.md`).
its credentials and refetch everything. This is a full teardown-and-reload
every time — there is deliberately no simultaneous multi-account state or
background sync; only one account's content is ever live.
- `logout()` is just `removeAccount(activeAccountId)` — with other accounts
saved it falls back to one of them instead of ending the session;
`isLoggedIn` only ever becomes `false` when the *last* account is removed.
- **`logout()` vs `removeAccount()`** are deliberately different actions,
both funneling into a shared `_deactivateSession()` helper for the
teardown/pointer-clearing part:
- `logout()` ends the active session but keeps the account itself fully
intact (password, prefs, its entry in `accounts` all untouched) —
always lands on `LoginView` even if other accounts are saved (it does
*not* fall back to one of them the way `removeAccount` does). This
exists so `LoginView` can offer a "Continue as ..." one-tap resume list
(`_SavedAccountsSection` in `login_view.dart`) with no Login Flow v2
needed - logging out must never be mistaken for forgetting an account.
- `removeAccount(id)` deletes everything for that account (secure-storage
password, namespaced prefs, its `accounts` entry) and, only if it was
the active one, falls back to another saved account or - if none
remain - calls the same `_deactivateSession()`. This is the only path
(besides `logout()`) that can set `isLoggedIn` false, and the only one
that's actually destructive/irreversible - UI call sites (`AccountView`)
gate it behind a confirmation dialog; `logout()` doesn't need one.
- Any UI code that calls either and might have ended the session should
check `!provider.isLoggedIn` afterward and `Navigator.popUntil((r) =>
r.isFirst)` if so — otherwise a screen pushed on top (Settings) is left
stranded over a root route that's silently swapped to `LoginView`
underneath it. Don't pop unconditionally — removing a *non-active*
account, or one that fell back to another, keeps the user logged in and
Settings should just stay open.
## App lock (login lock)
An orthogonal, app-wide security layer on top of the Nextcloud
login/session above — not account credentials, just a gate on *using* the
app. [`AppLockService`](../../lib/services/app_lock_service.dart) wraps
`local_auth`; this app never implements its own PIN entry/storage/hashing —
`authenticate()` always delegates to whatever the OS already has configured
(biometric, or device PIN/pattern/password as fallback, via
`biometricOnly: false`). Never build a custom in-app PIN screen for this —
extend `AppLockService`/the `ServerProvider` gates described in
`architecture.md` instead.
**Android native requirements** (both already done, keep them if you touch
these files): `MainActivity.kt` must extend `FlutterFragmentActivity`, not
the default `FlutterActivity` — `local_auth`'s Android implementation hosts
its prompt via a Fragment and silently fails to build/crashes without it.
`AndroidManifest.xml` needs `<uses-permission
android:name="android.permission.USE_BIOMETRIC"/>` (also declared by the
plugin's own manifest via merge, but kept explicit here too).
`android/app/build.gradle.kts` floors `minSdk` at 24 (`local_auth_android`'s
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
Flutter's own default to already be high enough.
`isRestoringSession` still gates the splash screen until the above resolves
— see `standards.md` for why widget tests must mock both storage channels
+26
View File
@@ -66,6 +66,32 @@ class/method already makes obvious.
of failing fast. See `test/widget_test.dart` for the reference setup.
- Run with `flutter test`.
## Local install/deploy
Never use `flutter install` to push a build to a test device — it always
does a full **uninstall-then-install** (prints "Uninstalling old
version..."), and Android deletes all app data (SharedPreferences, secure
storage — every saved account/preference) on uninstall. This wipes the app
clean on every single deploy, which looks like an account/settings-loss bug
but is actually just the install method.
Instead, build then install with `adb`'s replace flag, which updates the
APK in place and preserves app data:
```bash
flutter build apk --release
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
This only preserves data if the new APK's signature matches what's already
on the device — a local build's release variant currently reuses the debug
signing config (`android/app/build.gradle.kts`), so consecutive local
builds share a key and `adb install -r` works cleanly. Installing a build
signed with a different key (e.g. a CI-signed release APK from the Gitea
release pipeline) over a differently-signed local build forces Android to
require a full uninstall regardless of the install method used — there's no
way around that from the tooling side.
## Dependencies
Networking is deliberately split: `package:http` for simple JSON/XML
+4 -1
View File
@@ -18,7 +18,10 @@ android {
applicationId = "dev.ayushya.noo"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
// local_auth (app-lock/biometric) requires API 24+; Flutter's own
// default may be lower, so floor it here rather than relying on
// whatever flutter.minSdkVersion currently resolves to.
minSdk = maxOf(24, flutter.minSdkVersion)
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
+2
View File
@@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<!-- For local_auth's biometric app-lock (Settings > Security). -->
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<application
android:label="Noo"
android:name="${applicationName}"
@@ -1,5 +1,8 @@
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()
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

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

+6 -3
View File
@@ -6,6 +6,7 @@ import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'models/app_tab.dart';
import 'providers/server_provider.dart';
import 'theme/app_theme.dart';
import 'views/lock_screen_view.dart';
import 'views/login_view.dart';
import 'views/search_view.dart';
import 'views/share_upload_view.dart';
@@ -48,9 +49,11 @@ class NextcloudApp extends StatelessWidget {
themeMode: provider.themeMode,
home: provider.isRestoringSession
? const _SplashView()
: (provider.isLoggedIn
? const MainShellView()
: const LoginView()),
: (!provider.isLoggedIn
? const LoginView()
: (provider.needsUnlock
? const LockScreenView()
: const MainShellView())),
);
},
);
+3
View File
@@ -139,6 +139,7 @@ class NextcloudUserQuota {
final String userName;
final String email;
final String serverVersion;
final List<String> groups;
const NextcloudUserQuota({
required this.usedBytes,
@@ -147,6 +148,7 @@ class NextcloudUserQuota {
required this.userName,
required this.email,
required this.serverVersion,
this.groups = const [],
});
factory NextcloudUserQuota.demo() {
@@ -157,6 +159,7 @@ class NextcloudUserQuota {
userName: 'Ayush Admin',
email: 'ayush@cloud.internal',
serverVersion: 'Nextcloud 29.0.4 Hub 8',
groups: ['Family', 'Friends'],
);
}
}
+168 -27
View File
@@ -11,6 +11,7 @@ import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart';
import '../models/saved_account.dart';
import '../services/account_store.dart';
import '../services/app_lock_service.dart';
import '../services/login_flow_service.dart';
import '../services/nextcloud_service.dart';
import '../theme/app_theme.dart';
@@ -84,6 +85,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
static const _prefCachePolicy = 'ui_cache_policy';
static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
static const _prefLoginLockEnabled = 'ui_login_lock_enabled';
static const _prefLockAccountSwitching = 'ui_lock_account_switching';
static const _prefLockHiddenFiles = 'ui_lock_hidden_files';
final Future<SharedPreferences> _prefsFuture =
SharedPreferences.getInstance();
@@ -114,6 +118,17 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
double _bottomBarBlur = 28;
bool _tapTabToScrollTop = true;
// App lock (PIN/biometric via the device's own credential, not our own
// storage - see AppLockService). Global, not per-account: it guards
// access to the app/its accounts, not any one account's content.
bool _loginLockEnabled = false;
bool _lockAccountSwitching = false;
bool _lockHiddenFiles = false;
// Transient (never persisted) - starts locked whenever the app process
// starts, and re-locks on every backgrounding if a lock is configured;
// see didChangeAppLifecycleState.
bool _isUnlocked = false;
// Navigation state
String _currentFolderPath = '/';
@@ -217,12 +232,20 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
/// Starts/stops the periodic folder-listing refresh as the app leaves and
/// returns to the foreground - only relevant under [CachePolicy.interval].
/// Also re-locks the app on backgrounding when login lock is set up - a
/// one-time unlock at cold start would give the feature no real security
/// value, since the realistic threat is someone else picking up an
/// already-running, unlocked phone.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_startCacheRefreshTimerIfNeeded();
} else if (state == AppLifecycleState.paused) {
_cacheRefreshTimer?.cancel();
if (_loginLockEnabled && _isUnlocked) {
_isUnlocked = false;
notifyListeners();
}
}
}
@@ -260,6 +283,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
double get bottomBarOpacity => _bottomBarOpacity;
double get bottomBarBlur => _bottomBarBlur;
bool get tapTabToScrollTop => _tapTabToScrollTop;
bool get loginLockEnabled => _loginLockEnabled;
bool get lockAccountSwitching => _lockAccountSwitching;
bool get lockHiddenFiles => _lockHiddenFiles;
bool get needsUnlock => _loginLockEnabled && !_isUnlocked;
String get currentFolderPath => _currentFolderPath;
AppTab? get requestedTab => _requestedTab;
@@ -450,6 +477,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
_tapTabToScrollTop =
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
_loginLockEnabled =
prefs.getBool(_prefLoginLockEnabled) ?? _loginLockEnabled;
_lockAccountSwitching =
prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching;
_lockHiddenFiles =
prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles;
_applyAccountPrefs(prefs, _activeAccountId);
final savedOrderNames = prefs.getStringList(_prefTabOrder);
@@ -865,15 +898,21 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
}
/// Switches to an already-saved account. No-op if it's already active or
/// unknown.
/// unknown. Gated behind login lock when [lockAccountSwitching] is on.
Future<void> switchAccount(String accountId) async {
if (accountId == _activeAccountId) return;
if (!_accounts.any((a) => a.id == accountId)) return;
if (!await _passGate(_lockAccountSwitching, 'Unlock to switch accounts')) {
return;
}
await _activateAccount(accountId);
}
SavedAccount? _cycleAccount(int direction) {
Future<SavedAccount?> _cycleAccount(int direction) async {
if (_accounts.length < 2) return null;
if (!await _passGate(_lockAccountSwitching, 'Unlock to switch accounts')) {
return null;
}
final currentIndex = _accounts.indexWhere((a) => a.id == _activeAccountId);
final targetIndex = currentIndex == -1
? 0
@@ -886,17 +925,18 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
/// Cycles to the next/previous saved account (by the order they were
/// added) - used by the avatar's swipe-up/down quick-switch gesture.
/// Returns the account it's switching to (synchronously, before the
/// switch's own network verification completes) so the caller can show
/// immediate feedback, or null if there's fewer than 2 saved accounts.
SavedAccount? cycleToNextAccount() => _cycleAccount(1);
SavedAccount? cycleToPreviousAccount() => _cycleAccount(-1);
/// Returns the account it's switching to once any login-lock gate has
/// passed (the switch's own network verification is still fire-and-forget
/// after that, same as before), or null if there's fewer than 2 saved
/// accounts or the gate was not passed.
Future<SavedAccount?> cycleToNextAccount() => _cycleAccount(1);
Future<SavedAccount?> cycleToPreviousAccount() => _cycleAccount(-1);
/// Removes a saved account entirely: its stored password, its
/// namespaced prefs, and its entry in the saved-accounts list. If it was
/// the active account, falls back to another saved account, or - if none
/// remain - performs a full logout (the only path that sets
/// [isLoggedIn] false).
/// remain - deactivates the session (the only path here that sets
/// [isLoggedIn] false; unlike [logout], there's nothing left to keep).
Future<void> removeAccount(String accountId) async {
final index = _accounts.indexWhere((a) => a.id == accountId);
if (index == -1) return;
@@ -916,28 +956,45 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
}
if (_accounts.isEmpty) {
_sessionGeneration++;
_activeAccountId = null;
await _accountStore.saveActiveAccountId(prefs, null);
_cacheRefreshTimer?.cancel();
_directoryCache.clear();
_clearActiveContent();
_isLoggedIn = false;
_serverUrl = '';
_username = '';
_password = '';
_applyAccountPrefs(prefs, null);
notifyListeners();
await _deactivateSession();
return;
}
await _activateAccount(_accounts.first.id);
}
/// 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 ?? '');
/// 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++;
_cacheRefreshTimer?.cancel();
_directoryCache.clear();
_clearActiveContent();
_isLoggedIn = false;
_serverUrl = '';
_username = '';
_password = '';
_activeAccountId = null;
final prefs = await _prefsFuture;
await _accountStore.saveActiveAccountId(prefs, null);
_applyAccountPrefs(prefs, null);
notifyListeners();
}
/// 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();
}
Future<void> refreshData() async {
if (!_isLoggedIn || _service == null) {
@@ -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;
notifyListeners();
_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;
notifyListeners();
_persistAccountPref(
@@ -1643,6 +1712,78 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_prefsFuture.then((p) => p.setBool(_prefTapTabToScrollTop, value));
}
/// Confirms the device can do local auth and prompts once to enable login
/// lock. Returns false (leaving lock disabled) if the device has no
/// biometric/PIN capability or the user cancels/fails the confirmation.
Future<bool> setupLoginLock() async {
if (!await AppLockService.isDeviceSupported()) return false;
final confirmed = await AppLockService.authenticate(
'Confirm to turn on login lock',
);
if (!confirmed) return false;
_loginLockEnabled = true;
// Already just authenticated - don't immediately re-prompt behind it.
_isUnlocked = true;
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, true));
return true;
}
/// Turns login lock off, along with both of its sub-toggles (meaningless
/// once the base lock is gone). Requires a successful auth first, same as
/// turning it on - otherwise anyone with momentary access to an unlocked
/// phone could just switch it off.
Future<bool> disableLoginLock() async {
if (!_loginLockEnabled) return true;
final confirmed = await AppLockService.authenticate(
'Confirm to turn off login lock',
);
if (!confirmed) return false;
_loginLockEnabled = false;
_lockAccountSwitching = false;
_lockHiddenFiles = false;
_isUnlocked = false;
notifyListeners();
_prefsFuture.then((p) {
p.setBool(_prefLoginLockEnabled, false);
p.setBool(_prefLockAccountSwitching, false);
p.setBool(_prefLockHiddenFiles, false);
});
return true;
}
void setLockAccountSwitching(bool value) {
if (!_loginLockEnabled) return;
_lockAccountSwitching = value;
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
}
void setLockHiddenFiles(bool value) {
if (!_loginLockEnabled) return;
_lockHiddenFiles = value;
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value));
}
/// Called by the lock screen. Returns whether it actually unlocked.
Future<bool> attemptUnlock() async {
final success = await AppLockService.authenticate('Unlock Noo');
if (success) {
_isUnlocked = true;
notifyListeners();
}
return success;
}
/// Prompts for auth if [gate] is on and login lock is configured;
/// returns true immediately (no prompt) otherwise. Shared by the
/// account-switching and hidden-files gates below.
Future<bool> _passGate(bool gate, String reason) async {
if (!_loginLockEnabled || !gate) return true;
return AppLockService.authenticate(reason);
}
void setThemeMode(ThemeMode mode) {
_themeMode = mode;
notifyListeners();
+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
/// path or a full absolute URL) down to just its path, trailing slash
/// stripped, so hrefs from either form can be compared directly.
@@ -872,6 +881,9 @@ class NextcloudService {
final pct = total > 0 ? (used / total).clamp(0.0, 1.0) : 0.0;
final display = ocsData['displayname'] ?? username;
final email = ocsData['email'] ?? '$username@$serverUrl';
final groups = ((ocsData['groups'] as List?) ?? [])
.map((g) => g.toString())
.toList();
return NextcloudUserQuota(
usedBytes: used,
@@ -880,6 +892,7 @@ class NextcloudService {
userName: display,
email: email,
serverVersion: 'Nextcloud Server',
groups: groups,
);
} else {
throw Exception(
@@ -914,9 +927,7 @@ class NextcloudService {
id: (a['activity_id'] ?? '').toString(),
title: a['subject'] ?? 'Server Activity',
subject: a['message'] ?? (a['subject'] ?? ''),
timestamp: DateTime.fromMillisecondsSinceEpoch(
(a['timestamp'] as int? ?? 0) * 1000,
),
timestamp: _parseActivityDateTime(a['datetime']),
icon: Icons.cloud_outlined,
author: a['user'] ?? username,
);
@@ -1500,9 +1511,7 @@ class NextcloudService {
id: (a['activity_id'] ?? '').toString(),
title: a['subject'] ?? 'Activity',
subject: a['message'] ?? (a['subject'] ?? ''),
timestamp: DateTime.fromMillisecondsSinceEpoch(
(a['timestamp'] as int? ?? 0) * 1000,
),
timestamp: _parseActivityDateTime(a['datetime']),
icon: Icons.cloud_outlined,
author: a['user'] ?? username,
);
+244 -100
View File
@@ -6,17 +6,12 @@ import '../providers/server_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/frosted_glass_container.dart';
import '../widgets/seek_bar_painter.dart';
import '../widgets/synced_header_scaffold.dart' show formatQuota;
import 'login_view.dart';
class AccountView extends StatelessWidget {
const AccountView({super.key});
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 GB';
final gb = bytes / (1024 * 1024 * 1024);
return '${gb.toStringAsFixed(2)} GB';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -80,16 +75,33 @@ class AccountView extends StatelessWidget {
],
),
),
IconButton(
onPressed: () =>
_showLogoutConfirmation(context, provider),
icon: const Icon(Icons.logout_rounded),
tooltip: 'Log Out',
color: colorScheme.error,
],
),
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.public_rounded,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
Uri.tryParse(provider.serverUrl)?.host ??
provider.serverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 20),
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 16),
Row(
@@ -102,11 +114,7 @@ class AccountView extends StatelessWidget {
),
),
Text(
quota != null
? (quota.totalBytes > 0
? '${_formatBytes(quota.usedBytes)} of ${_formatBytes(quota.totalBytes)}'
: '${_formatBytes(quota.usedBytes)} (Unlimited)')
: 'Live Server Storage',
quota != null ? formatQuota(quota) : 'Loading…',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
@@ -129,52 +137,111 @@ class AccountView extends StatelessWidget {
),
),
const SizedBox(height: 8),
Text(
quota != null
? (quota.totalBytes > 0
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
: 'Unlimited Storage Plan')
: 'WebDAV connection active',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
Align(
alignment: Alignment.centerRight,
child: Text(
quota != null
? (quota.totalBytes > 0
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
: 'Unlimited storage')
: '',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
),
if (quota != null && quota.groups.isNotEmpty) ...[
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 16),
Text(
'Groups',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final group in quota.groups)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Text(
group,
style: theme.textTheme.bodyMedium,
),
),
],
),
],
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 12),
const SizedBox(height: 16),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: provider.isLoggedIn
? Colors.green.withValues(alpha: 0.2)
: Colors.red.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
provider.isLoggedIn ? 'ONLINE' : 'OFFLINE',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: provider.isLoggedIn
? Colors.green.shade800
: Colors.red.shade800,
Expanded(
child: OutlinedButton(
onPressed: () async {
await provider.refreshData();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Refreshed WebDAV data'),
behavior: SnackBarBehavior.floating,
),
);
}
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Refresh Cache',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Connected to ${provider.serverUrl}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
child: FilledButton(
onPressed: () => _handleLogout(context, provider),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.errorContainer,
foregroundColor: colorScheme.onErrorContainer,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Logout',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: () =>
_confirmRemoveActive(context, provider),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Remove',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
),
@@ -196,47 +263,15 @@ class AccountView extends StatelessWidget {
const _AccountsCard(),
const SizedBox(height: 24),
// Server Credentials Section
// Security Section
Text(
'Server Connection Info',
'Security',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.dns_rounded),
title: const Text('Nextcloud Host'),
subtitle: Text(provider.serverUrl),
),
const Divider(height: 1, indent: 16, endIndent: 16),
ListTile(
leading: const Icon(Icons.person_rounded),
title: const Text('Logged In User'),
subtitle: Text(provider.username),
),
const Divider(height: 1, indent: 16, endIndent: 16),
ListTile(
leading: const Icon(Icons.refresh_rounded),
title: const Text('Refresh WebDAV Cache'),
onTap: () async {
await provider.refreshData();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Refreshed WebDAV directory data'),
behavior: SnackBarBehavior.floating,
),
);
}
},
),
],
),
),
const _SecurityCard(),
const SizedBox(height: 24),
// Material You Design Settings
@@ -452,34 +487,62 @@ class AccountView extends StatelessWidget {
);
}
void _showLogoutConfirmation(BuildContext context, ServerProvider provider) {
showDialog(
/// Logout keeps the account saved (see [ServerProvider.logout]) so it's
/// harmless/reversible from a one-tap resume on the login screen -
/// doesn't need a confirmation dialog the way [_confirmRemoveActive] does.
Future<void> _handleLogout(
BuildContext context,
ServerProvider provider,
) async {
final navigator = Navigator.of(context);
await provider.logout();
navigator.popUntil((route) => route.isFirst);
}
Future<void> _confirmRemoveActive(
BuildContext context,
ServerProvider provider,
) async {
final host = Uri.tryParse(provider.serverUrl)?.host ?? provider.serverUrl;
final hasOtherAccounts = provider.accounts.length > 1;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
builder: (dialogContext) {
return AlertDialog(
title: const Text('Log Out'),
title: const Text('Remove Account'),
content: Text(
'Are you sure you want to disconnect from ${provider.serverUrl}?',
hasOtherAccounts
? 'Remove ${provider.username} ($host)? Another saved account will become active.'
: 'Remove ${provider.username} ($host)? You can add it again later.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () {
Navigator.of(context).popUntil((route) => route.isFirst);
provider.logout();
},
child: const Text('Log Out'),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Remove'),
),
],
);
},
);
if (confirmed != true) return;
final id = provider.activeAccountId;
if (id == null) return;
if (!context.mounted) return;
final navigator = Navigator.of(context);
await provider.removeAccount(id);
// Only pop back to the root route if that was the last account and the
// session actually ended - if it fell back to another saved account,
// Settings just keeps showing (now for that account) instead.
if (!provider.isLoggedIn) {
navigator.popUntil((route) => route.isFirst);
}
}
}
@@ -523,7 +586,17 @@ class _AccountsCard extends StatelessWidget {
},
);
if (confirmed == true) {
if (!context.mounted) return;
final navigator = Navigator.of(context);
await provider.removeAccount(account.id);
// Only when this was the last saved account does isLoggedIn drop to
// false and main.dart swap the root route to LoginView underneath -
// pop back to it then, rather than leaving Settings stranded on top.
// Removing a non-active account, or falling back to another one,
// both keep the user logged in, so Settings should just stay put.
if (!provider.isLoggedIn) {
navigator.popUntil((route) => route.isFirst);
}
}
}
@@ -615,6 +688,77 @@ class _AccountRow extends StatelessWidget {
}
}
/// Login lock: gates opening the app, switching accounts, and revealing
/// hidden files behind the device's own PIN/biometric credential (see
/// AppLockService - this app never stores or handles a PIN itself).
class _SecurityCard extends StatelessWidget {
const _SecurityCard();
Future<void> _handleLoginLockChanged(
BuildContext context,
ServerProvider provider,
bool value,
) async {
final success = value
? await provider.setupLoginLock()
: await provider.disableLoginLock();
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
value
? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured"
: 'Could not turn off login lock',
),
behavior: SnackBarBehavior.floating,
),
);
}
}
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
return Card(
child: Column(
children: [
SwitchListTile(
secondary: const Icon(Icons.lock_outline_rounded),
title: const Text('Login Lock'),
subtitle: const Text(
"Require this device's PIN or biometric to open Noo",
),
value: provider.loginLockEnabled,
onChanged: (value) =>
_handleLoginLockChanged(context, provider, value),
),
const Divider(height: 1, indent: 16, endIndent: 16),
SwitchListTile(
secondary: const Icon(Icons.swap_horiz_rounded),
title: const Text('Lock account switching'),
subtitle: const Text('Unlock to switch between saved accounts'),
value: provider.lockAccountSwitching,
onChanged: provider.loginLockEnabled
? provider.setLockAccountSwitching
: null,
),
const Divider(height: 1, indent: 16, endIndent: 16),
SwitchListTile(
secondary: const Icon(Icons.visibility_off_rounded),
title: const Text('Lock hidden files'),
subtitle: const Text('Unlock to reveal hidden files and folders'),
value: provider.lockHiddenFiles,
onChanged: provider.loginLockEnabled
? provider.setLockHiddenFiles
: null,
),
],
),
);
}
}
class _BottomBarAppearanceCard extends StatefulWidget {
const _BottomBarAppearanceCard();
+1 -1
View File
@@ -381,7 +381,7 @@ class _FilesViewState extends State<FilesView> {
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: provider.showHiddenFiles,
onTap: provider.toggleShowHiddenFiles,
onTap: () => provider.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
+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'),
),
),
],
),
),
),
);
}
}
+127 -11
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/saved_account.dart';
import '../providers/server_provider.dart';
class LoginView extends StatefulWidget {
@@ -82,17 +83,53 @@ class _LoginViewState extends State<LoginView> {
onCancel: () => provider.cancelLoginFlow(),
onReopenBrowser: provider.reopenLoginBrowser,
)
: _ServerForm(
formKey: _formKey,
urlController: _urlController,
isLoading: isInitiating || provider.isLoading,
errorMessage:
provider.loginFlowStatus == LoginFlowStatus.error
? provider.errorMessage
: null,
onContinue: _handleContinue,
theme: theme,
colorScheme: colorScheme,
: Column(
mainAxisSize: MainAxisSize.min,
children: [
// A logout keeps the account saved rather than
// deleting it, specifically so it can be resumed
// from here with one tap - no need to repeat
// Login Flow v2.
if (!widget.isAddingAccount &&
provider.accounts.isNotEmpty) ...[
_SavedAccountsSection(
accounts: provider.accounts,
onSelect: (account) =>
provider.switchAccount(account.id),
),
const SizedBox(height: 28),
Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
),
child: Text(
'or',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: 28),
],
_ServerForm(
formKey: _formKey,
urlController: _urlController,
isLoading: isInitiating || provider.isLoading,
errorMessage:
provider.loginFlowStatus == LoginFlowStatus.error
? provider.errorMessage
: null,
onContinue: _handleContinue,
theme: theme,
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 {
final VoidCallback onCancel;
final VoidCallback onReopenBrowser;
+1 -1
View File
@@ -147,7 +147,7 @@ class _PhotosViewState extends State<PhotosView> {
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: provider.showHiddenPhotos,
onTap: provider.toggleShowHiddenPhotos,
onTap: () => provider.toggleShowHiddenPhotos(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
+7 -4
View File
@@ -13,15 +13,18 @@ import '../views/account_view.dart';
class ProfileAvatarButton extends StatelessWidget {
const ProfileAvatarButton({super.key});
void _handleVerticalSwipe(BuildContext context, DragEndDetails details) {
Future<void> _handleVerticalSwipe(
BuildContext context,
DragEndDetails details,
) async {
final velocity = details.primaryVelocity ?? 0;
if (velocity.abs() < 250) return;
final provider = context.read<ServerProvider>();
final target = velocity < 0
? provider.cycleToNextAccount()
: provider.cycleToPreviousAccount();
if (target == null) return;
? await provider.cycleToNextAccount()
: await provider.cycleToPreviousAccount();
if (target == null || !context.mounted) return;
HapticFeedback.selectionClick();
ScaffoldMessenger.of(context).showSnackBar(
+48
View File
@@ -278,6 +278,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_secure_storage:
dependency: "direct main"
description:
@@ -456,6 +464,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
local_auth:
dependency: "direct main"
description:
name: local_auth
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
local_auth_android:
dependency: transitive
description:
name: local_auth_android
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
url: "https://pub.dev"
source: hosted
version: "1.0.56"
local_auth_darwin:
dependency: transitive
description:
name: local_auth_darwin
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
url: "https://pub.dev"
source: hosted
version: "1.6.1"
local_auth_platform_interface:
dependency: transitive
description:
name: local_auth_platform_interface
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
url: "https://pub.dev"
source: hosted
version: "1.1.0"
local_auth_windows:
dependency: transitive
description:
name: local_auth_windows
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
url: "https://pub.dev"
source: hosted
version: "1.0.11"
logging:
dependency: transitive
description:
+1
View File
@@ -55,6 +55,7 @@ dependencies:
shared_preferences: ^2.5.5
receive_sharing_intent: ^1.8.1
marquee: ^2.3.0
local_auth: ^2.3.0
dev_dependencies:
flutter_test:
@@ -9,6 +9,7 @@
#include <dynamic_color/dynamic_color_plugin_c_api.h>
#include <file_saver/file_saver_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_auth_windows/local_auth_plugin.h>
#include <pdfx/pdfx_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
@@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSaverPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
PdfxPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PdfxPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
+1
View File
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color
file_saver
flutter_secure_storage_windows
local_auth_windows
pdfx
share_plus
url_launcher_windows