fix(android): fix application connection
@@ -82,6 +82,33 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Android box registration discovery by scanning nearby BLE
|
||||
advertisements before applying the OpenParcelBox name/service filter in the
|
||||
application, waiting for the actual scan timeout, and refreshing registration
|
||||
results as advertisements arrive.
|
||||
- Replaced the per-device registration icon with radio-button box selection and
|
||||
an explicit add button, with inline errors and terminal diagnostics for
|
||||
provisioning failures.
|
||||
- Kept a completed administrator registration when the following clock
|
||||
synchronization fails instead of reporting the whole addition as failed.
|
||||
- Serialized Android BLE connection, secure bonding, MTU negotiation, and GATT
|
||||
discovery before the first encrypted state read.
|
||||
- Added one-time stale-bond recovery when Android disconnects during the first
|
||||
encrypted provisioning read.
|
||||
- Requested BLE security immediately on firmware connection and restarted
|
||||
connectable advertising after every recycled connection, including failed
|
||||
initial pairing attempts.
|
||||
- Replaced Zephyr Secure Connections Only Mode, which implicitly required
|
||||
unavailable level-4 passkey/OOB authentication, with Secure Connections
|
||||
pairing-only mode and encrypted level-2 bonding for the headless XIAO.
|
||||
- Added a Material surface behind registration radio rows so Android ink and
|
||||
selection rendering no longer emits an invisible-background warning.
|
||||
- Increased the firmware ATT MTU to 247 bytes with matching ACL buffers so the
|
||||
120-byte administrator provisioning JSON fits in one acknowledged GATT write
|
||||
instead of exceeding the previous 62-byte payload limit.
|
||||
- Fixed the Android startup logo safe area so the complete logo remains
|
||||
centered instead of being cropped by the system splash icon mask, while
|
||||
restoring `background.jpg` and increasing the visible logo size.
|
||||
- Removed oversized Settings callback allocations that exceeded the 1024-byte
|
||||
main stack once opening-history or application-identity records existed.
|
||||
- Moved Settings/NVS loading to a dedicated 4096-byte services thread and
|
||||
|
||||
@@ -19,9 +19,13 @@ at the newest persisted opening timestamp after a reboot.
|
||||
|
||||
## Security
|
||||
|
||||
Pairing is restricted to BLE LE Secure Connections. BLE AES-CCM link encryption
|
||||
protects every GATT command, state read, and notification, and Zephyr persists
|
||||
bonding keys.
|
||||
Pairing is restricted to BLE LE Secure Connections with legacy pairing
|
||||
disabled. The headless XIAO uses encrypted Security Mode 1 Level 2 pairing;
|
||||
Security Mode 1 Level 4 is not requested because it requires a passkey display,
|
||||
input, or out-of-band confirmation that the box does not provide. BLE AES-CCM
|
||||
link encryption protects every GATT command, state read, and notification, and
|
||||
Zephyr persists bonding keys. The separate random application identity remains
|
||||
mandatory for command authorization.
|
||||
|
||||
At startup, the controller is enabled first, the Zephyr `bt/*` settings subtree
|
||||
is then restored, and advertising starts only after the Bluetooth identity has
|
||||
@@ -32,6 +36,23 @@ This complete sequence runs in a dedicated thread after local keypad
|
||||
initialization. Bluetooth failure or slow bond restoration therefore cannot
|
||||
prevent offline keypad access or lock operation.
|
||||
|
||||
On each connection, the peripheral immediately requests encrypted Bluetooth
|
||||
security before the application reads or writes an administration
|
||||
characteristic. Android completes bonding before MTU negotiation and GATT
|
||||
service discovery. This ordering avoids a disconnect caused by overlapping the
|
||||
first encrypted read, pairing, and MTU negotiation.
|
||||
|
||||
The firmware supports an ATT MTU of 247 bytes with matching 251-byte ACL
|
||||
buffers. This provides a 244-byte GATT payload, allowing provisioning and other
|
||||
JSON commands to remain atomic writes with responses. The default Zephyr MTU of
|
||||
65 bytes only provides a 62-byte payload and cannot carry the administrator
|
||||
provisioning document.
|
||||
|
||||
Connectable advertising stops when Zephyr allocates the only connection object.
|
||||
The firmware restarts advertising from the connection `recycled` callback after
|
||||
every disconnect, including failed initial pairing, so the box remains
|
||||
discoverable for another attempt.
|
||||
|
||||
Each phone also owns a random 128-bit identity key stored in its secure
|
||||
keystore. The first phone can send `provision_admin` only while no administrator
|
||||
exists. Afterwards every command requires a valid administrator or guest
|
||||
|
||||
@@ -25,7 +25,10 @@ CONFIG_BT_MAX_CONN=1
|
||||
CONFIG_BT_SMP=y
|
||||
CONFIG_BT_BONDABLE=y
|
||||
CONFIG_BT_SETTINGS=y
|
||||
CONFIG_BT_SMP_SC_ONLY=y
|
||||
CONFIG_BT_SMP_SC_PAIR_ONLY=y
|
||||
CONFIG_BT_PRIVACY=y
|
||||
CONFIG_BT_RX_STACK_SIZE=2048
|
||||
CONFIG_BT_BUF_ACL_RX_SIZE=251
|
||||
CONFIG_BT_BUF_ACL_TX_SIZE=251
|
||||
CONFIG_BT_L2CAP_TX_MTU=247
|
||||
CONFIG_ENTROPY_GENERATOR=y
|
||||
|
||||
@@ -69,6 +69,7 @@ static ssize_t command_write(struct bt_conn *conn,
|
||||
const struct bt_gatt_attr *attr, const void *buf,
|
||||
uint16_t len, uint16_t offset, uint8_t flags);
|
||||
static void state_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value);
|
||||
static void restart_advertising(void);
|
||||
|
||||
static void factory_reset_work_handler(struct k_work *work) {
|
||||
ARG_UNUSED(work);
|
||||
@@ -76,8 +77,23 @@ static void factory_reset_work_handler(struct k_work *work) {
|
||||
printf("BLE bonds cleared after factory reset\n");
|
||||
}
|
||||
|
||||
static void connected(struct bt_conn *conn, uint8_t err) {
|
||||
int ret;
|
||||
|
||||
if (err != 0U) {
|
||||
printf("BLE connection failed: %u\n", err);
|
||||
return;
|
||||
}
|
||||
|
||||
printf("BLE connected\n");
|
||||
ret = bt_conn_set_security(conn, BT_SECURITY_L2);
|
||||
if (ret < 0) {
|
||||
printf("BLE security request failed: %d\n", ret);
|
||||
}
|
||||
}
|
||||
|
||||
static void disconnected(struct bt_conn *conn, uint8_t reason) {
|
||||
ARG_UNUSED(reason);
|
||||
printf("BLE disconnected: %u\n", reason);
|
||||
if (conn == authenticated_conn) {
|
||||
authenticated_conn = NULL;
|
||||
memset(&authenticated_identity, 0, sizeof(authenticated_identity));
|
||||
@@ -85,8 +101,27 @@ static void disconnected(struct bt_conn *conn, uint8_t reason) {
|
||||
}
|
||||
}
|
||||
|
||||
static void security_changed(struct bt_conn *conn, bt_security_t level,
|
||||
enum bt_security_err err) {
|
||||
ARG_UNUSED(conn);
|
||||
|
||||
if (err == BT_SECURITY_ERR_SUCCESS) {
|
||||
printf("BLE security established: level %u\n", level);
|
||||
return;
|
||||
}
|
||||
|
||||
printf("BLE security failed: level %u error %d\n", level, err);
|
||||
if (!app_identities_has_admin()) {
|
||||
(void)bt_unpair(BT_ID_DEFAULT, NULL);
|
||||
printf("BLE bonds cleared after failed initial pairing\n");
|
||||
}
|
||||
}
|
||||
|
||||
BT_CONN_CB_DEFINE(opb_conn_callbacks) = {
|
||||
.connected = connected,
|
||||
.disconnected = disconnected,
|
||||
.recycled = restart_advertising,
|
||||
.security_changed = security_changed,
|
||||
};
|
||||
|
||||
BT_GATT_SERVICE_DEFINE(
|
||||
@@ -111,6 +146,19 @@ static const struct bt_data scan_response_data[] = {
|
||||
sizeof(CONFIG_BT_DEVICE_NAME) - 1),
|
||||
};
|
||||
|
||||
static void restart_advertising(void) {
|
||||
int ret = bt_le_adv_start(BT_LE_ADV_CONN_FAST_1, advertising_data,
|
||||
ARRAY_SIZE(advertising_data), scan_response_data,
|
||||
ARRAY_SIZE(scan_response_data));
|
||||
|
||||
if (ret < 0 && ret != -EALREADY) {
|
||||
printf("BLE advertising restart failed: %d\n", ret);
|
||||
return;
|
||||
}
|
||||
|
||||
printf("BLE advertising: OpenParcelBox\n");
|
||||
}
|
||||
|
||||
static const char *json_skip_ws(const char *cursor) {
|
||||
while (*cursor != '\0' && isspace((unsigned char)*cursor)) {
|
||||
cursor++;
|
||||
|
||||
@@ -7,7 +7,11 @@ Flutter application for local OpenParcelBox setup and administration.
|
||||
- Dark solid background `#333333`, button surfaces `#292929`, modal surfaces
|
||||
`#303030`, accent `#c19d60`, and text `#c6c6c6`.
|
||||
- Project logo used for the Android launcher icon and centered native splash.
|
||||
- Native splash uses the project `background.jpg`.
|
||||
The splash variant includes transparent safe-area padding so Android displays
|
||||
the complete logo instead of cropping it through the system icon mask.
|
||||
- Native splash uses the project `background.jpg`. Android 12 only permits a
|
||||
solid color behind its system splash icon, so the first Flutter frame keeps
|
||||
the same centered logo over `background.jpg` for a consistent startup screen.
|
||||
- Automatic French or English selection from the phone, with a manual override.
|
||||
- Empty state restricted to box registration and encrypted-backup restoration.
|
||||
- Shortcut bar for opening and history, plus modal cards for codes, NFC tags,
|
||||
@@ -20,9 +24,11 @@ Flutter application for local OpenParcelBox setup and administration.
|
||||
The first phone connecting to an unprovisioned box:
|
||||
|
||||
1. chooses the box identifier;
|
||||
2. generates a random 128-bit administrator identity;
|
||||
3. sends both values over the encrypted BLE link;
|
||||
4. stores the remote BLE identifier, box name, role, and identity in the phone
|
||||
2. selects one detected box with a radio button and confirms with the explicit
|
||||
add button;
|
||||
3. generates a random 128-bit administrator identity;
|
||||
4. sends both values over the encrypted BLE link;
|
||||
5. stores the remote BLE identifier, box name, role, and identity in the phone
|
||||
secure keystore.
|
||||
|
||||
An administrator creates named guest identities and displays an invitation QR
|
||||
@@ -53,6 +59,16 @@ Bluetooth uses AES-CCM link encryption. Every application command is also
|
||||
authorized with the random administrator or guest identity stored in the phone
|
||||
keystore.
|
||||
|
||||
On Android, connection setup is serialized as connection, secure bonding, MTU
|
||||
negotiation, and GATT service discovery. The application does not perform the
|
||||
first encrypted state read until bonding has completed. If the initial
|
||||
provisioning connection is dropped because Android retained an obsolete bond,
|
||||
the application removes that bond and retries the setup once.
|
||||
|
||||
The firmware negotiates an ATT MTU of 247 bytes, leaving 244 bytes for a single
|
||||
GATT write. This keeps the administrator provisioning JSON in one acknowledged
|
||||
write instead of relying on prepared long-write support.
|
||||
|
||||
Service UUID:
|
||||
|
||||
```text
|
||||
@@ -87,6 +103,11 @@ The app uses:
|
||||
`flutter_blue_plus` 2.3.10 requires a license mode when connecting. The app uses
|
||||
`License.nonprofit` for this open-source project.
|
||||
|
||||
The discovery scan is intentionally unfiltered at the Android API level, then
|
||||
restricted in the application to advertisements carrying the OpenParcelBox
|
||||
name or service UUID. The registration modal listens to scan results in real
|
||||
time and the scan action remains active until the plugin's timeout completes.
|
||||
|
||||
## Development
|
||||
|
||||
```powershell
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -7,151 +7,131 @@ class AppStrings {
|
||||
|
||||
bool get isFrench => locale.languageCode == 'fr';
|
||||
|
||||
String text(String key) => (_values[key] ?? const <String, String>{})[
|
||||
isFrench ? 'fr' : 'en'] ??
|
||||
key;
|
||||
String text(String key) =>
|
||||
(_values[key] ?? const <String, String>{})[isFrench ? 'fr' : 'en'] ?? key;
|
||||
|
||||
static const Map<String, Map<String, String>> _values =
|
||||
<String, Map<String, String>>{
|
||||
'add_box': {
|
||||
'fr': 'Ajouter une nouvelle OpenParcelBox',
|
||||
'en': 'Add a new OpenParcelBox',
|
||||
},
|
||||
'restore_backup': {
|
||||
'fr': 'Restaurer une sauvegarde',
|
||||
'en': 'Restore a backup',
|
||||
},
|
||||
'open': {'fr': 'Ouvrir', 'en': 'Open'},
|
||||
'history': {'fr': 'Historique', 'en': 'History'},
|
||||
'permanent_codes': {
|
||||
'fr': 'Codes permanents',
|
||||
'en': 'Permanent codes',
|
||||
},
|
||||
'temporary_codes': {
|
||||
'fr': 'Codes temporaires',
|
||||
'en': 'Temporary codes',
|
||||
},
|
||||
'nfc_tags': {'fr': 'Tags NFC', 'en': 'NFC tags'},
|
||||
'guests': {'fr': 'Invités', 'en': 'Guests'},
|
||||
'settings': {'fr': 'Paramètres', 'en': 'Settings'},
|
||||
'close': {'fr': 'Fermer', 'en': 'Close'},
|
||||
'add': {'fr': 'Ajouter', 'en': 'Add'},
|
||||
'delete': {'fr': 'Supprimer', 'en': 'Delete'},
|
||||
'edit': {'fr': 'Modifier', 'en': 'Edit'},
|
||||
'cancel': {'fr': 'Annuler', 'en': 'Cancel'},
|
||||
'save': {'fr': 'Enregistrer', 'en': 'Save'},
|
||||
'scan': {'fr': 'Scanner', 'en': 'Scan'},
|
||||
'scan_qr': {
|
||||
'fr': 'Scanner un QR code invité',
|
||||
'en': 'Scan a guest QR code',
|
||||
},
|
||||
'box_name': {
|
||||
'fr': 'Identifiant de la box',
|
||||
'en': 'Box identifier',
|
||||
},
|
||||
'nearby_boxes': {
|
||||
'fr': 'OpenParcelBox détectées',
|
||||
'en': 'Detected OpenParcelBox devices',
|
||||
},
|
||||
'search': {'fr': 'Rechercher', 'en': 'Search'},
|
||||
'no_results': {
|
||||
'fr': 'Aucune OpenParcelBox détectée.',
|
||||
'en': 'No OpenParcelBox detected.',
|
||||
},
|
||||
'connected': {'fr': 'Connectée', 'en': 'Connected'},
|
||||
'disconnected': {'fr': 'Déconnectée', 'en': 'Disconnected'},
|
||||
'connecting': {'fr': 'Connexion…', 'en': 'Connecting…'},
|
||||
'no_code': {'fr': 'Aucun code.', 'en': 'No code.'},
|
||||
'new_code': {'fr': 'Générer un code', 'en': 'Generate a code'},
|
||||
'code_limit': {
|
||||
'fr': 'La limite de codes est atteinte.',
|
||||
'en': 'The code limit has been reached.',
|
||||
},
|
||||
'no_history': {
|
||||
'fr': 'Aucune ouverture enregistrée.',
|
||||
'en': 'No opening has been recorded.',
|
||||
},
|
||||
'opened_by_permanent': {
|
||||
'fr': 'Code permanent',
|
||||
'en': 'Permanent code',
|
||||
},
|
||||
'opened_by_temporary': {
|
||||
'fr': 'Code temporaire',
|
||||
'en': 'Temporary code',
|
||||
},
|
||||
'opened_by_nfc': {'fr': 'Tag NFC', 'en': 'NFC tag'},
|
||||
'opened_by_app': {'fr': 'Application', 'en': 'Application'},
|
||||
'tag_name': {'fr': 'Nom du tag', 'en': 'Tag name'},
|
||||
'tag_uid': {'fr': 'UID du tag', 'en': 'Tag UID'},
|
||||
'scan_phone_nfc': {
|
||||
'fr': 'Scanner avec le téléphone',
|
||||
'en': 'Scan with phone',
|
||||
},
|
||||
'pair_on_box': {
|
||||
'fr': 'Appairer sur la box',
|
||||
'en': 'Pair on the box',
|
||||
},
|
||||
'manual_uid': {'fr': 'Ajouter un UID', 'en': 'Add a UID'},
|
||||
'nfc_unavailable': {
|
||||
'fr': 'Le NFC est indisponible sur ce téléphone.',
|
||||
'en': 'NFC is unavailable on this phone.',
|
||||
},
|
||||
'hold_tag': {
|
||||
'fr': 'Approchez un tag NFC du téléphone…',
|
||||
'en': 'Hold an NFC tag near the phone…',
|
||||
},
|
||||
'guest_name': {'fr': "Nom de l'invité", 'en': 'Guest name'},
|
||||
'show_qr': {'fr': 'Voir le QR code', 'en': 'Show QR code'},
|
||||
'no_guest': {'fr': 'Aucun invité.', 'en': 'No guest.'},
|
||||
'language': {'fr': 'Langue', 'en': 'Language'},
|
||||
'automatic': {'fr': 'Automatique', 'en': 'Automatic'},
|
||||
'french': {'fr': 'Français', 'en': 'French'},
|
||||
'english': {'fr': 'Anglais', 'en': 'English'},
|
||||
'backup': {
|
||||
'fr': "Sauvegarder les paramètres de l'app",
|
||||
'en': 'Back up app settings',
|
||||
},
|
||||
'backup_location': {
|
||||
'fr': "Choisir l'emplacement de sauvegarde",
|
||||
'en': 'Choose backup location',
|
||||
},
|
||||
'restore': {
|
||||
'fr': 'Restaurer une sauvegarde',
|
||||
'en': 'Restore a backup',
|
||||
},
|
||||
'password': {'fr': 'Mot de passe', 'en': 'Password'},
|
||||
'firmware_update': {
|
||||
'fr': 'Mise à jour du firmware (à venir)',
|
||||
'en': 'Firmware update (coming later)',
|
||||
},
|
||||
'factory_reset': {
|
||||
'fr': "Réinitialiser la box",
|
||||
'en': 'Factory-reset the box',
|
||||
},
|
||||
'forget_box': {'fr': 'Supprimer la box', 'en': 'Forget the box'},
|
||||
'danger': {'fr': 'Danger', 'en': 'Danger'},
|
||||
'factory_warning': {
|
||||
'fr':
|
||||
"Cette action supprime tous les paramètres de la box et autorise l'appairage d'un nouvel administrateur.",
|
||||
'en':
|
||||
'This deletes every setting on the box and allows a new administrator to pair.',
|
||||
},
|
||||
'forget_warning': {
|
||||
'fr':
|
||||
"Seules les informations de l'application seront supprimées. Les paramètres de la box restent inchangés et un administrateur peut perdre définitivement l'accès.",
|
||||
'en':
|
||||
'Only app information will be deleted. Box settings remain unchanged and an administrator may permanently lose access.',
|
||||
},
|
||||
'confirm': {'fr': 'Confirmer', 'en': 'Confirm'},
|
||||
'administrator': {'fr': 'Administrateur', 'en': 'Administrator'},
|
||||
'guest': {'fr': 'Invité', 'en': 'Guest'},
|
||||
'backup_done': {
|
||||
'fr': 'Sauvegarde chiffrée créée.',
|
||||
'en': 'Encrypted backup created.',
|
||||
},
|
||||
'restore_done': {
|
||||
'fr': 'Sauvegarde restaurée.',
|
||||
'en': 'Backup restored.',
|
||||
},
|
||||
};
|
||||
static const Map<String, Map<String, String>>
|
||||
_values = <String, Map<String, String>>{
|
||||
'add_box': {
|
||||
'fr': 'Ajouter une nouvelle OpenParcelBox',
|
||||
'en': 'Add a new OpenParcelBox',
|
||||
},
|
||||
'restore_backup': {
|
||||
'fr': 'Restaurer une sauvegarde',
|
||||
'en': 'Restore a backup',
|
||||
},
|
||||
'open': {'fr': 'Ouvrir', 'en': 'Open'},
|
||||
'history': {'fr': 'Historique', 'en': 'History'},
|
||||
'permanent_codes': {'fr': 'Codes permanents', 'en': 'Permanent codes'},
|
||||
'temporary_codes': {'fr': 'Codes temporaires', 'en': 'Temporary codes'},
|
||||
'nfc_tags': {'fr': 'Tags NFC', 'en': 'NFC tags'},
|
||||
'guests': {'fr': 'Invités', 'en': 'Guests'},
|
||||
'settings': {'fr': 'Paramètres', 'en': 'Settings'},
|
||||
'close': {'fr': 'Fermer', 'en': 'Close'},
|
||||
'add': {'fr': 'Ajouter', 'en': 'Add'},
|
||||
'delete': {'fr': 'Supprimer', 'en': 'Delete'},
|
||||
'edit': {'fr': 'Modifier', 'en': 'Edit'},
|
||||
'cancel': {'fr': 'Annuler', 'en': 'Cancel'},
|
||||
'save': {'fr': 'Enregistrer', 'en': 'Save'},
|
||||
'scan': {'fr': 'Scanner', 'en': 'Scan'},
|
||||
'scan_qr': {
|
||||
'fr': 'Scanner un QR code invité',
|
||||
'en': 'Scan a guest QR code',
|
||||
},
|
||||
'box_name': {'fr': 'Identifiant de la box', 'en': 'Box identifier'},
|
||||
'nearby_boxes': {
|
||||
'fr': 'OpenParcelBox détectées',
|
||||
'en': 'Detected OpenParcelBox devices',
|
||||
},
|
||||
'search': {'fr': 'Rechercher', 'en': 'Search'},
|
||||
'register_box': {'fr': 'Ajouter cette box', 'en': 'Add this box'},
|
||||
'select_box': {
|
||||
'fr': 'Sélectionnez une OpenParcelBox dans la liste.',
|
||||
'en': 'Select an OpenParcelBox from the list.',
|
||||
},
|
||||
'no_results': {
|
||||
'fr': 'Aucune OpenParcelBox détectée.',
|
||||
'en': 'No OpenParcelBox detected.',
|
||||
},
|
||||
'connected': {'fr': 'Connectée', 'en': 'Connected'},
|
||||
'disconnected': {'fr': 'Déconnectée', 'en': 'Disconnected'},
|
||||
'connecting': {'fr': 'Connexion…', 'en': 'Connecting…'},
|
||||
'no_code': {'fr': 'Aucun code.', 'en': 'No code.'},
|
||||
'new_code': {'fr': 'Générer un code', 'en': 'Generate a code'},
|
||||
'code_limit': {
|
||||
'fr': 'La limite de codes est atteinte.',
|
||||
'en': 'The code limit has been reached.',
|
||||
},
|
||||
'no_history': {
|
||||
'fr': 'Aucune ouverture enregistrée.',
|
||||
'en': 'No opening has been recorded.',
|
||||
},
|
||||
'opened_by_permanent': {'fr': 'Code permanent', 'en': 'Permanent code'},
|
||||
'opened_by_temporary': {'fr': 'Code temporaire', 'en': 'Temporary code'},
|
||||
'opened_by_nfc': {'fr': 'Tag NFC', 'en': 'NFC tag'},
|
||||
'opened_by_app': {'fr': 'Application', 'en': 'Application'},
|
||||
'tag_name': {'fr': 'Nom du tag', 'en': 'Tag name'},
|
||||
'tag_uid': {'fr': 'UID du tag', 'en': 'Tag UID'},
|
||||
'scan_phone_nfc': {
|
||||
'fr': 'Scanner avec le téléphone',
|
||||
'en': 'Scan with phone',
|
||||
},
|
||||
'pair_on_box': {'fr': 'Appairer sur la box', 'en': 'Pair on the box'},
|
||||
'manual_uid': {'fr': 'Ajouter un UID', 'en': 'Add a UID'},
|
||||
'nfc_unavailable': {
|
||||
'fr': 'Le NFC est indisponible sur ce téléphone.',
|
||||
'en': 'NFC is unavailable on this phone.',
|
||||
},
|
||||
'hold_tag': {
|
||||
'fr': 'Approchez un tag NFC du téléphone…',
|
||||
'en': 'Hold an NFC tag near the phone…',
|
||||
},
|
||||
'guest_name': {'fr': "Nom de l'invité", 'en': 'Guest name'},
|
||||
'show_qr': {'fr': 'Voir le QR code', 'en': 'Show QR code'},
|
||||
'no_guest': {'fr': 'Aucun invité.', 'en': 'No guest.'},
|
||||
'language': {'fr': 'Langue', 'en': 'Language'},
|
||||
'automatic': {'fr': 'Automatique', 'en': 'Automatic'},
|
||||
'french': {'fr': 'Français', 'en': 'French'},
|
||||
'english': {'fr': 'Anglais', 'en': 'English'},
|
||||
'backup': {
|
||||
'fr': "Sauvegarder les paramètres de l'app",
|
||||
'en': 'Back up app settings',
|
||||
},
|
||||
'backup_location': {
|
||||
'fr': "Choisir l'emplacement de sauvegarde",
|
||||
'en': 'Choose backup location',
|
||||
},
|
||||
'restore': {'fr': 'Restaurer une sauvegarde', 'en': 'Restore a backup'},
|
||||
'password': {'fr': 'Mot de passe', 'en': 'Password'},
|
||||
'firmware_update': {
|
||||
'fr': 'Mise à jour du firmware (à venir)',
|
||||
'en': 'Firmware update (coming later)',
|
||||
},
|
||||
'factory_reset': {
|
||||
'fr': "Réinitialiser la box",
|
||||
'en': 'Factory-reset the box',
|
||||
},
|
||||
'forget_box': {'fr': 'Supprimer la box', 'en': 'Forget the box'},
|
||||
'danger': {'fr': 'Danger', 'en': 'Danger'},
|
||||
'factory_warning': {
|
||||
'fr':
|
||||
"Cette action supprime tous les paramètres de la box et autorise l'appairage d'un nouvel administrateur.",
|
||||
'en':
|
||||
'This deletes every setting on the box and allows a new administrator to pair.',
|
||||
},
|
||||
'forget_warning': {
|
||||
'fr':
|
||||
"Seules les informations de l'application seront supprimées. Les paramètres de la box restent inchangés et un administrateur peut perdre définitivement l'accès.",
|
||||
'en':
|
||||
'Only app information will be deleted. Box settings remain unchanged and an administrator may permanently lose access.',
|
||||
},
|
||||
'confirm': {'fr': 'Confirmer', 'en': 'Confirm'},
|
||||
'administrator': {'fr': 'Administrateur', 'en': 'Administrator'},
|
||||
'guest': {'fr': 'Invité', 'en': 'Guest'},
|
||||
'backup_done': {
|
||||
'fr': 'Sauvegarde chiffrée créée.',
|
||||
'en': 'Encrypted backup created.',
|
||||
},
|
||||
'restore_done': {'fr': 'Sauvegarde restaurée.', 'en': 'Backup restored.'},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,10 +97,8 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
.timeout(const Duration(seconds: 8));
|
||||
isScanning = true;
|
||||
notifyListeners();
|
||||
await FlutterBluePlus.startScan(
|
||||
withServices: <Guid>[Guid(_serviceUuid)],
|
||||
timeout: const Duration(seconds: 8),
|
||||
);
|
||||
await FlutterBluePlus.startScan(timeout: const Duration(seconds: 8));
|
||||
await FlutterBluePlus.isScanning.where((scanning) => !scanning).first;
|
||||
} catch (error) {
|
||||
errorMessage = '$error';
|
||||
} finally {
|
||||
@@ -117,8 +115,8 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
}
|
||||
_setBusy(true);
|
||||
try {
|
||||
await _connectDevice(result.device);
|
||||
final publicState = await _readState(updateCollections: false);
|
||||
errorMessage = null;
|
||||
final publicState = await _connectForRegistration(result.device);
|
||||
if (publicState?['admin_exists'] == true) {
|
||||
throw StateError('This box already has an administrator.');
|
||||
}
|
||||
@@ -140,12 +138,72 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
await _upsertSavedBox(saved);
|
||||
currentBox = saved;
|
||||
isAuthenticated = true;
|
||||
await syncClock();
|
||||
try {
|
||||
await syncClock();
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
'OpenParcelBox clock synchronization after registration failed: '
|
||||
'$error\n$stackTrace',
|
||||
);
|
||||
errorMessage = null;
|
||||
}
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
errorMessage = '$error';
|
||||
debugPrint(
|
||||
'OpenParcelBox administrator registration failed: '
|
||||
'$error\n$stackTrace',
|
||||
);
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
} finally {
|
||||
_setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _connectForRegistration(
|
||||
BluetoothDevice device,
|
||||
) async {
|
||||
try {
|
||||
await _connectDevice(device);
|
||||
return await _readState(updateCollections: false);
|
||||
} catch (error, stackTrace) {
|
||||
final canRecover =
|
||||
!kIsWeb &&
|
||||
defaultTargetPlatform == TargetPlatform.android &&
|
||||
error is FlutterBluePlusException &&
|
||||
error.code == FbpErrorCode.deviceIsDisconnected.index;
|
||||
if (!canRecover) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'OpenParcelBox initial pairing disconnected; resetting the Android '
|
||||
'bond and retrying once.\n$error\n$stackTrace',
|
||||
);
|
||||
await _resetAndroidBond(device);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 750));
|
||||
await _connectDevice(device);
|
||||
return _readState(updateCollections: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetAndroidBond(BluetoothDevice device) async {
|
||||
connectedDevice = null;
|
||||
_commandCharacteristic = null;
|
||||
_stateCharacteristic = null;
|
||||
isAuthenticated = false;
|
||||
|
||||
if (device.isConnected) {
|
||||
await device.disconnect();
|
||||
}
|
||||
try {
|
||||
await device.removeBond();
|
||||
} catch (error) {
|
||||
debugPrint('OpenParcelBox Android bond removal reported: $error');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> importGuestInvitation(String rawPayload) async {
|
||||
final decoded = jsonDecode(rawPayload);
|
||||
if (decoded is! Map<String, dynamic> ||
|
||||
@@ -193,6 +251,7 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
await device.connect(
|
||||
license: License.nonprofit,
|
||||
timeout: const Duration(seconds: 12),
|
||||
mtu: null,
|
||||
);
|
||||
_connectionSubscription?.cancel();
|
||||
_connectionSubscription = device.connectionState.listen((state) {
|
||||
@@ -205,6 +264,10 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
await _ensureAndroidBond(device);
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
await device.requestMtu(512);
|
||||
}
|
||||
final services = await device.discoverServices();
|
||||
_commandCharacteristic = _findCharacteristic(services, _commandUuid);
|
||||
_stateCharacteristic = _findCharacteristic(services, _stateUuid);
|
||||
@@ -215,6 +278,26 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
status = 'connected';
|
||||
}
|
||||
|
||||
Future<void> _ensureAndroidBond(BluetoothDevice device) async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
return;
|
||||
}
|
||||
|
||||
var bondState = await device.bondState.first;
|
||||
if (bondState == BluetoothBondState.bonded) {
|
||||
return;
|
||||
}
|
||||
|
||||
await device.createBond(timeout: 30);
|
||||
bondState = await device.bondState
|
||||
.where((state) => state != BluetoothBondState.bonding)
|
||||
.first
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (bondState != BluetoothBondState.bonded) {
|
||||
throw StateError('Secure Bluetooth pairing did not complete.');
|
||||
}
|
||||
}
|
||||
|
||||
BluetoothCharacteristic? _findCharacteristic(
|
||||
List<BluetoothService> services,
|
||||
String uuid,
|
||||
@@ -236,8 +319,9 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
final name = result.advertisementData.advName.isNotEmpty
|
||||
? result.advertisementData.advName
|
||||
: result.device.platformName;
|
||||
return name.toUpperCase().startsWith('OPB-') ||
|
||||
name.toLowerCase().contains('openparcelbox') ||
|
||||
final normalizedName = name.trim().toLowerCase();
|
||||
return normalizedName.startsWith('opb-') ||
|
||||
normalizedName.contains('openparcelbox') ||
|
||||
result.advertisementData.serviceUuids.any(
|
||||
(uuid) => uuid.str.toLowerCase() == _serviceUuid,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ 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);
|
||||
@@ -29,10 +30,16 @@ void main() {
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
const MyApp({this.store, this.backupService, super.key});
|
||||
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();
|
||||
@@ -50,12 +57,14 @@ class _MyAppState extends State<MyApp> {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -122,11 +131,35 @@ class _MyAppState extends State<MyApp> {
|
||||
selectedLanguage: _locale?.languageCode,
|
||||
onLanguageChanged: _changeLanguage,
|
||||
)
|
||||
: const ColoredBox(color: _background),
|
||||
: 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 OpenParcelBoxHome extends StatefulWidget {
|
||||
const OpenParcelBoxHome({
|
||||
required this.store,
|
||||
@@ -216,94 +249,157 @@ class _OpenParcelBoxHomeState extends State<OpenParcelBoxHome> {
|
||||
|
||||
Future<void> _showAddBox() async {
|
||||
var boxName = '';
|
||||
ScanResult? selectedResult;
|
||||
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();
|
||||
builder: (context, setModalState) => ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, child) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
_showMessage('$error');
|
||||
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,
|
||||
},
|
||||
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
|
||||
: _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(context).colorScheme.error),
|
||||
),
|
||||
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
|
||||
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: 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 {
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed:
|
||||
selectedResult == null ||
|
||||
boxName.trim().isEmpty ||
|
||||
_controller.isBusy
|
||||
? null
|
||||
: () async {
|
||||
FocusScope.of(context).unfocus();
|
||||
try {
|
||||
await _controller.registerAdministrator(
|
||||
result,
|
||||
selectedResult!,
|
||||
boxName,
|
||||
);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (error) {
|
||||
_showMessage('$error');
|
||||
} catch (_) {
|
||||
// The controller logs the detailed failure and exposes
|
||||
// it inline in this modal.
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.link),
|
||||
color: _accent,
|
||||
),
|
||||
],
|
||||
),
|
||||
icon: const Icon(Icons.add_link),
|
||||
label: Text(strings.text('register_box')),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -69,6 +69,7 @@ flutter:
|
||||
assets:
|
||||
- images/background.jpg
|
||||
- images/openparcelbox-logo-256.png
|
||||
- images/openparcelbox-splash.png
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
@@ -78,14 +79,14 @@ flutter_launcher_icons:
|
||||
adaptive_icon_foreground: images/openparcelbox-logo-256.png
|
||||
|
||||
flutter_native_splash:
|
||||
color: "#333333"
|
||||
background_image: images/background.jpg
|
||||
image: images/openparcelbox-logo-256.png
|
||||
image: images/openparcelbox-splash.png
|
||||
android_gravity: center
|
||||
android: true
|
||||
ios: false
|
||||
android_12:
|
||||
color: "#333333"
|
||||
image: images/openparcelbox-logo-256.png
|
||||
image: images/openparcelbox-splash.png
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
@@ -38,10 +38,29 @@ class _FakeBackupService extends BackupService {
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('empty home only exposes registration and restore actions', (
|
||||
testWidgets('startup splash shows the background and centered logo', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(MyApp(store: _MemoryStore()));
|
||||
|
||||
expect(
|
||||
find.image(const AssetImage('images/background.jpg')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.image(const AssetImage('images/openparcelbox-logo-256.png')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 900));
|
||||
});
|
||||
|
||||
testWidgets('empty home only exposes registration and restore actions', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MyApp(store: _MemoryStore(), splashDuration: Duration.zero),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Add a new OpenParcelBox'), findsOneWidget);
|
||||
@@ -53,7 +72,9 @@ void main() {
|
||||
testWidgets('registration opens the styled setup modal', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(MyApp(store: _MemoryStore()));
|
||||
await tester.pumpWidget(
|
||||
MyApp(store: _MemoryStore(), splashDuration: Duration.zero),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Add a new OpenParcelBox'));
|
||||
@@ -62,13 +83,23 @@ void main() {
|
||||
expect(find.text('Box identifier'), findsOneWidget);
|
||||
expect(find.text('Scan a guest QR code'), findsOneWidget);
|
||||
expect(find.text('Detected OpenParcelBox devices'), findsOneWidget);
|
||||
expect(find.text('Add this box'), findsOneWidget);
|
||||
final addButton = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Add this box'),
|
||||
);
|
||||
expect(addButton.onPressed, isNull);
|
||||
expect(find.byIcon(Icons.close), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('registration text field works with the French locale', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(MyApp(store: _MemoryStore(language: 'fr')));
|
||||
await tester.pumpWidget(
|
||||
MyApp(
|
||||
store: _MemoryStore(language: 'fr'),
|
||||
splashDuration: Duration.zero,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Ajouter une nouvelle OpenParcelBox'));
|
||||
@@ -81,7 +112,9 @@ void main() {
|
||||
testWidgets(
|
||||
'closing registration after editing does not dispose input early',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(MyApp(store: _MemoryStore()));
|
||||
await tester.pumpWidget(
|
||||
MyApp(store: _MemoryStore(), splashDuration: Duration.zero),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Add a new OpenParcelBox'));
|
||||
@@ -100,7 +133,11 @@ void main() {
|
||||
) async {
|
||||
final backupService = _FakeBackupService();
|
||||
await tester.pumpWidget(
|
||||
MyApp(store: _MemoryStore(), backupService: backupService),
|
||||
MyApp(
|
||||
store: _MemoryStore(),
|
||||
backupService: backupService,
|
||||
splashDuration: Duration.zero,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
||||