2403 lines
76 KiB
Dart
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;
|
|
}
|