diff --git a/AGENTS.md b/AGENTS.md index d64648e..f5fc5c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ a risk score with concrete action recommendations. - **Repository**: - **Platform targets**: Android, iOS, Web, Windows, macOS, Linux -- **Languages**: Dart / Flutter (Flutter 3.11.5+, Dart 3.1.0+) +- **Languages**: Dart / Flutter (Flutter 3.11.5+, Dart 3.11.5+) - **Build system**: Gradle (Android), Xcode (iOS), CMake (Linux/macOS/Windows) - **Key packages**: `pdf` (PDF export), `share_plus` (share sheet) - **Supported locales**: `de` (default), `en`, `cs`, `pl`, `fr`, `nl`, `da` @@ -102,6 +102,7 @@ test/ app_localizations_test.dart — Localization fallback and interpolation tests language_controller_test.dart — Language persistence tests summary_screen_test.dart — Summary screen rendering tests + review_fixes_test.dart — PDF font coverage, tolerant loading, camera/lock questions ``` --- @@ -130,8 +131,8 @@ test/ ISO 8859-1) — the `pdf` package's default fonts don't support more than that. - Avoid en-dashes (`–`), em-dashes (`—`), and non-Latin accented characters in strings used in PDF export. -- If a locale needs unsupported characters, embed a Unicode font (e.g. Roboto) - rather than relaxing this rule. +- The PDF export embeds Roboto (`assets/fonts/`), so cs/pl/fr text renders + correctly; `test/review_fixes_test.dart` checks every ARB character is covered. ### Risk scoring diff --git a/assets/fonts/Roboto-Bold.ttf b/assets/fonts/Roboto-Bold.ttf new file mode 100644 index 0000000..d3f01ad Binary files /dev/null and b/assets/fonts/Roboto-Bold.ttf differ diff --git a/assets/fonts/Roboto-LICENSE.txt b/assets/fonts/Roboto-LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/assets/fonts/Roboto-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/fonts/Roboto-Regular.ttf b/assets/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..2c97eea Binary files /dev/null and b/assets/fonts/Roboto-Regular.ttf differ diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index e0d09f1..0a326b1 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -355,6 +355,8 @@ "recommended": "Doporučeno", "reportExportedAt": "Exportováno", "restart": "Začít znovu", + "restartConfirmTitle": "Začít znovu?", + "restartConfirmBody": "Všechny zadané místnosti, zařízení a odpovědi budou trvale smazány. Tuto akci nelze vrátit zpět.", "results": "Výsledky", "resume": "Pokračovat", "risk": "Riziko", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index 9eabd25..e30b64f 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -355,6 +355,8 @@ "recommended": "Anbefalet", "reportExportedAt": "Eksporteret den", "restart": "Start forfra", + "restartConfirmTitle": "Starte forfra?", + "restartConfirmBody": "Alle registrerede rum, enheder og svar slettes permanent. Denne handling kan ikke fortrydes.", "results": "Resultater", "resume": "Fortsæt", "risk": "Risiko", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 71673a7..c389bf3 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -133,6 +133,8 @@ "shareAsText": "Als Text teilen", "shareAsPdf": "Als PDF teilen", "restart": "Neu starten", + "restartConfirmTitle": "Neu starten?", + "restartConfirmBody": "Alle erfassten Räume, Geräte und Antworten werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", "noDevicesCaptured": "Keine Geräte erfasst.", "noDevicesHint": "Starten Sie erneut und fügen Sie Geräte hinzu, um eine Bewertung zu erhalten.", "highRisk": "Hohes Risiko", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 82890f2..13dfe1e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -133,6 +133,8 @@ "shareAsText": "Share as text", "shareAsPdf": "Share as PDF", "restart": "Restart", + "restartConfirmTitle": "Restart?", + "restartConfirmBody": "All captured rooms, devices, and answers will be permanently deleted. This action cannot be undone.", "noDevicesCaptured": "No devices captured.", "noDevicesHint": "Restart and add devices to receive an assessment.", "highRisk": "High risk", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e4ca11a..e51ece3 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -355,6 +355,8 @@ "recommended": "Recommandé", "reportExportedAt": "Exporté le", "restart": "Recommencer", + "restartConfirmTitle": "Recommencer ?", + "restartConfirmBody": "Toutes les pièces, tous les appareils et toutes les réponses saisis seront définitivement supprimés. Cette action est irréversible.", "results": "Résultats", "resume": "Continuer", "risk": "Risque", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0ef0b2c..e341469 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -906,6 +906,18 @@ abstract class AppLocalizations { /// **'Restart'** String get restart; + /// No description provided for @restartConfirmTitle. + /// + /// In en, this message translates to: + /// **'Restart?'** + String get restartConfirmTitle; + + /// No description provided for @restartConfirmBody. + /// + /// In en, this message translates to: + /// **'All captured rooms, devices, and answers will be permanently deleted. This action cannot be undone.'** + String get restartConfirmBody; + /// No description provided for @noDevicesCaptured. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_cs.dart b/lib/l10n/app_localizations_cs.dart index 7b3e24f..e04ba88 100644 --- a/lib/l10n/app_localizations_cs.dart +++ b/lib/l10n/app_localizations_cs.dart @@ -439,6 +439,13 @@ class AppLocalizationsCs extends AppLocalizations { @override String get restart => 'Začít znovu'; + @override + String get restartConfirmTitle => 'Začít znovu?'; + + @override + String get restartConfirmBody => + 'Všechny zadané místnosti, zařízení a odpovědi budou trvale smazány. Tuto akci nelze vrátit zpět.'; + @override String get noDevicesCaptured => 'Nebyla zaznamenána žádná zařízení.'; diff --git a/lib/l10n/app_localizations_da.dart b/lib/l10n/app_localizations_da.dart index 828512e..92ec469 100644 --- a/lib/l10n/app_localizations_da.dart +++ b/lib/l10n/app_localizations_da.dart @@ -436,6 +436,13 @@ class AppLocalizationsDa extends AppLocalizations { @override String get restart => 'Start forfra'; + @override + String get restartConfirmTitle => 'Starte forfra?'; + + @override + String get restartConfirmBody => + 'Alle registrerede rum, enheder og svar slettes permanent. Denne handling kan ikke fortrydes.'; + @override String get noDevicesCaptured => 'Ingen enheder registreret.'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 86e1245..b259a8a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -438,6 +438,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get restart => 'Neu starten'; + @override + String get restartConfirmTitle => 'Neu starten?'; + + @override + String get restartConfirmBody => + 'Alle erfassten Räume, Geräte und Antworten werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.'; + @override String get noDevicesCaptured => 'Keine Geräte erfasst.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7be5142..7bfda60 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -434,6 +434,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get restart => 'Restart'; + @override + String get restartConfirmTitle => 'Restart?'; + + @override + String get restartConfirmBody => + 'All captured rooms, devices, and answers will be permanently deleted. This action cannot be undone.'; + @override String get noDevicesCaptured => 'No devices captured.'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 3ae53de..f4291ed 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -444,6 +444,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get restart => 'Recommencer'; + @override + String get restartConfirmTitle => 'Recommencer ?'; + + @override + String get restartConfirmBody => + 'Toutes les pièces, tous les appareils et toutes les réponses saisis seront définitivement supprimés. Cette action est irréversible.'; + @override String get noDevicesCaptured => 'Aucun appareil enregistré.'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 5bc792d..5369707 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -438,6 +438,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get restart => 'Opnieuw starten'; + @override + String get restartConfirmTitle => 'Opnieuw beginnen?'; + + @override + String get restartConfirmBody => + 'Alle vastgelegde kamers, apparaten en antwoorden worden permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.'; + @override String get noDevicesCaptured => 'Geen apparaten vastgelegd.'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 16480bd..9fd3725 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -438,6 +438,13 @@ class AppLocalizationsPl extends AppLocalizations { @override String get restart => 'Uruchom ponownie'; + @override + String get restartConfirmTitle => 'Zacząć od nowa?'; + + @override + String get restartConfirmBody => + 'Wszystkie dodane pomieszczenia, urządzenia i odpowiedzi zostaną trwale usunięte. Tej czynności nie można cofnąć.'; + @override String get noDevicesCaptured => 'Nie dodano żadnych urządzeń.'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 05eefef..54788d7 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -355,6 +355,8 @@ "recommended": "Aanbevolen", "reportExportedAt": "Geëxporteerd op", "restart": "Opnieuw starten", + "restartConfirmTitle": "Opnieuw beginnen?", + "restartConfirmBody": "Alle vastgelegde kamers, apparaten en antwoorden worden permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.", "results": "Resultaten", "resume": "Doorgaan", "risk": "Risico", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index d1f6209..8784639 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -355,6 +355,8 @@ "recommended": "Zalecane", "reportExportedAt": "Wyeksportowano", "restart": "Uruchom ponownie", + "restartConfirmTitle": "Zacząć od nowa?", + "restartConfirmBody": "Wszystkie dodane pomieszczenia, urządzenia i odpowiedzi zostaną trwale usunięte. Tej czynności nie można cofnąć.", "results": "Wyniki", "resume": "Wznów", "risk": "Ryzyko", diff --git a/lib/models/RISK_SCORING_README.md b/lib/models/RISK_SCORING_README.md index df013da..1470fdb 100644 --- a/lib/models/RISK_SCORING_README.md +++ b/lib/models/RISK_SCORING_README.md @@ -54,6 +54,7 @@ Applied in all (or most) devices: - Separate IoT network: `no +10`, `dontKnow +5` - Household informed: `no +10`, `dontKnow +5` - App permissions reduced: `no +5`, `dontKnow +3` +- Cameras additionally ask password + updates; locks additionally ask updates (flat device-specific penalty applies, see below) - Camera consent (if camera device): `no +15`, `dontKnow +8` - Mic deactivation (if microphone device): `no +10`, `dontKnow +5` diff --git a/lib/models/device.dart b/lib/models/device.dart index 27ec3e2..fa48ad3 100644 --- a/lib/models/device.dart +++ b/lib/models/device.dart @@ -619,23 +619,25 @@ class DeviceInstance { // ── Camera ──────────────────────────────────────────────────────────────── if (template.deviceType == DeviceCategory.camera) { - return const [ - DeviceQuestion( + return [ + qPassword, + qUpdates, + const DeviceQuestion( id: 'video_encryption', text: 'q_video_encryption_text', hint: 'q_video_encryption_hint', ), - DeviceQuestion( + const DeviceQuestion( id: 'video_storage', text: 'q_video_storage_text', hint: 'q_video_storage_hint', ), - DeviceQuestion( + const DeviceQuestion( id: 'sharing_restrictions', text: 'q_sharing_restrictions_text', hint: 'q_sharing_restrictions_hint', ), - DeviceQuestion( + const DeviceQuestion( id: 'motion_detection', text: 'q_motion_detection_text', hint: 'q_motion_detection_hint', @@ -715,6 +717,7 @@ class DeviceInstance { if (template.deviceType == DeviceCategory.lock) { return [ qPassword, + qUpdates, const DeviceQuestion( id: 'offline_unlock', text: 'q_offline_unlock_text', diff --git a/lib/models/survey_state.dart b/lib/models/survey_state.dart index a752fa0..0e4575b 100644 --- a/lib/models/survey_state.dart +++ b/lib/models/survey_state.dart @@ -365,19 +365,23 @@ class SurveyState extends ChangeNotifier { .whereType(), ); + // Malformed custom rooms/devices are skipped individually so one bad + // entry can't cost the user the rest of the save file. customRooms ..clear() ..addAll( (data['customRooms'] as List? ?? const []) .whereType() .map((e) => Map.from(e)) - .map( - (e) => Room( - id: e['id'] as String, - name: e['name'] as String, - icon: _resolveIcon(e), - ), - ), + .map((e) { + final id = e['id']; + final name = e['name']; + if (id is! String || name is! String) { + return null; + } + return Room(id: id, name: name, icon: _resolveIcon(e)); + }) + .whereType(), ); customDevices @@ -386,12 +390,18 @@ class SurveyState extends ChangeNotifier { (data['customDevices'] as List? ?? const []) .whereType() .map((e) => Map.from(e)) - .map( - (e) => DeviceTemplate( - id: e['id'] as String, - name: e['name'] as String, + .map((e) { + final id = e['id']; + final name = e['name']; + final baseRisk = e['baseRiskScore']; + if (id is! String || name is! String || baseRisk is! num) { + return null; + } + return DeviceTemplate( + id: id, + name: name, icon: _resolveIcon(e), - baseRiskScore: (e['baseRiskScore'] as num).toInt(), + baseRiskScore: baseRisk.toInt(), hasCamera: e['hasCamera'] as bool? ?? false, hasMicrophone: e['hasMicrophone'] as bool? ?? false, roomIds: (e['roomIds'] as List? ?? const []) @@ -399,8 +409,9 @@ class SurveyState extends ChangeNotifier { .toList(), deviceType: DeviceCategory.custom, isCustom: true, - ), - ), + ); + }) + .whereType(), ); // Backward compatibility: infer room ownership for older saved custom @@ -647,7 +658,15 @@ class SurveyState extends ChangeNotifier { Future? _pendingWrite; Future _enqueueWrite(Future Function() task) { - final next = (_pendingWrite ?? Future.value()).then((_) => task()); + // A failed write must not poison the chain: `.then` on an errored future + // would skip every later save, so swallow the error per task. + final next = (_pendingWrite ?? Future.value()).then((_) async { + try { + await task(); + } catch (_) { + // Persistence is best-effort; in-memory state stays authoritative. + } + }); _pendingWrite = next; return next; } diff --git a/lib/screens/summary_screen.dart b/lib/screens/summary_screen.dart index 4c52d5c..a90d5eb 100644 --- a/lib/screens/summary_screen.dart +++ b/lib/screens/summary_screen.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; import 'package:pdf/widgets.dart' as pw; import 'package:share_plus/share_plus.dart'; @@ -78,6 +79,40 @@ class _SummaryScreenState extends State { ); } + /// Asks for confirmation before wiping all captured data, then resets. + Future _confirmRestart() async { + final localizations = context.l10n; + final shouldRestart = await showDialog( + context: context, + builder: (context) => AlertDialog( + icon: const Icon(Icons.warning_amber_rounded), + title: Text(localizations.restartConfirmTitle), + content: Text(localizations.restartConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(localizations.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + localizations.restart, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + if (shouldRestart != true) { + return; + } + await widget.state.reset(); + if (!mounted) { + return; + } + Navigator.of(context).popUntil((route) => route.isFirst); + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -135,13 +170,7 @@ class _SummaryScreenState extends State { TextButton.icon( icon: const Icon(Icons.restart_alt, size: 18), label: Text(localizations.restart), - onPressed: () async { - await widget.state.reset(); - if (!context.mounted) { - return; - } - Navigator.of(context).popUntil((route) => route.isFirst); - }, + onPressed: _confirmRestart, ), ], ), @@ -325,6 +354,7 @@ class _SummaryScreenState extends State { name: localizations.summaryPdfFileName, ), ], + fileNameOverrides: [localizations.summaryPdfFileName], sharePositionOrigin: origin, ), ); @@ -395,11 +425,7 @@ class _SummaryReport { return (0.4 * max + 0.6 * mean).round(); })(); - final overallLevel = overallScore <= 33 - ? RiskLevel.low - : overallScore <= 66 - ? RiskLevel.medium - : RiskLevel.high; + final overallLevel = RiskLevel.fromScore(overallScore); final dontKnowAnswers = evaluatedDevices .map((d) => d.dontKnowAnswerCount) @@ -570,7 +596,22 @@ Future _buildSharePdf( _SummaryReport report, AppLocalizations localizations, ) async { - final pdf = pw.Document(); + // Helvetica (the pdf package default) only covers Latin-1, which breaks cs, + // pl and fr text, so embed Roboto. + final regular = pw.Font.ttf( + await rootBundle.load('assets/fonts/Roboto-Regular.ttf'), + ); + final bold = pw.Font.ttf( + await rootBundle.load('assets/fonts/Roboto-Bold.ttf'), + ); + final pdf = pw.Document( + theme: pw.ThemeData.withFont( + base: regular, + bold: bold, + italic: regular, + boldItalic: bold, + ), + ); final generatedAt = _formatDate(DateTime.now()); pdf.addPage( diff --git a/pubspec.lock b/pubspec.lock index e3c893e..8849a60 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "4.3.0" args: dependency: transitive description: @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: code_assets - sha256: dad6bf6b9f4f378b0a69edbf42584d336efd1a9ce15deb1ba591cbb1b5ff440f + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" collection: dependency: transitive description: @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.dev" source: hosted - version: "0.3.5+2" + version: "0.3.5+5" crypto: dependency: transitive description: @@ -209,26 +209,26 @@ packages: dependency: transitive description: name: get_it - sha256: "568d62f0e68666fb5d95519743b3c24a34c7f19d834b0658c46e26d778461f66" + sha256: "2b5c6b077aad30bba9782178baff6076a2044141f1d5f2f15b43bca3fbe74874" url: "https://pub.dev" source: hosted - version: "9.2.1" + version: "9.3.0" hooks: dependency: transitive description: name: hooks - sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.2" image: dependency: transitive description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.10.1" intl: dependency: "direct main" description: @@ -241,18 +241,26 @@ packages: dependency: transitive description: name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" jni_flutter: dependency: transitive description: name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + sha256: c399ad579bfba12eb20c924f7a23160a42902f3023a871e1cad40a8a679656a4 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.0" json_annotation: dependency: transitive description: @@ -329,34 +337,34 @@ packages: dependency: transitive description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" msix: dependency: "direct dev" description: name: msix - sha256: b6b08e7a7b5d1845f2b1d31216d5b1fb558e98251efefe54eb79ed00d27bc2ac + sha256: "61415c352e8aea084332b8a5514e6408c961c8cdeca202804fff1040eb555ac7" url: "https://pub.dev" source: hosted - version: "3.16.13" + version: "3.18.0" objective_c: dependency: transitive description: name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e url: "https://pub.dev" source: hosted - version: "9.4.1" + version: "9.5.0" package_config: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" path: dependency: transitive description: @@ -377,10 +385,10 @@ packages: dependency: transitive description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -401,18 +409,18 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -425,10 +433,10 @@ packages: dependency: "direct main" description: name: pdf - sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + sha256: "6e40827f370d196e97c6611724d923a3d86c431ce8e2ce1e59edd03a67a1a6e6" url: "https://pub.dev" source: hosted - version: "3.12.0" + version: "3.13.1" petitparser: dependency: transitive description: @@ -441,10 +449,10 @@ packages: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.2.0" plugin_platform_interface: dependency: transitive description: @@ -457,18 +465,18 @@ packages: dependency: transitive description: name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.5.2" pub_semver: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" qr: dependency: transitive description: @@ -489,18 +497,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0 + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" url: "https://pub.dev" source: hosted - version: "13.1.0" + version: "13.3.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9" + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" url: "https://pub.dev" source: hosted - version: "7.1.0" + version: "7.2.0" shared_preferences: dependency: "direct main" description: @@ -513,18 +521,18 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" url: "https://pub.dev" source: hosted - version: "2.4.23" + version: "2.4.28" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.5.7" shared_preferences_linux: dependency: transitive description: @@ -630,34 +638,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" url: "https://pub.dev" source: hosted - version: "6.3.30" + version: "6.3.33" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.4.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.6" url_launcher_platform_interface: dependency: transitive description: @@ -678,18 +686,18 @@ packages: dependency: transitive description: name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" uuid: dependency: transitive description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.3" + version: "4.6.0" vector_math: dependency: transitive description: @@ -702,10 +710,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "15.3.0" web: dependency: transitive description: @@ -718,10 +726,10 @@ packages: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.4.0" xdg_directories: dependency: transitive description: @@ -734,18 +742,18 @@ packages: dependency: transitive description: name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" url: "https://pub.dev" source: hosted - version: "6.6.1" + version: "7.0.1" yaml: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" sdks: - dart: ">=3.11.5 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 5e8a82f..1fb6be1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: 'none' # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+42 +version: 1.0.0+43 environment: sdk: ^3.11.5 @@ -37,10 +37,10 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - pdf: ^3.11.3 - share_plus: ^13.1.0 + pdf: ^3.13.1 + share_plus: ^13.3.0 shared_preferences: ^2.5.3 - intl: any + intl: ^0.20.2 url_launcher: ^6.3.1 dev_dependencies: @@ -49,7 +49,7 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.3 - msix: ^3.16.13 + msix: ^3.18.0 msix_config: display_name: Simplications @@ -86,6 +86,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/icon/ + - assets/fonts/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/review_fixes_test.dart b/test/review_fixes_test.dart new file mode 100644 index 0000000..0fc1c22 --- /dev/null +++ b/test/review_fixes_test.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pdf/src/pdf/font/ttf_parser.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:simplications/data/catalog_data.dart'; +import 'package:simplications/models/device.dart'; +import 'package:simplications/models/survey_state.dart'; + +DeviceInstance _instance(String templateId) => DeviceInstance( + instanceId: 'i_$templateId', + template: CatalogData.allDeviceTemplates.firstWhere( + (t) => t.id == templateId, + ), + roomId: 'living', + roomName: 'room_living', +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('embedded PDF font covers every character of every locale', () async { + final font = TtfParser( + await rootBundle.load('assets/fonts/Roboto-Regular.ttf'), + ); + for (final file in Directory('lib/l10n').listSync().whereType()) { + if (!file.path.endsWith('.arb')) continue; + final data = jsonDecode(file.readAsStringSync()) as Map; + for (final entry in data.entries) { + final value = entry.value; + if (entry.key.startsWith('@') || value is! String) continue; + for (final rune in value.runes) { + if (rune == 0x0A || rune == 0x20) continue; + expect( + font.charToGlyphIndexMap.containsKey(rune), + isTrue, + reason: '${file.path} ${entry.key}: U+${rune.toRadixString(16)}', + ); + } + } + } + }); + + test('cameras and locks ask about updates; cameras about password', () { + final camera = _instance('indoor_camera').questions.map((q) => q.id); + expect(camera, containsAll(['password', 'updates', 'video_encryption'])); + final lock = _instance('smart_lock').questions.map((q) => q.id); + expect(lock, containsAll(['password', 'updates'])); + }); + + test( + 'malformed custom entries are skipped without losing the rest', + () async { + SharedPreferences.setMockInitialValues({ + 'survey_state_v1': jsonEncode({ + 'completedRoomIds': ['living'], + 'customRooms': [ + {'name': 'no id'}, + {'id': 'custom_1', 'name': 'Good', 'iconKey': 'home'}, + ], + 'customDevices': [ + {'id': 'custom_d'}, + { + 'id': 'custom_d2', + 'name': 'Gadget', + 'baseRiskScore': 10, + 'roomIds': ['custom_1'], + }, + ], + 'devices': [ + {'templateId': 'custom_d2', 'roomId': 'custom_1'}, + ], + }), + }); + final state = SurveyState(); + await state.loadFromStorage(); + expect(state.customRooms.map((r) => r.id), ['custom_1']); + expect(state.customDevices.map((d) => d.id), ['custom_d2']); + expect(state.devices, hasLength(1)); + expect(state.completedRoomIds, {'living'}); + }, + ); +}