Twenty-eight commits landed on the web app and the API since the phone was last touched, and the phone's own README opens by claiming full feature parity. It was not a small drift: a whole tab, two whole cards, and the two settings that decide how a time is read. The scheduler arrives as the third charging tab. One list of tasks covering every charger the account owns, where the charger's own cloud schedule is one window inside one box. A task is a flow — start at 23:00, cap to 10 A at 01:00, stop at 06:30 — on the days and the chargers it names, and naming no charger means all of them, including the ones imported later. The clock is the server's, so the tab only writes tasks and reads back how each one last went, and any step can be fired now to find out whether it will reach the charger before the night it matters. The RFID card comes with it: the list the account holds, a card added by its number or by holding it against the charger's own reader, and the charger's own list read back from the device. Both halves are written by every add and remove and they can still come apart, so when they disagree the card says which list each card is missing from — nothing else on the page would. The charger settings card the phone never had at all goes in whole rather than only its new half. Over Modbus that is the four writable registers; over the cloud it is the charger's whole settings group in sections, drawn from the same block table the web reads, one write per section because the charger takes a command whole and a schedule carrying only its switch is a schedule whose times have just been set to midnight. The clock and the week become settings. format.dart grows formatTime, the weekday order and the short names, with "auto" asking intl's own hour pattern and FIRSTDAYOFWEEK rather than a table here; Settings › Appearance asks both questions beneath the date. Flutter's own picker renders on the device locale, which nothing in this app steers, so TimeField types four digits on whichever clock is in force and keeps the meridiem as its own control — a box reading 13:45 beside a dial saying 01:45 PM is the disagreement the setting exists to end. The smaller ones travel too. The control card says which charger its buttons drive, picture and name, because it follows a serial and not the highlighted row; its two tiles take the names of the readings they actually hold; and the limit slider leaves it wherever a settings card now owns that value. The list's reachability re-asks every thirty seconds while the tab is in front, merged rather than replaced — "we could not ask" is not an answer, and it certainly is not "unknown". A settings frame that answers half a minute late is chased at widening gaps and then given up on. The information card names the fields the service sent under its own names and groups list records under their own, so list[0].* stops being read as one alphabetical run. An inherited integration field shows what it inherited rather than an example. The sign-in fields say nothing until you type. One gap stays open, and deliberately. The task form sends the phone's zone only when Dart reports an IANA name; Android usually answers with an abbreviation like CEST, which is not a zone, so it sends nothing and the server falls back to its own clock. A name the server would misread is worse than no name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
410 lines
15 KiB
Dart
410 lines
15 KiB
Dart
import "package:flutter/material.dart";
|
|
|
|
import "../api.dart";
|
|
import "../biometric.dart";
|
|
import "../config.dart";
|
|
import "../i18n.dart";
|
|
import "../main.dart";
|
|
import "../servers.dart";
|
|
import "../theme.dart";
|
|
import "servers_sheet.dart";
|
|
|
|
class LoginScreen extends StatefulWidget {
|
|
const LoginScreen({super.key});
|
|
@override
|
|
State<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
class _LoginScreenState extends State<LoginScreen> {
|
|
final _email = TextEditingController();
|
|
final _password = TextEditingController();
|
|
final _server = TextEditingController();
|
|
final _formKey = GlobalKey<FormState>();
|
|
bool _loading = false;
|
|
bool _showPassword = false;
|
|
String? _error;
|
|
String? _serverSaved;
|
|
|
|
// Biometric ("Face recognition" / "Fingerprint") sign-in state.
|
|
bool _bioAvailable = false;
|
|
bool _bioEnabled = false;
|
|
bool _hasFace = false;
|
|
bool _hasFingerprint = false;
|
|
String? _bioEmail;
|
|
bool _autoPrompted = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// The login form signs into whichever server is active, so Server settings
|
|
// edits that one's address — on a fresh install, the home server's.
|
|
_server.text = serverRegistry.active.url;
|
|
_initBiometrics();
|
|
}
|
|
|
|
Future<void> _initBiometrics() async {
|
|
final available = await biometricAuth.isAvailable();
|
|
final email = await biometricAuth.enabledEmail();
|
|
final face = available && await biometricAuth.hasFace();
|
|
final finger = available && await biometricAuth.hasFingerprint();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_bioAvailable = available;
|
|
_bioEnabled = email != null;
|
|
_bioEmail = email;
|
|
_hasFace = face;
|
|
_hasFingerprint = finger;
|
|
});
|
|
// If biometric login is set up, offer it immediately (once) on screen load.
|
|
if (_bioEnabled && _bioAvailable && !_autoPrompted) {
|
|
_autoPrompted = true;
|
|
_biometricLogin();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_email.dispose();
|
|
_password.dispose();
|
|
_server.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _saveServer() {
|
|
final saved = serverRegistry.update(serverRegistry.activeId, url: _server.text);
|
|
setState(() {
|
|
_server.text = saved?.url ?? "";
|
|
_serverSaved = t("login.savedNote");
|
|
});
|
|
}
|
|
|
|
void _resetServer() {
|
|
serverRegistry.update(serverRegistry.activeId, url: "");
|
|
setState(() {
|
|
_server.clear();
|
|
_serverSaved = t("login.resetNote");
|
|
});
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
setState(() {
|
|
_loading = true;
|
|
_error = null;
|
|
});
|
|
final email = _email.text.trim();
|
|
final password = _password.text;
|
|
try {
|
|
await authService.login(email, password);
|
|
// Pull the full profile so saved appearance prefs (theme/font/date) apply.
|
|
try {
|
|
appSettings.applyFromProfile(await apiClient.getMe());
|
|
} catch (_) {}
|
|
// Offer to remember these (known-good) credentials for biometric login.
|
|
await _maybeOfferBiometricEnrollment(email, password);
|
|
// The app root rebuilds to the dashboard via the auth listener.
|
|
} catch (e) {
|
|
setState(() => _error = e.toString());
|
|
} finally {
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
/// After a successful password login, if the device supports biometrics and
|
|
/// they aren't set up yet, ask whether to enable biometric sign-in.
|
|
Future<void> _maybeOfferBiometricEnrollment(String email, String password) async {
|
|
if (!_bioAvailable || _bioEnabled || !mounted) return;
|
|
final method = _hasFace && !_hasFingerprint
|
|
? t("login.methodFace")
|
|
: _hasFingerprint && !_hasFace
|
|
? t("login.methodFingerprint")
|
|
: t("login.methodBiometrics");
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(t("login.enrollTitle")),
|
|
content: Text(t("login.enrollBody", params: {"method": method})),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("login.enrollNotNow"))),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t("login.enrollEnable"))),
|
|
],
|
|
),
|
|
);
|
|
if (ok == true) {
|
|
await biometricAuth.enable(email, password);
|
|
}
|
|
}
|
|
|
|
Future<void> _biometricLogin() async {
|
|
setState(() {
|
|
_loading = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final creds = await biometricAuth.unlock(
|
|
reason: _bioEmail != null
|
|
? t("login.bioReasonSignInAs", params: {"email": _bioEmail})
|
|
: t("login.bioReasonSignIn"),
|
|
);
|
|
if (creds == null) {
|
|
// User cancelled the prompt, or nothing is stored.
|
|
if (mounted) setState(() => _loading = false);
|
|
return;
|
|
}
|
|
await authService.login(creds.$1, creds.$2);
|
|
try {
|
|
appSettings.applyFromProfile(await apiClient.getMe());
|
|
} catch (_) {}
|
|
} on BiometricException catch (e) {
|
|
if (mounted) setState(() => _error = e.message);
|
|
} on ApiException catch (e) {
|
|
// Stored credentials no longer work (e.g. password changed) — turn off
|
|
// biometric login and fall back to the password form.
|
|
if (e.status == 400 || e.status == 401) {
|
|
await biometricAuth.disable();
|
|
if (mounted) {
|
|
setState(() {
|
|
_bioEnabled = false;
|
|
_error = t("login.savedInvalid");
|
|
});
|
|
}
|
|
} else if (mounted) {
|
|
setState(() => _error = e.message);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) setState(() => _error = e.toString());
|
|
} finally {
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
/// Biometric sign-in buttons, shown only once biometric login is set up on
|
|
/// this device. Labels reflect the enrolled biometric kind(s).
|
|
Widget _buildBiometricSection() {
|
|
if (!_bioEnabled || !_bioAvailable) return const SizedBox.shrink();
|
|
|
|
final buttons = <Widget>[];
|
|
Widget bioButton(IconData icon, String label) => Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: OutlinedButton.icon(
|
|
onPressed: _loading ? null : _biometricLogin,
|
|
icon: Icon(icon),
|
|
label: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(label),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (_hasFace) buttons.add(bioButton(Icons.face, t("login.signInWithFace")));
|
|
if (_hasFingerprint) buttons.add(bioButton(Icons.fingerprint, t("login.signInWithFingerprint")));
|
|
if (buttons.isEmpty) buttons.add(bioButton(Icons.lock_outline, t("login.signInWithBiometrics")));
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
const Expanded(child: Divider()),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Text(t("login.or"), style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
|
),
|
|
const Expanded(child: Divider()),
|
|
],
|
|
),
|
|
),
|
|
...buttons,
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 380),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const DriverVaultLogo(markSize: 40, fontSize: 30),
|
|
const SizedBox(height: 10),
|
|
Text(t("login.tagline"),
|
|
style: TextStyle(color: DriverVault.muted(context))),
|
|
const SizedBox(height: 24),
|
|
Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
children: [
|
|
if (_error != null)
|
|
Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.errorContainer,
|
|
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
|
),
|
|
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
|
|
),
|
|
TextFormField(
|
|
controller: _email,
|
|
keyboardType: TextInputType.emailAddress,
|
|
decoration: InputDecoration(
|
|
labelText: t("login.email"),
|
|
border: const OutlineInputBorder(),
|
|
hintText: "you@example.com",
|
|
),
|
|
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
controller: _password,
|
|
obscureText: !_showPassword,
|
|
decoration: InputDecoration(
|
|
labelText: t("login.password"),
|
|
border: const OutlineInputBorder(),
|
|
hintText: "••••••••",
|
|
suffixIcon: IconButton(
|
|
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
|
|
tooltip: _showPassword ? t("login.hidePassword") : t("login.showPassword"),
|
|
onPressed: () => setState(() => _showPassword = !_showPassword),
|
|
),
|
|
),
|
|
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
|
|
onFieldSubmitted: (_) => _submit(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: _loading ? null : _submit,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Text(_loading ? t("login.submitting") : t("login.submit")),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_buildBiometricSection(),
|
|
const SizedBox(height: 8),
|
|
_ServerSettings(
|
|
controller: _server,
|
|
savedNote: _serverSaved,
|
|
onSave: _saveServer,
|
|
onReset: _resetServer,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Collapsible "Server settings" on the login screen: the address of the server
|
|
/// this form signs into (blank = the compile-time default), plus the way back to
|
|
/// the others once more than one has been added. Mirrors the web app.
|
|
class _ServerSettings extends StatefulWidget {
|
|
final TextEditingController controller;
|
|
final String? savedNote;
|
|
final VoidCallback onSave;
|
|
final VoidCallback onReset;
|
|
const _ServerSettings({
|
|
required this.controller,
|
|
required this.savedNote,
|
|
required this.onSave,
|
|
required this.onReset,
|
|
});
|
|
@override
|
|
State<_ServerSettings> createState() => _ServerSettingsState();
|
|
}
|
|
|
|
class _ServerSettingsState extends State<_ServerSettings> {
|
|
bool _open = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
InkWell(
|
|
onTap: () => setState(() => _open = !_open),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
serverRegistry.list.length > 1
|
|
? "${t("login.serverSettings")} · ${serverRegistry.displayName(serverRegistry.active)}"
|
|
: t("login.serverSettings"),
|
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey)),
|
|
Icon(_open ? Icons.expand_less : Icons.expand_more, size: 18, color: Colors.grey),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (_open) ...[
|
|
TextField(
|
|
controller: widget.controller,
|
|
keyboardType: TextInputType.url,
|
|
autocorrect: false,
|
|
decoration: InputDecoration(
|
|
labelText: t("login.apiServerUrl"),
|
|
hintText: kDefaultApiBase,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(t("login.leaveBlank"),
|
|
style: const TextStyle(color: Colors.grey, fontSize: 11)),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
OutlinedButton(onPressed: widget.onSave, child: Text(t("common.save"))),
|
|
const SizedBox(width: 8),
|
|
TextButton(onPressed: widget.onReset, child: Text(t("login.reset"))),
|
|
if (widget.savedNote != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 4),
|
|
child: Text(widget.savedNote!,
|
|
style: const TextStyle(color: DriverVault.success, fontSize: 12)),
|
|
),
|
|
],
|
|
),
|
|
// A session can expire on a server that isn't home, which lands here
|
|
// with that one still active — so the picker has to be reachable
|
|
// before signing in, not only from inside the app.
|
|
if (serverRegistry.list.length > 1)
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton.icon(
|
|
onPressed: () async {
|
|
await showServerPicker(context);
|
|
if (!mounted) return;
|
|
setState(() => widget.controller.text = serverRegistry.active.url);
|
|
},
|
|
icon: const Icon(Icons.dns_outlined, size: 18),
|
|
label: Text(t("servers.title")),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|