import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../l10n/app_localizations.dart';
import '../main.dart';
import '../models/setting.dart';
import '../models/two_factor_account.dart';
import '../services/backup_service.dart';
import '../services/security_service.dart';
import '../services/storage_service.dart';
import '../services/otp_service.dart';
import '../utils/exceptions.dart';
import '../utils/style_utils.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with WidgetsBindingObserver, RouteAware {
final StorageService _storageService = StorageService();
final BackupService _backupService = BackupService();
final SecurityService _securityService = SecurityService();
static const _authFailureExitDelay = Duration(milliseconds: 500);
static const _operationStatusResetDuration = Duration(seconds: 3);
bool _needsAuthentication = false;
Setting? _setting;
List<TwoFactorAccount> _accounts = [];
List<TwoFactorAccount> _filteredAccounts = [];
final Map<String, String> _codes = {};
final Map<String, int> _remainingSeconds = {};
final TextEditingController _searchController = TextEditingController();
String _searchText = '';
Timer? _timer;
bool _isBackupRunning = false;
bool _isRestoreRunning = false;
int _operationStatus = 0;
bool _isUploadIconFilling = false;
bool _isDownloadIconFilling = false;
Timer? _searchDebounceTimer;
Timer? _loadAccountsDebounceTimer;
bool _isSortingMode = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _initialize();
_startTimer();
});
_searchController.addListener(() {
if (_searchDebounceTimer != null) {
_searchDebounceTimer?.cancel();
}
_searchDebounceTimer = Timer(const Duration(milliseconds: 300), () {
if (!mounted) return;
setState(() {
_searchText = _searchController.text;
_filterAccounts();
});
});
});
}
Future<void> _initialize() async {
try {
await _loadSettingsConfig();
await _checkSecurityLock();
await _updateAccounts();
} catch (e) {
debugPrint('初始化失败: $e');
if (mounted) {
final l10n = AppLocalizations.of(context);
StyleUtils.errorSnackBar(context, l10n.snackInitFailed(e.toString()));
}
}
}
Future<void> _checkSecurityLock() async {
if (_setting == null) return;
if (_securityService.needsAuthentication(
_setting!.securitySetting.appLockEnabled,
)) {
setState(() {
_needsAuthentication = true;
});
final l10n = AppLocalizations.of(context);
bool authenticated = await _securityService.authenticate(
reason: l10n.authReasonAccess,
);
if (!authenticated) {
Future.delayed(_authFailureExitDelay, () {
if (mounted) {
if (Platform.isAndroid) {
SystemNavigator.pop();
} else if (Platform.isIOS) {
exit(0);
}
}
});
} else {
if (!mounted) return;
setState(() {
_needsAuthentication = false;
});
}
}
}
Future<void> _updateAccounts() async {
try {
final accounts = await _storageService.getAllAccounts();
if (!mounted) return;
final oldMap = {for (final a in _accounts) a.id: a};
var changed = _accounts.length != accounts.length;
if (!changed) {
for (final newAccount in accounts) {
final oldAccount = oldMap[newAccount.id];
if (oldAccount == null ||
oldAccount.issuer != newAccount.issuer ||
oldAccount.name != newAccount.name ||
oldAccount.secret != newAccount.secret ||
oldAccount.period != newAccount.period ||
oldAccount.algorithm != newAccount.algorithm ||
oldAccount.type != newAccount.type ||
oldAccount.counter != newAccount.counter) {
changed = true;
break;
}
}
}
if (changed) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_accounts = accounts;
});
_updateCodes();
_filterAccounts();
});
} else {
_updateCodes();
}
} catch (e) {
debugPrint('加载动态口令失败: $e');
}
}
Future<void> _loadSettingsConfig() async {
_setting = await _backupService.loadConfig();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
routeObserver.unsubscribe(this);
_timer?.cancel();
_loadAccountsDebounceTimer?.cancel();
_searchDebounceTimer?.cancel();
_searchController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.paused:
case AppLifecycleState.inactive:
_securityService.resetAuthentication();
debugPrint('应用切换到后台,认证状态已重置');
break;
case AppLifecycleState.resumed:
_handleAppResumed();
break;
case AppLifecycleState.detached:
case AppLifecycleState.hidden:
break;
}
}
Future<void> _handleAppResumed() async {
await _loadSettingsConfig();
await _checkSecurityLock();
_loadAccounts();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context)!);
}
@override
void didPopNext() {
_loadAccounts();
_loadSettingsConfig();
}
void _filterAccounts() {
if (_searchText.isEmpty) {
_filteredAccounts = _accounts;
} else {
_filteredAccounts = _accounts.where((account) {
final code = _codes[account.id] ?? '';
return account.displayIssuerName.toLowerCase().contains(
_searchText.toLowerCase(),
) ||
code.contains(_searchText);
}).toList();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
if (_needsAuthentication) {
final themeColor = Theme.of(context).colorScheme.primary;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(themeColor),
),
const SizedBox(height: 16),
Text(l10n.loadingAuthenticating),
const SizedBox(height: 8),
Text(l10n.loadingAuthPrompt, style: const TextStyle(color: Colors.grey)),
],
),
),
);
}
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.light
? Colors.grey.shade200
: Colors.grey.shade800,
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
Builder(
builder: (context) => IconButton(
icon: StyleUtils.menuIcon,
onPressed: () => Scaffold.of(context).openDrawer(),
style: StyleUtils.iconButtonStyle(),
),
),
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: l10n.hintSearch,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
IconButton(
icon: Icon(_isSortingMode ? Icons.check : Icons.sort),
onPressed: _toggleSortingMode,
style: StyleUtils.iconButtonStyle(),
),
Builder(
builder: (buttonContext) => IconButton(
icon: _buildCloudIcon(),
onPressed: () =>
_handleBackupButtonPressed(buttonContext),
style: StyleUtils.iconButtonStyle(),
),
),
],
),
),
),
),
drawer: const _MenuDrawer(),
body: _isSortingMode
? _buildSortableList()
: RefreshIndicator(
onRefresh: _loadAccounts,
child: _accounts.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.qr_code_scanner,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
l10n.emptyNoAccounts,
style: const TextStyle(fontSize: 18, color: Colors.grey),
),
const SizedBox(height: 8),
Text(
l10n.emptyAddPrompt,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
)
: ListView.builder(
itemCount: _filteredAccounts.length,
itemBuilder: (context, index) {
final account = _filteredAccounts[index];
final code = _codes[account.id] ?? '';
final remainingSeconds =
_remainingSeconds[account.id] ?? 0;
final progress = remainingSeconds / account.period;
final themeColor = Theme.of(
context,
).colorScheme.primary;
return Dismissible(
key: Key(account.id.toString()),
direction: DismissDirection.horizontal,
dismissThresholds: const {
DismissDirection.startToEnd: 0.8,
DismissDirection.endToStart: 0.8,
},
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
await _navigateToEdit(account);
return false;
} else {
await _showDeleteConfirmDialog(account);
return false;
}
},
background: Container(
color: Colors.blue,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 20),
child: const Icon(
Icons.edit,
color: Colors.white,
size: 24,
),
),
secondaryBackground: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(
Icons.delete,
color: Colors.white,
size: 24,
),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 0,
vertical: 8,
),
title: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
account.displayName,
style: const TextStyle(fontSize: 16),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
if (account.issuer != null &&
account.issuer!.isNotEmpty)
Text(
account.issuer!,
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
),
subtitle: Text(
code,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 2,
color: themeColor,
),
),
trailing: account.isHotp
? _buildHotpTrailing(account)
: SizedBox(
width: 48,
height: 48,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress,
strokeWidth: 3,
valueColor:
AlwaysStoppedAnimation<
Color
>(
progress > 0.3
? Colors.green
: Colors.orange,
),
backgroundColor:
Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
Text(
'${remainingSeconds}s',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: progress > 0.3
? Colors.green
: Colors.orange,
),
),
],
),
),
),
),
Divider(
color: Theme.of(context).brightness == Brightness.light
? Colors.grey.shade200
: Colors.grey.shade800,
height: 1,
),
],
),
);
},
),
),
floatingActionButton: Material(
color: Theme.of(context).colorScheme.primary,
shape: const CircleBorder(),
elevation: 6,
child: PopupMenuButton(
icon: const Icon(Icons.add, color: Colors.white),
color: Theme.of(context).colorScheme.surface,
itemBuilder: (context) => [
PopupMenuItem(
value: 'scan',
child: ListTile(
leading: const Icon(Icons.qr_code_scanner),
title: Text(l10n.fabScanQr),
),
),
PopupMenuItem(
value: 'manual',
child: ListTile(
leading: const Icon(Icons.keyboard),
title: Text(l10n.fabEnterKey),
),
),
],
onSelected: (value) async {
if (value == 'scan') {
final result = await Navigator.pushNamed(context, '/addScan');
if (result == true) {
await _loadAccounts();
}
} else if (value == 'manual') {
final result = await Navigator.pushNamed(context, '/addManual');
if (result == true) {
await _loadAccounts();
}
}
},
),
),
);
}
Future<void> _loadAccounts() async {
if (_loadAccountsDebounceTimer != null) {
_loadAccountsDebounceTimer?.cancel();
}
_loadAccountsDebounceTimer = Timer(const Duration(milliseconds: 50), () async {
await _updateAccounts();
});
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_updateCodes();
});
}
Widget _buildCloudIcon() {
if (_operationStatus == 1) {
return const Icon(Icons.cloud, color: Colors.green);
} else if (_operationStatus == 2) {
return const Icon(Icons.cloud, color: Colors.red);
}
if (_isBackupRunning) {
return Icon(
_isUploadIconFilling ? Icons.cloud_upload : Icons.cloud_upload_outlined,
color: Colors.blue,
);
}
if (_isRestoreRunning) {
return Icon(
_isDownloadIconFilling
? Icons.cloud_download
: Icons.cloud_download_outlined,
color: Colors.blue,
);
}
return StyleUtils.cloudIcon;
}
Widget _buildHotpTrailing(TwoFactorAccount account) {
final l10n = AppLocalizations.of(context);
return SizedBox(
width: 48,
height: 48,
child: IconButton(
icon: const Icon(Icons.refresh, size: 22),
padding: EdgeInsets.zero,
tooltip: l10n.hotpIncrementTooltip(account.counter),
onPressed: () => _incrementCounter(account),
),
);
}
Future<void> _incrementCounter(TwoFactorAccount account) async {
final l10n = AppLocalizations.of(context);
final newCounter = account.counter + 1;
final updatedAccount = TwoFactorAccount(
account.id,
account.issuer,
account.name,
account.secret,
account.period,
account.algorithm,
account.createdAt,
DateTime.now(),
type: account.type,
counter: newCounter,
);
try {
await _storageService.updateAccount(updatedAccount);
await _loadAccounts();
} catch (e) {
if (mounted) {
StyleUtils.errorSnackBar(context, l10n.snackSaveConfigFailed(e.toString()));
}
}
}
void _updateCodes() {
if (_accounts.isEmpty) return;
setState(() {
for (final account in _accounts) {
try {
_codes[account.id] = OtpService.generateCode(
secret: account.secret,
period: account.period,
algorithm: account.algorithm,
counter: account.isHotp ? account.counter : null,
);
} catch (e) {
_codes[account.id] = 'ERROR';
debugPrint('生成动态码失败 - 动态口令 ${account.displayIssuerName}: $e');
}
_remainingSeconds[account.id] = account.isHotp
? 0
: OtpService.getRemainingSeconds(period: account.period);
}
});
}
Future<void> _navigateToEdit(TwoFactorAccount account) async {
await Navigator.pushNamed(context, '/edit', arguments: account);
await _loadAccounts();
}
Future<bool> _showDeleteConfirmDialog(TwoFactorAccount account) async {
final l10n = AppLocalizations.of(context);
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.dialogTitleDeleteAccount),
content: Text(l10n.dialogConfirmDeleteAccount(account.displayIssuerName)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l10n.buttonCancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l10n.buttonDelete),
),
],
),
);
if (result == true) {
try {
await _storageService.deleteAccount(account.id);
await _loadAccounts();
} catch (e) {
if (mounted) {
StyleUtils.errorSnackBar(context, l10n.snackDeleteFailed);
}
}
}
return false;
}
void _toggleSortingMode() async {
if (_isSortingMode) {
await _saveSortOrder();
} else {
setState(() {
_isSortingMode = true;
_searchController.clear();
_searchText = '';
_filterAccounts();
});
}
}
Future<void> _saveSortOrder() async {
final l10n = AppLocalizations.of(context);
final newOrder = _accounts.map((account) => account.id).toList();
_filteredAccounts = List.from(_accounts);
try {
await _storageService.updateAccountOrder(newOrder);
if (!mounted) return;
setState(() {
_isSortingMode = false;
});
StyleUtils.successSnackBar(context, l10n.snackSortSaved);
await _loadAccounts();
} catch (e) {
if (!mounted) return;
StyleUtils.errorSnackBar(context, l10n.snackSortSaveFailed);
}
}
Widget _buildSortableList() {
return ReorderableListView(
children: _filteredAccounts.map((account) {
return Container(
key: Key(account.id),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 0,
vertical: 8,
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
account.displayName,
style: const TextStyle(fontSize: 16),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
if (account.issuer != null && account.issuer!.isNotEmpty)
Text(
account.issuer!,
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
),
trailing: const Icon(Icons.drag_handle),
),
Divider(color: Theme.of(context).brightness == Brightness.light
? Colors.grey.shade200
: Colors.grey.shade800, height: 1),
],
),
);
}).toList(),
onReorderItem: (oldIndex, newIndex) {
setState(() {
final account = _filteredAccounts.removeAt(oldIndex);
_filteredAccounts.insert(newIndex, account);
});
},
);
}
Future<void> _handleBackupButtonPressed(BuildContext buttonContext) async {
if (_setting == null || _setting!.backupSetting.type == BackupType.off) {
final result = await Navigator.pushNamed(
context,
'/settings',
arguments: {'fromCloudIcon': true},
);
if (result is Map<String, dynamic>) {
final operation = result['operation'] as String;
final status = result['status'] as String;
setState(() {
if (operation == 'backup') {
_isBackupRunning = false;
_isUploadIconFilling = false;
} else if (operation == 'restore') {
_isRestoreRunning = false;
_isDownloadIconFilling = false;
}
_operationStatus = status == 'success' ? 1 : 2;
});
}
} else {
final RenderBox box =
buttonContext.findRenderObject() as RenderBox;
final offset = box.localToGlobal(Offset.zero);
final buttonRect = offset & box.size;
final position = RelativeRect.fromRect(
buttonRect,
Offset.zero & MediaQuery.of(context).size,
);
final l10n = AppLocalizations.of(context);
showMenu(
context: context,
position: position,
items: [
PopupMenuItem(
value: 'backup',
child: ListTile(
leading: const Icon(Icons.cloud_upload),
title: Text(l10n.backupToRemote),
),
),
PopupMenuItem(
value: 'restore',
child: ListTile(
leading: const Icon(Icons.cloud_download),
title: Text(l10n.restoreFromRemote),
),
),
],
).then((value) async {
if (!mounted) return;
if (value == 'backup') {
setState(() {
_isBackupRunning = true;
_isUploadIconFilling = true;
});
await Future.delayed(const Duration(milliseconds: 50));
try {
await _backupService.performBackup(_setting!);
if (mounted) {
StyleUtils.successSnackBar(context, l10n.snackBackupSuccess);
setState(() {
_operationStatus = 1;
});
Future.delayed(_operationStatusResetDuration, () {
if (mounted) {
setState(() {
_operationStatus = 0;
});
}
});
}
} catch (e) {
if (mounted) {
String errorMessage = l10n.snackBackupFailed;
String? errorDetails;
if (e is AppException) {
errorMessage = e.message;
errorDetails = e.details;
} else {
errorMessage = '${l10n.snackBackupFailed}: ${e.toString()}';
}
StyleUtils.errorSnackBar(context, errorMessage, errorDetails);
setState(() {
_operationStatus = 2;
});
Future.delayed(_operationStatusResetDuration, () {
if (mounted) {
setState(() {
_operationStatus = 0;
});
}
});
}
} finally {
if (mounted) {
setState(() {
_isBackupRunning = false;
_isUploadIconFilling = false;
});
}
}
} else if (value == 'restore') {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.dialogTitleConfirmRestore),
content: Text(l10n.dialogConfirmRestoreBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l10n.buttonCancel),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: Text(l10n.dialogButtonConfirmRestore),
),
],
),
);
if (result == true && mounted) {
setState(() {
_isRestoreRunning = true;
_isDownloadIconFilling = true;
});
await Future.delayed(const Duration(milliseconds: 50));
try {
await _backupService.restoreBackup(_setting!);
if (mounted) {
StyleUtils.successSnackBar(context, l10n.snackRestoreSuccess);
setState(() {
_operationStatus = 1;
});
await _loadAccounts();
Future.delayed(_operationStatusResetDuration, () {
if (mounted) {
setState(() {
_operationStatus = 0;
});
}
});
}
} catch (e) {
if (mounted) {
String errorMessage = l10n.snackRestoreFailed;
String? errorDetails;
if (e is AppException) {
errorMessage = e.message;
errorDetails = e.details;
} else {
errorMessage = '${l10n.snackRestoreFailed}: ${e.toString()}';
}
StyleUtils.errorSnackBar(context, errorMessage, errorDetails);
setState(() {
_operationStatus = 2;
});
Future.delayed(_operationStatusResetDuration, () {
if (mounted) {
setState(() {
_operationStatus = 0;
});
}
});
}
} finally {
if (mounted) {
setState(() {
_isRestoreRunning = false;
_isDownloadIconFilling = false;
});
}
}
}
}
});
}
}
}
class _MenuDrawer extends StatelessWidget {
const _MenuDrawer();
@override
Widget build(BuildContext context) {
final themeColor = Theme.of(context).colorScheme.primary;
final l10n = AppLocalizations.of(context);
return Drawer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(height: 64),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: 'Easy',
style: TextStyle(
color: themeColor,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: 'Auth',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
ListTile(
leading: const Icon(Icons.swap_horiz),
title: Text(l10n.drawerImportExport),
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, '/importExport');
},
),
ListTile(
leading: const Icon(Icons.settings),
title: Text(l10n.drawerSettings),
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, '/settings');
},
),
ListTile(
leading: const Icon(Icons.info),
title: Text(l10n.drawerAbout),
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, '/about');
},
),
],
),
);
}
}