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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- For local_auth's biometric app-lock (Settings > Security). -->
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||
<application
|
||||
android:label="Noo"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
// FlutterFragmentActivity (not the default FlutterActivity) is required by
|
||||
// local_auth's Android implementation, which hosts its biometric/device
|
||||
// credential prompt via a Fragment.
|
||||
class MainActivity : FlutterFragmentActivity()
|
||||
|
||||
+6
-3
@@ -6,6 +6,7 @@ import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
import 'models/app_tab.dart';
|
||||
import 'providers/server_provider.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'views/lock_screen_view.dart';
|
||||
import 'views/login_view.dart';
|
||||
import 'views/search_view.dart';
|
||||
import 'views/share_upload_view.dart';
|
||||
@@ -48,9 +49,11 @@ class NextcloudApp extends StatelessWidget {
|
||||
themeMode: provider.themeMode,
|
||||
home: provider.isRestoringSession
|
||||
? const _SplashView()
|
||||
: (provider.isLoggedIn
|
||||
? const MainShellView()
|
||||
: const LoginView()),
|
||||
: (!provider.isLoggedIn
|
||||
? const LoginView()
|
||||
: (provider.needsUnlock
|
||||
? const LockScreenView()
|
||||
: const MainShellView())),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -139,6 +139,7 @@ class NextcloudUserQuota {
|
||||
final String userName;
|
||||
final String email;
|
||||
final String serverVersion;
|
||||
final List<String> groups;
|
||||
|
||||
const NextcloudUserQuota({
|
||||
required this.usedBytes,
|
||||
@@ -147,6 +148,7 @@ class NextcloudUserQuota {
|
||||
required this.userName,
|
||||
required this.email,
|
||||
required this.serverVersion,
|
||||
this.groups = const [],
|
||||
});
|
||||
|
||||
factory NextcloudUserQuota.demo() {
|
||||
@@ -157,6 +159,7 @@ class NextcloudUserQuota {
|
||||
userName: 'Ayush Admin',
|
||||
email: 'ayush@cloud.internal',
|
||||
serverVersion: 'Nextcloud 29.0.4 Hub 8',
|
||||
groups: ['Family', 'Friends'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../models/nextcloud_share.dart';
|
||||
import '../models/nextcloud_sharee.dart';
|
||||
import '../models/saved_account.dart';
|
||||
import '../services/account_store.dart';
|
||||
import '../services/app_lock_service.dart';
|
||||
import '../services/login_flow_service.dart';
|
||||
import '../services/nextcloud_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
@@ -84,6 +85,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
static const _prefCachePolicy = 'ui_cache_policy';
|
||||
static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
|
||||
static const _prefLoginLockEnabled = 'ui_login_lock_enabled';
|
||||
static const _prefLockAccountSwitching = 'ui_lock_account_switching';
|
||||
static const _prefLockHiddenFiles = 'ui_lock_hidden_files';
|
||||
|
||||
final Future<SharedPreferences> _prefsFuture =
|
||||
SharedPreferences.getInstance();
|
||||
@@ -114,6 +118,17 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
double _bottomBarBlur = 28;
|
||||
bool _tapTabToScrollTop = true;
|
||||
|
||||
// App lock (PIN/biometric via the device's own credential, not our own
|
||||
// storage - see AppLockService). Global, not per-account: it guards
|
||||
// access to the app/its accounts, not any one account's content.
|
||||
bool _loginLockEnabled = false;
|
||||
bool _lockAccountSwitching = false;
|
||||
bool _lockHiddenFiles = false;
|
||||
// Transient (never persisted) - starts locked whenever the app process
|
||||
// starts, and re-locks on every backgrounding if a lock is configured;
|
||||
// see didChangeAppLifecycleState.
|
||||
bool _isUnlocked = false;
|
||||
|
||||
// Navigation state
|
||||
String _currentFolderPath = '/';
|
||||
|
||||
@@ -217,12 +232,20 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
|
||||
/// Starts/stops the periodic folder-listing refresh as the app leaves and
|
||||
/// returns to the foreground - only relevant under [CachePolicy.interval].
|
||||
/// Also re-locks the app on backgrounding when login lock is set up - a
|
||||
/// one-time unlock at cold start would give the feature no real security
|
||||
/// value, since the realistic threat is someone else picking up an
|
||||
/// already-running, unlocked phone.
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_startCacheRefreshTimerIfNeeded();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
_cacheRefreshTimer?.cancel();
|
||||
if (_loginLockEnabled && _isUnlocked) {
|
||||
_isUnlocked = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +283,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
double get bottomBarOpacity => _bottomBarOpacity;
|
||||
double get bottomBarBlur => _bottomBarBlur;
|
||||
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
||||
bool get loginLockEnabled => _loginLockEnabled;
|
||||
bool get lockAccountSwitching => _lockAccountSwitching;
|
||||
bool get lockHiddenFiles => _lockHiddenFiles;
|
||||
bool get needsUnlock => _loginLockEnabled && !_isUnlocked;
|
||||
|
||||
String get currentFolderPath => _currentFolderPath;
|
||||
AppTab? get requestedTab => _requestedTab;
|
||||
@@ -450,6 +477,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
|
||||
_tapTabToScrollTop =
|
||||
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
|
||||
_loginLockEnabled =
|
||||
prefs.getBool(_prefLoginLockEnabled) ?? _loginLockEnabled;
|
||||
_lockAccountSwitching =
|
||||
prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching;
|
||||
_lockHiddenFiles =
|
||||
prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles;
|
||||
_applyAccountPrefs(prefs, _activeAccountId);
|
||||
|
||||
final savedOrderNames = prefs.getStringList(_prefTabOrder);
|
||||
@@ -865,15 +898,21 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
/// Switches to an already-saved account. No-op if it's already active or
|
||||
/// unknown.
|
||||
/// unknown. Gated behind login lock when [lockAccountSwitching] is on.
|
||||
Future<void> switchAccount(String accountId) async {
|
||||
if (accountId == _activeAccountId) return;
|
||||
if (!_accounts.any((a) => a.id == accountId)) return;
|
||||
if (!await _passGate(_lockAccountSwitching, 'Unlock to switch accounts')) {
|
||||
return;
|
||||
}
|
||||
await _activateAccount(accountId);
|
||||
}
|
||||
|
||||
SavedAccount? _cycleAccount(int direction) {
|
||||
Future<SavedAccount?> _cycleAccount(int direction) async {
|
||||
if (_accounts.length < 2) return null;
|
||||
if (!await _passGate(_lockAccountSwitching, 'Unlock to switch accounts')) {
|
||||
return null;
|
||||
}
|
||||
final currentIndex = _accounts.indexWhere((a) => a.id == _activeAccountId);
|
||||
final targetIndex = currentIndex == -1
|
||||
? 0
|
||||
@@ -886,17 +925,18 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
|
||||
/// Cycles to the next/previous saved account (by the order they were
|
||||
/// added) - used by the avatar's swipe-up/down quick-switch gesture.
|
||||
/// Returns the account it's switching to (synchronously, before the
|
||||
/// switch's own network verification completes) so the caller can show
|
||||
/// immediate feedback, or null if there's fewer than 2 saved accounts.
|
||||
SavedAccount? cycleToNextAccount() => _cycleAccount(1);
|
||||
SavedAccount? cycleToPreviousAccount() => _cycleAccount(-1);
|
||||
/// Returns the account it's switching to once any login-lock gate has
|
||||
/// passed (the switch's own network verification is still fire-and-forget
|
||||
/// after that, same as before), or null if there's fewer than 2 saved
|
||||
/// accounts or the gate was not passed.
|
||||
Future<SavedAccount?> cycleToNextAccount() => _cycleAccount(1);
|
||||
Future<SavedAccount?> cycleToPreviousAccount() => _cycleAccount(-1);
|
||||
|
||||
/// Removes a saved account entirely: its stored password, its
|
||||
/// namespaced prefs, and its entry in the saved-accounts list. If it was
|
||||
/// the active account, falls back to another saved account, or - if none
|
||||
/// remain - performs a full logout (the only path that sets
|
||||
/// [isLoggedIn] false).
|
||||
/// remain - deactivates the session (the only path here that sets
|
||||
/// [isLoggedIn] false; unlike [logout], there's nothing left to keep).
|
||||
Future<void> removeAccount(String accountId) async {
|
||||
final index = _accounts.indexWhere((a) => a.id == accountId);
|
||||
if (index == -1) return;
|
||||
@@ -916,28 +956,45 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
if (_accounts.isEmpty) {
|
||||
_sessionGeneration++;
|
||||
_activeAccountId = null;
|
||||
await _accountStore.saveActiveAccountId(prefs, null);
|
||||
_cacheRefreshTimer?.cancel();
|
||||
_directoryCache.clear();
|
||||
_clearActiveContent();
|
||||
_isLoggedIn = false;
|
||||
_serverUrl = '';
|
||||
_username = '';
|
||||
_password = '';
|
||||
_applyAccountPrefs(prefs, null);
|
||||
notifyListeners();
|
||||
await _deactivateSession();
|
||||
return;
|
||||
}
|
||||
|
||||
await _activateAccount(_accounts.first.id);
|
||||
}
|
||||
|
||||
/// Removes the active account. Kept as the app's one "Log Out" action -
|
||||
/// with multiple accounts saved this falls back to another one instead
|
||||
/// of ending the session, exactly like removing any other account would.
|
||||
Future<void> logout() => removeAccount(_activeAccountId ?? '');
|
||||
/// Clears the active session pointer and tears down its content, without
|
||||
/// touching any saved account's own data - shared by [logout] (which
|
||||
/// deliberately keeps the account around to resume later, no
|
||||
/// re-authentication needed) and [removeAccount]'s "nothing left to fall
|
||||
/// back to" branch (where the account's data has already been deleted by
|
||||
/// the time this runs).
|
||||
Future<void> _deactivateSession() async {
|
||||
_sessionGeneration++;
|
||||
_cacheRefreshTimer?.cancel();
|
||||
_directoryCache.clear();
|
||||
_clearActiveContent();
|
||||
_isLoggedIn = false;
|
||||
_serverUrl = '';
|
||||
_username = '';
|
||||
_password = '';
|
||||
_activeAccountId = null;
|
||||
final prefs = await _prefsFuture;
|
||||
await _accountStore.saveActiveAccountId(prefs, null);
|
||||
_applyAccountPrefs(prefs, null);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Ends the active session but keeps this account saved - unlike
|
||||
/// [removeAccount], nothing is deleted (password, prefs, its entry in
|
||||
/// [accounts] all remain), so it's available to resume with a single tap
|
||||
/// from the login screen's saved-accounts list, no Login Flow v2 needed.
|
||||
/// Always lands on the login screen even if other accounts are saved -
|
||||
/// deliberately not the same as switching to one of them.
|
||||
Future<void> logout() async {
|
||||
if (_activeAccountId == null) return;
|
||||
await _deactivateSession();
|
||||
}
|
||||
|
||||
Future<void> refreshData() async {
|
||||
if (!_isLoggedIn || _service == null) {
|
||||
@@ -1145,7 +1202,14 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
void toggleShowHiddenFiles() {
|
||||
/// Only *enabling* hidden-files visibility is gated - hiding them again
|
||||
/// never exposes anything, so that direction is always allowed instantly.
|
||||
Future<void> toggleShowHiddenFiles() async {
|
||||
if (!_showHiddenFiles) {
|
||||
if (!await _passGate(_lockHiddenFiles, 'Unlock to show hidden files')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHiddenFiles = !_showHiddenFiles;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
@@ -1154,7 +1218,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
void toggleShowHiddenPhotos() {
|
||||
Future<void> toggleShowHiddenPhotos() async {
|
||||
if (!_showHiddenPhotos) {
|
||||
if (!await _passGate(_lockHiddenFiles, 'Unlock to show hidden files')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHiddenPhotos = !_showHiddenPhotos;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
@@ -1643,6 +1712,78 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_prefsFuture.then((p) => p.setBool(_prefTapTabToScrollTop, value));
|
||||
}
|
||||
|
||||
/// Confirms the device can do local auth and prompts once to enable login
|
||||
/// lock. Returns false (leaving lock disabled) if the device has no
|
||||
/// biometric/PIN capability or the user cancels/fails the confirmation.
|
||||
Future<bool> setupLoginLock() async {
|
||||
if (!await AppLockService.isDeviceSupported()) return false;
|
||||
final confirmed = await AppLockService.authenticate(
|
||||
'Confirm to turn on login lock',
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
_loginLockEnabled = true;
|
||||
// Already just authenticated - don't immediately re-prompt behind it.
|
||||
_isUnlocked = true;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, true));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Turns login lock off, along with both of its sub-toggles (meaningless
|
||||
/// once the base lock is gone). Requires a successful auth first, same as
|
||||
/// turning it on - otherwise anyone with momentary access to an unlocked
|
||||
/// phone could just switch it off.
|
||||
Future<bool> disableLoginLock() async {
|
||||
if (!_loginLockEnabled) return true;
|
||||
final confirmed = await AppLockService.authenticate(
|
||||
'Confirm to turn off login lock',
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
_loginLockEnabled = false;
|
||||
_lockAccountSwitching = false;
|
||||
_lockHiddenFiles = false;
|
||||
_isUnlocked = false;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) {
|
||||
p.setBool(_prefLoginLockEnabled, false);
|
||||
p.setBool(_prefLockAccountSwitching, false);
|
||||
p.setBool(_prefLockHiddenFiles, false);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void setLockAccountSwitching(bool value) {
|
||||
if (!_loginLockEnabled) return;
|
||||
_lockAccountSwitching = value;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
|
||||
}
|
||||
|
||||
void setLockHiddenFiles(bool value) {
|
||||
if (!_loginLockEnabled) return;
|
||||
_lockHiddenFiles = value;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value));
|
||||
}
|
||||
|
||||
/// Called by the lock screen. Returns whether it actually unlocked.
|
||||
Future<bool> attemptUnlock() async {
|
||||
final success = await AppLockService.authenticate('Unlock Noo');
|
||||
if (success) {
|
||||
_isUnlocked = true;
|
||||
notifyListeners();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Prompts for auth if [gate] is on and login lock is configured;
|
||||
/// returns true immediately (no prompt) otherwise. Shared by the
|
||||
/// account-switching and hidden-files gates below.
|
||||
Future<bool> _passGate(bool gate, String reason) async {
|
||||
if (!_loginLockEnabled || !gate) return true;
|
||||
return AppLockService.authenticate(reason);
|
||||
}
|
||||
|
||||
void setThemeMode(ThemeMode mode) {
|
||||
_themeMode = mode;
|
||||
notifyListeners();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
|
||||
/// PIN storage/hashing - `authenticate` delegates to whatever the OS
|
||||
/// already has configured (fingerprint/face, or the device's own PIN/
|
||||
/// pattern/password as a fallback when `biometricOnly` is false), which is
|
||||
/// both simpler and more secure than reimplementing credential storage.
|
||||
class AppLockService {
|
||||
static final LocalAuthentication _auth = LocalAuthentication();
|
||||
|
||||
/// Whether this device can do *some* form of local auth - biometric
|
||||
/// enrolled, or at minimum a device PIN/pattern/password set up.
|
||||
static Future<bool> isDeviceSupported() async {
|
||||
try {
|
||||
final canCheckBiometrics = await _auth.canCheckBiometrics;
|
||||
if (canCheckBiometrics) return true;
|
||||
return await _auth.isDeviceSupported();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompts for biometric or device-credential auth. Returns false (never
|
||||
/// throws) on cancellation, failure, or any platform error, so callers
|
||||
/// can treat every non-true result the same way: stay locked/blocked.
|
||||
static Future<bool> authenticate(String reason) async {
|
||||
try {
|
||||
return await _auth.authenticate(
|
||||
localizedReason: reason,
|
||||
options: const AuthenticationOptions(
|
||||
biometricOnly: false,
|
||||
stickyAuth: true,
|
||||
// Default (true) requires an extra manual confirmation tap after
|
||||
// a *passive* modality like face/iris recognizes you - Android's
|
||||
// own guidance reserves that for confirming risky actions
|
||||
// (a purchase); unlocking the app is exactly the "lower-risk"
|
||||
// case they say to pass false for, and on some devices/Android
|
||||
// versions it also determines whether face is offered as an
|
||||
// option in the prompt at all, not just whether it needs a
|
||||
// second tap to accept.
|
||||
sensitiveTransaction: false,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,15 @@ DateTime? _parseDavDate(String? raw) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The OCS Activity API reports each event's time as an ISO 8601 string in
|
||||
/// a `datetime` field (e.g. "2025-09-15T12:34:56+00:00") - there is no
|
||||
/// numeric `timestamp` field despite that being a very easy name to guess.
|
||||
/// Falls back to now() only if the field is missing/unparseable, so a
|
||||
/// broken response reads as "just now" rather than the Unix epoch.
|
||||
DateTime _parseActivityDateTime(dynamic raw) {
|
||||
return DateTime.tryParse(raw?.toString() ?? '') ?? DateTime.now();
|
||||
}
|
||||
|
||||
/// Normalizes a WebDAV `href` (which servers may return as either a bare
|
||||
/// path or a full absolute URL) down to just its path, trailing slash
|
||||
/// stripped, so hrefs from either form can be compared directly.
|
||||
@@ -872,6 +881,9 @@ class NextcloudService {
|
||||
final pct = total > 0 ? (used / total).clamp(0.0, 1.0) : 0.0;
|
||||
final display = ocsData['displayname'] ?? username;
|
||||
final email = ocsData['email'] ?? '$username@$serverUrl';
|
||||
final groups = ((ocsData['groups'] as List?) ?? [])
|
||||
.map((g) => g.toString())
|
||||
.toList();
|
||||
|
||||
return NextcloudUserQuota(
|
||||
usedBytes: used,
|
||||
@@ -880,6 +892,7 @@ class NextcloudService {
|
||||
userName: display,
|
||||
email: email,
|
||||
serverVersion: 'Nextcloud Server',
|
||||
groups: groups,
|
||||
);
|
||||
} else {
|
||||
throw Exception(
|
||||
@@ -914,9 +927,7 @@ class NextcloudService {
|
||||
id: (a['activity_id'] ?? '').toString(),
|
||||
title: a['subject'] ?? 'Server Activity',
|
||||
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(a['timestamp'] as int? ?? 0) * 1000,
|
||||
),
|
||||
timestamp: _parseActivityDateTime(a['datetime']),
|
||||
icon: Icons.cloud_outlined,
|
||||
author: a['user'] ?? username,
|
||||
);
|
||||
@@ -1500,9 +1511,7 @@ class NextcloudService {
|
||||
id: (a['activity_id'] ?? '').toString(),
|
||||
title: a['subject'] ?? 'Activity',
|
||||
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(a['timestamp'] as int? ?? 0) * 1000,
|
||||
),
|
||||
timestamp: _parseActivityDateTime(a['datetime']),
|
||||
icon: Icons.cloud_outlined,
|
||||
author: a['user'] ?? username,
|
||||
);
|
||||
|
||||
+244
-100
@@ -6,17 +6,12 @@ import '../providers/server_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/frosted_glass_container.dart';
|
||||
import '../widgets/seek_bar_painter.dart';
|
||||
import '../widgets/synced_header_scaffold.dart' show formatQuota;
|
||||
import 'login_view.dart';
|
||||
|
||||
class AccountView extends StatelessWidget {
|
||||
const AccountView({super.key});
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 GB';
|
||||
final gb = bytes / (1024 * 1024 * 1024);
|
||||
return '${gb.toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -80,16 +75,33 @@ class AccountView extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
_showLogoutConfirmation(context, provider),
|
||||
icon: const Icon(Icons.logout_rounded),
|
||||
tooltip: 'Log Out',
|
||||
color: colorScheme.error,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.public_rounded,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
Uri.tryParse(provider.serverUrl)?.host ??
|
||||
provider.serverUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
@@ -102,11 +114,7 @@ class AccountView extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
quota != null
|
||||
? (quota.totalBytes > 0
|
||||
? '${_formatBytes(quota.usedBytes)} of ${_formatBytes(quota.totalBytes)}'
|
||||
: '${_formatBytes(quota.usedBytes)} (Unlimited)')
|
||||
: 'Live Server Storage',
|
||||
quota != null ? formatQuota(quota) : 'Loading…',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -129,52 +137,111 @@ class AccountView extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
quota != null
|
||||
? (quota.totalBytes > 0
|
||||
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
|
||||
: 'Unlimited Storage Plan')
|
||||
: 'WebDAV connection active',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
quota != null
|
||||
? (quota.totalBytes > 0
|
||||
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
|
||||
: 'Unlimited storage')
|
||||
: '',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (quota != null && quota.groups.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Groups',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final group in quota.groups)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
group,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: provider.isLoggedIn
|
||||
? Colors.green.withValues(alpha: 0.2)
|
||||
: Colors.red.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
provider.isLoggedIn ? 'ONLINE' : 'OFFLINE',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: provider.isLoggedIn
|
||||
? Colors.green.shade800
|
||||
: Colors.red.shade800,
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
await provider.refreshData();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Refreshed WebDAV data'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'Refresh Cache',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Connected to ${provider.serverUrl}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: FilledButton(
|
||||
onPressed: () => _handleLogout(context, provider),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
foregroundColor: colorScheme.onErrorContainer,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'Logout',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () =>
|
||||
_confirmRemoveActive(context, provider),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.error,
|
||||
foregroundColor: colorScheme.onError,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'Remove',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -196,47 +263,15 @@ class AccountView extends StatelessWidget {
|
||||
const _AccountsCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Server Credentials Section
|
||||
// Security Section
|
||||
Text(
|
||||
'Server Connection Info',
|
||||
'Security',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.dns_rounded),
|
||||
title: const Text('Nextcloud Host'),
|
||||
subtitle: Text(provider.serverUrl),
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_rounded),
|
||||
title: const Text('Logged In User'),
|
||||
subtitle: Text(provider.username),
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh_rounded),
|
||||
title: const Text('Refresh WebDAV Cache'),
|
||||
onTap: () async {
|
||||
await provider.refreshData();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Refreshed WebDAV directory data'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const _SecurityCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Material You Design Settings
|
||||
@@ -452,34 +487,62 @@ class AccountView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showLogoutConfirmation(BuildContext context, ServerProvider provider) {
|
||||
showDialog(
|
||||
/// Logout keeps the account saved (see [ServerProvider.logout]) so it's
|
||||
/// harmless/reversible from a one-tap resume on the login screen -
|
||||
/// doesn't need a confirmation dialog the way [_confirmRemoveActive] does.
|
||||
Future<void> _handleLogout(
|
||||
BuildContext context,
|
||||
ServerProvider provider,
|
||||
) async {
|
||||
final navigator = Navigator.of(context);
|
||||
await provider.logout();
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
Future<void> _confirmRemoveActive(
|
||||
BuildContext context,
|
||||
ServerProvider provider,
|
||||
) async {
|
||||
final host = Uri.tryParse(provider.serverUrl)?.host ?? provider.serverUrl;
|
||||
final hasOtherAccounts = provider.accounts.length > 1;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Log Out'),
|
||||
title: const Text('Remove Account'),
|
||||
content: Text(
|
||||
'Are you sure you want to disconnect from ${provider.serverUrl}?',
|
||||
hasOtherAccounts
|
||||
? 'Remove ${provider.username} ($host)? Another saved account will become active.'
|
||||
: 'Remove ${provider.username} ($host)? You can add it again later.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
provider.logout();
|
||||
},
|
||||
child: const Text('Log Out'),
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final id = provider.activeAccountId;
|
||||
if (id == null) return;
|
||||
if (!context.mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
await provider.removeAccount(id);
|
||||
// Only pop back to the root route if that was the last account and the
|
||||
// session actually ended - if it fell back to another saved account,
|
||||
// Settings just keeps showing (now for that account) instead.
|
||||
if (!provider.isLoggedIn) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +586,17 @@ class _AccountsCard extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
if (confirmed == true) {
|
||||
if (!context.mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
await provider.removeAccount(account.id);
|
||||
// Only when this was the last saved account does isLoggedIn drop to
|
||||
// false and main.dart swap the root route to LoginView underneath -
|
||||
// pop back to it then, rather than leaving Settings stranded on top.
|
||||
// Removing a non-active account, or falling back to another one,
|
||||
// both keep the user logged in, so Settings should just stay put.
|
||||
if (!provider.isLoggedIn) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,6 +688,77 @@ class _AccountRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Login lock: gates opening the app, switching accounts, and revealing
|
||||
/// hidden files behind the device's own PIN/biometric credential (see
|
||||
/// AppLockService - this app never stores or handles a PIN itself).
|
||||
class _SecurityCard extends StatelessWidget {
|
||||
const _SecurityCard();
|
||||
|
||||
Future<void> _handleLoginLockChanged(
|
||||
BuildContext context,
|
||||
ServerProvider provider,
|
||||
bool value,
|
||||
) async {
|
||||
final success = value
|
||||
? await provider.setupLoginLock()
|
||||
: await provider.disableLoginLock();
|
||||
if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
value
|
||||
? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured"
|
||||
: 'Could not turn off login lock',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<ServerProvider>();
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.lock_outline_rounded),
|
||||
title: const Text('Login Lock'),
|
||||
subtitle: const Text(
|
||||
"Require this device's PIN or biometric to open Noo",
|
||||
),
|
||||
value: provider.loginLockEnabled,
|
||||
onChanged: (value) =>
|
||||
_handleLoginLockChanged(context, provider, value),
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.swap_horiz_rounded),
|
||||
title: const Text('Lock account switching'),
|
||||
subtitle: const Text('Unlock to switch between saved accounts'),
|
||||
value: provider.lockAccountSwitching,
|
||||
onChanged: provider.loginLockEnabled
|
||||
? provider.setLockAccountSwitching
|
||||
: null,
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.visibility_off_rounded),
|
||||
title: const Text('Lock hidden files'),
|
||||
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
||||
value: provider.lockHiddenFiles,
|
||||
onChanged: provider.loginLockEnabled
|
||||
? provider.setLockHiddenFiles
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomBarAppearanceCard extends StatefulWidget {
|
||||
const _BottomBarAppearanceCard();
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
? Icons.visibility_rounded
|
||||
: Icons.visibility_off_rounded,
|
||||
isSelected: provider.showHiddenFiles,
|
||||
onTap: provider.toggleShowHiddenFiles,
|
||||
onTap: () => provider.toggleShowHiddenFiles(),
|
||||
tooltip: 'Show hidden files',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
|
||||
/// Shown in place of the main shell whenever [ServerProvider.needsUnlock] is
|
||||
/// true - a fresh app launch with login lock configured, or returning to the
|
||||
/// foreground after being backgrounded. Prompts automatically on first show
|
||||
/// and again whenever the app resumes while still locked (e.g. the user
|
||||
/// switched away to enter their device PIN in the system UI and came back).
|
||||
class LockScreenView extends StatefulWidget {
|
||||
const LockScreenView({super.key});
|
||||
|
||||
@override
|
||||
State<LockScreenView> createState() => _LockScreenViewState();
|
||||
}
|
||||
|
||||
class _LockScreenViewState extends State<LockScreenView>
|
||||
with WidgetsBindingObserver {
|
||||
bool _authenticating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _attemptUnlock());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed || !mounted || _authenticating) {
|
||||
return;
|
||||
}
|
||||
if (context.read<ServerProvider>().needsUnlock) _attemptUnlock();
|
||||
}
|
||||
|
||||
Future<void> _attemptUnlock() async {
|
||||
if (_authenticating || !mounted) return;
|
||||
setState(() => _authenticating = true);
|
||||
await context.read<ServerProvider>().attemptUnlock();
|
||||
if (mounted) setState(() => _authenticating = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Same monochrome-tinted treatment as the splash screen (see
|
||||
// main.dart's _SplashView) - the asset is a plain white
|
||||
// silhouette on transparent, meant to be recolored rather
|
||||
// than shown as-is.
|
||||
ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
colorScheme.onSurface,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/icon/app_icon_monochrome.png',
|
||||
width: 80,
|
||||
height: 80,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Noo is locked',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Unlock with your PIN or biometrics to continue',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
height: 54,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _authenticating ? null : _attemptUnlock,
|
||||
icon: _authenticating
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.lock_open_rounded),
|
||||
label: Text(_authenticating ? 'Unlocking…' : 'Unlock'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+127
-11
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/saved_account.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
|
||||
class LoginView extends StatefulWidget {
|
||||
@@ -82,17 +83,53 @@ class _LoginViewState extends State<LoginView> {
|
||||
onCancel: () => provider.cancelLoginFlow(),
|
||||
onReopenBrowser: provider.reopenLoginBrowser,
|
||||
)
|
||||
: _ServerForm(
|
||||
formKey: _formKey,
|
||||
urlController: _urlController,
|
||||
isLoading: isInitiating || provider.isLoading,
|
||||
errorMessage:
|
||||
provider.loginFlowStatus == LoginFlowStatus.error
|
||||
? provider.errorMessage
|
||||
: null,
|
||||
onContinue: _handleContinue,
|
||||
theme: theme,
|
||||
colorScheme: colorScheme,
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// A logout keeps the account saved rather than
|
||||
// deleting it, specifically so it can be resumed
|
||||
// from here with one tap - no need to repeat
|
||||
// Login Flow v2.
|
||||
if (!widget.isAddingAccount &&
|
||||
provider.accounts.isNotEmpty) ...[
|
||||
_SavedAccountsSection(
|
||||
accounts: provider.accounts,
|
||||
onSelect: (account) =>
|
||||
provider.switchAccount(account.id),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
child: Text(
|
||||
'or',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
_ServerForm(
|
||||
formKey: _formKey,
|
||||
urlController: _urlController,
|
||||
isLoading: isInitiating || provider.isLoading,
|
||||
errorMessage:
|
||||
provider.loginFlowStatus == LoginFlowStatus.error
|
||||
? provider.errorMessage
|
||||
: null,
|
||||
onContinue: _handleContinue,
|
||||
theme: theme,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -265,6 +302,85 @@ class _ServerForm extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved accounts a logout left recoverable - tapping one resumes it via
|
||||
/// [ServerProvider.switchAccount] instead of repeating Login Flow v2.
|
||||
class _SavedAccountsSection extends StatelessWidget {
|
||||
final List<SavedAccount> accounts;
|
||||
final ValueChanged<SavedAccount> onSelect;
|
||||
|
||||
const _SavedAccountsSection({required this.accounts, required this.onSelect});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text(
|
||||
'Continue as',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (final account in accounts) ...[
|
||||
_SavedAccountRow(
|
||||
account: account,
|
||||
onTap: () => onSelect(account),
|
||||
),
|
||||
if (account != accounts.last)
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SavedAccountRow extends StatelessWidget {
|
||||
final SavedAccount account;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SavedAccountRow({required this.account, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
|
||||
final initial = account.username.isNotEmpty
|
||||
? account.username[0].toUpperCase()
|
||||
: '?';
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: colorScheme.primary,
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(account.username),
|
||||
subtitle: Text(host),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WaitingForBrowser extends StatelessWidget {
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onReopenBrowser;
|
||||
|
||||
@@ -147,7 +147,7 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
? Icons.visibility_rounded
|
||||
: Icons.visibility_off_rounded,
|
||||
isSelected: provider.showHiddenPhotos,
|
||||
onTap: provider.toggleShowHiddenPhotos,
|
||||
onTap: () => provider.toggleShowHiddenPhotos(),
|
||||
tooltip: 'Show hidden files',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
@@ -13,15 +13,18 @@ import '../views/account_view.dart';
|
||||
class ProfileAvatarButton extends StatelessWidget {
|
||||
const ProfileAvatarButton({super.key});
|
||||
|
||||
void _handleVerticalSwipe(BuildContext context, DragEndDetails details) {
|
||||
Future<void> _handleVerticalSwipe(
|
||||
BuildContext context,
|
||||
DragEndDetails details,
|
||||
) async {
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
if (velocity.abs() < 250) return;
|
||||
|
||||
final provider = context.read<ServerProvider>();
|
||||
final target = velocity < 0
|
||||
? provider.cycleToNextAccount()
|
||||
: provider.cycleToPreviousAccount();
|
||||
if (target == null) return;
|
||||
? await provider.cycleToNextAccount()
|
||||
: await provider.cycleToPreviousAccount();
|
||||
if (target == null || !context.mounted) return;
|
||||
|
||||
HapticFeedback.selectionClick();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -55,6 +55,7 @@ dependencies:
|
||||
shared_preferences: ^2.5.5
|
||||
receive_sharing_intent: ^1.8.1
|
||||
marquee: ^2.3.0
|
||||
local_auth: ^2.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
||||
#include <file_saver/file_saver_plugin.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <local_auth_windows/local_auth_plugin.h>
|
||||
#include <pdfx/pdfx_plugin.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
@@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("FileSaverPlugin"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
LocalAuthPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||
PdfxPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PdfxPlugin"));
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user