2 Commits
Author SHA1 Message Date
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
30 changed files with 800 additions and 156 deletions
+4 -1
View File
@@ -18,7 +18,10 @@ android {
applicationId = "dev.ayushya.noo" applicationId = "dev.ayushya.noo"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion // local_auth (app-lock/biometric) requires API 24+; Flutter's own
// default may be lower, so floor it here rather than relying on
// whatever flutter.minSdkVersion currently resolves to.
minSdk = maxOf(24, flutter.minSdkVersion)
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
+2
View File
@@ -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()
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 '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())),
); );
}, },
); );
+3
View File
@@ -139,6 +139,7 @@ class NextcloudUserQuota {
final String userName; final String userName;
final String email; final String email;
final String serverVersion; final String serverVersion;
final List<String> groups;
const NextcloudUserQuota({ const NextcloudUserQuota({
required this.usedBytes, required this.usedBytes,
@@ -147,6 +148,7 @@ class NextcloudUserQuota {
required this.userName, required this.userName,
required this.email, required this.email,
required this.serverVersion, required this.serverVersion,
this.groups = const [],
}); });
factory NextcloudUserQuota.demo() { factory NextcloudUserQuota.demo() {
@@ -157,6 +159,7 @@ class NextcloudUserQuota {
userName: 'Ayush Admin', userName: 'Ayush Admin',
email: 'ayush@cloud.internal', email: 'ayush@cloud.internal',
serverVersion: 'Nextcloud 29.0.4 Hub 8', serverVersion: 'Nextcloud 29.0.4 Hub 8',
groups: ['Family', 'Friends'],
); );
} }
} }
+161 -20
View File
@@ -11,6 +11,7 @@ import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart'; import '../models/nextcloud_sharee.dart';
import '../models/saved_account.dart'; import '../models/saved_account.dart';
import '../services/account_store.dart'; import '../services/account_store.dart';
import '../services/app_lock_service.dart';
import '../services/login_flow_service.dart'; import '../services/login_flow_service.dart';
import '../services/nextcloud_service.dart'; import '../services/nextcloud_service.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
@@ -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();
+48
View File
@@ -0,0 +1,48 @@
import 'package:local_auth/local_auth.dart';
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
/// PIN storage/hashing - `authenticate` delegates to whatever the OS
/// already has configured (fingerprint/face, or the device's own PIN/
/// pattern/password as a fallback when `biometricOnly` is false), which is
/// both simpler and more secure than reimplementing credential storage.
class AppLockService {
static final LocalAuthentication _auth = LocalAuthentication();
/// Whether this device can do *some* form of local auth - biometric
/// enrolled, or at minimum a device PIN/pattern/password set up.
static Future<bool> isDeviceSupported() async {
try {
final canCheckBiometrics = await _auth.canCheckBiometrics;
if (canCheckBiometrics) return true;
return await _auth.isDeviceSupported();
} catch (_) {
return false;
}
}
/// Prompts for biometric or device-credential auth. Returns false (never
/// throws) on cancellation, failure, or any platform error, so callers
/// can treat every non-true result the same way: stay locked/blocked.
static Future<bool> authenticate(String reason) async {
try {
return await _auth.authenticate(
localizedReason: reason,
options: const AuthenticationOptions(
biometricOnly: false,
stickyAuth: true,
// Default (true) requires an extra manual confirmation tap after
// a *passive* modality like face/iris recognizes you - Android's
// own guidance reserves that for confirming risky actions
// (a purchase); unlocking the app is exactly the "lower-risk"
// case they say to pass false for, and on some devices/Android
// versions it also determines whether face is offered as an
// option in the prompt at all, not just whether it needs a
// second tap to accept.
sensitiveTransaction: false,
),
);
} catch (_) {
return false;
}
}
}
+15 -6
View File
@@ -44,6 +44,15 @@ DateTime? _parseDavDate(String? raw) {
} }
} }
/// The OCS Activity API reports each event's time as an ISO 8601 string in
/// a `datetime` field (e.g. "2025-09-15T12:34:56+00:00") - there is no
/// numeric `timestamp` field despite that being a very easy name to guess.
/// Falls back to now() only if the field is missing/unparseable, so a
/// broken response reads as "just now" rather than the Unix epoch.
DateTime _parseActivityDateTime(dynamic raw) {
return DateTime.tryParse(raw?.toString() ?? '') ?? DateTime.now();
}
/// Normalizes a WebDAV `href` (which servers may return as either a bare /// Normalizes a WebDAV `href` (which servers may return as either a bare
/// path or a full absolute URL) down to just its path, trailing slash /// path or a full absolute URL) down to just its path, trailing slash
/// stripped, so hrefs from either form can be compared directly. /// stripped, so hrefs from either form can be compared directly.
@@ -872,6 +881,9 @@ class NextcloudService {
final pct = total > 0 ? (used / total).clamp(0.0, 1.0) : 0.0; final pct = total > 0 ? (used / total).clamp(0.0, 1.0) : 0.0;
final display = ocsData['displayname'] ?? username; final display = ocsData['displayname'] ?? username;
final email = ocsData['email'] ?? '$username@$serverUrl'; final email = ocsData['email'] ?? '$username@$serverUrl';
final groups = ((ocsData['groups'] as List?) ?? [])
.map((g) => g.toString())
.toList();
return NextcloudUserQuota( return NextcloudUserQuota(
usedBytes: used, usedBytes: used,
@@ -880,6 +892,7 @@ class NextcloudService {
userName: display, userName: display,
email: email, email: email,
serverVersion: 'Nextcloud Server', serverVersion: 'Nextcloud Server',
groups: groups,
); );
} else { } else {
throw Exception( throw Exception(
@@ -914,9 +927,7 @@ class NextcloudService {
id: (a['activity_id'] ?? '').toString(), id: (a['activity_id'] ?? '').toString(),
title: a['subject'] ?? 'Server Activity', title: a['subject'] ?? 'Server Activity',
subject: a['message'] ?? (a['subject'] ?? ''), subject: a['message'] ?? (a['subject'] ?? ''),
timestamp: DateTime.fromMillisecondsSinceEpoch( timestamp: _parseActivityDateTime(a['datetime']),
(a['timestamp'] as int? ?? 0) * 1000,
),
icon: Icons.cloud_outlined, icon: Icons.cloud_outlined,
author: a['user'] ?? username, author: a['user'] ?? username,
); );
@@ -1500,9 +1511,7 @@ class NextcloudService {
id: (a['activity_id'] ?? '').toString(), id: (a['activity_id'] ?? '').toString(),
title: a['subject'] ?? 'Activity', title: a['subject'] ?? 'Activity',
subject: a['message'] ?? (a['subject'] ?? ''), subject: a['message'] ?? (a['subject'] ?? ''),
timestamp: DateTime.fromMillisecondsSinceEpoch( timestamp: _parseActivityDateTime(a['datetime']),
(a['timestamp'] as int? ?? 0) * 1000,
),
icon: Icons.cloud_outlined, icon: Icons.cloud_outlined,
author: a['user'] ?? username, author: a['user'] ?? username,
); );
+233 -89
View File
@@ -6,17 +6,12 @@ import '../providers/server_provider.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../widgets/frosted_glass_container.dart'; import '../widgets/frosted_glass_container.dart';
import '../widgets/seek_bar_painter.dart'; import '../widgets/seek_bar_painter.dart';
import '../widgets/synced_header_scaffold.dart' show formatQuota;
import 'login_view.dart'; import 'login_view.dart';
class AccountView extends StatelessWidget { class AccountView extends StatelessWidget {
const AccountView({super.key}); const AccountView({super.key});
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 GB';
final gb = bytes / (1024 * 1024 * 1024);
return '${gb.toStringAsFixed(2)} GB';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -80,16 +75,33 @@ class AccountView extends StatelessWidget {
], ],
), ),
), ),
IconButton( ],
onPressed: () => ),
_showLogoutConfirmation(context, provider), const SizedBox(height: 16),
icon: const Icon(Icons.logout_rounded), const Divider(height: 1),
tooltip: 'Log Out', const SizedBox(height: 16),
color: colorScheme.error, Row(
children: [
Icon(
Icons.public_rounded,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
Uri.tryParse(provider.serverUrl)?.host ??
provider.serverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
), ),
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 16),
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
@@ -102,11 +114,7 @@ class AccountView extends StatelessWidget {
), ),
), ),
Text( Text(
quota != null quota != null ? formatQuota(quota) : 'Loading…',
? (quota.totalBytes > 0
? '${_formatBytes(quota.usedBytes)} of ${_formatBytes(quota.totalBytes)}'
: '${_formatBytes(quota.usedBytes)} (Unlimited)')
: 'Live Server Storage',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -129,52 +137,111 @@ class AccountView extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Align(
alignment: Alignment.centerRight,
child: Text(
quota != null quota != null
? (quota.totalBytes > 0 ? (quota.totalBytes > 0
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used' ? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
: 'Unlimited Storage Plan') : 'Unlimited storage')
: 'WebDAV connection active', : '',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontSize: 11, fontSize: 11,
), ),
), ),
),
if (quota != null && quota.groups.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 12), const SizedBox(height: 16),
Row( Text(
'Groups',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [ children: [
for (final group in quota.groups)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 14,
vertical: 4, vertical: 8,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: provider.isLoggedIn color: colorScheme.surfaceContainerHigh,
? Colors.green.withValues(alpha: 0.2) borderRadius: BorderRadius.circular(20),
: Colors.red.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
), ),
child: Text( child: Text(
provider.isLoggedIn ? 'ONLINE' : 'OFFLINE', group,
style: TextStyle( style: theme.textTheme.bodyMedium,
fontSize: 11, ),
fontWeight: FontWeight.bold, ),
color: provider.isLoggedIn ],
? Colors.green.shade800 ),
: Colors.red.shade800, ],
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () async {
await provider.refreshData();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Refreshed WebDAV data'),
behavior: SnackBarBehavior.floating,
),
);
}
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Refresh Cache',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
), ),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: FilledButton(
'Connected to ${provider.serverUrl}', onPressed: () => _handleLogout(context, provider),
maxLines: 1, style: FilledButton.styleFrom(
overflow: TextOverflow.ellipsis, backgroundColor: colorScheme.errorContainer,
style: theme.textTheme.bodySmall?.copyWith( foregroundColor: colorScheme.onErrorContainer,
color: colorScheme.onSurfaceVariant, padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Logout',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: () =>
_confirmRemoveActive(context, provider),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Remove',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
), ),
), ),
), ),
@@ -196,47 +263,15 @@ class AccountView extends StatelessWidget {
const _AccountsCard(), const _AccountsCard(),
const SizedBox(height: 24), const SizedBox(height: 24),
// Server Credentials Section // Security Section
Text( Text(
'Server Connection Info', 'Security',
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Card( const _SecurityCard(),
child: Column(
children: [
ListTile(
leading: const Icon(Icons.dns_rounded),
title: const Text('Nextcloud Host'),
subtitle: Text(provider.serverUrl),
),
const Divider(height: 1, indent: 16, endIndent: 16),
ListTile(
leading: const Icon(Icons.person_rounded),
title: const Text('Logged In User'),
subtitle: Text(provider.username),
),
const Divider(height: 1, indent: 16, endIndent: 16),
ListTile(
leading: const Icon(Icons.refresh_rounded),
title: const Text('Refresh WebDAV Cache'),
onTap: () async {
await provider.refreshData();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Refreshed WebDAV directory data'),
behavior: SnackBarBehavior.floating,
),
);
}
},
),
],
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
// Material You Design Settings // Material You Design Settings
@@ -452,34 +487,62 @@ class AccountView extends StatelessWidget {
); );
} }
void _showLogoutConfirmation(BuildContext context, ServerProvider provider) { /// Logout keeps the account saved (see [ServerProvider.logout]) so it's
showDialog( /// harmless/reversible from a one-tap resume on the login screen -
/// doesn't need a confirmation dialog the way [_confirmRemoveActive] does.
Future<void> _handleLogout(
BuildContext context,
ServerProvider provider,
) async {
final navigator = Navigator.of(context);
await provider.logout();
navigator.popUntil((route) => route.isFirst);
}
Future<void> _confirmRemoveActive(
BuildContext context,
ServerProvider provider,
) async {
final host = Uri.tryParse(provider.serverUrl)?.host ?? provider.serverUrl;
final hasOtherAccounts = provider.accounts.length > 1;
final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Log Out'), title: const Text('Remove Account'),
content: Text( content: Text(
'Are you sure you want to disconnect from ${provider.serverUrl}?', hasOtherAccounts
? 'Remove ${provider.username} ($host)? Another saved account will become active.'
: 'Remove ${provider.username} ($host)? You can add it again later.',
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error, backgroundColor: Theme.of(dialogContext).colorScheme.error,
), ),
onPressed: () { onPressed: () => Navigator.pop(dialogContext, true),
Navigator.of(context).popUntil((route) => route.isFirst); child: const Text('Remove'),
provider.logout();
},
child: const Text('Log Out'),
), ),
], ],
); );
}, },
); );
if (confirmed != true) return;
final id = provider.activeAccountId;
if (id == null) return;
if (!context.mounted) return;
final navigator = Navigator.of(context);
await provider.removeAccount(id);
// Only pop back to the root route if that was the last account and the
// session actually ended - if it fell back to another saved account,
// Settings just keeps showing (now for that account) instead.
if (!provider.isLoggedIn) {
navigator.popUntil((route) => route.isFirst);
}
} }
} }
@@ -523,7 +586,17 @@ class _AccountsCard extends StatelessWidget {
}, },
); );
if (confirmed == true) { if (confirmed == true) {
if (!context.mounted) return;
final navigator = Navigator.of(context);
await provider.removeAccount(account.id); await provider.removeAccount(account.id);
// Only when this was the last saved account does isLoggedIn drop to
// false and main.dart swap the root route to LoginView underneath -
// pop back to it then, rather than leaving Settings stranded on top.
// Removing a non-active account, or falling back to another one,
// both keep the user logged in, so Settings should just stay put.
if (!provider.isLoggedIn) {
navigator.popUntil((route) => route.isFirst);
}
} }
} }
@@ -615,6 +688,77 @@ class _AccountRow extends StatelessWidget {
} }
} }
/// Login lock: gates opening the app, switching accounts, and revealing
/// hidden files behind the device's own PIN/biometric credential (see
/// AppLockService - this app never stores or handles a PIN itself).
class _SecurityCard extends StatelessWidget {
const _SecurityCard();
Future<void> _handleLoginLockChanged(
BuildContext context,
ServerProvider provider,
bool value,
) async {
final success = value
? await provider.setupLoginLock()
: await provider.disableLoginLock();
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
value
? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured"
: 'Could not turn off login lock',
),
behavior: SnackBarBehavior.floating,
),
);
}
}
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
return Card(
child: Column(
children: [
SwitchListTile(
secondary: const Icon(Icons.lock_outline_rounded),
title: const Text('Login Lock'),
subtitle: const Text(
"Require this device's PIN or biometric to open Noo",
),
value: provider.loginLockEnabled,
onChanged: (value) =>
_handleLoginLockChanged(context, provider, value),
),
const Divider(height: 1, indent: 16, endIndent: 16),
SwitchListTile(
secondary: const Icon(Icons.swap_horiz_rounded),
title: const Text('Lock account switching'),
subtitle: const Text('Unlock to switch between saved accounts'),
value: provider.lockAccountSwitching,
onChanged: provider.loginLockEnabled
? provider.setLockAccountSwitching
: null,
),
const Divider(height: 1, indent: 16, endIndent: 16),
SwitchListTile(
secondary: const Icon(Icons.visibility_off_rounded),
title: const Text('Lock hidden files'),
subtitle: const Text('Unlock to reveal hidden files and folders'),
value: provider.lockHiddenFiles,
onChanged: provider.loginLockEnabled
? provider.setLockHiddenFiles
: null,
),
],
),
);
}
}
class _BottomBarAppearanceCard extends StatefulWidget { class _BottomBarAppearanceCard extends StatefulWidget {
const _BottomBarAppearanceCard(); const _BottomBarAppearanceCard();
+1 -1
View File
@@ -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),
+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'),
),
),
],
),
),
),
);
}
}
+117 -1
View File
@@ -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;
+1 -1
View File
@@ -147,7 +147,7 @@ class _PhotosViewState extends State<PhotosView> {
? Icons.visibility_rounded ? Icons.visibility_rounded
: Icons.visibility_off_rounded, : Icons.visibility_off_rounded,
isSelected: provider.showHiddenPhotos, isSelected: provider.showHiddenPhotos,
onTap: provider.toggleShowHiddenPhotos, onTap: () => provider.toggleShowHiddenPhotos(),
tooltip: 'Show hidden files', tooltip: 'Show hidden files',
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
+7 -4
View File
@@ -13,15 +13,18 @@ import '../views/account_view.dart';
class ProfileAvatarButton extends StatelessWidget { class ProfileAvatarButton extends StatelessWidget {
const ProfileAvatarButton({super.key}); const ProfileAvatarButton({super.key});
void _handleVerticalSwipe(BuildContext context, DragEndDetails details) { Future<void> _handleVerticalSwipe(
BuildContext context,
DragEndDetails details,
) async {
final velocity = details.primaryVelocity ?? 0; final velocity = details.primaryVelocity ?? 0;
if (velocity.abs() < 250) return; if (velocity.abs() < 250) return;
final provider = context.read<ServerProvider>(); final provider = context.read<ServerProvider>();
final target = velocity < 0 final target = velocity < 0
? provider.cycleToNextAccount() ? await provider.cycleToNextAccount()
: provider.cycleToPreviousAccount(); : await provider.cycleToPreviousAccount();
if (target == null) return; if (target == null || !context.mounted) return;
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
+48
View File
@@ -278,6 +278,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_secure_storage: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -456,6 +464,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
local_auth:
dependency: "direct main"
description:
name: local_auth
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
local_auth_android:
dependency: transitive
description:
name: local_auth_android
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
url: "https://pub.dev"
source: hosted
version: "1.0.56"
local_auth_darwin:
dependency: transitive
description:
name: local_auth_darwin
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
url: "https://pub.dev"
source: hosted
version: "1.6.1"
local_auth_platform_interface:
dependency: transitive
description:
name: local_auth_platform_interface
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
url: "https://pub.dev"
source: hosted
version: "1.1.0"
local_auth_windows:
dependency: transitive
description:
name: local_auth_windows
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
url: "https://pub.dev"
source: hosted
version: "1.0.11"
logging: logging:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -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(
+1
View File
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color dynamic_color
file_saver file_saver
flutter_secure_storage_windows flutter_secure_storage_windows
local_auth_windows
pdfx pdfx
share_plus share_plus
url_launcher_windows url_launcher_windows