243 lines
6.7 KiB
Dart
243 lines
6.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:jwt_decoder/jwt_decoder.dart';
|
|
import 'package:logging/logging.dart';
|
|
import 'package:sheetless/core/services/api_client.dart';
|
|
import 'package:sheetless/core/services/storage_service.dart';
|
|
|
|
import '../home/home_page.dart';
|
|
|
|
/// Login page for authenticating with the Sheetless server.
|
|
class LoginPage extends StatefulWidget {
|
|
const LoginPage({super.key});
|
|
|
|
@override
|
|
State<LoginPage> createState() => _LoginPageState();
|
|
}
|
|
|
|
class _LoginPageState extends State<LoginPage> {
|
|
final _log = Logger('LoginPage');
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _storageService = StorageService();
|
|
|
|
final _urlController = TextEditingController(
|
|
text: 'https://sheetless.julian-mutter.de',
|
|
);
|
|
final _usernameController = TextEditingController();
|
|
final _passwordController = TextEditingController();
|
|
|
|
String? _errorMessage;
|
|
bool _isLoggingIn = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tryAutoLogin();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_urlController.dispose();
|
|
_usernameController.dispose();
|
|
_passwordController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto-Login
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Attempts to auto-login using a stored JWT token.
|
|
Future<void> _tryAutoLogin() async {
|
|
final jwt = await _storageService.readSecure(SecureStorageKey.jwt);
|
|
|
|
if (jwt != null && _isTokenValid(jwt)) {
|
|
await _navigateToHome();
|
|
return;
|
|
}
|
|
|
|
// Restore saved credentials for convenience
|
|
await _restoreCredentials();
|
|
}
|
|
|
|
/// Checks if a JWT token is still valid (not expired).
|
|
bool _isTokenValid(String jwt) {
|
|
try {
|
|
return !JwtDecoder.isExpired(jwt);
|
|
} on FormatException {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Restores previously saved URL and username for convenience.
|
|
Future<void> _restoreCredentials() async {
|
|
final url = await _storageService.readSecure(SecureStorageKey.url);
|
|
final username = await _storageService.readSecure(SecureStorageKey.email);
|
|
|
|
if (url != null) _urlController.text = url;
|
|
if (username != null) _usernameController.text = username;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Login
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Handles the login button press.
|
|
Future<void> _handleLogin() async {
|
|
if (_isLoggingIn) return;
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
setState(() {
|
|
_isLoggingIn = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
try {
|
|
final apiClient = ApiClient(baseUrl: _urlController.text);
|
|
await apiClient.login(_usernameController.text, _passwordController.text);
|
|
|
|
// Save credentials for next time
|
|
await _saveCredentials(apiClient.token!);
|
|
await _navigateToHome();
|
|
} catch (e) {
|
|
_log.warning('Login failed', e);
|
|
setState(() {
|
|
_errorMessage = 'Login failed.\n$e';
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isLoggingIn = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Saves credentials after successful login.
|
|
Future<void> _saveCredentials(String token) async {
|
|
await _storageService.writeSecure(
|
|
SecureStorageKey.url,
|
|
_urlController.text,
|
|
);
|
|
await _storageService.writeSecure(SecureStorageKey.jwt, token);
|
|
await _storageService.writeSecure(
|
|
SecureStorageKey.email,
|
|
_usernameController.text,
|
|
);
|
|
}
|
|
|
|
/// Navigates to the home page after successful authentication.
|
|
Future<void> _navigateToHome() async {
|
|
final config = await _storageService.readConfig();
|
|
|
|
if (!mounted) return;
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (_) => HomePage(config: config)),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
String? _validateNotEmpty(String? value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'This field is required';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Login')),
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildUrlField(),
|
|
const SizedBox(height: 16),
|
|
_buildUsernameField(),
|
|
const SizedBox(height: 16),
|
|
_buildPasswordField(),
|
|
const SizedBox(height: 24),
|
|
_buildLoginButton(),
|
|
if (_errorMessage != null) _buildErrorMessage(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildUrlField() {
|
|
return TextFormField(
|
|
controller: _urlController,
|
|
validator: _validateNotEmpty,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Server URL',
|
|
prefixIcon: Icon(Icons.dns),
|
|
),
|
|
keyboardType: TextInputType.url,
|
|
textInputAction: TextInputAction.next,
|
|
);
|
|
}
|
|
|
|
Widget _buildUsernameField() {
|
|
return TextFormField(
|
|
controller: _usernameController,
|
|
validator: _validateNotEmpty,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Username',
|
|
prefixIcon: Icon(Icons.person),
|
|
),
|
|
textInputAction: TextInputAction.next,
|
|
);
|
|
}
|
|
|
|
Widget _buildPasswordField() {
|
|
return TextFormField(
|
|
controller: _passwordController,
|
|
validator: _validateNotEmpty,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Password',
|
|
prefixIcon: Icon(Icons.lock),
|
|
),
|
|
obscureText: true,
|
|
textInputAction: TextInputAction.done,
|
|
onFieldSubmitted: (_) => _handleLogin(),
|
|
);
|
|
}
|
|
|
|
Widget _buildLoginButton() {
|
|
return ElevatedButton(
|
|
onPressed: _isLoggingIn ? null : _handleLogin,
|
|
child: _isLoggingIn
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Login'),
|
|
);
|
|
}
|
|
|
|
Widget _buildErrorMessage() {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 16.0),
|
|
child: Text(
|
|
_errorMessage!,
|
|
style: const TextStyle(color: Colors.red),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
);
|
|
}
|
|
}
|