Files
Rapollo/lib/main.dart
2026-05-22 22:00:37 +08:00

327 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'config/strings.dart';
import 'config/title_server_config.dart';
import 'services/api_service.dart';
import 'services/file_picker_service.dart';
import 'services/qr_service.dart';
import 'pages/home_page.dart';
import 'pages/settings_page.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await TitleServerConfigHolder().init();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: AppStrings.appTitle,
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.system,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
),
home: const LoginPage(),
);
}
}
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _qrController = TextEditingController();
final _apiService = ApiService();
String _qrCode = '';
bool _loading = false;
@override
void dispose() {
_qrController.dispose();
super.dispose();
}
void _onQRContentChanged(String value) {
setState(() {
_qrCode = _apiService.extractQRCode(value.trim());
});
}
Future<void> _onUploadQR() async {
try {
final bytes = await pickImageBytes();
if (bytes == null) return;
final result = await decodeQRFromBytes(bytes);
if (result != null && result.isNotEmpty) {
_qrController.text = result;
setState(() {
_qrCode = _apiService.extractQRCode(result);
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.qrScanSuccess)),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.qrScanFailed)),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppStrings.qrScanError}: $e')),
);
}
}
}
Future<void> _onLogin() async {
if (_qrCode.isEmpty) return;
setState(() => _loading = true);
try {
final result = await _apiService.login(_qrCode);
if (!mounted) return;
if (result.success) {
if (!mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => HomePage(
userId: result.userId,
token: result.token,
),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppStrings.loginFailed} (errorID: ${result.errorId})')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppStrings.requestFailed}: $e')),
);
}
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text(AppStrings.appTitle),
actions: [
IconButton(
icon: const Icon(Icons.settings),
tooltip: AppStrings.settingsTooltip,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsPage()),
);
},
),
],
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.qr_code_scanner,
size: 64,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
AppStrings.appTitle,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
AppStrings.loginSubtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 40),
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.key,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
AppStrings.qrCodeToken,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _qrController,
maxLines: 3,
onChanged: _onQRContentChanged,
style: theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
letterSpacing: 0.5,
),
decoration: InputDecoration(
hintText: AppStrings.qrHint,
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.5),
fontFamily: 'monospace',
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
],
),
),
),
if (_qrCode.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppStrings.qrParsedResult,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 6),
Text(
_qrCode,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurface,
),
),
],
),
),
],
const SizedBox(height: 28),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _loading ? null : _onUploadQR,
icon: const Icon(Icons.image, size: 20),
label: const Text(AppStrings.uploadQR),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton.icon(
onPressed:
(_qrCode.isNotEmpty && !_loading) ? _onLogin : null,
icon: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.arrow_forward, size: 20),
label: Text(_loading ? AppStrings.loggingIn : AppStrings.login),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
],
),
),
),
),
);
}
}