Token management, Factory reset, UX/UI Improvements

This commit is contained in:
2026-02-22 19:35:20 +07:00
parent 639a8417e6
commit 36953e35df
30 changed files with 1601 additions and 197 deletions
+261 -97
View File
@@ -1,11 +1,15 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:liteauthconfig/models/appconfig.dart';
import 'package:liteauthconfig/l10n/app_localizations.dart';
import 'package:liteauthconfig/models/device_status.dart';
import 'package:liteauthconfig/models/logentry.dart';
import 'package:liteauthconfig/pages/reconfigure_network.dart';
import 'package:liteauthconfig/pages/tokeninfo.dart';
import 'package:liteauthconfig/utils/network.dart';
class DeviceInfoPage extends StatefulWidget {
@@ -17,13 +21,18 @@ class DeviceInfoPage extends StatefulWidget {
State<StatefulWidget> createState() => _DeviceInfoPageState();
}
enum TokenState { requesting, exists, error }
class _DeviceInfoPageState extends State<DeviceInfoPage> {
late Dio httpClient;
late Device device;
DeviceStatus? deviceStatus;
String statusMessage = "";
bool online = false;
bool loading = false;
Timer? refreshTimer;
late Timer logPollTimer;
List<LogEntry> logEntries = [];
@override
void initState() {
@@ -31,40 +40,67 @@ class _DeviceInfoPageState extends State<DeviceInfoPage> {
refreshDevice();
checkStatus();
logPollTimer = Timer.periodic(
Duration(seconds: 15),
(timer) => queryLogs(),
);
}
void refreshDevice() {
device = AppConfig().devices[widget.deviceIndex];
}
Future checkToken() async {
var appLocal = AppLocalizations.of(context)!;
var resp = await httpClient.get("https://liteauth.local/api/getToken");
if (resp.statusCode != 200) {
statusMessage = appLocal.tokenForbidden;
return;
}
device.token = resp.data;
final config = AppConfig();
config.devices[widget.deviceIndex] = device;
await config.save();
}
Future checkStatus() async {
statusMessage = "";
setState(() {
loading = true;
});
var client = await createEspHttpClient();
httpClient = await createEspHttpClient(token: device.token);
try {
var resp = await client.get(
Uri.parse("https://liteauth.local/api/status"),
);
deviceStatus = DeviceStatus.fromJson(jsonDecode(resp.body));
online = resp.statusCode >= 200 && resp.statusCode < 300;
var resp = await httpClient.get("https://liteauth.local/api/status");
deviceStatus = DeviceStatus.fromJson(resp.data);
online = resp.statusCode! >= 200 && resp.statusCode! < 300;
} catch (e) {
statusMessage = e.toString();
online = false;
refreshTimer ??= Timer.periodic(const Duration(seconds: 5), (
refreshTimer ??= Timer.periodic(const Duration(seconds: 10), (
timer,
) async {
if (loading) return;
await checkStatus();
if (online) {
statusMessage = "";
timer.cancel();
refreshTimer = null;
}
});
}
if (online && device.token.isEmpty) {
try {
await checkToken();
} catch (e) {
statusMessage = e.toString();
}
}
if (mounted) {
setState(() {
loading = false;
@@ -75,28 +111,96 @@ class _DeviceInfoPageState extends State<DeviceInfoPage> {
void factoryReset() async {
var appLocal = AppLocalizations.of(context)!;
var reset = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog.adaptive(
title: Text(appLocal.factoryReset),
content: Text(appLocal.resetConfirm),
actions: [
TextButton(onPressed: () {
Navigator.of(ctx).pop(false);
}, child: Text(appLocal.cancel)),
TextButton(onPressed: () {
Navigator.of(ctx).pop(true);
}, child: Text(appLocal.reset)),
],
)) ?? false;
var reset =
await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog.adaptive(
title: Text(appLocal.factoryReset),
content: Text(appLocal.resetConfirm),
actions: [
TextButton(
onPressed: () {
Navigator.of(ctx).pop(false);
},
child: Text(appLocal.cancel),
),
TextButton(
onPressed: () {
Navigator.of(ctx).pop(true);
},
child: Text(appLocal.reset),
),
],
),
) ??
false;
if (!reset) return;
var client = await createEspHttpClient();
client.get(Uri.parse("https://liteauth.local/api/factoryReset"));
if (!mounted) {
return;
}
Navigator.pop(context);
setState(() {
loading = true;
});
final resp = await httpClient.get(
"https://liteauth.local/api/factoryReset",
);
setState(() {
loading = false;
});
if (resp.statusCode != 200 && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(appLocal.resetFailedCode(resp.statusCode!))),
);
return;
}
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(appLocal.resetSuccessful)));
}
device.token = "";
var config = AppConfig();
config.devices[widget.deviceIndex] = device;
await config.save();
reconfigureNetwork();
}
void queryLogs() async {
if (!online) return;
setState(() {
loading = true;
});
try {
var resp = await httpClient.get("https://liteauth.local/api/logs");
print(DateTime.now().toIso8601String());
logEntries = (resp.data as List<dynamic>).reversed
.map((data) => LogEntry.fromJson(data))
.toList();
} catch (e) {
if (kDebugMode) {
print(e);
}
}
setState(() {
loading = false;
});
}
@override
void dispose() {
refreshTimer?.cancel();
logPollTimer.cancel();
super.dispose();
}
@@ -115,81 +219,7 @@ class _DeviceInfoPageState extends State<DeviceInfoPage> {
)
: null,
actions: [
MenuAnchor(
builder: (context, controller, child) {
return IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: Icon(Icons.adaptive.more),
);
},
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.refresh),
onPressed: checkStatus,
child: Text(appLocal.refresh),
),
MenuItemButton(
leadingIcon: const Icon(Icons.network_wifi),
child: Text(appLocal.reconfigureNetwork),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
ReconfigureNetworkPage(widget.deviceIndex),
),
).then((result) {
if (result) {
refreshDevice();
checkStatus();
}
});
},
),
MenuItemButton(
leadingIcon: const Icon(Icons.history),
child: Text(appLocal.factoryReset),
onPressed: factoryReset,
),
MenuItemButton(
leadingIcon: const Icon(Icons.delete),
child: Text(appLocal.delete),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
title: Text(appLocal.deletion),
content: Text(appLocal.deviceDeleteConfirm),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(appLocal.cancel),
),
TextButton(
onPressed: () {
AppConfig()
..devices.removeAt(widget.deviceIndex)
..save();
Navigator.pop(context);
Navigator.pop(context);
},
child: Text(appLocal.delete),
),
],
),
);
},
),
],
),
IconButton(onPressed: moreActions, icon: Icon(Icons.adaptive.more)),
],
),
body: SingleChildScrollView(
@@ -251,6 +281,25 @@ class _DeviceInfoPageState extends State<DeviceInfoPage> {
),
],
),
ListView.builder(
shrinkWrap: true,
itemCount: logEntries.length,
itemBuilder: (ctx, idx) {
var entry = logEntries[idx];
return Row(
spacing: 8,
children: [
Text(
// DateFormat.yMd().add_jms().format(
// entry.time.toLocal(),
// ),
entry.time.toIso8601String(),
),
Text(entry.uid),
],
);
},
),
],
),
],
@@ -259,4 +308,119 @@ class _DeviceInfoPageState extends State<DeviceInfoPage> {
),
);
}
void reconfigureNetwork() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ReconfigureNetworkPage(widget.deviceIndex),
),
).then((result) {
if (result == true) {
refreshDevice();
checkStatus();
}
});
}
void moreActions() {
final appTheme = Theme.of(context);
final appLocal = AppLocalizations.of(context)!;
showModalBottomSheet(
context: context,
builder: (ctx) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(
appLocal.additionalActions,
style: appTheme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
ListTile(
leading: const Icon(Icons.refresh),
onTap: () {
if (online) {
queryLogs();
} else {
checkStatus();
}
Navigator.pop(context);
},
title: Text(appLocal.refresh),
),
ListTile(
leading: const Icon(Icons.key),
onTap: () {
Navigator.of(ctx).push(
MaterialPageRoute(
builder: (context) =>
TokenInfo(widget.deviceIndex, device.token),
),
);
},
title: Text(appLocal.manageToken),
),
Visibility(
visible: Platform.isAndroid || Platform.isIOS,
child: ListTile(
leading: const Icon(Icons.network_wifi),
title: Text(appLocal.reconfigureNetwork),
onTap: reconfigureNetwork,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Text(
appLocal.dangerousActions,
style: appTheme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
ListTile(
leading: const Icon(Icons.history),
title: Text(appLocal.factoryReset),
onTap: factoryReset,
),
ListTile(
leading: const Icon(Icons.delete),
title: Text(appLocal.delete),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
title: Text(appLocal.deletion),
content: Text(appLocal.deviceDeleteConfirm),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(appLocal.cancel),
),
TextButton(
onPressed: () {
AppConfig()
..devices.removeAt(widget.deviceIndex)
..save();
Navigator.pop(context);
Navigator.pop(context);
Navigator.pop(context);
},
child: Text(appLocal.delete),
),
],
),
);
},
),
],
),
);
}
}
+1
View File
@@ -31,6 +31,7 @@ class _DeviceListPageState extends State<DeviceListPage> {
if (!(Platform.isAndroid || Platform.isIOS)) return;
availability = await NfcManager.instance.checkAvailability();
setState(() {});
if (availability != NfcAvailability.enabled) return;
+16
View File
@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class QRScanner extends StatelessWidget {
const QRScanner({super.key});
@override
Widget build(BuildContext context) {
return MobileScanner(
onDetect: (result) {
Navigator.pop(context, result);
},
);
}
}
+21 -19
View File
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:liteauthconfig/models/appconfig.dart';
import 'package:liteauthconfig/dialogs/esptouchdialog.dart';
import 'package:liteauthconfig/l10n/app_localizations.dart';
import 'package:multicast_dns/multicast_dns.dart';
import 'package:network_info_plus/network_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -44,7 +43,7 @@ class _RegisterPageState extends State<RegisterPage> {
var appLocal = AppLocalizations.of(context)!;
var appTheme = Theme.of(context);
if (await Permission.location.isDenied) {
if ((Platform.isAndroid || Platform.isIOS) && await Permission.location.isDenied) {
showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
@@ -70,7 +69,7 @@ class _RegisterPageState extends State<RegisterPage> {
],
),
);
} else if (await Permission.location.isGranted) {
} else if (!(Platform.isAndroid || Platform.isIOS) || await Permission.location.isGranted) {
fetchNetworkInfo();
}
}
@@ -95,12 +94,29 @@ class _RegisterPageState extends State<RegisterPage> {
}
void submit() async {
var appLocal = AppLocalizations.of(context)!;
var name = nameController.text.isEmpty ? ssidController.text : nameController.text;
if (ssidController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(
appLocal.cannotBeEmpty("SSID")
),));
return;
}
if (bssidController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(
appLocal.cannotBeEmpty("BSSID")
),));
return;
}
if (widget.setup) {
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => ESPTouchDialog(
name: nameController.text,
name: name,
ssid: ssidController.text,
bssid: bssidController.text,
password: passwordController.text,
@@ -109,26 +125,12 @@ class _RegisterPageState extends State<RegisterPage> {
);
} else {
final dev = Device(
name: nameController.text,
name: name,
routerSsid: ssidController.text,
routerBssid: bssidController.text,
networkPassword: passwordController.text,
);
final MDnsClient client = MDnsClient();
await client.start();
// TODO: Change to dynamic mDNS name
String name = "liteauth.local";
await for (final PtrResourceRecord ptr
in client.lookup<PtrResourceRecord>(
ResourceRecordQuery.serverPointer(name),
)) {
await for (final SrvResourceRecord srv in client.lookup(ResourceRecordQuery.service(ptr.domainName))) {
// TODO: Actually implement record lookup
}
}
AppConfig().devices.add(dev);
}
if (context.mounted) {
+163
View File
@@ -0,0 +1,163 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:liteauthconfig/l10n/app_localizations.dart';
import 'package:liteauthconfig/models/appconfig.dart';
import 'package:liteauthconfig/pages/qrscanner.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:qr_flutter/qr_flutter.dart';
class TokenInfo extends StatefulWidget {
final int deviceIndex;
final String token;
const TokenInfo(this.deviceIndex, this.token, {super.key});
@override
State<StatefulWidget> createState() => _TokenInfoState();
}
class _TokenInfoState extends State<TokenInfo> {
TextEditingController tokenController = TextEditingController();
bool showToken = false;
@override
void initState() {
tokenController.text = widget.token;
super.initState();
}
Future saveToken() async {
final appLocal = AppLocalizations.of(context)!;
var config = AppConfig();
config.devices[widget.deviceIndex].token =
tokenController.text;
await config.save();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(appLocal.saved)),
);
}
}
Future scanQrCode() async {
var code = await Navigator.push<BarcodeCapture>(
context,
MaterialPageRoute(builder: (ctx) => QRScanner()),
);
var value = code?.barcodes.first.rawValue;
if (code == null || value == null) return;
tokenController.text = value;
await saveToken();
}
@override
Widget build(BuildContext context) {
final appLocal = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(appLocal.token)),
body: SingleChildScrollView(
child: Padding(
padding: EdgeInsetsGeometry.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(appLocal.viewTokenWarn),
Row(
children: [
Checkbox.adaptive(
semanticLabel: appLocal.showToken,
value: showToken,
onChanged: (newValue) {
setState(() {
showToken = newValue!;
});
},
),
Text(appLocal.showToken),
],
),
Visibility(
visible: showToken,
child: Center(
child: Column(
children: [
Visibility(
visible: tokenController.text.isNotEmpty,
replacement: Text(appLocal.tokenNotFound),
child: Column(
children: [
SizedBox.square(
dimension: 200,
child: QrImageView(
data: tokenController.text,
backgroundColor: Colors.white,
padding: EdgeInsets.all(16),
),
),
Gap(16),
OutlinedButton(
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: tokenController.text),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(appLocal.copied)),
);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.copy),
Gap(8),
Text(appLocal.copyToken),
],
),
),
],
),
),
Gap(16),
TextField(
controller: tokenController,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: appLocal.token,
suffixIcon: IconButton(
onPressed: Platform.isLinux || Platform.isWindows
? null
: scanQrCode,
icon: const Icon(Icons.qr_code_scanner),
),
),
onChanged: (newValue) {
setState(() {});
},
),
Gap(16),
FilledButton(
onPressed: tokenController.text == widget.token
? null
: saveToken,
child: Text(appLocal.saveToken),
),
],
),
),
),
],
),
),
),
);
}
}