Files
OpenParcelBox/mobile-app/app/lib/main.dart
T

2403 lines
76 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:nfc_manager/nfc_manager.dart' hide NfcTag;
import 'package:nfc_manager/nfc_manager_android.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'app_strings.dart';
import 'backup_service.dart';
import 'ble_controller.dart';
import 'models.dart';
import 'secure_box_store.dart';
const _logoAsset = 'images/openparcelbox-logo-256.png';
const _backgroundAsset = 'images/background.jpg';
const _background = Color(0xFF333333);
const _surface = Color(0xFF292929);
const _modalSurface = Color(0xFF303030);
const _headerBackground = Color(0xFF292929);
const _disconnectedBanner = Color(0xFF353535);
const _accent = Color(0xFFC19D60);
const _text = Color(0xFFC6C6C6);
void main() {
FlutterBluePlus.setOperationQueueMode(OperationQueueMode.perDevice);
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({
this.store,
this.backupService,
this.splashDuration = const Duration(milliseconds: 900),
super.key,
});
final SecureBoxStore? store;
final BackupService? backupService;
final Duration splashDuration;
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final SecureBoxStore _store = widget.store ?? SecureBoxStore();
Locale? _locale;
bool _languageLoaded = false;
@override
void initState() {
super.initState();
_loadLanguage();
}
Future<void> _loadLanguage() async {
final minimumSplashTime = Future<void>.delayed(widget.splashDuration);
try {
final language = await _store.loadLanguage();
_locale = language == null ? null : Locale(language);
} catch (_) {
_locale = null;
}
await minimumSplashTime;
if (mounted) {
setState(() => _languageLoaded = true);
}
}
Future<void> _changeLanguage(String? language) async {
await _store.saveLanguage(language);
setState(() => _locale = language == null ? null : Locale(language));
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'OpenParcelBox',
locale: _locale,
supportedLocales: const <Locale>[Locale('en'), Locale('fr')],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
localeResolutionCallback: (locale, supported) =>
locale?.languageCode == 'fr'
? const Locale('fr')
: const Locale('en'),
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: _background,
colorScheme: const ColorScheme.dark(
primary: _accent,
secondary: _accent,
surface: _surface,
onPrimary: Color(0xFF1F1F1F),
onSurface: _text,
error: Color(0xFFE06B6B),
),
textTheme: ThemeData.dark().textTheme.apply(
bodyColor: _text,
displayColor: _accent,
),
dialogTheme: const DialogThemeData(backgroundColor: _modalSurface),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF252525),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: _accent),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: _accent,
foregroundColor: const Color(0xFF1F1F1F),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 15),
),
),
useMaterial3: true,
),
home: _languageLoaded
? OpenParcelBoxHome(
store: _store,
backupService: widget.backupService,
selectedLanguage: _locale?.languageCode,
onLanguageChanged: _changeLanguage,
)
: const _StartupSplash(),
);
}
}
class _StartupSplash extends StatelessWidget {
const _StartupSplash();
@override
Widget build(BuildContext context) => ColoredBox(
color: _background,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
Image.asset(_backgroundAsset, fit: BoxFit.cover),
Center(
child: LayoutBuilder(
builder: (context, constraints) => Image.asset(
_logoAsset,
width: (constraints.maxWidth * 0.62).clamp(220.0, 300.0),
fit: BoxFit.contain,
),
),
),
],
),
);
}
class _AppBackground extends StatelessWidget {
const _AppBackground({required this.child});
final Widget child;
@override
Widget build(BuildContext context) => DecoratedBox(
key: const ValueKey<String>('app-background'),
decoration: const BoxDecoration(
color: _background,
image: DecorationImage(
image: AssetImage(_backgroundAsset),
fit: BoxFit.cover,
),
),
child: SizedBox.expand(child: child),
);
}
class OpenParcelBoxHome extends StatefulWidget {
const OpenParcelBoxHome({
required this.store,
this.backupService,
required this.selectedLanguage,
required this.onLanguageChanged,
super.key,
});
final SecureBoxStore store;
final BackupService? backupService;
final String? selectedLanguage;
final Future<void> Function(String?) onLanguageChanged;
@override
State<OpenParcelBoxHome> createState() => _OpenParcelBoxHomeState();
}
class _OpenParcelBoxHomeState extends State<OpenParcelBoxHome> {
late final OpenParcelBoxBleController _controller =
OpenParcelBoxBleController(store: widget.store);
late final BackupService _backupService =
widget.backupService ?? BackupService();
String? _backupDirectory;
DateTime? _lastBackupAt;
String? _nfcScanStatus;
AppStrings get strings => AppStrings(Localizations.localeOf(context));
@override
void initState() {
super.initState();
_controller
..addListener(_refresh)
..initialize();
_loadBackupSettings();
}
Future<void> _loadBackupSettings() async {
try {
final path = await widget.store.loadBackupDirectory();
if (mounted) {
setState(() => _backupDirectory = path);
}
} catch (_) {
// A missing optional stored path must not block the settings screen.
}
try {
final date = await widget.store.loadLastBackupAt();
if (mounted) {
setState(() => _lastBackupAt = date);
}
} catch (_) {
// A missing optional timestamp is equivalent to no previous backup.
}
}
void _refresh() {
if (mounted) {
setState(() {});
}
}
@override
void dispose() {
_controller
..removeListener(_refresh)
..dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_controller.initialized) {
return const Scaffold(
body: _AppBackground(child: Center(child: CircularProgressIndicator())),
);
}
return Scaffold(
body: _AppBackground(
child: SafeArea(
child: Stack(
children: <Widget>[
if (!_controller.hasBox)
_EmptyHome(
addLabel: strings.text('add_box'),
restoreLabel: strings.text('restore_backup'),
onAdd: _showAddBox,
onRestore: _restoreBackup,
)
else
_Dashboard(
controller: _controller,
strings: strings,
onOpen: () => _guarded(_controller.openLock),
onSynchronize: () => _guarded(_controller.synchronize),
onReconnect: () => _guarded(_controller.connectSavedBox),
onHistory: _showHistory,
onCodes: _showCodes,
onNfc: _showNfc,
onGuests: _showGuests,
onSettings: _showSettings,
),
if (_controller.isBusy)
const Positioned(
left: 0,
right: 0,
top: 0,
child: LinearProgressIndicator(),
),
],
),
),
),
);
}
Future<void> _showAddBox() async {
var boxName = '';
ScanResult? selectedResult;
var registrationInProgress = false;
String? registrationError;
await showOpbModal(
context,
title: strings.text('add_box'),
child: StatefulBuilder(
builder: (modalContext, setModalState) {
if (registrationInProgress || registrationError != null) {
return _RegistrationStatus(
error: registrationError,
waitingText: strings.text('registration_wait'),
errorTitle: strings.text('registration_failed'),
closeLabel: strings.text('close'),
onClose: () => Navigator.of(modalContext).pop(),
);
}
return ListenableBuilder(
listenable: _controller,
builder: (formContext, child) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(strings.text('box_name_help')),
const SizedBox(height: 12),
TextFormField(
maxLength: 31,
textInputAction: TextInputAction.done,
onChanged: (value) {
boxName = value;
setModalState(() {});
},
onFieldSubmitted: (_) => FocusScope.of(context).unfocus(),
decoration: InputDecoration(
labelText: strings.text('box_name'),
),
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () async {
final payload = await _scanQrCode();
if (payload == null) {
return;
}
try {
await _controller.importGuestInvitation(payload);
if (modalContext.mounted) {
Navigator.of(modalContext).pop();
}
} catch (error) {
if (modalContext.mounted) {
await _showError(error);
}
}
},
icon: const Icon(Icons.qr_code_scanner),
label: Text(strings.text('scan_qr')),
),
const SizedBox(height: 16),
Row(
children: <Widget>[
Expanded(
child: Text(
strings.text('nearby_boxes'),
style: Theme.of(formContext).textTheme.titleMedium
?.copyWith(
color: _accent,
fontWeight: FontWeight.w700,
),
),
),
IconButton.filledTonal(
onPressed: _controller.isScanning
? null
: _controller.startScan,
icon: const Icon(Icons.radar),
tooltip: strings.text('search'),
),
],
),
const SizedBox(height: 8),
if (_controller.errorMessage != null) ...<Widget>[
Text(
_controller.errorMessage!,
style: TextStyle(
color: Theme.of(formContext).colorScheme.error,
),
),
const SizedBox(height: 8),
],
if (_controller.scanResults.isEmpty)
_EmptyRow(text: strings.text('no_results'))
else
RadioGroup<ScanResult>(
groupValue: selectedResult,
onChanged: (result) =>
setModalState(() => selectedResult = result),
child: Column(
children: _controller.scanResults
.map(
(result) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: _surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: selectedResult == result
? _accent
: Colors.white10,
),
),
clipBehavior: Clip.antiAlias,
child: RadioListTile<ScanResult>(
value: result,
activeColor: _accent,
selected: selectedResult == result,
secondary: const Icon(
Icons.inventory_2_outlined,
color: _accent,
),
title: Text(_deviceName(result)),
subtitle: Text(result.device.remoteId.str),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
),
),
),
),
)
.toList(),
),
),
if (selectedResult == null &&
_controller.scanResults.isNotEmpty) ...<Widget>[
const SizedBox(height: 4),
Text(
strings.text('select_box'),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
const SizedBox(height: 16),
FilledButton.icon(
onPressed: selectedResult == null || boxName.trim().isEmpty
? null
: () async {
FocusScope.of(modalContext).unfocus();
setModalState(() {
registrationInProgress = true;
registrationError = null;
});
try {
await _controller.registerAdministrator(
selectedResult!,
boxName,
);
if (modalContext.mounted) {
Navigator.of(modalContext).pop();
}
} catch (error) {
if (modalContext.mounted) {
setModalState(() {
registrationInProgress = true;
registrationError = _registrationErrorText(
error,
);
});
}
}
},
icon: const Icon(Icons.add_link),
label: Text(strings.text('register_box')),
),
],
),
);
},
),
barrierDismissible: false,
);
}
String _registrationErrorText(Object error) {
if (error is OpenParcelBoxAlreadyProvisionedException) {
return strings.text('box_already_has_admin');
}
if (error is OpenParcelBoxAuthenticationException) {
return strings.text('box_identity_mismatch');
}
return '$error';
}
Future<String?> _scanQrCode() {
bool handled = false;
return showDialog<String>(
context: context,
builder: (context) => Dialog(
backgroundColor: _modalSurface,
child: SizedBox(
height: 420,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
children: <Widget>[
MobileScanner(
onDetect: (capture) {
if (handled || capture.barcodes.isEmpty) {
return;
}
final value = capture.barcodes.first.rawValue;
if (value != null) {
handled = true;
Navigator.of(context).pop(value);
}
},
),
Positioned(
right: 12,
top: 12,
child: IconButton.filled(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
),
],
),
),
),
),
);
}
Future<void> _showHistory() => showOpbModal(
context,
title: strings.text('history'),
child: ListenableBuilder(
listenable: _controller,
builder: (context, child) => _controller.history.isEmpty
? _EmptyRow(text: strings.text('no_history'))
: Column(
children: _controller.history.reversed
.map(
(event) => _DataRowCard(
icon: Icons.lock_open,
title: _formatDate(event.date),
subtitle: _historyLabel(event),
),
)
.toList(),
),
),
);
Future<void> _showCodes(CredentialKind kind) {
final codes = kind == CredentialKind.permanent
? _controller.permanentCodes
: _controller.oneTimeCodes;
final limit = kind == CredentialKind.permanent ? 8 : 20;
final hasModifyPermission =
_controller.currentBox?.role == BoxRole.administrator ||
kind == CredentialKind.oneTime;
final canModify = hasModifyPermission && _controller.isConnected;
return showOpbModal(
context,
title: strings.text(
kind == CredentialKind.permanent
? 'permanent_codes'
: 'temporary_codes',
),
child: StatefulBuilder(
builder: (context, setModalState) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (hasModifyPermission)
FilledButton.icon(
onPressed: !_controller.isConnected || codes.length >= limit
? null
: () async {
await _guarded(
() => _controller.addGeneratedCode(kind),
);
setModalState(() {});
},
icon: const Icon(Icons.add),
label: Text(
'${strings.text('new_code')} (${codes.length}/$limit)',
),
)
else
Text(
'${strings.text('code_count')}: ${codes.length}/$limit',
textAlign: TextAlign.center,
),
if (codes.length >= limit)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(strings.text('code_limit')),
),
const SizedBox(height: 12),
if (codes.isEmpty)
_EmptyRow(text: strings.text('no_code'))
else
...codes.map(
(code) => _DataRowCard(
icon: Icons.password,
title: code.code,
actions: <Widget>[
IconButton(
key: ValueKey<String>('copy-code-${code.id}'),
onPressed: () =>
Clipboard.setData(ClipboardData(text: code.code)),
icon: const Icon(Icons.copy_outlined),
tooltip: strings.text('copy_code'),
),
if (hasModifyPermission) ...<Widget>[
if (kind == CredentialKind.permanent)
IconButton(
onPressed: canModify
? () async {
final changed = await _editPermanentCode(
code,
);
if (changed) {
setModalState(() {});
}
}
: null,
icon: const Icon(Icons.edit_outlined),
tooltip: strings.text('edit_code'),
),
IconButton(
onPressed: canModify
? () async {
await _guarded(
() => _controller.removeCode(code),
);
setModalState(() {});
}
: null,
icon: const Icon(Icons.delete_outline),
tooltip: strings.text('delete'),
),
],
],
),
),
],
),
),
);
}
Future<bool> _editPermanentCode(AccessCode code) async {
final newCode = await showDialog<String>(
context: context,
builder: (context) => CodeEditorDialog(
title: strings.text('edit_permanent_code'),
description: strings.text('code_input_help'),
initialCode: code.code,
cancelLabel: strings.text('cancel'),
confirmLabel: strings.text('save'),
),
);
if (newCode == null || newCode == code.code) {
return false;
}
if (!mounted) {
return false;
}
final duplicatesPermanent = _controller.permanentCodes.any(
(item) => item.id != code.id && item.code == newCode,
);
if (duplicatesPermanent) {
await _showError(strings.text('code_already_permanent'));
return false;
}
final convertsTemporary = _controller.oneTimeCodes.any(
(item) => item.code == newCode,
);
if (convertsTemporary) {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(strings.text('convert_temporary_code_title')),
content: Text(strings.text('convert_temporary_code_message')),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(strings.text('cancel')),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(strings.text('convert')),
),
],
),
);
if (confirmed != true) {
return false;
}
}
return _guarded(() => _controller.replacePermanentCode(code, newCode));
}
Future<void> _showNfc() async {
_nfcScanStatus = null;
await showOpbModal(
context,
title: strings.text('nfc_tags'),
child: StatefulBuilder(
builder: (context, setModalState) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
DecoratedBox(
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: <Widget>[
Expanded(
child: _Shortcut(
icon: Icons.smartphone,
label: strings.text('scan'),
onTap: _controller.isConnected
? () => _scanNfcWithPhone(setModalState)
: null,
),
),
Container(width: 1, height: 52, color: Colors.white12),
Expanded(
child: _Shortcut(
icon: Icons.sensors,
label: strings.text('pairing_mode'),
onTap: _controller.isConnected
? () => _addNfcFromBox(setModalState)
: null,
),
),
Container(width: 1, height: 52, color: Colors.white12),
Expanded(
child: _Shortcut(
icon: Icons.draw_outlined,
label: strings.text('add_manually'),
onTap: _controller.isConnected
? () => _addManualNfc(setModalState)
: null,
),
),
],
),
),
if (_nfcScanStatus != null) ...<Widget>[
const SizedBox(height: 12),
_InlineNotice(icon: Icons.nfc, text: _nfcScanStatus!),
],
const SizedBox(height: 12),
if (_controller.nfcTags.isEmpty)
_EmptyRow(text: strings.text('nfc_tags'))
else
..._controller.nfcTags.map(
(tag) => _DataRowCard(
icon: Icons.contactless,
title: tag.name.isEmpty ? tag.uid : tag.name,
subtitle: tag.name.isEmpty ? null : tag.uid,
actions: <Widget>[
IconButton(
onPressed: _controller.isConnected
? () => _renameNfc(tag, setModalState)
: null,
icon: const Icon(Icons.edit_outlined),
tooltip: strings.text('edit'),
),
IconButton(
onPressed: _controller.isConnected
? () async {
await _guarded(
() => _controller.removeNfcTag(tag),
);
setModalState(() {});
}
: null,
icon: const Icon(Icons.delete_outline),
tooltip: strings.text('delete'),
),
],
),
),
],
),
),
);
_nfcScanStatus = null;
try {
await NfcManager.instance.stopSession();
} catch (_) {
// There may be no active phone-side NFC session to stop.
}
}
Future<void> _scanNfcWithPhone(StateSetter refreshModal) async {
final availability = await NfcManager.instance.checkAvailability();
if (availability != NfcAvailability.enabled) {
refreshModal(() => _nfcScanStatus = strings.text('nfc_unavailable'));
return;
}
refreshModal(() => _nfcScanStatus = strings.text('hold_tag'));
await NfcManager.instance.startSession(
pollingOptions: const <NfcPollingOption>{
NfcPollingOption.iso14443,
NfcPollingOption.iso15693,
},
onDiscovered: (tag) async {
final androidTag = Platform.isAndroid ? NfcTagAndroid.from(tag) : null;
final uid = androidTag?.id
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(':');
await NfcManager.instance.stopSession();
if (uid != null && mounted) {
refreshModal(() => _nfcScanStatus = null);
final name = await _askText(
strings.text('tag_name'),
description: strings.text('tag_name_help'),
);
if (name != null) {
await _guarded(() => _controller.addNfcTag(uid, name));
refreshModal(() {});
}
}
},
);
}
Future<void> _addNfcFromBox(StateSetter refreshModal) async {
final added = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) =>
NfcEnrollmentDialog(controller: _controller, strings: strings),
);
if (added == true && mounted) {
refreshModal(() {});
}
}
Future<void> _addManualNfc(StateSetter refreshModal) async {
final uid = await _askText(
strings.text('tag_uid'),
description: strings.text('tag_uid_help'),
);
if (uid == null) {
return;
}
final name = await _askText(
strings.text('tag_name'),
description: strings.text('tag_name_help'),
);
if (name != null) {
await _guarded(() => _controller.addNfcTag(uid, name));
refreshModal(() {});
}
}
Future<void> _renameNfc(NfcTag tag, StateSetter refreshModal) async {
final name = await _askText(
strings.text('tag_name'),
initialValue: tag.name,
description: strings.text('rename_tag_help'),
);
if (name != null) {
await _guarded(() => _controller.addNfcTag(tag.uid, name));
refreshModal(() {});
}
}
Future<void> _showGuests() => showOpbModal(
context,
title: strings.text('guests'),
child: StatefulBuilder(
builder: (context, setModalState) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
FilledButton.icon(
onPressed: _controller.isConnected
? () async {
final name = await _askText(
strings.text('guest_name'),
description: strings.text('guest_name_help'),
);
if (name == null || name.trim().isEmpty) {
return;
}
try {
final key = await _controller.addGuest(name);
final guest = GuestIdentity(name: name.trim(), key: key);
if (context.mounted) {
await _showGuestQr(guest);
setModalState(() {});
}
} catch (error) {
await _showError(error);
}
}
: null,
icon: const Icon(Icons.person_add_alt_1),
label: Text(strings.text('add')),
),
const SizedBox(height: 12),
if (_controller.guests.isEmpty)
_EmptyRow(text: strings.text('no_guest'))
else
..._controller.guests.map(
(guest) => _DataRowCard(
icon: Icons.person_outline,
title: guest.name,
actions: <Widget>[
IconButton(
onPressed: () => _showGuestQr(guest),
icon: const Icon(Icons.qr_code),
tooltip: strings.text('show_qr'),
),
IconButton(
onPressed: _controller.isConnected
? () async {
await _guarded(
() => _controller.removeGuest(guest),
);
setModalState(() {});
}
: null,
icon: const Icon(Icons.delete_outline),
tooltip: strings.text('delete'),
),
],
),
),
],
),
),
);
Future<void> _showGuestQr(GuestIdentity guest) => showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(guest.name, style: const TextStyle(color: _accent)),
content: SizedBox.square(
dimension: 260,
child: ColoredBox(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.all(12),
child: QrImageView(
data: _controller.invitationPayload(guest),
backgroundColor: Colors.white,
dataModuleStyle: const QrDataModuleStyle(
color: Colors.black,
dataModuleShape: QrDataModuleShape.square,
),
),
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(strings.text('close')),
),
],
),
);
Future<void> _showSettings() => showOpbModal(
context,
title: strings.text('settings'),
child: StatefulBuilder(
builder: (context, refreshModal) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
DropdownButtonFormField<String>(
key: ValueKey(widget.selectedLanguage ?? 'auto'),
initialValue: widget.selectedLanguage ?? 'auto',
decoration: InputDecoration(labelText: strings.text('language')),
items: <DropdownMenuItem<String>>[
DropdownMenuItem(
value: 'auto',
child: _LanguageChoice(
flag: '🌐',
label: strings.text('automatic'),
),
),
DropdownMenuItem(
value: 'fr',
child: _LanguageChoice(
flag: '🇫🇷',
label: strings.text('french'),
),
),
DropdownMenuItem(
value: 'en',
child: _LanguageChoice(
flag: '🇬🇧',
label: strings.text('english'),
),
),
],
onChanged: (value) async {
await widget.onLanguageChanged(value == 'auto' ? null : value);
if (context.mounted) {
refreshModal(() {});
}
},
),
const SizedBox(height: 12),
if (_controller.currentBox?.role == BoxRole.administrator)
_SettingsButton(
icon: Icons.system_update,
label: strings.text('firmware_update'),
onPressed: null,
),
const Divider(height: 28),
_SettingsButton(
icon: Icons.folder_outlined,
label: strings.text('backup_location'),
onPressed: () => _chooseBackupDirectory(refreshModal),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 2),
child: Text(
_backupDirectory?.isNotEmpty == true
? _backupDirectory!
: strings.text('no_backup_location'),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.white60),
),
),
_SettingsButton(
icon: Icons.backup_outlined,
label: strings.text('backup'),
onPressed: () => _createBackup(refreshModal),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 2),
child: Text(
_lastBackupAt == null
? strings.text('last_backup_never')
: '${strings.text('last_backup')}: '
'${_formatBackupDate(_lastBackupAt!)}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.white60),
),
),
_SettingsButton(
icon: Icons.restore,
label: strings.text('restore'),
onPressed: _restoreBackup,
),
const Divider(height: 28),
_SettingsButton(
icon: Icons.delete_forever_outlined,
label: strings.text('forget_box'),
danger: true,
onPressed: _confirmForgetBox,
),
],
),
),
);
Future<void> _chooseBackupDirectory(StateSetter refreshModal) async {
final path = await FilePicker.getDirectoryPath();
if (path != null) {
await widget.store.saveBackupDirectory(path);
_backupDirectory = path;
refreshModal(() {});
_showMessage(path);
}
}
Future<void> _createBackup(StateSetter refreshModal) async {
final password = await _askPassword(strings.text('backup_password_help'));
if (password == null) {
return;
}
try {
if (await _backupService.exportBoxes(
_controller.savedBoxes,
password,
initialDirectory: await widget.store.loadBackupDirectory(),
)) {
final savedAt = DateTime.now();
await widget.store.saveLastBackupAt(savedAt);
_lastBackupAt = savedAt;
refreshModal(() {});
_showMessage(strings.text('backup_done'));
}
} catch (error) {
await _showError(error);
}
}
Future<void> _restoreBackup() async {
try {
final backup = await _backupService.pickBackupFile();
if (backup == null || !mounted) {
return;
}
final password = await _askPassword(
strings.text('restore_password_help'),
);
if (password == null) {
return;
}
final boxes = await _backupService.importBoxes(backup, password);
await _controller.replaceSavedBoxes(boxes);
_showMessage(strings.text('restore_done'));
} catch (error) {
await _showError(error);
}
}
Future<void> _confirmForgetBox() async {
final resetBox = await _boxDeletionConfirmation();
if (resetBox == null) {
return;
}
final succeeded = resetBox
? await _guarded(_controller.factoryReset)
: await _guarded(_controller.forgetCurrentBox);
if (succeeded && mounted) {
Navigator.of(context).pop();
}
}
Future<bool?> _boxDeletionConfirmation() {
final administrator = _controller.currentBox?.role == BoxRole.administrator;
final canResetBox = _controller.isConnected;
var resetBox = false;
return showDialog<bool>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, refreshDialog) => AlertDialog(
title: Text(
strings.text('forget_box'),
style: const TextStyle(color: Color(0xFFE06B6B)),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
strings.text(
administrator
? 'forget_admin_warning'
: 'forget_guest_warning',
),
),
if (administrator) ...<Widget>[
const SizedBox(height: 16),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: resetBox,
onChanged: canResetBox
? (value) =>
refreshDialog(() => resetBox = value ?? false)
: null,
title: Text(
strings.text('also_reset_box'),
style: TextStyle(
color: canResetBox ? null : Colors.white38,
decoration: canResetBox
? TextDecoration.none
: TextDecoration.lineThrough,
),
),
subtitle: canResetBox
? null
: Text(
strings.text('box_reset_offline_unavailable'),
style: const TextStyle(color: Colors.white54),
),
),
],
],
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(strings.text('cancel')),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFE06B6B),
),
onPressed: () => Navigator.of(dialogContext).pop(resetBox),
child: Text(strings.text('confirm')),
),
],
),
),
);
}
Future<String?> _askText(
String label, {
String initialValue = '',
String? description,
}) async {
return showDialog<String>(
context: context,
builder: (context) => _TextPromptDialog(
title: label,
description: description ?? strings.text('input_help'),
fieldLabel: label,
initialValue: initialValue,
cancelLabel: strings.text('cancel'),
confirmLabel: strings.text('save'),
),
);
}
Future<String?> _askPassword(String description) async {
return showDialog<String>(
context: context,
builder: (context) => _TextPromptDialog(
title: strings.text('password'),
description: description,
fieldLabel: strings.text('password'),
cancelLabel: strings.text('cancel'),
confirmLabel: strings.text('confirm'),
showPasswordLabel: strings.text('show_password'),
hidePasswordLabel: strings.text('hide_password'),
obscureText: true,
),
);
}
Future<bool> _guarded(Future<void> Function() action) async {
try {
await action();
return true;
} catch (error) {
await _showError(error);
return false;
}
}
Future<void> _showError(Object error) async {
if (!mounted) {
return;
}
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(
strings.text('error'),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
content: SelectableText(_errorText(error)),
actions: <Widget>[
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(strings.text('close')),
),
],
),
);
}
String _errorText(Object error) {
if (error is OpenParcelBoxAlreadyProvisionedException) {
return strings.text('box_already_has_admin');
}
if (error is OpenParcelBoxAuthenticationException) {
return strings.text('box_identity_mismatch');
}
return '$error';
}
void _showMessage(String message) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
String _historyLabel(OpeningEvent event) {
final source = strings.text(switch (event.kind) {
0 => 'opened_by_permanent',
1 => 'opened_by_temporary',
2 => 'opened_by_nfc',
_ => 'opened_by_app',
});
return event.actor.isEmpty ? source : '$source · ${event.actor}';
}
String _formatDate(DateTime date) {
String two(int value) => value.toString().padLeft(2, '0');
return '${two(date.day)}/${two(date.month)}/${date.year} · '
'${two(date.hour)}:${two(date.minute)}:${two(date.second)}';
}
String _formatBackupDate(DateTime date) {
String two(int value) => value.toString().padLeft(2, '0');
return '${two(date.day)}/${two(date.month)}/${date.year} '
'${two(date.hour)}:${two(date.minute)}';
}
}
class _TextPromptDialog extends StatefulWidget {
const _TextPromptDialog({
required this.title,
required this.description,
required this.fieldLabel,
required this.cancelLabel,
required this.confirmLabel,
this.showPasswordLabel = '',
this.hidePasswordLabel = '',
this.initialValue = '',
this.obscureText = false,
});
final String title;
final String description;
final String fieldLabel;
final String cancelLabel;
final String confirmLabel;
final String showPasswordLabel;
final String hidePasswordLabel;
final String initialValue;
final bool obscureText;
@override
State<_TextPromptDialog> createState() => _TextPromptDialogState();
}
class _TextPromptDialogState extends State<_TextPromptDialog> {
late final TextEditingController _controller = TextEditingController(
text: widget.initialValue,
);
bool _passwordVisible = false;
bool get _isValid => _controller.text.trim().isNotEmpty;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title, style: const TextStyle(color: _accent)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(widget.description),
const SizedBox(height: 14),
TextField(
controller: _controller,
obscureText: widget.obscureText && !_passwordVisible,
autofocus: true,
decoration: InputDecoration(
labelText: widget.fieldLabel,
suffixIcon: widget.obscureText
? IconButton(
onPressed: () =>
setState(() => _passwordVisible = !_passwordVisible),
tooltip: _passwordVisible
? widget.hidePasswordLabel
: widget.showPasswordLabel,
icon: Icon(
_passwordVisible
? Icons.visibility_off
: Icons.visibility,
),
)
: null,
),
onChanged: (_) => setState(() {}),
onSubmitted: (_) {
if (_isValid) {
Navigator.of(context).pop(_controller.text);
}
},
),
],
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(widget.cancelLabel),
),
FilledButton(
onPressed: _isValid
? () => Navigator.of(context).pop(_controller.text)
: null,
child: Text(widget.confirmLabel),
),
],
);
}
}
class CodeEditorDialog extends StatefulWidget {
const CodeEditorDialog({
required this.title,
required this.description,
required this.initialCode,
required this.cancelLabel,
required this.confirmLabel,
super.key,
});
final String title;
final String description;
final String initialCode;
final String cancelLabel;
final String confirmLabel;
@override
State<CodeEditorDialog> createState() => _CodeEditorDialogState();
}
class _CodeEditorDialogState extends State<CodeEditorDialog> {
late final TextEditingController _controller = TextEditingController(
text: widget.initialCode,
);
final FocusNode _focusNode = FocusNode();
bool get _isComplete => _controller.text.length == 6;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
_focusNode.requestFocus();
_selectDigit(5);
});
}
void _selectDigit(int index) {
_focusNode.requestFocus();
final length = _controller.text.length;
_controller.selection = index < length
? TextSelection(baseOffset: index, extentOffset: index + 1)
: TextSelection.collapsed(offset: length);
setState(() {});
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(widget.description),
const SizedBox(height: 20),
SizedBox(
width: 1,
height: 1,
child: Opacity(
opacity: 0,
child: TextField(
key: const ValueKey<String>('code-input'),
controller: _controller,
focusNode: _focusNode,
autofocus: true,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
maxLength: 6,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
onChanged: (_) => setState(() {}),
onSubmitted: (_) {
if (_isComplete) {
Navigator.of(context).pop(_controller.text);
}
},
),
),
),
Row(
children: List<Widget>.generate(11, (position) {
if (position.isOdd) {
return const SizedBox(width: 6);
}
final index = position ~/ 2;
final selected =
_focusNode.hasFocus &&
(_controller.selection.start == index ||
(_controller.selection.isCollapsed &&
_controller.selection.extentOffset == index));
return Expanded(
child: GestureDetector(
key: ValueKey<String>('code-digit-$index'),
onTap: () => _selectDigit(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
height: 54,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF252525),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: selected ? _accent : Colors.white24,
width: selected ? 2 : 1,
),
),
child: Text(
index < _controller.text.length
? _controller.text[index]
: '',
style: Theme.of(context).textTheme.titleLarge,
),
),
),
);
}),
),
],
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(widget.cancelLabel),
),
FilledButton(
onPressed: _isComplete
? () => Navigator.of(context).pop(_controller.text)
: null,
child: Text(widget.confirmLabel),
),
],
);
}
}
class _EmptyHome extends StatelessWidget {
const _EmptyHome({
required this.addLabel,
required this.restoreLabel,
required this.onAdd,
required this.onRestore,
});
final String addLabel;
final String restoreLabel;
final VoidCallback onAdd;
final VoidCallback onRestore;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
FilledButton(
onPressed: onAdd,
child: Text(addLabel, textAlign: TextAlign.center),
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: onRestore,
icon: const Icon(Icons.restore),
label: Text(restoreLabel),
),
],
),
),
);
}
}
class _Dashboard extends StatelessWidget {
const _Dashboard({
required this.controller,
required this.strings,
required this.onOpen,
required this.onSynchronize,
required this.onReconnect,
required this.onHistory,
required this.onCodes,
required this.onNfc,
required this.onGuests,
required this.onSettings,
});
final OpenParcelBoxBleController controller;
final AppStrings strings;
final VoidCallback onOpen;
final VoidCallback onSynchronize;
final VoidCallback onReconnect;
final VoidCallback onHistory;
final ValueChanged<CredentialKind> onCodes;
final VoidCallback onNfc;
final VoidCallback onGuests;
final VoidCallback onSettings;
@override
Widget build(BuildContext context) {
final admin = controller.currentBox?.role == BoxRole.administrator;
final identityName = controller.currentBox?.identityName.trim();
final identityLabel = identityName?.isNotEmpty == true
? identityName!
: strings.text(admin ? 'administrator' : 'guest');
final connected = controller.isConnected;
final naturalCodeActionSize = (MediaQuery.sizeOf(context).width - 54) / 2;
final codeActionHeight = naturalCodeActionSize > 190
? 190.0
: naturalCodeActionSize;
return CustomScrollView(
slivers: <Widget>[
SliverToBoxAdapter(
child: ColoredBox(
color: _headerBackground,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 14),
child: Row(
children: <Widget>[
Image.asset(_logoAsset, width: 54, height: 54),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
controller.currentBox?.name ?? 'OpenParcelBox',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(
color: _accent,
fontWeight: FontWeight.w800,
),
),
Text(
identityLabel,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: _text),
),
],
),
),
Icon(
controller.isConnected
? Icons.bluetooth_connected
: Icons.bluetooth_disabled,
color: controller.isConnected ? _accent : _text,
semanticLabel: strings.text(
controller.isConnected ? 'connected' : controller.status,
),
),
],
),
),
),
),
if (!connected)
SliverToBoxAdapter(
child: Container(
width: double.infinity,
color: _disconnectedBanner,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 9),
child: Text(
strings.text('disconnected'),
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w700,
),
),
),
),
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
DecoratedBox(
key: const ValueKey<String>('quick-actions'),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: <Widget>[
Expanded(
child: _Shortcut(
icon: Icons.lock_open,
label: strings.text('open'),
onTap: connected ? onOpen : null,
),
),
Container(width: 1, height: 44, color: Colors.white12),
Expanded(
child: _Shortcut(
icon: connected ? Icons.sync : Icons.link,
label: strings.text(
connected ? 'synchronize' : 'reconnect',
),
onTap: connected ? onSynchronize : onReconnect,
),
),
Container(width: 1, height: 44, color: Colors.white12),
Expanded(
child: _Shortcut(
icon: Icons.history,
label: strings.text('history'),
onTap: onHistory,
),
),
],
),
),
const SizedBox(height: 28),
SizedBox(
key: const ValueKey<String>('code-actions'),
height: codeActionHeight,
child: Row(
children: <Widget>[
Expanded(
child: KeyedSubtree(
key: const ValueKey<String>('temporary-code-action'),
child: _SquareAction(
icon: Icons.timer_outlined,
label: strings.text('temporary_codes'),
onTap: () => onCodes(CredentialKind.oneTime),
),
),
),
const SizedBox(width: 14),
Expanded(
child: KeyedSubtree(
key: const ValueKey<String>('permanent-code-action'),
child: _SquareAction(
icon: Icons.pin_outlined,
label: strings.text('permanent_codes'),
onTap: () => onCodes(CredentialKind.permanent),
),
),
),
],
),
),
const SizedBox(height: 14),
Row(
children: <Widget>[
if (admin) ...<Widget>[
Expanded(
child: _CompactAction(
icon: Icons.nfc,
label: strings.text('nfc_tags'),
onTap: onNfc,
),
),
const SizedBox(width: 14),
Expanded(
child: _CompactAction(
icon: Icons.group_outlined,
label: strings.text('guests'),
onTap: onGuests,
),
),
const SizedBox(width: 14),
],
Expanded(
child: _CompactAction(
icon: Icons.settings_outlined,
label: strings.text('settings'),
onTap: onSettings,
),
),
if (!admin) ...const <Widget>[
SizedBox(width: 14),
Spacer(),
SizedBox(width: 14),
Spacer(),
],
],
),
],
),
),
),
],
);
}
}
class _Shortcut extends StatelessWidget {
const _Shortcut({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final foreground = enabled ? _accent : Colors.white38;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(icon, color: foreground, size: 28),
const SizedBox(height: 5),
Text(
label,
textAlign: TextAlign.center,
style: TextStyle(color: enabled ? null : Colors.white38),
),
],
),
),
);
}
}
class _SquareAction extends StatelessWidget {
const _SquareAction({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final foreground = enabled ? _accent : Colors.white38;
return Material(
color: _surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(icon, size: 54, color: foreground),
const SizedBox(height: 12),
Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
color: enabled ? null : Colors.white38,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
);
}
}
class _CompactAction extends StatelessWidget {
const _CompactAction({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final foreground = enabled ? _accent : Colors.white38;
return SizedBox(
key: ValueKey<String>('compact-$label'),
height: 104,
child: Material(
color: _surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(icon, size: 36, color: foreground),
const SizedBox(height: 8),
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: enabled ? null : Colors.white38,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
);
}
}
class _DataRowCard extends StatelessWidget {
const _DataRowCard({
required this.icon,
required this.title,
this.subtitle,
this.actions = const <Widget>[],
});
final IconData icon;
final String title;
final String? subtitle;
final List<Widget> actions;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.fromLTRB(12, 8, 6, 8),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white10),
),
child: Row(
children: <Widget>[
Icon(icon, color: _accent),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w700),
),
if (subtitle != null)
Text(subtitle!, style: Theme.of(context).textTheme.bodySmall),
],
),
),
...actions,
],
),
);
}
}
class _EmptyRow extends StatelessWidget {
const _EmptyRow({required this.text});
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Center(
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54),
),
),
);
}
class NfcEnrollmentDialog extends StatefulWidget {
const NfcEnrollmentDialog({
required this.controller,
required this.strings,
super.key,
});
final OpenParcelBoxBleController controller;
final AppStrings strings;
@override
State<NfcEnrollmentDialog> createState() => _NfcEnrollmentDialogState();
}
class _NfcEnrollmentDialogState extends State<NfcEnrollmentDialog> {
final TextEditingController _nameController = TextEditingController();
String? _localError;
bool _saving = false;
bool _completed = false;
@override
void initState() {
super.initState();
unawaited(_start());
}
Future<void> _start() async {
try {
await widget.controller.startNfcEnrollment();
} catch (error) {
if (mounted) setState(() => _localError = '$error');
}
}
String _firmwareError(String error) => switch (error) {
'timeout' => widget.strings.text('nfc_pairing_timeout'),
'already_exists' => widget.strings.text('nfc_pairing_exists'),
'reader_unavailable' => widget.strings.text('nfc_reader_unavailable'),
_ => error,
};
Future<void> _cancel() async {
try {
await widget.controller.cancelNfcEnrollment();
} catch (_) {
// Closing the local modal must remain possible after a disconnect.
}
if (mounted) Navigator.of(context).pop(false);
}
Future<void> _save(String uid) async {
final name = _nameController.text.trim();
if (name.isEmpty || _saving) return;
setState(() {
_saving = true;
_localError = null;
});
try {
await widget.controller.addNfcTag(uid, name);
_completed = true;
if (mounted) Navigator.of(context).pop(true);
} catch (error) {
if (mounted) {
setState(() {
_saving = false;
_localError = '$error';
});
}
}
}
@override
void dispose() {
_nameController.dispose();
if (!_completed && widget.controller.nfcEnrollmentActive) {
unawaited(widget.controller.cancelNfcEnrollment().catchError((_) {}));
}
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: widget.controller,
builder: (context, child) {
final uid = widget.controller.nfcEnrollmentUid;
final firmwareError = widget.controller.nfcEnrollmentError;
final error =
_localError ??
(firmwareError == null ? null : _firmwareError(firmwareError));
return AlertDialog(
key: const ValueKey<String>('nfc-enrollment-modal'),
title: Text(widget.strings.text('nfc_pairing_title')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (uid == null && error == null) ...<Widget>[
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 18),
Text(widget.strings.text('nfc_pairing_wait')),
],
if (uid != null) ...<Widget>[
Text(widget.strings.text('nfc_pairing_detected')),
const SizedBox(height: 8),
Text(uid, key: const ValueKey<String>('nfc-enrollment-uid')),
const SizedBox(height: 12),
TextField(
key: const ValueKey<String>('nfc-enrollment-name'),
controller: _nameController,
autofocus: true,
maxLength: 31,
decoration: InputDecoration(
labelText: widget.strings.text('tag_name'),
),
onChanged: (_) => setState(() {}),
onSubmitted: (_) => _save(uid),
),
],
if (error != null) ...<Widget>[
Text(
error,
key: const ValueKey<String>('nfc-enrollment-error'),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
],
),
actions: <Widget>[
TextButton(
onPressed: _saving ? null : _cancel,
child: Text(
error == null
? widget.strings.text('cancel')
: widget.strings.text('close'),
),
),
if (uid != null)
FilledButton(
key: const ValueKey<String>('nfc-enrollment-save'),
onPressed: _saving || _nameController.text.trim().isEmpty
? null
: () => _save(uid),
child: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(widget.strings.text('save')),
),
],
);
},
);
}
class _RegistrationStatus extends StatelessWidget {
const _RegistrationStatus({
required this.error,
required this.waitingText,
required this.errorTitle,
required this.closeLabel,
required this.onClose,
});
final String? error;
final String waitingText;
final String errorTitle;
final String closeLabel;
final VoidCallback onClose;
@override
Widget build(BuildContext context) => SizedBox(
height: MediaQuery.sizeOf(context).height * 0.52,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Spacer(),
if (error == null) ...<Widget>[
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 24),
Text(
waitingText,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
] else ...<Widget>[
Icon(
Icons.error_outline,
size: 56,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
errorTitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
SelectableText(error!, textAlign: TextAlign.center),
],
const Spacer(),
if (error != null)
FilledButton.icon(
onPressed: onClose,
icon: const Icon(Icons.close),
label: Text(closeLabel),
),
],
),
);
}
class _InlineNotice extends StatelessWidget {
const _InlineNotice({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _accent.withValues(alpha: 0.45)),
),
child: Row(
children: <Widget>[
Icon(icon, color: _accent),
const SizedBox(width: 10),
Expanded(child: Text(text)),
],
),
);
}
class _LanguageChoice extends StatelessWidget {
const _LanguageChoice({required this.flag, required this.label});
final String flag;
final String label;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(flag, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 10),
Text(label),
],
);
}
class _SettingsButton extends StatelessWidget {
const _SettingsButton({
required this.icon,
required this.label,
required this.onPressed,
this.danger = false,
});
final IconData icon;
final String label;
final VoidCallback? onPressed;
final bool danger;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(top: 8),
child: OutlinedButton.icon(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: danger ? const Color(0xFFE06B6B) : _text,
side: BorderSide(
color: danger ? const Color(0xFFE06B6B) : Colors.white24,
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
alignment: Alignment.centerLeft,
),
icon: Icon(icon),
label: Text(label),
),
);
}
Future<void> showOpbModal(
BuildContext context, {
required String title,
required Widget child,
bool barrierDismissible = true,
}) {
final size = MediaQuery.sizeOf(context);
return showGeneralDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
barrierLabel: title,
barrierColor: Colors.black54,
transitionDuration: const Duration(milliseconds: 220),
pageBuilder: (context, animation, secondaryAnimation) => Material(
color: Colors.transparent,
child: Padding(
padding: EdgeInsets.fromLTRB(
size.width * 0.05,
size.height * 0.20,
size.width * 0.05,
0,
),
child: Stack(
clipBehavior: Clip.none,
children: <Widget>[
Positioned.fill(
child: Container(
decoration: const BoxDecoration(
color: _modalSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 72, 14),
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: _accent,
fontWeight: FontWeight.w800,
),
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.viewPaddingOf(context).bottom,
),
child: child,
),
),
],
),
),
),
Positioned(
right: 8,
top: 8,
child: IconButton.filled(
style: IconButton.styleFrom(
backgroundColor: _accent,
foregroundColor: const Color(0xFF1F1F1F),
fixedSize: const Size(48, 48),
),
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
),
],
),
),
),
transitionBuilder: (context, animation, secondaryAnimation, child) =>
SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
child: FadeTransition(opacity: animation, child: child),
),
);
}
String _deviceName(ScanResult result) {
final advertisedName = result.advertisementData.advName;
if (advertisedName.isNotEmpty) {
return advertisedName;
}
return result.device.platformName.isNotEmpty
? result.device.platformName
: result.device.remoteId.str;
}