Add multi-account support and update project docs

- Add SavedAccount/AccountStore and rework ServerProvider around a list
  of saved accounts: switch/remove/add, swipe-to-cycle on the avatar
  button, per-account browsing prefs (sort/view/filters/cache), and a
  session-generation guard so a stale fetch from an abandoned account
  can't land in the newly active one
- Migrate existing single-account installs into the new schema
  automatically so upgrading users stay logged in
- Settings gains an Accounts section; LoginView gains an "add account"
  mode pushed on top of the main shell instead of replacing it
- Give every Scrollbar in the app a thicker, draggable, Android-style
  thumb via a shared ScrollbarTheme instead of the default hairline
- Splash screen: use the monochrome app icon (tinted per theme) and a
  smaller spinner
- Update CLAUDE.md and .claude/context/*.md to reflect the accumulated
  multi-account, sharing, and UI changes across this session, and add
  a standing instruction to keep these docs current going forward

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 11:21:36 -04:00
co-authored by Claude Sonnet 5
parent 5299ac5c2c
commit 1fbdc1f8d0
15 changed files with 1121 additions and 222 deletions
+18 -4
View File
@@ -68,16 +68,30 @@ class _SplashView extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(24),
// The monochrome adaptive-icon layer is a plain white silhouette
// on transparent, meant to be tinted rather than shown as-is -
// srcIn recolors it to the theme's foreground so it reads
// correctly in both light and dark mode.
ColorFiltered(
colorFilter: ColorFilter.mode(
colorScheme.onSurface,
BlendMode.srcIn,
),
child: Image.asset(
'assets/icon/app_icon.png',
'assets/icon/app_icon_monochrome.png',
width: 72,
height: 72,
),
),
const SizedBox(height: 24),
CircularProgressIndicator(color: colorScheme.primary),
SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.primary,
),
),
],
),
),
+35
View File
@@ -0,0 +1,35 @@
/// A saved Nextcloud login. The app password itself lives in secure
/// storage under a key derived from [id] - this model only carries the
/// non-secret identity fields that are safe to keep in SharedPreferences.
class SavedAccount {
final String id;
final String serverUrl;
final String username;
const SavedAccount({
required this.id,
required this.serverUrl,
required this.username,
});
/// A deterministic id from the server + username, so re-adding the same
/// account refreshes its stored password instead of creating a duplicate
/// entry in the saved-accounts list.
static String makeId(String serverUrl, String username) {
String slug(String s) =>
s.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '_');
return '${slug(serverUrl)}__${slug(username)}';
}
Map<String, dynamic> toJson() => {
'id': id,
'serverUrl': serverUrl,
'username': username,
};
factory SavedAccount.fromJson(Map<String, dynamic> json) => SavedAccount(
id: json['id'] as String,
serverUrl: json['serverUrl'] as String,
username: json['username'] as String,
);
}
+422 -147
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -10,6 +9,8 @@ import '../models/nextcloud_file_version.dart';
import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart';
import '../models/saved_account.dart';
import '../services/account_store.dart';
import '../services/login_flow_service.dart';
import '../services/nextcloud_service.dart';
import '../theme/app_theme.dart';
@@ -58,11 +59,6 @@ class _CachedDirectory {
enum MediaProgressBarStyle { classic, wavy, slim, squiggly }
class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
static const _storage = FlutterSecureStorage();
static const _keyServerUrl = 'nc_server_url';
static const _keyLoginName = 'nc_login_name';
static const _keyAppPassword = 'nc_app_password';
// Cached UI settings/toggles (SharedPreferences keys)
static const _prefThemeMode = 'ui_theme_mode';
static const _prefUseDynamicColor = 'ui_use_dynamic_color';
@@ -190,10 +186,33 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
NextcloudService? _service;
// Multi-account state
final AccountStore _accountStore = AccountStore();
List<SavedAccount> _accounts = [];
String? _activeAccountId;
// Bumped at the start of every account switch/removal/activation so an
// in-flight fetch from the account being left can recognize it's stale
// (by comparing against the generation it captured at its own start) and
// discard its result instead of writing it into the now-active account's
// state.
int _sessionGeneration = 0;
// UI-only signal for whether the in-progress login flow is "add another
// account" (started from Settings while already logged in) vs. the
// first/only login - ServerProvider itself doesn't branch persistence
// behavior on this, only the Add Account screen's own navigation does.
bool _addAccountFlowActive = false;
ServerProvider() {
WidgetsBinding.instance.addObserver(this);
_restoreSession();
_loadPreferences();
_init();
}
Future<void> _init() async {
final prefs = await _prefsFuture;
await _accountStore.migrateLegacyIfNeeded(prefs);
_accounts = _accountStore.loadAccounts(prefs);
_activeAccountId = _accountStore.loadActiveAccountId(prefs);
await Future.wait([_loadPreferences(), _restoreSession()]);
}
/// Starts/stops the periodic folder-listing refresh as the app leaves and
@@ -227,6 +246,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
LoginFlowStatus get loginFlowStatus => _loginFlowStatus;
Uri? get pendingLoginUrl => _pendingLoginUrl;
List<SavedAccount> get accounts => List.unmodifiable(_accounts);
String? get activeAccountId => _activeAccountId;
SavedAccount? get activeAccount =>
_accounts.where((a) => a.id == _activeAccountId).firstOrNull;
bool get isAddAccountFlow => _addAccountFlowActive;
Color get seedColor => _seedColor;
ThemeMode get themeMode => _themeMode;
bool get useDynamicColor => _useDynamicColor;
@@ -382,13 +407,13 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
Future<void> _restoreSession() async {
try {
final server = await _storage.read(key: _keyServerUrl);
final loginName = await _storage.read(key: _keyLoginName);
final appPassword = await _storage.read(key: _keyAppPassword);
if (server != null && loginName != null && appPassword != null) {
await _applyCredentials(server, loginName, appPassword, persist: false);
}
final id = _activeAccountId;
if (id == null) return;
final account = _accounts.where((a) => a.id == id).firstOrNull;
if (account == null) return;
final password = await _accountStore.readPassword(id);
if (password == null) return;
await _applyCredentialsForAccount(account, password);
} catch (e) {
debugPrint('[ServerProvider] Session restore failed: $e');
} finally {
@@ -425,53 +450,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
_tapTabToScrollTop =
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
_isGridView = prefs.getBool(_prefGridView) ?? _isGridView;
_showFavoritesOnlyFiles =
prefs.getBool(_prefShowFavoritesOnlyFiles) ?? _showFavoritesOnlyFiles;
_showFavoritesOnlyPhotos =
prefs.getBool(_prefShowFavoritesOnlyPhotos) ??
_showFavoritesOnlyPhotos;
final storageScopeName = prefs.getString(_prefStorageScope);
if (storageScopeName != null) {
_storageScope = StorageScope.values.firstWhere(
(s) => s.name == storageScopeName,
orElse: () => StorageScope.cloud,
);
}
_showHiddenFiles =
prefs.getBool(_prefShowHiddenFiles) ?? _showHiddenFiles;
_showHiddenPhotos =
prefs.getBool(_prefShowHiddenPhotos) ?? _showHiddenPhotos;
final sortFieldName = prefs.getString(_prefSortField);
if (sortFieldName != null) {
_photosSortField = FileSortField.values.firstWhere(
(f) => f.name == sortFieldName,
orElse: () => FileSortField.name,
);
}
_photosSortAscending =
prefs.getBool(_prefSortAscending) ?? _photosSortAscending;
final folderSortJson = prefs.getString(_prefFolderSort);
if (folderSortJson != null) {
try {
final decoded = jsonDecode(folderSortJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
final value = entry.value as Map<String, dynamic>;
final fieldName = value['field'] as String?;
if (fieldName != null) {
_folderSortField[entry.key] = FileSortField.values.firstWhere(
(f) => f.name == fieldName,
orElse: () => FileSortField.name,
);
}
final ascending = value['ascending'] as bool?;
if (ascending != null) _folderSortAscending[entry.key] = ascending;
}
} catch (e) {
debugPrint('[ServerProvider] Folder sort restore failed: $e');
}
}
_applyAccountPrefs(prefs, _activeAccountId);
final savedOrderNames = prefs.getStringList(_prefTabOrder);
if (savedOrderNames != null) {
@@ -531,15 +510,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
orElse: () => _swipeRightAction,
);
}
final cachePolicyName = prefs.getString(_prefCachePolicy);
if (cachePolicyName != null) {
_cachePolicy = CachePolicy.values.firstWhere(
(c) => c.name == cachePolicyName,
orElse: () => _cachePolicy,
);
}
_cacheIntervalMinutes =
prefs.getInt(_prefCacheIntervalMinutes) ?? _cacheIntervalMinutes;
_startCacheRefreshTimerIfNeeded();
notifyListeners();
@@ -548,34 +518,99 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
}
}
Future<void> _persistCredentials(
String server,
String loginName,
/// (Re)loads every per-account browsing pref (grid/list view,
/// favorites-only, storage scope, show-hidden, sort, folder sort, cache
/// policy) for [accountId] - called once at startup and again on every
/// account switch. Resets to defaults when [accountId] is null (no saved
/// account yet).
void _applyAccountPrefs(SharedPreferences prefs, String? accountId) {
_folderSortField.clear();
_folderSortAscending.clear();
if (accountId == null) {
_isGridView = false;
_showFavoritesOnlyFiles = false;
_showFavoritesOnlyPhotos = false;
_storageScope = StorageScope.cloud;
_showHiddenFiles = false;
_showHiddenPhotos = false;
_photosSortField = FileSortField.name;
_photosSortAscending = true;
_cachePolicy = CachePolicy.never;
_cacheIntervalMinutes = 5;
return;
}
String k(String base) => _accountStore.accountPrefKey(accountId, base);
_isGridView = prefs.getBool(k(_prefGridView)) ?? false;
_showFavoritesOnlyFiles =
prefs.getBool(k(_prefShowFavoritesOnlyFiles)) ?? false;
_showFavoritesOnlyPhotos =
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
final storageScopeName = prefs.getString(k(_prefStorageScope));
_storageScope = StorageScope.values.firstWhere(
(s) => s.name == storageScopeName,
orElse: () => StorageScope.cloud,
);
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
_showHiddenPhotos = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
final sortFieldName = prefs.getString(k(_prefSortField));
_photosSortField = FileSortField.values.firstWhere(
(f) => f.name == sortFieldName,
orElse: () => FileSortField.name,
);
_photosSortAscending = prefs.getBool(k(_prefSortAscending)) ?? true;
final folderSortJson = prefs.getString(k(_prefFolderSort));
if (folderSortJson != null) {
try {
final decoded = jsonDecode(folderSortJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
final value = entry.value as Map<String, dynamic>;
final fieldName = value['field'] as String?;
if (fieldName != null) {
_folderSortField[entry.key] = FileSortField.values.firstWhere(
(f) => f.name == fieldName,
orElse: () => FileSortField.name,
);
}
final ascending = value['ascending'] as bool?;
if (ascending != null) _folderSortAscending[entry.key] = ascending;
}
} catch (e) {
debugPrint('[ServerProvider] Folder sort restore failed: $e');
}
}
final cachePolicyName = prefs.getString(k(_prefCachePolicy));
_cachePolicy = CachePolicy.values.firstWhere(
(c) => c.name == cachePolicyName,
orElse: () => CachePolicy.never,
);
_cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5;
}
/// Verifies [appPassword] for [account] and, on success, makes it the
/// live session. The password is expected to already be durably saved by
/// the caller (either freshly, via [_completeLoginFlow], or previously,
/// since this is also how a saved session is restored/switched to) -
/// this method only writes to [AccountStore] to drop a password that
/// turns out to no longer work.
Future<bool> _applyCredentialsForAccount(
SavedAccount account,
String appPassword,
) async {
await _storage.write(key: _keyServerUrl, value: server);
await _storage.write(key: _keyLoginName, value: loginName);
await _storage.write(key: _keyAppPassword, value: appPassword);
}
Future<void> _clearPersistedCredentials() async {
await _storage.delete(key: _keyServerUrl);
await _storage.delete(key: _keyLoginName);
await _storage.delete(key: _keyAppPassword);
}
Future<bool> _applyCredentials(
String serverUrl,
String username,
String appPassword, {
bool persist = true,
}) async {
final gen = _sessionGeneration;
_isLoading = true;
_errorMessage = null;
notifyListeners();
_serverUrl = serverUrl;
_username = username;
_serverUrl = account.serverUrl;
_username = account.username;
_password = appPassword;
_service = NextcloudService(
@@ -586,27 +621,27 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
try {
final success = await _service!.testConnection();
if (gen != _sessionGeneration) return false;
if (success) {
_isLoggedIn = true;
_currentFolderPath = '/';
_pathStack = ['/'];
if (persist) {
await _persistCredentials(_serverUrl, _username, _password);
}
_startCacheRefreshTimerIfNeeded();
await refreshData();
if (gen != _sessionGeneration) return false;
_isLoading = false;
notifyListeners();
return true;
}
} catch (e) {
if (gen != _sessionGeneration) return false;
_errorMessage = e.toString().replaceAll('Exception: ', '');
if (!persist) {
// A previously-saved app password no longer works; drop it.
await _clearPersistedCredentials();
}
// The stored app password for this account no longer works - drop it
// so it doesn't keep silently failing on every future restore/switch.
await _accountStore.deletePassword(account.id);
}
if (gen != _sessionGeneration) return false;
_isLoggedIn = false;
_service = null;
_isLoading = false;
@@ -617,8 +652,16 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
/// Starts Nextcloud Login Flow v2: asks the server for a one-time login
/// URL, opens it in the system browser, then polls until the user
/// authorizes and the server hands back a scoped app password. The app
/// never sees the user's real password.
Future<void> startLoginFlow(String serverUrl) async {
/// never sees the user's real password. [addAccount] only marks the
/// flow as "add another account" for [isAddAccountFlow] - the pushed Add
/// Account screen reads that to decide when to pop itself; this method's
/// own persistence behavior on success ([_completeLoginFlow]) is the same
/// either way (create-or-refresh the resulting account, then activate it).
Future<void> startLoginFlow(
String serverUrl, {
bool addAccount = false,
}) async {
_addAccountFlowActive = addAccount;
_loginFlowStatus = LoginFlowStatus.initiating;
_errorMessage = null;
notifyListeners();
@@ -666,11 +709,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_pollTimeoutTimer?.cancel();
_loginFlowStatus = LoginFlowStatus.idle;
_pendingLoginUrl = null;
await _applyCredentials(
result.serverUrl,
result.loginName,
result.appPassword,
);
await _completeLoginFlow(result);
_addAccountFlowActive = false;
}
} on http.ClientException catch (e) {
// A single dropped connection (e.g. the network briefly
@@ -705,6 +745,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_pollTimer?.cancel();
_pollTimeoutTimer?.cancel();
_pendingLoginUrl = null;
_addAccountFlowActive = false;
_loginFlowStatus = errorMessage != null
? LoginFlowStatus.error
: LoginFlowStatus.idle;
@@ -712,25 +753,192 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
notifyListeners();
}
Future<void> logout() async {
_isLoggedIn = false;
_serverUrl = '';
_username = '';
_password = '';
/// Turns a completed Login Flow v2 result into a saved account (creating
/// it, or refreshing its password if it already existed - e.g. an
/// expired app password re-authenticated) and makes it the active
/// session. Used for both the first/only login and "add account".
Future<void> _completeLoginFlow(LoginFlowResult result) async {
final serverUrl = LoginFlowService.normalizeServerUrl(result.serverUrl);
final id = SavedAccount.makeId(serverUrl, result.loginName);
final account = SavedAccount(
id: id,
serverUrl: serverUrl,
username: result.loginName,
);
_accounts = [
for (final a in _accounts)
if (a.id != id) a,
account,
];
final prefs = await _prefsFuture;
await _accountStore.saveAccounts(prefs, _accounts);
await _accountStore.writePassword(id, result.appPassword);
await _activateAccount(id);
}
/// Clears every field that holds the *content* of whichever account is
/// currently active (files/photos/trash/shares/recent/service) - shared
/// by [_activateAccount] (about to load a different account's content)
/// and [removeAccount]'s "no accounts left" path (nothing left to load).
/// Deliberately does not touch [_isLoggedIn] - that's the caller's call.
void _clearActiveContent() {
_items = [];
_quota = null;
_activities = [];
_allMedia = [];
_isMediaLoading = false;
_mediaErrorMessage = null;
_trashItems = [];
_isTrashLoading = false;
_trashErrorMessage = null;
_shares = [];
_isSharesLoading = false;
_sharesErrorMessage = null;
_sharesWithMe = false;
_recentItems = [];
_isRecentLoading = false;
_recentErrorMessage = null;
_currentFolderPath = '/';
_pathStack = ['/'];
_service = null;
_directoryCache.clear();
_cacheRefreshTimer?.cancel();
await _clearPersistedCredentials();
notifyListeners();
}
/// The shared engine behind switching accounts, falling back to another
/// account after removing the active one, and landing on the newly
/// created/refreshed account after a login flow completes: tears down
/// the outgoing account's live content (without ever setting
/// [isLoggedIn] false - see below), then loads the target account's own
/// prefs and credentials.
///
/// Non-goal, by design: no simultaneous multi-account state. This is a
/// full teardown-and-reload of the active session every time, exactly
/// like today's single-account [logout] already did - just without
/// touching any *other* saved account's stored credentials/prefs.
Future<void> _activateAccount(String accountId) async {
// Invalidates any fetch still in flight for the account being left, so
// a slow response can't land in the new account's state - see the
// `gen != _sessionGeneration` checks in refreshData/fetchAllMedia/
// fetchTrash/fetchShares/fetchRecent/_applyCredentialsForAccount.
_sessionGeneration++;
// A pending "add account" flow can't stay pending through a manual
// switch/cycle - simplicity over blocking the gesture.
if (_loginFlowStatus != LoginFlowStatus.idle) cancelLoginFlow();
_cacheRefreshTimer?.cancel();
_directoryCache.clear();
_clearActiveContent();
// Not `_isLoggedIn = false` - that would bounce main.dart's root
// routing through LoginView mid-switch. Each tab already shows its own
// spinner from `_isLoading`/`_isXLoading`, so this alone is enough to
// avoid flashing the outgoing account's stale content.
_isLoading = true;
notifyListeners();
_activeAccountId = accountId;
final prefs = await _prefsFuture;
await _accountStore.saveActiveAccountId(prefs, accountId);
_applyAccountPrefs(prefs, accountId);
notifyListeners();
final account = _accounts.where((a) => a.id == accountId).firstOrNull;
final password = account == null
? null
: await _accountStore.readPassword(accountId);
if (account == null || password == null) {
_isLoading = false;
_isLoggedIn = false;
notifyListeners();
return;
}
await _applyCredentialsForAccount(account, password);
// The active account's Files listing is loaded synchronously above
// (inside _applyCredentialsForAccount -> refreshData); the other tabs
// stay mounted across the switch (MainShellView's IndexedStack) so
// their one-shot initState fetches won't naturally re-run - kick them
// off here instead.
unawaited(fetchAllMedia());
unawaited(fetchTrash());
unawaited(fetchShares());
unawaited(fetchRecent());
}
/// Switches to an already-saved account. No-op if it's already active or
/// unknown.
Future<void> switchAccount(String accountId) async {
if (accountId == _activeAccountId) return;
if (!_accounts.any((a) => a.id == accountId)) return;
await _activateAccount(accountId);
}
SavedAccount? _cycleAccount(int direction) {
if (_accounts.length < 2) return null;
final currentIndex = _accounts.indexWhere((a) => a.id == _activeAccountId);
final targetIndex = currentIndex == -1
? 0
: (currentIndex + direction) % _accounts.length;
final target =
_accounts[(targetIndex + _accounts.length) % _accounts.length];
unawaited(_activateAccount(target.id));
return target;
}
/// 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);
/// 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).
Future<void> removeAccount(String accountId) async {
final index = _accounts.indexWhere((a) => a.id == accountId);
if (index == -1) return;
final wasActive = accountId == _activeAccountId;
_accounts = [..._accounts]..removeAt(index);
final prefs = await _prefsFuture;
await _accountStore.saveAccounts(prefs, _accounts);
await _accountStore.deletePassword(accountId);
for (final key in AccountStore.perAccountPrefKeys) {
await prefs.remove(_accountStore.accountPrefKey(accountId, key));
}
if (!wasActive) {
notifyListeners();
return;
}
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();
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 ?? '');
Future<void> refreshData() async {
if (!_isLoggedIn || _service == null) {
debugPrint(
@@ -738,6 +946,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
);
return;
}
// Captured so a response landing after an account switch mid-flight
// can recognize it's for an account the user has already left and
// discard itself instead of overwriting the new account's content.
final gen = _sessionGeneration;
_isLoading = true;
_errorMessage = null;
@@ -748,7 +960,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
);
try {
_items = await _service!.fetchDirectory(_currentFolderPath);
final items = await _service!.fetchDirectory(_currentFolderPath);
if (gen != _sessionGeneration) return;
_items = items;
_directoryCache[_currentFolderPath] = _CachedDirectory(
_items,
DateTime.now(),
@@ -773,11 +987,14 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
debugPrint('[ServerProvider] Activity fetch warning: $e');
}
} catch (e) {
if (gen != _sessionGeneration) return;
debugPrint('[ServerProvider] Error fetching directory: $e');
_errorMessage = e.toString().replaceAll('Exception: ', '');
} finally {
_isLoading = false;
notifyListeners();
if (gen == _sessionGeneration) {
_isLoading = false;
notifyListeners();
}
}
}
@@ -789,20 +1006,26 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
/// Loads every image/video across the whole account for the Photos tab.
Future<void> fetchAllMedia() async {
if (!_isLoggedIn || _service == null) return;
final gen = _sessionGeneration;
_isMediaLoading = true;
_mediaErrorMessage = null;
notifyListeners();
try {
_allMedia = await _service!.fetchAllMedia();
final media = await _service!.fetchAllMedia();
if (gen != _sessionGeneration) return;
_allMedia = media;
debugPrint('[ServerProvider] Loaded ${_allMedia.length} media items');
} catch (e) {
if (gen != _sessionGeneration) return;
debugPrint('[ServerProvider] Error fetching all media: $e');
_mediaErrorMessage = e.toString().replaceAll('Exception: ', '');
} finally {
_isMediaLoading = false;
notifyListeners();
if (gen == _sessionGeneration) {
_isMediaLoading = false;
notifyListeners();
}
}
}
@@ -873,26 +1096,42 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
await refreshData();
}
/// Persists a per-account browsing pref under its `acct_<id>_`-namespaced
/// key. No-op if there's no active account (shouldn't normally happen -
/// these setters are only reachable from screens that require one).
void _persistAccountPref(
String baseKey,
void Function(SharedPreferences prefs, String key) write,
) {
final id = _activeAccountId;
if (id == null) return;
_prefsFuture.then(
(p) => write(p, _accountStore.accountPrefKey(id, baseKey)),
);
}
void setGridView(bool value) {
if (_isGridView == value) return;
_isGridView = value;
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefGridView, value));
_persistAccountPref(_prefGridView, (p, key) => p.setBool(key, value));
}
void toggleFavoritesFilterFiles() {
_showFavoritesOnlyFiles = !_showFavoritesOnlyFiles;
notifyListeners();
_prefsFuture.then(
(p) => p.setBool(_prefShowFavoritesOnlyFiles, _showFavoritesOnlyFiles),
_persistAccountPref(
_prefShowFavoritesOnlyFiles,
(p, key) => p.setBool(key, _showFavoritesOnlyFiles),
);
}
void toggleFavoritesFilterPhotos() {
_showFavoritesOnlyPhotos = !_showFavoritesOnlyPhotos;
notifyListeners();
_prefsFuture.then(
(p) => p.setBool(_prefShowFavoritesOnlyPhotos, _showFavoritesOnlyPhotos),
_persistAccountPref(
_prefShowFavoritesOnlyPhotos,
(p, key) => p.setBool(key, _showFavoritesOnlyPhotos),
);
}
@@ -900,20 +1139,27 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
if (_storageScope == scope) return;
_storageScope = scope;
notifyListeners();
_prefsFuture.then((p) => p.setString(_prefStorageScope, scope.name));
_persistAccountPref(
_prefStorageScope,
(p, key) => p.setString(key, scope.name),
);
}
void toggleShowHiddenFiles() {
_showHiddenFiles = !_showHiddenFiles;
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefShowHiddenFiles, _showHiddenFiles));
_persistAccountPref(
_prefShowHiddenFiles,
(p, key) => p.setBool(key, _showHiddenFiles),
);
}
void toggleShowHiddenPhotos() {
_showHiddenPhotos = !_showHiddenPhotos;
notifyListeners();
_prefsFuture.then(
(p) => p.setBool(_prefShowHiddenPhotos, _showHiddenPhotos),
_persistAccountPref(
_prefShowHiddenPhotos,
(p, key) => p.setBool(key, _showHiddenPhotos),
);
}
@@ -921,14 +1167,18 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
if (_photosSortField == field) return;
_photosSortField = field;
notifyListeners();
_prefsFuture.then((p) => p.setString(_prefSortField, field.name));
_persistAccountPref(
_prefSortField,
(p, key) => p.setString(key, field.name),
);
}
void togglePhotosSortOrder() {
_photosSortAscending = !_photosSortAscending;
notifyListeners();
_prefsFuture.then(
(p) => p.setBool(_prefSortAscending, _photosSortAscending),
_persistAccountPref(
_prefSortAscending,
(p, key) => p.setBool(key, _photosSortAscending),
);
}
@@ -943,8 +1193,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
'ascending': _folderSortAscending[path],
};
}
_prefsFuture.then(
(p) => p.setString(_prefFolderSort, jsonEncode(combined)),
_persistAccountPref(
_prefFolderSort,
(p, key) => p.setString(key, jsonEncode(combined)),
);
}
@@ -1043,7 +1294,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
if (_cachePolicy == policy) return;
_cachePolicy = policy;
notifyListeners();
_prefsFuture.then((p) => p.setString(_prefCachePolicy, policy.name));
_persistAccountPref(
_prefCachePolicy,
(p, key) => p.setString(key, policy.name),
);
_startCacheRefreshTimerIfNeeded();
}
@@ -1052,7 +1306,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
if (_cacheIntervalMinutes == clamped) return;
_cacheIntervalMinutes = clamped;
notifyListeners();
_prefsFuture.then((p) => p.setInt(_prefCacheIntervalMinutes, clamped));
_persistAccountPref(
_prefCacheIntervalMinutes,
(p, key) => p.setInt(key, clamped),
);
_startCacheRefreshTimerIfNeeded();
}
@@ -1095,19 +1352,25 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
Future<void> fetchTrash() async {
if (!_isLoggedIn || _service == null) return;
final gen = _sessionGeneration;
_isTrashLoading = true;
_trashErrorMessage = null;
notifyListeners();
try {
_trashItems = await _service!.fetchTrash();
final trash = await _service!.fetchTrash();
if (gen != _sessionGeneration) return;
_trashItems = trash;
} catch (e) {
if (gen != _sessionGeneration) return;
debugPrint('[ServerProvider] Error fetching trash: $e');
_trashErrorMessage = e.toString().replaceAll('Exception: ', '');
} finally {
_isTrashLoading = false;
notifyListeners();
if (gen == _sessionGeneration) {
_isTrashLoading = false;
notifyListeners();
}
}
}
@@ -1136,19 +1399,25 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
Future<void> fetchShares() async {
if (!_isLoggedIn || _service == null) return;
final gen = _sessionGeneration;
_isSharesLoading = true;
_sharesErrorMessage = null;
notifyListeners();
try {
_shares = await _service!.fetchShares(sharedWithMe: _sharesWithMe);
final shares = await _service!.fetchShares(sharedWithMe: _sharesWithMe);
if (gen != _sessionGeneration) return;
_shares = shares;
} catch (e) {
if (gen != _sessionGeneration) return;
debugPrint('[ServerProvider] Error fetching shares: $e');
_sharesErrorMessage = e.toString().replaceAll('Exception: ', '');
} finally {
_isSharesLoading = false;
notifyListeners();
if (gen == _sessionGeneration) {
_isSharesLoading = false;
notifyListeners();
}
}
}
@@ -1172,19 +1441,25 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
Future<void> fetchRecent() async {
if (!_isLoggedIn || _service == null) return;
final gen = _sessionGeneration;
_isRecentLoading = true;
_recentErrorMessage = null;
notifyListeners();
try {
_recentItems = await _service!.fetchRecentFiles();
final recent = await _service!.fetchRecentFiles();
if (gen != _sessionGeneration) return;
_recentItems = recent;
} catch (e) {
if (gen != _sessionGeneration) return;
debugPrint('[ServerProvider] Error fetching recent files: $e');
_recentErrorMessage = e.toString().replaceAll('Exception: ', '');
} finally {
_isRecentLoading = false;
notifyListeners();
if (gen == _sessionGeneration) {
_isRecentLoading = false;
notifyListeners();
}
}
}
+138
View File
@@ -0,0 +1,138 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/saved_account.dart';
/// Persists the list of saved accounts, which one is active, and each
/// account's app password - plus a one-shot migration from the app's
/// original single-account storage schema (3 flat secure-storage keys and
/// a handful of un-namespaced SharedPreferences keys) into this one.
class AccountStore {
static const _storage = FlutterSecureStorage();
// Legacy single-account keys (pre-multi-account).
static const _keyLegacyServerUrl = 'nc_server_url';
static const _keyLegacyLoginName = 'nc_login_name';
static const _keyLegacyAppPassword = 'nc_app_password';
static const _passwordKeyPrefix = 'nc_app_password_';
static const _prefAccountsList = 'accounts_list';
static const _prefActiveAccountId = 'active_account_id';
static const _prefMigrationDone = 'account_migration_v1_done';
/// The per-account browsing prefs that get namespaced under
/// `acct_<id>_<key>` on migration/save - everything else (theme, tab
/// order, seek bar style, etc.) stays a global/app-wide pref, untouched
/// by account switching.
static const perAccountPrefKeys = [
'ui_grid_view',
'ui_show_favorites_only',
'ui_show_favorites_only_photos',
'ui_storage_scope',
'ui_show_hidden',
'ui_show_hidden_photos',
'ui_sort_field',
'ui_sort_ascending',
'ui_folder_sort',
'ui_cache_policy',
'ui_cache_interval_minutes',
];
String accountPrefKey(String accountId, String baseKey) =>
'acct_${accountId}_$baseKey';
/// Runs once, ever: if a legacy single-account login is found and this
/// hasn't already migrated, turns it into the first saved (and active)
/// account, copies its browsing prefs to their namespaced keys, and
/// deletes the legacy keys. Safe to call on every launch - it's a no-op
/// once the migration marker is set, including on a fresh install with
/// nothing to migrate.
Future<void> migrateLegacyIfNeeded(SharedPreferences prefs) async {
if (prefs.getBool(_prefMigrationDone) == true) return;
try {
final serverUrl = await _storage.read(key: _keyLegacyServerUrl);
final username = await _storage.read(key: _keyLegacyLoginName);
final appPassword = await _storage.read(key: _keyLegacyAppPassword);
if (serverUrl != null && username != null && appPassword != null) {
final id = SavedAccount.makeId(serverUrl, username);
await _storage.write(key: '$_passwordKeyPrefix$id', value: appPassword);
for (final key in perAccountPrefKeys) {
final value = prefs.get(key);
if (value == null) continue;
final namespacedKey = accountPrefKey(id, key);
if (value is bool) {
await prefs.setBool(namespacedKey, value);
} else if (value is int) {
await prefs.setInt(namespacedKey, value);
} else if (value is double) {
await prefs.setDouble(namespacedKey, value);
} else if (value is String) {
await prefs.setString(namespacedKey, value);
} else if (value is List<String>) {
await prefs.setStringList(namespacedKey, value);
}
await prefs.remove(key);
}
await saveAccounts(prefs, [
SavedAccount(id: id, serverUrl: serverUrl, username: username),
]);
await saveActiveAccountId(prefs, id);
await _storage.delete(key: _keyLegacyServerUrl);
await _storage.delete(key: _keyLegacyLoginName);
await _storage.delete(key: _keyLegacyAppPassword);
}
} finally {
// Set unconditionally, even when there was nothing to migrate, so
// this block truly never runs again.
await prefs.setBool(_prefMigrationDone, true);
}
}
List<SavedAccount> loadAccounts(SharedPreferences prefs) {
final json = prefs.getString(_prefAccountsList);
if (json == null) return [];
try {
final decoded = jsonDecode(json) as List<dynamic>;
return decoded
.map((e) => SavedAccount.fromJson(e as Map<String, dynamic>))
.toList();
} catch (_) {
return [];
}
}
Future<void> saveAccounts(
SharedPreferences prefs,
List<SavedAccount> accounts,
) async {
await prefs.setString(
_prefAccountsList,
jsonEncode(accounts.map((a) => a.toJson()).toList()),
);
}
String? loadActiveAccountId(SharedPreferences prefs) =>
prefs.getString(_prefActiveAccountId);
Future<void> saveActiveAccountId(SharedPreferences prefs, String? id) async {
if (id == null) {
await prefs.remove(_prefActiveAccountId);
} else {
await prefs.setString(_prefActiveAccountId, id);
}
}
Future<String?> readPassword(String accountId) =>
_storage.read(key: '$_passwordKeyPrefix$accountId');
Future<void> writePassword(String accountId, String password) =>
_storage.write(key: '$_passwordKeyPrefix$accountId', value: password);
Future<void> deletePassword(String accountId) =>
_storage.delete(key: '$_passwordKeyPrefix$accountId');
}
+33
View File
@@ -30,6 +30,37 @@ class AppTheme {
Color(0xFF2E7D32), // Emerald Green
];
// Flutter's default Scrollbar renders a ~6px hairline that's easy to miss
// and hard to grab. This tunes it to read like Android's standard
// draggable fast-scroll thumb: thicker, pill-shaped, and always at least
// faintly visible (not just flashing in on scroll), while still getting
// a touch more visible/opaque while actively hovered or dragged.
static ScrollbarThemeData _scrollbarTheme(ColorScheme colorScheme) {
final thumbColor = colorScheme.onSurfaceVariant;
return ScrollbarThemeData(
thickness: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.dragged) ||
states.contains(WidgetState.hovered)) {
return 10.0;
}
return 8.0;
}),
radius: const Radius.circular(8),
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.dragged)) {
return thumbColor.withValues(alpha: 0.9);
}
if (states.contains(WidgetState.hovered)) {
return thumbColor.withValues(alpha: 0.8);
}
return thumbColor.withValues(alpha: 0.6);
}),
thumbVisibility: const WidgetStatePropertyAll(true),
trackVisibility: const WidgetStatePropertyAll(false),
interactive: true,
);
}
static ThemeData light(
Color seedColor, {
ColorScheme? dynamicScheme,
@@ -49,6 +80,7 @@ class AppTheme {
scaffoldBackgroundColor: colorScheme.surface,
pageTransitionsTheme: _pageTransitionsTheme,
sliderTheme: _sliderTheme,
scrollbarTheme: _scrollbarTheme(colorScheme),
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme)
.copyWith(
headlineMedium: GoogleFonts.inter(
@@ -115,6 +147,7 @@ class AppTheme {
scaffoldBackgroundColor: colorScheme.surface,
pageTransitionsTheme: _pageTransitionsTheme,
sliderTheme: _sliderTheme,
scrollbarTheme: _scrollbarTheme(colorScheme),
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme)
.copyWith(
headlineMedium: GoogleFonts.inter(
+145
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../models/saved_account.dart';
import '../providers/server_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/frosted_glass_container.dart';
import '../widgets/seek_bar_painter.dart';
import 'login_view.dart';
class AccountView extends StatelessWidget {
const AccountView({super.key});
@@ -177,6 +179,17 @@ class AccountView extends StatelessWidget {
),
const SizedBox(height: 24),
// Accounts Section
Text(
'Accounts',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
const _AccountsCard(),
const SizedBox(height: 24),
// Server Credentials Section
Text(
'Server Connection Info',
@@ -465,6 +478,138 @@ class AccountView extends StatelessWidget {
}
}
/// Lists every saved account, letting the user switch to an inactive one,
/// remove any of them, or add another via [LoginView] pushed in "add
/// account" mode.
class _AccountsCard extends StatelessWidget {
const _AccountsCard();
Future<void> _confirmRemove(
BuildContext context,
ServerProvider provider,
SavedAccount account,
) async {
final isActive = account.id == provider.activeAccountId;
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Remove Account'),
content: Text(
isActive && provider.accounts.length > 1
? 'Remove ${account.username} ($host)? Another saved account will become active.'
: 'Remove ${account.username} ($host)? You can add it again later.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Remove'),
),
],
);
},
);
if (confirmed == true) {
await provider.removeAccount(account.id);
}
}
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final accounts = provider.accounts;
return Card(
child: Column(
children: [
for (final account in accounts) ...[
_AccountRow(
account: account,
isActive: account.id == provider.activeAccountId,
onTap: account.id == provider.activeAccountId
? null
: () => provider.switchAccount(account.id),
onRemove: () => _confirmRemove(context, provider, account),
),
const Divider(height: 1, indent: 16, endIndent: 16),
],
ListTile(
leading: const Icon(Icons.add_circle_outline_rounded),
title: const Text('Add account'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const LoginView(isAddingAccount: true),
),
);
},
),
],
),
);
}
}
class _AccountRow extends StatelessWidget {
final SavedAccount account;
final bool isActive;
final VoidCallback? onTap;
final VoidCallback onRemove;
const _AccountRow({
required this.account,
required this.isActive,
required this.onTap,
required this.onRemove,
});
@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: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isActive)
Icon(Icons.check_circle_rounded, color: colorScheme.primary),
IconButton(
icon: const Icon(Icons.delete_outline_rounded),
tooltip: 'Remove account',
onPressed: onRemove,
),
],
),
onTap: onTap,
);
}
}
class _BottomBarAppearanceCard extends StatefulWidget {
const _BottomBarAppearanceCard();
+6 -1
View File
@@ -21,7 +21,12 @@ class ActivityView extends StatelessWidget {
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (activities.isEmpty)
if (provider.isLoading && activities.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (activities.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
+52 -3
View File
@@ -3,7 +3,13 @@ import 'package:provider/provider.dart';
import '../providers/server_provider.dart';
class LoginView extends StatefulWidget {
const LoginView({super.key});
/// True when this is pushed from Settings ("Add account") on top of an
/// already-logged-in session, rather than shown as the app's root screen
/// with no account yet. Adds an AppBar/back affordance and auto-pops once
/// the new account becomes active.
final bool isAddingAccount;
const LoginView({super.key, this.isAddingAccount = false});
@override
State<LoginView> createState() => _LoginViewState();
@@ -12,6 +18,16 @@ class LoginView extends StatefulWidget {
class _LoginViewState extends State<LoginView> {
final _formKey = GlobalKey<FormState>();
final _urlController = TextEditingController();
String? _originalActiveAccountId;
bool _popped = false;
@override
void initState() {
super.initState();
if (widget.isAddingAccount) {
_originalActiveAccountId = context.read<ServerProvider>().activeAccountId;
}
}
@override
void dispose() {
@@ -22,7 +38,10 @@ class _LoginViewState extends State<LoginView> {
void _handleContinue() {
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
context.read<ServerProvider>().startLoginFlow(_urlController.text.trim());
context.read<ServerProvider>().startLoginFlow(
_urlController.text.trim(),
addAccount: widget.isAddingAccount,
);
}
@override
@@ -35,7 +54,22 @@ class _LoginViewState extends State<LoginView> {
provider.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
final isInitiating = provider.loginFlowStatus == LoginFlowStatus.initiating;
return Scaffold(
// A new/refreshed account has just become active - pop back to
// Settings rather than leaving this form sitting on top of it.
if (widget.isAddingAccount &&
!_popped &&
provider.loginFlowStatus == LoginFlowStatus.idle &&
provider.activeAccountId != _originalActiveAccountId) {
_popped = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).pop();
});
}
final body = Scaffold(
appBar: widget.isAddingAccount
? AppBar(title: const Text('Add Account'))
: null,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
@@ -65,6 +99,21 @@ class _LoginViewState extends State<LoginView> {
),
),
);
if (!widget.isAddingAccount) return body;
// Backing out mid-flow (system back/swipe-back, not just the explicit
// Cancel button in _WaitingForBrowser) should cancel the pending login
// flow rather than leaving its poll timer running after this screen is
// gone - this view could never be popped before "add account" existed,
// so that case wasn't reachable until now.
return PopScope(
canPop: provider.loginFlowStatus != LoginFlowStatus.awaitingBrowser,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) provider.cancelLoginFlow();
},
child: body,
);
}
}
+44 -18
View File
@@ -1,14 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/server_provider.dart';
import '../views/account_view.dart';
/// Top-bar avatar button that opens the account screen. Replaces the old
/// "Account" bottom-nav destination, matching how Google Drive surfaces
/// account access from a profile picture in the app bar.
/// account access from a profile picture in the app bar. Also doubles as a
/// quick account switcher: swiping up/down on it cycles to the next/
/// previous saved account immediately, with no confirmation - a shortcut
/// alongside the full switcher list in Settings.
class ProfileAvatarButton extends StatelessWidget {
const ProfileAvatarButton({super.key});
void _handleVerticalSwipe(BuildContext context, DragEndDetails details) {
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;
HapticFeedback.selectionClick();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Switched to ${target.username}'),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
@@ -19,23 +42,26 @@ class ProfileAvatarButton extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: 'Settings',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AccountView()),
);
},
icon: CircleAvatar(
radius: 17,
backgroundColor: colorScheme.primary,
child: Text(
initial,
style: TextStyle(
color: colorScheme.onPrimary,
fontWeight: FontWeight.bold,
fontSize: 14,
child: GestureDetector(
onVerticalDragEnd: (details) => _handleVerticalSwipe(context, details),
child: IconButton(
tooltip: 'Settings',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AccountView()),
);
},
icon: CircleAvatar(
radius: 17,
backgroundColor: colorScheme.primary,
child: Text(
initial,
style: TextStyle(
color: colorScheme.onPrimary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
),