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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user