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
+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,
);
}
}