flutter_account_manager 1.0.0
flutter_account_manager: ^1.0.0 copied to clipboard
A Flutter plugin for cross-platform account management, authentication, and background sync using native platform APIs.
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_account_manager/account_manager.dart';
const _accountType = 'com.lkrjangid.account_manager';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
try {
await AccountManagerPlugin.instance.initialize();
} catch (e) {
log('Plugin init error: $e');
}
runApp(const MyApp());
}
// ─────────────────────────────────────────────────────────────────────────────
// App root
// ─────────────────────────────────────────────────────────────────────────────
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Account Manager Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1A73E8)),
useMaterial3: true,
),
home: const _Home(),
);
}
}
class _Home extends StatelessWidget {
const _Home();
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 5,
child: Scaffold(
appBar: AppBar(
title: const Text('Account Manager'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
bottom: const TabBar(
labelColor: Colors.white,
unselectedLabelColor: Colors.white60,
indicatorColor: Colors.white,
isScrollable: true,
tabs: [
Tab(icon: Icon(Icons.manage_accounts), text: 'Accounts'),
Tab(icon: Icon(Icons.lock), text: 'Credentials'),
Tab(icon: Icon(Icons.token), text: 'Tokens'),
Tab(icon: Icon(Icons.sync), text: 'Sync'),
Tab(icon: Icon(Icons.event), text: 'Events'),
],
),
),
body: const TabBarView(
children: [
_AccountsTab(),
_CredentialsTab(),
_TokensTab(),
_SyncTab(),
_EventsTab(),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Shared helpers
// ─────────────────────────────────────────────────────────────────────────────
final _am = AccountManagerPlugin.instance;
/// Displays a snackbar with [message].
void _snack(BuildContext context, String message, {bool error = false}) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message),
backgroundColor: error ? Colors.red[700] : null,
behavior: SnackBarBehavior.floating,
));
}
/// Section card with a title and children.
class _Section extends StatelessWidget {
const _Section({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(color: Theme.of(context).colorScheme.primary)),
const Divider(),
...children,
],
),
),
);
}
}
/// A full-width elevated button with a label.
class _Btn extends StatelessWidget {
const _Btn({required this.label, required this.onPressed, this.icon});
final String label;
final VoidCallback onPressed;
final IconData? icon;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: ElevatedButton.icon(
onPressed: onPressed,
icon: Icon(icon ?? Icons.chevron_right, size: 18),
label: Text(label),
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(42),
),
),
);
}
}
/// Key-value row used in detail displays.
class _KV extends StatelessWidget {
const _KV(this.label, this.value);
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text('$label:',
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 13)),
),
Expanded(
child: Text(value,
style: const TextStyle(fontSize: 13,
fontFamily: 'monospace'))),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tab 1 — Accounts
// ─────────────────────────────────────────────────────────────────────────────
class _AccountsTab extends StatefulWidget {
const _AccountsTab();
@override
State<_AccountsTab> createState() => _AccountsTabState();
}
class _AccountsTabState extends State<_AccountsTab>
with AutomaticKeepAliveClientMixin {
List<Account> _accounts = [];
bool _loading = false;
final _usernameCtrl = TextEditingController(text: 'user@example.com');
final _displayCtrl = TextEditingController(text: 'Demo User');
final _passwordCtrl = TextEditingController(text: 'Secret123!');
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_loadAccounts();
}
Future<void> _loadAccounts() async {
setState(() => _loading = true);
try {
final list = await _am.getAccounts(_accountType);
setState(() => _accounts = list);
} catch (e) {
if (mounted) _snack(context, 'Load failed: $e', error: true);
} finally {
setState(() => _loading = false);
}
}
Future<void> _addAccount() async {
final username = _usernameCtrl.text.trim();
final display = _displayCtrl.text.trim();
final password = _passwordCtrl.text;
if (username.isEmpty || password.isEmpty) {
_snack(context, 'Username and password are required', error: true);
return;
}
setState(() => _loading = true);
try {
final account = Account(
username: username,
accountType: _accountType,
displayName: display.isEmpty ? null : display,
userData: {'createdFrom': 'demo_app', 'role': 'user'},
);
final ok = await _am.addAccount(account, password);
if (mounted) {
_snack(context, ok ? 'Account created' : 'Already exists');
}
await _loadAccounts();
} on AccountAlreadyExistsException {
if (mounted) _snack(context, 'Account already exists', error: true);
} on AccountManagerException catch (e) {
if (mounted) _snack(context, '[${e.errorCode}] ${e.message}', error: true);
} finally {
setState(() => _loading = false);
}
}
Future<void> _checkExists(Account account) async {
final exists =
await _am.accountExists(account.username, account.accountType);
if (mounted) {
_snack(context,
'${account.username} → ${exists ? 'EXISTS' : 'NOT FOUND'}');
}
}
Future<void> _updateAccount(Account account) async {
try {
final updated = account.copyWith(
displayName: '${account.displayName ?? account.username} (updated)',
userData: {...?account.userData, 'updatedAt': DateTime.now().toIso8601String()},
);
await _am.updateAccount(updated);
if (mounted) _snack(context, 'Display name updated');
await _loadAccounts();
} on AccountManagerException catch (e) {
if (mounted) _snack(context, e.message, error: true);
}
}
Future<void> _removeAccount(Account account) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Remove Account'),
content: Text('Remove ${account.username}?\n'
'This also deletes all tokens and sync data.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child:
const Text('Remove', style: TextStyle(color: Colors.red))),
],
),
);
if (confirmed != true) return;
try {
final ok = await _am.removeAccount(account);
if (mounted) _snack(context, ok ? 'Account removed' : 'Remove failed');
await _loadAccounts();
} on AccountManagerException catch (e) {
if (mounted) _snack(context, e.message, error: true);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return ListView(
children: [
// ── Create account ──────────────────────────────────────────────────
_Section(
title: 'Create Account',
children: [
TextField(
controller: _usernameCtrl,
decoration: const InputDecoration(
labelText: 'Username (email)',
prefixIcon: Icon(Icons.email_outlined)),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 8),
TextField(
controller: _displayCtrl,
decoration: const InputDecoration(
labelText: 'Display name',
prefixIcon: Icon(Icons.person_outline)),
),
const SizedBox(height: 8),
TextField(
controller: _passwordCtrl,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock_outline)),
obscureText: true,
),
_Btn(
label: 'Add Account',
icon: Icons.person_add,
onPressed: _loading ? () {} : _addAccount,
),
],
),
// ── Account list ────────────────────────────────────────────────────
_Section(
title: 'Accounts (${_accounts.length})',
children: [
_Btn(
label: 'Refresh',
icon: Icons.refresh,
onPressed: _loadAccounts,
),
if (_loading)
const Padding(
padding: EdgeInsets.all(12),
child: Center(child: CircularProgressIndicator()),
)
else if (_accounts.isEmpty)
const Padding(
padding: EdgeInsets.all(8),
child: Text('No accounts found.',
style: TextStyle(color: Colors.grey)),
)
else
...(_accounts.map((acc) => _AccountTile(
account: acc,
onCheckExists: () => _checkExists(acc),
onUpdate: () => _updateAccount(acc),
onRemove: () => _removeAccount(acc),
))),
],
),
// ── Platform capabilities ───────────────────────────────────────────
const _PlatformCapabilitiesCard(),
],
);
}
}
class _AccountTile extends StatelessWidget {
const _AccountTile({
required this.account,
required this.onCheckExists,
required this.onUpdate,
required this.onRemove,
});
final Account account;
final VoidCallback onCheckExists;
final VoidCallback onUpdate;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return ExpansionTile(
leading: CircleAvatar(
child: Text(
(account.displayName ?? account.username)[0].toUpperCase(),
),
),
title: Text(account.displayName ?? account.username),
subtitle: Text(account.username,
style: const TextStyle(fontSize: 12, color: Colors.grey)),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_KV('Type', account.accountType),
if (account.userData != null)
...account.userData!.entries
.where((e) => e.key.isNotEmpty)
.map((e) => _KV(e.key, e.value)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
onPressed: onCheckExists,
icon: const Icon(Icons.search, size: 16),
label: const Text('Exists?'),
),
OutlinedButton.icon(
onPressed: onUpdate,
icon: const Icon(Icons.edit, size: 16),
label: const Text('Update'),
),
OutlinedButton.icon(
onPressed: onRemove,
icon: const Icon(Icons.delete_outline, size: 16),
label: const Text('Remove'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
),
),
],
),
],
),
),
],
);
}
}
class _PlatformCapabilitiesCard extends StatefulWidget {
const _PlatformCapabilitiesCard();
@override
State<_PlatformCapabilitiesCard> createState() =>
_PlatformCapabilitiesCardState();
}
class _PlatformCapabilitiesCardState
extends State<_PlatformCapabilitiesCard> {
Map<String, bool>? _caps;
Future<void> _load() async {
final caps = await _am.getPlatformCapabilities();
setState(() => _caps = caps);
}
@override
void initState() {
super.initState();
_load();
}
@override
Widget build(BuildContext context) {
return _Section(
title: 'Platform Capabilities',
children: _caps == null
? [const Center(child: CircularProgressIndicator())]
: _caps!.entries
.map((e) => Row(
children: [
Icon(
e.value ? Icons.check_circle : Icons.cancel,
color: e.value ? Colors.green : Colors.grey,
size: 18,
),
const SizedBox(width: 8),
Text(e.key, style: const TextStyle(fontSize: 13)),
],
))
.toList(),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tab 2 — Credentials
// ─────────────────────────────────────────────────────────────────────────────
class _CredentialsTab extends StatefulWidget {
const _CredentialsTab();
@override
State<_CredentialsTab> createState() => _CredentialsTabState();
}
class _CredentialsTabState extends State<_CredentialsTab>
with AutomaticKeepAliveClientMixin {
List<Account> _accounts = [];
Account? _selected;
final _validatePwdCtrl = TextEditingController();
final _newPwdCtrl = TextEditingController(text: 'NewPass456!');
bool? _validateResult;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_loadAccounts();
}
Future<void> _loadAccounts() async {
final list = await _am.getAccounts(_accountType);
setState(() {
_accounts = list;
if (_selected != null &&
!list.any((a) => a.username == _selected!.username)) {
_selected = null;
}
});
}
Future<void> _validate() async {
if (_selected == null || _validatePwdCtrl.text.isEmpty) return;
final ok = await _am.validateCredentials(
_selected!.username,
_validatePwdCtrl.text,
_accountType,
);
setState(() => _validateResult = ok);
if (mounted) _snack(context, ok ? 'Credentials valid ✓' : 'Invalid password ✗');
}
Future<void> _updatePassword() async {
if (_selected == null || _newPwdCtrl.text.isEmpty) return;
try {
await _am.updateCredentials(_selected!, _newPwdCtrl.text);
if (mounted) _snack(context, 'Password updated');
} on AccountManagerException catch (e) {
if (mounted) _snack(context, e.message, error: true);
}
}
Future<void> _clearCredentials() async {
if (_selected == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Clear Credentials'),
content: const Text(
'This removes the stored password but keeps the account.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Clear')),
],
),
);
if (confirmed != true) return;
try {
await _am.clearCredentials(_selected!);
if (mounted) _snack(context, 'Credentials cleared');
} on AccountManagerException catch (e) {
if (mounted) _snack(context, e.message, error: true);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return ListView(
children: [
// ── Account selector ────────────────────────────────────────────────
_Section(
title: 'Select Account',
children: [
_Btn(label: 'Refresh accounts', icon: Icons.refresh,
onPressed: _loadAccounts),
if (_accounts.isEmpty)
const Text('No accounts. Add one in the Accounts tab.',
style: TextStyle(color: Colors.grey))
else
DropdownButton<Account>(
isExpanded: true,
value: _selected,
hint: const Text('Pick an account'),
items: _accounts
.map((a) => DropdownMenuItem(
value: a,
child: Text(a.displayName ?? a.username),
))
.toList(),
onChanged: (a) => setState(() {
_selected = a;
_validateResult = null;
}),
),
],
),
// ── Validate credentials ────────────────────────────────────────────
_Section(
title: 'Validate Credentials',
children: [
TextField(
controller: _validatePwdCtrl,
decoration: InputDecoration(
labelText: 'Password to test',
prefixIcon: const Icon(Icons.password),
suffixIcon: _validateResult == null
? null
: Icon(
_validateResult!
? Icons.check_circle
: Icons.cancel,
color:
_validateResult! ? Colors.green : Colors.red,
),
),
obscureText: true,
),
_Btn(
label: 'Validate',
icon: Icons.verified_user,
onPressed: _selected == null ? () {} : _validate,
),
],
),
// ── Update password ─────────────────────────────────────────────────
_Section(
title: 'Update Password',
children: [
TextField(
controller: _newPwdCtrl,
decoration: const InputDecoration(
labelText: 'New password',
prefixIcon: Icon(Icons.lock_reset)),
obscureText: true,
),
_Btn(
label: 'Update Password',
icon: Icons.save,
onPressed: _selected == null ? () {} : _updatePassword,
),
],
),
// ── Clear credentials ───────────────────────────────────────────────
_Section(
title: 'Clear Credentials',
children: [
const Text(
'Removes the stored password while keeping the account '
'metadata intact.',
style: TextStyle(color: Colors.grey, fontSize: 13),
),
_Btn(
label: 'Clear Credentials',
icon: Icons.delete_sweep,
onPressed: _selected == null ? () {} : _clearCredentials,
),
],
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tab 3 — Auth Tokens
// ─────────────────────────────────────────────────────────────────────────────
class _TokensTab extends StatefulWidget {
const _TokensTab();
@override
State<_TokensTab> createState() => _TokensTabState();
}
class _TokensTabState extends State<_TokensTab>
with AutomaticKeepAliveClientMixin {
List<Account> _accounts = [];
Account? _selected;
String? _fetchedToken;
final _tokenTypeCtrl = TextEditingController(text: 'api');
final _tokenValueCtrl =
TextEditingController(text: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.demo');
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_loadAccounts();
}
Future<void> _loadAccounts() async {
final list = await _am.getAccounts(_accountType);
setState(() => _accounts = list);
}
Future<void> _setToken() async {
if (_selected == null) return;
try {
await _am.setAuthToken(
_selected!, _tokenTypeCtrl.text.trim(), _tokenValueCtrl.text.trim());
if (mounted) _snack(context, 'Token stored for "${_tokenTypeCtrl.text}"');
} on AccountManagerException catch (e) {
if (mounted) _snack(context, e.message, error: true);
}
}
Future<void> _getToken() async {
if (_selected == null) return;
try {
final token =
await _am.getAuthToken(_selected!, _tokenTypeCtrl.text.trim());
setState(() => _fetchedToken = token ?? '(no token stored)');
} on AuthenticationRequiredException {
setState(() =>
_fetchedToken = '⚠ Re-authentication required — show login screen');
} on AccountManagerException catch (e) {
if (mounted) _snack(context, e.message, error: true);
}
}
Future<void> _invalidateToken() async {
if (_selected == null || _fetchedToken == null) return;
try {
await _am.invalidateAuthToken(_accountType, _fetchedToken!);
setState(() => _fetchedToken = null);
if (mounted) _snack(context, 'Token invalidated');
} on AccountManagerException catch (e) {
if (mounted) _snack(context, e.message, error: true);
}
}
Future<void> _invalidateAllTokens() async {
if (_selected == null) return;
try {
await _am.invalidateAllTokens(
_selected!, _tokenTypeCtrl.text.trim());
setState(() => _fetchedToken = null);
if (mounted) {
_snack(
context, 'All "${_tokenTypeCtrl.text}" tokens invalidated');
}
} on AccountManagerException catch (e) {
if (mounted) _snack(context, e.message, error: true);
}
}
Future<void> _getAvailableTypes() async {
if (_selected == null) return;
final types = await _am.getAvailableTokenTypes(_selected!);
if (mounted) {
_snack(
context,
types.isEmpty
? 'No token types available'
: 'Types: ${types.join(', ')}');
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return ListView(
children: [
// ── Account + token type ────────────────────────────────────────────
_Section(
title: 'Select Account & Token Type',
children: [
_Btn(
label: 'Refresh accounts',
icon: Icons.refresh,
onPressed: _loadAccounts),
if (_accounts.isEmpty)
const Text('No accounts. Add one in the Accounts tab.',
style: TextStyle(color: Colors.grey))
else
DropdownButton<Account>(
isExpanded: true,
value: _selected,
hint: const Text('Pick an account'),
items: _accounts
.map((a) => DropdownMenuItem(
value: a, child: Text(a.displayName ?? a.username)))
.toList(),
onChanged: (a) =>
setState(() {
_selected = a;
_fetchedToken = null;
}),
),
const SizedBox(height: 8),
TextField(
controller: _tokenTypeCtrl,
decoration: const InputDecoration(
labelText: 'Token type (e.g. api, refresh)',
prefixIcon: Icon(Icons.label_outline)),
),
_Btn(
label: 'List Available Types',
icon: Icons.list,
onPressed:
_selected == null ? () {} : _getAvailableTypes),
],
),
// ── Store token ─────────────────────────────────────────────────────
_Section(
title: 'Store Auth Token',
children: [
TextField(
controller: _tokenValueCtrl,
decoration: const InputDecoration(
labelText: 'Token value',
prefixIcon: Icon(Icons.vpn_key_outlined)),
maxLines: 2,
),
_Btn(
label: 'Set Token',
icon: Icons.save,
onPressed: _selected == null ? () {} : _setToken,
),
],
),
// ── Retrieve token ──────────────────────────────────────────────────
_Section(
title: 'Retrieve Auth Token',
children: [
_Btn(
label: 'Get Token',
icon: Icons.download,
onPressed: _selected == null ? () {} : _getToken,
),
if (_fetchedToken != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey[300]!),
),
child: SelectableText(
_fetchedToken!,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12),
),
),
],
],
),
// ── Invalidate ──────────────────────────────────────────────────────
_Section(
title: 'Invalidate Tokens',
children: [
_Btn(
label: 'Invalidate Current Token',
icon: Icons.block,
onPressed: _selected == null || _fetchedToken == null
? () {}
: _invalidateToken,
),
_Btn(
label: 'Invalidate ALL Tokens of This Type',
icon: Icons.delete_forever,
onPressed: _selected == null ? () {} : _invalidateAllTokens,
),
],
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tab 4 — Sync
// ─────────────────────────────────────────────────────────────────────────────
class _SyncTab extends StatefulWidget {
const _SyncTab();
@override
State<_SyncTab> createState() => _SyncTabState();
}
class _SyncTabState extends State<_SyncTab>
with AutomaticKeepAliveClientMixin {
List<Account> _accounts = [];
Account? _selected;
SyncResult? _lastResult;
SyncStatus? _status;
bool _autoSync = false;
bool _periodicScheduled = false;
final _intervalCtrl = TextEditingController(text: '30');
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_loadAccounts();
}
Future<void> _loadAccounts() async {
final list = await _am.getAccounts(_accountType);
setState(() => _accounts = list);
}
Future<void> _syncNow({bool expedited = false}) async {
if (_selected == null) return;
setState(() => _status = SyncStatus.active);
try {
final result = await _am.syncNow(_selected!, expedited: expedited);
setState(() {
_lastResult = result;
_status = result.success ? SyncStatus.idle : SyncStatus.failed;
});
} on AccountManagerException catch (e) {
if (mounted) _snack(context, e.message, error: true);
setState(() => _status = SyncStatus.failed);
}
}
Future<void> _checkStatus() async {
if (_selected == null) return;
final s = await _am.getSyncStatus(_selected!);
setState(() => _status = s);
if (mounted) _snack(context, 'Status: ${s.name.toUpperCase()}');
}
Future<void> _cancelSync() async {
if (_selected == null) return;
await _am.cancelSync(_selected!);
setState(() => _status = SyncStatus.idle);
if (mounted) _snack(context, 'Sync cancelled');
}
Future<void> _toggleAutoSync(bool value) async {
if (_selected == null) return;
await _am.setSyncAutomatically(_selected!, value);
setState(() => _autoSync = value);
}
Future<void> _loadAutoSync() async {
if (_selected == null) return;
final v = await _am.isSyncAutomatically(_selected!);
setState(() => _autoSync = v);
}
Future<void> _addPeriodicSync() async {
if (_selected == null) return;
final mins = int.tryParse(_intervalCtrl.text) ?? 30;
if (mins < 15) {
_snack(context, 'Minimum interval is 15 minutes', error: true);
return;
}
try {
await _am.addPeriodicSync(
_selected!,
Duration(minutes: mins),
requiresNetwork: true,
requiresCharging: false,
extras: {'source': 'demo_app'},
);
setState(() => _periodicScheduled = true);
if (mounted) {
_snack(context, 'Periodic sync scheduled every $mins min');
}
} on AccountManagerException catch (e) {
if (mounted) _snack(context, e.message, error: true);
}
}
Future<void> _removePeriodicSync() async {
if (_selected == null) return;
await _am.removePeriodicSync(_selected!);
setState(() => _periodicScheduled = false);
if (mounted) _snack(context, 'Periodic sync removed');
}
@override
Widget build(BuildContext context) {
super.build(context);
final statusColor = switch (_status) {
SyncStatus.active => Colors.blue,
SyncStatus.pending => Colors.orange,
SyncStatus.failed => Colors.red,
_ => Colors.green,
};
return ListView(
children: [
// ── Account selector ────────────────────────────────────────────────
_Section(
title: 'Select Account',
children: [
_Btn(
label: 'Refresh accounts',
icon: Icons.refresh,
onPressed: _loadAccounts),
if (_accounts.isEmpty)
const Text('No accounts. Add one in the Accounts tab.',
style: TextStyle(color: Colors.grey))
else
DropdownButton<Account>(
isExpanded: true,
value: _selected,
hint: const Text('Pick an account'),
items: _accounts
.map((a) => DropdownMenuItem(
value: a, child: Text(a.displayName ?? a.username)))
.toList(),
onChanged: (a) {
setState(() {
_selected = a;
_lastResult = null;
_status = null;
_periodicScheduled = false;
});
if (a != null) _loadAutoSync();
},
),
],
),
// ── Status display ──────────────────────────────────────────────────
if (_status != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Icon(Icons.circle, color: statusColor, size: 12),
const SizedBox(width: 6),
Text('Status: ${_status!.name.toUpperCase()}',
style: TextStyle(
color: statusColor, fontWeight: FontWeight.bold)),
],
),
),
// ── Manual sync ─────────────────────────────────────────────────────
_Section(
title: 'Manual Sync',
children: [
Row(
children: [
Expanded(
child: _Btn(
label: 'Sync Now',
icon: Icons.sync,
onPressed:
_selected == null ? () {} : () => _syncNow(),
),
),
const SizedBox(width: 8),
Expanded(
child: _Btn(
label: 'Expedited',
icon: Icons.bolt,
onPressed: _selected == null
? () {}
: () => _syncNow(expedited: true),
),
),
],
),
Row(
children: [
Expanded(
child: _Btn(
label: 'Check Status',
icon: Icons.info_outline,
onPressed: _selected == null ? () {} : _checkStatus,
),
),
const SizedBox(width: 8),
Expanded(
child: _Btn(
label: 'Cancel Sync',
icon: Icons.stop_circle_outlined,
onPressed: _selected == null ? () {} : _cancelSync,
),
),
],
),
// Last result
if (_lastResult != null) ...[
const Divider(),
_KV('Success',
_lastResult!.success ? 'YES' : 'NO'),
if (_lastResult!.stats != null) ...[
_KV('Uploaded',
'${_lastResult!.stats!.itemsUploaded}'),
_KV('Downloaded',
'${_lastResult!.stats!.itemsDownloaded}'),
_KV('Conflicts',
'${_lastResult!.stats!.conflicts}'),
_KV('Duration',
'${_lastResult!.stats!.syncTimeMs} ms'),
],
if (_lastResult!.errorMessage != null)
_KV('Error', _lastResult!.errorMessage!),
],
],
),
// ── Auto sync toggle ────────────────────────────────────────────────
_Section(
title: 'Automatic Sync',
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Enable automatic sync'),
subtitle: const Text(
'Syncs automatically when the system schedules it'),
value: _autoSync,
onChanged:
_selected == null ? null : _toggleAutoSync,
),
],
),
// ── Periodic sync ───────────────────────────────────────────────────
_Section(
title: 'Periodic Background Sync',
children: [
TextField(
controller: _intervalCtrl,
decoration: const InputDecoration(
labelText: 'Interval (minutes, min 15)',
prefixIcon: Icon(Icons.timer_outlined),
),
keyboardType: TextInputType.number,
),
_Btn(
label: _periodicScheduled
? 'Update Periodic Sync'
: 'Schedule Periodic Sync',
icon: Icons.schedule,
onPressed: _selected == null ? () {} : _addPeriodicSync,
),
if (_periodicScheduled)
_Btn(
label: 'Remove Periodic Sync',
icon: Icons.schedule_send,
onPressed: _selected == null
? () {}
: _removePeriodicSync,
),
],
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tab 5 — Events
// ─────────────────────────────────────────────────────────────────────────────
class _EventsTab extends StatefulWidget {
const _EventsTab();
@override
State<_EventsTab> createState() => _EventsTabState();
}
class _EventsTabState extends State<_EventsTab>
with AutomaticKeepAliveClientMixin {
final List<_EventEntry> _events = [];
final List<_EventEntry> _accountEvents = [];
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_am.syncEvents.listen(_onSyncEvent);
_am.accountEvents.listen(_onAccountEvent);
}
void _onSyncEvent(SyncEvent event) {
final entry = switch (event) {
SyncStartedEvent(:final account) => _EventEntry(
icon: Icons.play_circle_outline,
color: Colors.blue,
title: 'Sync Started',
detail: account.username,
),
SyncProgressEvent(:final account, :final progress) => _EventEntry(
icon: Icons.sync,
color: Colors.orange,
title: 'Progress: ${(progress.progress * 100).toInt()}%',
detail:
'[${progress.phase}] ${progress.message ?? ''} — ${account.username}',
),
SyncCompletedEvent(:final account, :final result) => _EventEntry(
icon: result.success
? Icons.check_circle_outline
: Icons.error_outline,
color: result.success ? Colors.green : Colors.red,
title: result.success ? 'Sync Completed' : 'Sync Failed',
detail: result.success
? '↑${result.stats?.itemsUploaded} ↓${result.stats?.itemsDownloaded} '
'⚡${result.stats?.syncTimeMs}ms — ${account.username}'
: '${result.errorMessage} — ${account.username}',
),
SyncCancelledEvent(:final account) => _EventEntry(
icon: Icons.cancel_outlined,
color: Colors.grey,
title: 'Sync Cancelled',
detail: account.username,
),
SyncConflictEvent(:final account, :final conflictId) => _EventEntry(
icon: Icons.warning_amber_outlined,
color: Colors.amber,
title: 'Conflict Detected',
detail: 'ID: $conflictId — ${account.username}',
),
};
setState(() => _events.insert(0, entry));
}
void _onAccountEvent(AccountEvent event) {
final entry = switch (event) {
AccountAddedEvent(:final account) => _EventEntry(
icon: Icons.person_add_alt,
color: Colors.green,
title: 'Account Added',
detail: account.username,
),
AccountRemovedEvent(:final account) => _EventEntry(
icon: Icons.person_remove_outlined,
color: Colors.red,
title: 'Account Removed',
detail: account.username,
),
AccountUpdatedEvent(:final account) => _EventEntry(
icon: Icons.edit_outlined,
color: Colors.blue,
title: 'Account Updated',
detail: account.username,
),
AuthTokenExpiredEvent(:final account, :final tokenType) => _EventEntry(
icon: Icons.token,
color: Colors.orange,
title: 'Token Expired',
detail: '"$tokenType" — ${account.username}',
),
};
setState(() => _accountEvents.insert(0, entry));
}
@override
Widget build(BuildContext context) {
super.build(context);
return ListView(
children: [
// ── Sync events ─────────────────────────────────────────────────────
_Section(
title: 'Sync Events (${_events.length})',
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Live stream from background sync callbacks',
style: TextStyle(color: Colors.grey, fontSize: 12)),
if (_events.isNotEmpty)
TextButton(
onPressed: () => setState(() => _events.clear()),
child: const Text('Clear'),
),
],
),
if (_events.isEmpty)
const Padding(
padding: EdgeInsets.all(8),
child: Text('No sync events yet. Trigger a sync.',
style: TextStyle(color: Colors.grey)),
)
else
..._events.take(20).map((e) => _EventTile(entry: e)),
],
),
// ── Account events ──────────────────────────────────────────────────
_Section(
title: 'Account Events (${_accountEvents.length})',
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Live stream of account lifecycle changes',
style: TextStyle(color: Colors.grey, fontSize: 12)),
if (_accountEvents.isNotEmpty)
TextButton(
onPressed: () =>
setState(() => _accountEvents.clear()),
child: const Text('Clear'),
),
],
),
if (_accountEvents.isEmpty)
const Padding(
padding: EdgeInsets.all(8),
child: Text('No account events yet.',
style: TextStyle(color: Colors.grey)),
)
else
..._accountEvents.take(20).map((e) => _EventTile(entry: e)),
],
),
// ── Legend ──────────────────────────────────────────────────────────
_Section(
title: 'Legend',
children: const [
_KV('SyncStarted', 'Sync operation began'),
_KV('SyncProgress', 'Phase update (upload / download / conflict)'),
_KV('SyncCompleted', 'Sync finished (success or failure)'),
_KV('SyncCancelled', 'Sync was cancelled by the app'),
_KV('SyncConflict', 'Conflict requires manual resolution'),
_KV('AccountAdded', 'New account registered'),
_KV('AccountRemoved', 'Account deleted'),
_KV('AccountUpdated', 'Account metadata changed'),
_KV('TokenExpired', 'Auth token needs refresh'),
],
),
],
);
}
}
class _EventEntry {
_EventEntry({
required this.icon,
required this.color,
required this.title,
required this.detail,
}) : time = DateTime.now();
final IconData icon;
final Color color;
final String title;
final String detail;
final DateTime time;
}
class _EventTile extends StatelessWidget {
const _EventTile({required this.entry});
final _EventEntry entry;
@override
Widget build(BuildContext context) {
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(entry.icon, color: entry.color, size: 20),
title: Text(entry.title,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
subtitle: Text(entry.detail,
style: const TextStyle(fontSize: 11, fontFamily: 'monospace')),
);
}
}