Files
OpenParcelBox/mobile-app/app/lib/main.dart
T
2026-07-16 17:36:28 +02:00

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;
}