diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d6e7d5a..99418bb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 160942d..b5fd30a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + 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'], ); } } diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index 563c25e..52a4cde 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -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 _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 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 _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 cycleToNextAccount() => _cycleAccount(1); + Future 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 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 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 _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 logout() async { + if (_activeAccountId == null) return; + await _deactivateSession(); + } Future 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 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 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 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 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 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 _passGate(bool gate, String reason) async { + if (!_loginLockEnabled || !gate) return true; + return AppLockService.authenticate(reason); + } + void setThemeMode(ThemeMode mode) { _themeMode = mode; notifyListeners(); diff --git a/lib/services/app_lock_service.dart b/lib/services/app_lock_service.dart new file mode 100644 index 0000000..8a205d7 --- /dev/null +++ b/lib/services/app_lock_service.dart @@ -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 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 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; + } + } +} diff --git a/lib/services/nextcloud_service.dart b/lib/services/nextcloud_service.dart index 0fb553b..6d656fb 100644 --- a/lib/services/nextcloud_service.dart +++ b/lib/services/nextcloud_service.dart @@ -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, ); diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index caadbdd..3ce1491 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -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 _handleLogout( + BuildContext context, + ServerProvider provider, + ) async { + final navigator = Navigator.of(context); + await provider.logout(); + navigator.popUntil((route) => route.isFirst); + } + + Future _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( 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 _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(); + + 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(); diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 7ac02cc..b333f00 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -381,7 +381,7 @@ class _FilesViewState extends State { ? Icons.visibility_rounded : Icons.visibility_off_rounded, isSelected: provider.showHiddenFiles, - onTap: provider.toggleShowHiddenFiles, + onTap: () => provider.toggleShowHiddenFiles(), tooltip: 'Show hidden files', ), const SizedBox(width: 4), diff --git a/lib/views/lock_screen_view.dart b/lib/views/lock_screen_view.dart new file mode 100644 index 0000000..8ff2700 --- /dev/null +++ b/lib/views/lock_screen_view.dart @@ -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 createState() => _LockScreenViewState(); +} + +class _LockScreenViewState extends State + 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().needsUnlock) _attemptUnlock(); + } + + Future _attemptUnlock() async { + if (_authenticating || !mounted) return; + setState(() => _authenticating = true); + await context.read().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'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/login_view.dart b/lib/views/login_view.dart index 182de2f..0443011 100644 --- a/lib/views/login_view.dart +++ b/lib/views/login_view.dart @@ -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 { 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 accounts; + final ValueChanged 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; diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index c0afa02..cb7ffa6 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -147,7 +147,7 @@ class _PhotosViewState extends State { ? Icons.visibility_rounded : Icons.visibility_off_rounded, isSelected: provider.showHiddenPhotos, - onTap: provider.toggleShowHiddenPhotos, + onTap: () => provider.toggleShowHiddenPhotos(), tooltip: 'Show hidden files', ), const SizedBox(width: 4), diff --git a/lib/widgets/profile_avatar_button.dart b/lib/widgets/profile_avatar_button.dart index d57585e..3cbbeaa 100644 --- a/lib/widgets/profile_avatar_button.dart +++ b/lib/widgets/profile_avatar_button.dart @@ -13,15 +13,18 @@ import '../views/account_view.dart'; class ProfileAvatarButton extends StatelessWidget { const ProfileAvatarButton({super.key}); - void _handleVerticalSwipe(BuildContext context, DragEndDetails details) { + Future _handleVerticalSwipe( + BuildContext context, + DragEndDetails details, + ) async { final velocity = details.primaryVelocity ?? 0; if (velocity.abs() < 250) return; final provider = context.read(); 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( diff --git a/pubspec.lock b/pubspec.lock index f112ea6..a441a02 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index e4e6500..b7a5bc7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 5df7a5a..bac2c28 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -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( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ee06182..947569c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -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