1326 lines
40 KiB
Dart
1326 lines
40 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.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 _background = Color(0xFF333333);
|
|
const _surface = Color(0xFF292929);
|
|
const _modalSurface = Color(0xFF303030);
|
|
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, super.key});
|
|
|
|
final SecureBoxStore? store;
|
|
final BackupService? backupService;
|
|
|
|
@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 {
|
|
try {
|
|
final language = await _store.loadLanguage();
|
|
_locale = language == null ? null : Locale(language);
|
|
} catch (_) {
|
|
_locale = null;
|
|
}
|
|
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 ColoredBox(color: _background),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 ValueChanged<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();
|
|
|
|
AppStrings get strings => AppStrings(Localizations.localeOf(context));
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller
|
|
..addListener(_refresh)
|
|
..initialize();
|
|
}
|
|
|
|
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: Center(child: CircularProgressIndicator()));
|
|
}
|
|
return Scaffold(
|
|
body: 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,
|
|
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 = '';
|
|
await showOpbModal(
|
|
context,
|
|
title: strings.text('add_box'),
|
|
child: StatefulBuilder(
|
|
builder: (context, setModalState) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: <Widget>[
|
|
TextFormField(
|
|
maxLength: 31,
|
|
onChanged: (value) => boxName = value,
|
|
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 (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
} catch (error) {
|
|
if (context.mounted) {
|
|
_showMessage('$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(context).textTheme.titleMedium?.copyWith(
|
|
color: _accent,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
IconButton.filledTonal(
|
|
onPressed: _controller.isScanning
|
|
? null
|
|
: () async {
|
|
await _controller.startScan();
|
|
setModalState(() {});
|
|
},
|
|
icon: const Icon(Icons.radar),
|
|
tooltip: strings.text('search'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (_controller.scanResults.isEmpty)
|
|
_EmptyRow(text: strings.text('no_results'))
|
|
else
|
|
..._controller.scanResults.map(
|
|
(result) => _DataRowCard(
|
|
icon: Icons.inventory_2_outlined,
|
|
title: _deviceName(result),
|
|
subtitle: result.device.remoteId.str,
|
|
actions: <Widget>[
|
|
IconButton(
|
|
onPressed: () async {
|
|
try {
|
|
await _controller.registerAdministrator(
|
|
result,
|
|
boxName,
|
|
);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
} catch (error) {
|
|
_showMessage('$error');
|
|
}
|
|
},
|
|
icon: const Icon(Icons.link),
|
|
color: _accent,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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: _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;
|
|
return showOpbModal(
|
|
context,
|
|
title: strings.text(
|
|
kind == CredentialKind.permanent
|
|
? 'permanent_codes'
|
|
: 'temporary_codes',
|
|
),
|
|
child: StatefulBuilder(
|
|
builder: (context, setModalState) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: <Widget>[
|
|
FilledButton.icon(
|
|
onPressed: 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)',
|
|
),
|
|
),
|
|
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(
|
|
onPressed: () async {
|
|
await _guarded(() => _controller.removeCode(code));
|
|
setModalState(() {});
|
|
},
|
|
icon: const Icon(Icons.delete_outline),
|
|
tooltip: strings.text('delete'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showNfc() => showOpbModal(
|
|
context,
|
|
title: strings.text('nfc_tags'),
|
|
child: StatefulBuilder(
|
|
builder: (context, setModalState) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: <Widget>[
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: <Widget>[
|
|
FilledButton.tonalIcon(
|
|
onPressed: () => _scanNfcWithPhone(setModalState),
|
|
icon: const Icon(Icons.nfc),
|
|
label: Text(strings.text('scan_phone_nfc')),
|
|
),
|
|
FilledButton.tonalIcon(
|
|
onPressed: () => _addNfcFromBox(setModalState),
|
|
icon: const Icon(Icons.sensors),
|
|
label: Text(strings.text('pair_on_box')),
|
|
),
|
|
FilledButton.tonalIcon(
|
|
onPressed: () => _addManualNfc(setModalState),
|
|
icon: const Icon(Icons.keyboard),
|
|
label: Text(strings.text('manual_uid')),
|
|
),
|
|
],
|
|
),
|
|
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: () => _renameNfc(tag, setModalState),
|
|
icon: const Icon(Icons.edit_outlined),
|
|
tooltip: strings.text('edit'),
|
|
),
|
|
IconButton(
|
|
onPressed: () async {
|
|
await _guarded(() => _controller.removeNfcTag(tag));
|
|
setModalState(() {});
|
|
},
|
|
icon: const Icon(Icons.delete_outline),
|
|
tooltip: strings.text('delete'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
Future<void> _scanNfcWithPhone(StateSetter refreshModal) async {
|
|
final availability = await NfcManager.instance.checkAvailability();
|
|
if (availability != NfcAvailability.enabled) {
|
|
_showMessage(strings.text('nfc_unavailable'));
|
|
return;
|
|
}
|
|
_showMessage(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) {
|
|
final name = await _askText(strings.text('tag_name'));
|
|
if (name != null) {
|
|
await _guarded(() => _controller.addNfcTag(uid, name));
|
|
refreshModal(() {});
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _addNfcFromBox(StateSetter refreshModal) async {
|
|
final name = await _askText(strings.text('tag_name'));
|
|
if (name != null) {
|
|
await _guarded(() => _controller.startNfcEnrollment(name));
|
|
refreshModal(() {});
|
|
}
|
|
}
|
|
|
|
Future<void> _addManualNfc(StateSetter refreshModal) async {
|
|
final uid = await _askText(strings.text('tag_uid'));
|
|
if (uid == null) {
|
|
return;
|
|
}
|
|
final name = await _askText(strings.text('tag_name'));
|
|
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,
|
|
);
|
|
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: () async {
|
|
final name = await _askText(strings.text('guest_name'));
|
|
if (name == null || name.trim().isEmpty) {
|
|
return;
|
|
}
|
|
final key = await _controller.addGuest(name);
|
|
final guest = GuestIdentity(name: name.trim(), key: key);
|
|
if (context.mounted) {
|
|
await _showGuestQr(guest);
|
|
setModalState(() {});
|
|
}
|
|
},
|
|
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: () async {
|
|
await _guarded(() => _controller.removeGuest(guest));
|
|
setModalState(() {});
|
|
},
|
|
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: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: <Widget>[
|
|
DropdownButtonFormField<String>(
|
|
initialValue: widget.selectedLanguage ?? 'auto',
|
|
decoration: InputDecoration(labelText: strings.text('language')),
|
|
items: <DropdownMenuItem<String>>[
|
|
DropdownMenuItem(
|
|
value: 'auto',
|
|
child: Text(strings.text('automatic')),
|
|
),
|
|
DropdownMenuItem(value: 'fr', child: Text(strings.text('french'))),
|
|
DropdownMenuItem(value: 'en', child: Text(strings.text('english'))),
|
|
],
|
|
onChanged: (value) =>
|
|
widget.onLanguageChanged(value == 'auto' ? null : value),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_SettingsButton(
|
|
icon: Icons.system_update,
|
|
label: strings.text('firmware_update'),
|
|
onPressed: null,
|
|
),
|
|
_SettingsButton(
|
|
icon: Icons.folder_outlined,
|
|
label: strings.text('backup_location'),
|
|
onPressed: _chooseBackupDirectory,
|
|
),
|
|
_SettingsButton(
|
|
icon: Icons.backup_outlined,
|
|
label: strings.text('backup'),
|
|
onPressed: _createBackup,
|
|
),
|
|
_SettingsButton(
|
|
icon: Icons.restore,
|
|
label: strings.text('restore'),
|
|
onPressed: _restoreBackup,
|
|
),
|
|
if (_controller.isAdministrator) ...<Widget>[
|
|
const Divider(height: 28),
|
|
_SettingsButton(
|
|
icon: Icons.warning_amber_rounded,
|
|
label: strings.text('factory_reset'),
|
|
danger: true,
|
|
onPressed: _confirmFactoryReset,
|
|
),
|
|
],
|
|
const Divider(height: 28),
|
|
_SettingsButton(
|
|
icon: Icons.delete_forever_outlined,
|
|
label: strings.text('forget_box'),
|
|
danger: true,
|
|
onPressed: _confirmForgetBox,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
Future<void> _chooseBackupDirectory() async {
|
|
final path = await FilePicker.getDirectoryPath();
|
|
if (path != null) {
|
|
await widget.store.saveBackupDirectory(path);
|
|
_showMessage(path);
|
|
}
|
|
}
|
|
|
|
Future<void> _createBackup() async {
|
|
final password = await _askPassword();
|
|
if (password == null) {
|
|
return;
|
|
}
|
|
try {
|
|
if (await _backupService.exportBoxes(
|
|
_controller.savedBoxes,
|
|
password,
|
|
initialDirectory: await widget.store.loadBackupDirectory(),
|
|
)) {
|
|
_showMessage(strings.text('backup_done'));
|
|
}
|
|
} catch (error) {
|
|
_showMessage('$error');
|
|
}
|
|
}
|
|
|
|
Future<void> _restoreBackup() async {
|
|
try {
|
|
final backup = await _backupService.pickBackupFile();
|
|
if (backup == null || !mounted) {
|
|
return;
|
|
}
|
|
final password = await _askPassword();
|
|
if (password == null) {
|
|
return;
|
|
}
|
|
final boxes = await _backupService.importBoxes(backup, password);
|
|
await _controller.replaceSavedBoxes(boxes);
|
|
_showMessage(strings.text('restore_done'));
|
|
} catch (error) {
|
|
_showMessage('$error');
|
|
}
|
|
}
|
|
|
|
Future<void> _confirmFactoryReset() async {
|
|
if (await _dangerConfirmation(strings.text('factory_warning'))) {
|
|
await _guarded(_controller.factoryReset);
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _confirmForgetBox() async {
|
|
if (await _dangerConfirmation(strings.text('forget_warning'))) {
|
|
await _controller.forgetCurrentBox();
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<bool> _dangerConfirmation(String message) async =>
|
|
await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(
|
|
strings.text('danger'),
|
|
style: const TextStyle(color: Color(0xFFE06B6B)),
|
|
),
|
|
content: Text(message),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: Text(strings.text('cancel')),
|
|
),
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: const Color(0xFFE06B6B),
|
|
),
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: Text(strings.text('confirm')),
|
|
),
|
|
],
|
|
),
|
|
) ??
|
|
false;
|
|
|
|
Future<String?> _askText(String label, {String initialValue = ''}) async {
|
|
return showDialog<String>(
|
|
context: context,
|
|
builder: (context) => _TextPromptDialog(
|
|
title: label,
|
|
fieldLabel: label,
|
|
initialValue: initialValue,
|
|
cancelLabel: strings.text('cancel'),
|
|
confirmLabel: strings.text('save'),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<String?> _askPassword() async {
|
|
return showDialog<String>(
|
|
context: context,
|
|
builder: (context) => _TextPromptDialog(
|
|
title: strings.text('password'),
|
|
fieldLabel: strings.text('password'),
|
|
cancelLabel: strings.text('cancel'),
|
|
confirmLabel: strings.text('confirm'),
|
|
obscureText: true,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _guarded(Future<void> Function() action) async {
|
|
try {
|
|
await action();
|
|
} catch (error) {
|
|
_showMessage('$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)}';
|
|
}
|
|
}
|
|
|
|
class _TextPromptDialog extends StatefulWidget {
|
|
const _TextPromptDialog({
|
|
required this.title,
|
|
required this.fieldLabel,
|
|
required this.cancelLabel,
|
|
required this.confirmLabel,
|
|
this.initialValue = '',
|
|
this.obscureText = false,
|
|
});
|
|
|
|
final String title;
|
|
final String fieldLabel;
|
|
final String cancelLabel;
|
|
final String confirmLabel;
|
|
final String initialValue;
|
|
final bool obscureText;
|
|
|
|
@override
|
|
State<_TextPromptDialog> createState() => _TextPromptDialogState();
|
|
}
|
|
|
|
class _TextPromptDialogState extends State<_TextPromptDialog> {
|
|
late final TextEditingController _controller = TextEditingController(
|
|
text: widget.initialValue,
|
|
);
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(widget.title, style: const TextStyle(color: _accent)),
|
|
content: TextField(
|
|
controller: _controller,
|
|
obscureText: widget.obscureText,
|
|
autofocus: true,
|
|
decoration: InputDecoration(labelText: widget.fieldLabel),
|
|
onSubmitted: (_) => Navigator.of(context).pop(_controller.text),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(widget.cancelLabel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(_controller.text),
|
|
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.onHistory,
|
|
required this.onCodes,
|
|
required this.onNfc,
|
|
required this.onGuests,
|
|
required this.onSettings,
|
|
});
|
|
|
|
final OpenParcelBoxBleController controller;
|
|
final AppStrings strings;
|
|
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;
|
|
return CustomScrollView(
|
|
slivers: <Widget>[
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 12),
|
|
sliver: SliverToBoxAdapter(
|
|
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(
|
|
'${strings.text(admin ? 'administrator' : 'guest')} · '
|
|
'${strings.text(controller.isConnected ? 'connected' : controller.status)}',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
controller.isConnected
|
|
? Icons.bluetooth_connected
|
|
: Icons.bluetooth_disabled,
|
|
color: controller.isConnected ? _accent : _text,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
sliver: SliverToBoxAdapter(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: _surface,
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: Row(
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: _Shortcut(
|
|
icon: Icons.lock_open,
|
|
label: strings.text('open'),
|
|
onTap: controller.openLock,
|
|
),
|
|
),
|
|
Container(width: 1, height: 44, color: Colors.white12),
|
|
Expanded(
|
|
child: _Shortcut(
|
|
icon: Icons.history,
|
|
label: strings.text('history'),
|
|
onTap: onHistory,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
|
sliver: SliverGrid.count(
|
|
crossAxisCount: 2,
|
|
mainAxisSpacing: 14,
|
|
crossAxisSpacing: 14,
|
|
childAspectRatio: 1,
|
|
children: <Widget>[
|
|
_SquareAction(
|
|
icon: Icons.pin_outlined,
|
|
label: strings.text('permanent_codes'),
|
|
onTap: () => onCodes(CredentialKind.permanent),
|
|
),
|
|
_SquareAction(
|
|
icon: Icons.timer_outlined,
|
|
label: strings.text('temporary_codes'),
|
|
onTap: () => onCodes(CredentialKind.oneTime),
|
|
),
|
|
if (admin)
|
|
_SquareAction(
|
|
icon: Icons.nfc,
|
|
label: strings.text('nfc_tags'),
|
|
onTap: onNfc,
|
|
),
|
|
if (admin)
|
|
_SquareAction(
|
|
icon: Icons.group_outlined,
|
|
label: strings.text('guests'),
|
|
onTap: onGuests,
|
|
),
|
|
_SquareAction(
|
|
icon: Icons.settings_outlined,
|
|
label: strings.text('settings'),
|
|
onTap: onSettings,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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: _accent, size: 28),
|
|
const SizedBox(height: 5),
|
|
Text(label, textAlign: TextAlign.center),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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: _accent),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
label,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(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 _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,
|
|
}) {
|
|
final size = MediaQuery.sizeOf(context);
|
|
return showGeneralDialog<void>(
|
|
context: context,
|
|
barrierDismissible: true,
|
|
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, 54, 14),
|
|
child: Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
color: _accent,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 28),
|
|
child: child,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: -22,
|
|
top: -22,
|
|
child: IconButton.filled(
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: _accent,
|
|
foregroundColor: const Color(0xFF1F1F1F),
|
|
fixedSize: const Size(44, 44),
|
|
),
|
|
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;
|
|
}
|