From d71eea93cf5c7d9794755065fbefbc3812e2f9e2 Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 28 May 2026 10:53:03 +0200 Subject: [PATCH 01/16] fix: update UI components for consistency and improve layout responsiveness --- lib/screens/about_screen.dart | 115 ++++++++++--------- lib/screens/device_questionnaire_screen.dart | 12 +- lib/screens/device_selection_screen.dart | 4 +- lib/screens/room_selection_screen.dart | 4 +- pubspec.yaml | 2 +- 5 files changed, 71 insertions(+), 66 deletions(-) diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index d35236c..42b5eaa 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -14,9 +14,9 @@ class AboutScreen extends StatelessWidget { final uri = Uri.parse(url); if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(localizations.websiteOpenFailed)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(localizations.websiteOpenFailed))); } } @@ -197,63 +197,68 @@ class AboutScreen extends StatelessWidget { style: text.titleSmall?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: colors.primaryContainer.withAlpha(120), + Material( + color: colors.primaryContainer.withAlpha(120), + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), - border: Border.all(color: colors.primary.withAlpha(80)), + side: BorderSide(color: colors.primary.withAlpha(80)), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.science_outlined, color: colors.primary), - title: Text( - localizations.expertModeToggleLabel, - style: text.titleSmall?.copyWith( - fontWeight: FontWeight.w700, + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.science_outlined, + color: colors.primary, ), - ), - subtitle: Text(localizations.expertModeToggleHint), - trailing: state == null - ? const Switch(value: false, onChanged: null) - : ListenableBuilder( - listenable: state!, - builder: (context, _) => Switch( - value: state!.expertModeEnabled, - onChanged: _setExpertMode, + title: Text( + localizations.expertModeToggleLabel, + style: text.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + subtitle: Text(localizations.expertModeToggleHint), + trailing: state == null + ? const Switch(value: false, onChanged: null) + : ListenableBuilder( + listenable: state!, + builder: (context, _) => Switch( + value: state!.expertModeEnabled, + onChanged: _setExpertMode, + ), ), - ), - ), - const SizedBox(height: 8), - Text( - localizations.expertModeSourcesHint, - style: text.bodySmall?.copyWith( - color: colors.onSurfaceVariant, ), - ), - const SizedBox(height: 10), - _PartnerTile( - colors: colors, - text: text, - label: localizations.expertModeSourceCmuLabel, - urlLabel: localizations.expertModeSourceCmuUrl, - url: localizations.expertModeSourceCmuUrl, - icon: Icons.open_in_new, - openUrl: (url) => _openUrl(context, url), - ), - _PartnerTile( - colors: colors, - text: text, - label: localizations.expertModeSourceUnboxingLabel, - urlLabel: localizations.expertModeSourceUnboxingUrl, - url: localizations.expertModeSourceUnboxingUrl, - icon: Icons.open_in_new, - openUrl: (url) => _openUrl(context, url), - ), - ], + const SizedBox(height: 8), + Text( + localizations.expertModeSourcesHint, + style: text.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + _PartnerTile( + colors: colors, + text: text, + label: localizations.expertModeSourceCmuLabel, + urlLabel: localizations.expertModeSourceCmuUrl, + url: localizations.expertModeSourceCmuUrl, + icon: Icons.open_in_new, + openUrl: (url) => _openUrl(context, url), + ), + _PartnerTile( + colors: colors, + text: text, + label: localizations.expertModeSourceUnboxingLabel, + urlLabel: localizations.expertModeSourceUnboxingUrl, + url: localizations.expertModeSourceUnboxingUrl, + icon: Icons.open_in_new, + openUrl: (url) => _openUrl(context, url), + ), + ], + ), ), ), const SizedBox(height: 20), diff --git a/lib/screens/device_questionnaire_screen.dart b/lib/screens/device_questionnaire_screen.dart index 3868535..d5a2b4f 100644 --- a/lib/screens/device_questionnaire_screen.dart +++ b/lib/screens/device_questionnaire_screen.dart @@ -443,13 +443,13 @@ class _QuestionCard extends StatelessWidget { ), ), const SizedBox(height: 8), - DecoratedBox( - decoration: BoxDecoration( - color: isNotApplicable - ? colors.tertiaryContainer.withAlpha(220) - : colors.surfaceContainerHighest, + Material( + color: isNotApplicable + ? colors.tertiaryContainer.withAlpha(220) + : colors.surfaceContainerHighest, + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - border: Border.all( + side: BorderSide( color: isNotApplicable ? colors.tertiary : colors.outline, ), ), diff --git a/lib/screens/device_selection_screen.dart b/lib/screens/device_selection_screen.dart index 06f572c..4da39da 100644 --- a/lib/screens/device_selection_screen.dart +++ b/lib/screens/device_selection_screen.dart @@ -210,8 +210,8 @@ class DeviceSelectionScreen extends StatelessWidget { SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 220, childAspectRatio: 2.2, crossAxisSpacing: 12, mainAxisSpacing: 12, diff --git a/lib/screens/room_selection_screen.dart b/lib/screens/room_selection_screen.dart index dfc2f48..79a9f87 100644 --- a/lib/screens/room_selection_screen.dart +++ b/lib/screens/room_selection_screen.dart @@ -116,8 +116,8 @@ class RoomSelectionScreen extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 220, childAspectRatio: 2.2, crossAxisSpacing: 12, mainAxisSpacing: 12, diff --git a/pubspec.yaml b/pubspec.yaml index 6935922..3759168 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+26 +version: 1.0.0+29 environment: sdk: ^3.11.5 From df8c785b94ad394995d2aef132662c9d6bd69e98 Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 28 May 2026 10:57:16 +0200 Subject: [PATCH 02/16] chore: add builtInKotlin and newDsl flags to gradle.properties for Flutter migration --- android/gradle.properties | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..d5da727 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false From 51a0be268f81955045ad0742a6155e3884fc7f6b Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 28 May 2026 11:02:13 +0200 Subject: [PATCH 03/16] fix: update privacy descriptions across multiple languages for clarity and consistency --- lib/l10n/app_cs.arb | 2 +- lib/l10n/app_da.arb | 2 +- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_cs.dart | 2 +- lib/l10n/app_localizations_da.dart | 2 +- lib/l10n/app_localizations_de.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- lib/l10n/app_localizations_fr.dart | 2 +- lib/l10n/app_localizations_nl.dart | 2 +- lib/l10n/app_localizations_pl.dart | 2 +- lib/l10n/app_nl.arb | 2 +- lib/l10n/app_pl.arb | 2 +- pubspec.yaml | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 76a7211..610efdb 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -450,7 +450,7 @@ "websiteOpenFailed": "Web se nepodařilo otevřít.", "welcomeDescription": "Zaznamenejte svá chytrá zařízení místnost po místnosti a získejte hodnocení soukromí s konkrétními doporučeními.", "welcomePrivacyTitle": "Soukromí bez cloudu", - "welcomePrivacyDescription": "Nejsou shromažďovány žádné osobní údaje. Vaše odpovědi zůstávají v tomto zařízení a nic se neodesílá na server ani do cloudu.", + "welcomePrivacyDescription": "Neshromažďujeme osobní údaje. Vaše odpovědi zůstávají v tomto zařízení a neposílají se do cloudu.", "welcomeTitle": "Simplications\nKontrola soukromí\nchytré domácnosti", "yes": "Ano" } diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index f4411f3..fbaab55 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -450,7 +450,7 @@ "websiteOpenFailed": "Kunne ikke åbne webstedet.", "welcomeDescription": "Registrer dine smarte enheder rum for rum, og få en privatlivsvurdering med konkrete anbefalinger.", "welcomePrivacyTitle": "Privatliv uden cloud", - "welcomePrivacyDescription": "Der indsamles ingen personlige data. Dine svar bliver på denne enhed, og intet sendes til en server eller cloud.", + "welcomePrivacyDescription": "Vi indsamler ikke personlige data. Dine svar bliver på denne enhed og sendes ikke til cloud.", "welcomeTitle": "Simplications\nSmart Home\nprivatlivstjek", "yes": "Ja" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index cd85346..7a97fb4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -4,7 +4,7 @@ "welcomeTitle": "Der Simplications\nSmart Home\nPrivatsphäre-Check", "welcomeDescription": "Erfassen Sie Raum für Raum Ihre smarten Geräte und erhalten Sie eine Privatsphärebewertung mit konkreten Empfehlungen.", "welcomePrivacyTitle": "Datenschutz ohne Cloud", - "welcomePrivacyDescription": "Es werden keine personenbezogenen Daten erhoben. Ihre Antworten bleiben auf diesem Gerät und nichts wird an einen Server oder in die Cloud gesendet.", + "welcomePrivacyDescription": "Wir sammeln keine persönlichen Daten. Ihre Antworten bleiben auf diesem Gerät und gehen nicht in die Cloud.", "stepSelectRooms": "Raum auswählen", "stepCaptureDevices": "Geräte erfassen", "stepAnswerQuestions": "Fragen beantworten", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b734163..b0dca0b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4,7 +4,7 @@ "welcomeTitle": "Simplications\nSmart Home\nPrivacy Check", "welcomeDescription": "Capture your smart devices room by room and receive a privacy assessment with concrete recommendations.", "welcomePrivacyTitle": "Private by design", - "welcomePrivacyDescription": "No personal data is collected. Your answers stay on this device and nothing is sent to a server or cloud.", + "welcomePrivacyDescription": "We do not collect personal data. Your answers stay on this device and are not sent to the cloud.", "stepSelectRooms": "Select room", "stepCaptureDevices": "Capture devices", "stepAnswerQuestions": "Answer questions", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 1247748..8703927 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -450,7 +450,7 @@ "websiteOpenFailed": "Impossible d’ouvrir le site web.", "welcomeDescription": "Renseignez vos appareils intelligents pièce par pièce et obtenez une évaluation de confidentialité avec des recommandations concrètes.", "welcomePrivacyTitle": "Confidentialité sans cloud", - "welcomePrivacyDescription": "Aucune donnée personnelle n'est collectée. Vos réponses restent sur cet appareil et rien n'est envoyé vers un serveur ou le cloud.", + "welcomePrivacyDescription": "Nous ne collectons pas de données personnelles. Vos réponses restent sur cet appareil et ne partent pas dans le cloud.", "welcomeTitle": "Simplications\nContrôle de confidentialité\nSmart Home", "yes": "Oui" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bc3339a..3c03e74 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -135,7 +135,7 @@ abstract class AppLocalizations { /// No description provided for @welcomePrivacyDescription. /// /// In en, this message translates to: - /// **'No personal data is collected. Your answers stay on this device and nothing is sent to a server or cloud.'** + /// **'We do not collect personal data. Your answers stay on this device and are not sent to the cloud.'** String get welcomePrivacyDescription; /// No description provided for @stepSelectRooms. diff --git a/lib/l10n/app_localizations_cs.dart b/lib/l10n/app_localizations_cs.dart index d5030ee..1ce3cfe 100644 --- a/lib/l10n/app_localizations_cs.dart +++ b/lib/l10n/app_localizations_cs.dart @@ -24,7 +24,7 @@ class AppLocalizationsCs extends AppLocalizations { @override String get welcomePrivacyDescription => - 'Nejsou shromažďovány žádné osobní údaje. Vaše odpovědi zůstávají v tomto zařízení a nic se neodesílá na server ani do cloudu.'; + 'Neshromažďujeme osobní údaje. Vaše odpovědi zůstávají v tomto zařízení a neposílají se do cloudu.'; @override String get stepSelectRooms => 'Vyberte místnost'; diff --git a/lib/l10n/app_localizations_da.dart b/lib/l10n/app_localizations_da.dart index 8b4107a..96e88a3 100644 --- a/lib/l10n/app_localizations_da.dart +++ b/lib/l10n/app_localizations_da.dart @@ -23,7 +23,7 @@ class AppLocalizationsDa extends AppLocalizations { @override String get welcomePrivacyDescription => - 'Der indsamles ingen personlige data. Dine svar bliver på denne enhed, og intet sendes til en server eller cloud.'; + 'Vi indsamler ikke personlige data. Dine svar bliver på denne enhed og sendes ikke til cloud.'; @override String get stepSelectRooms => 'Vælg ét rum'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 93e4c42..286a8c9 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -24,7 +24,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get welcomePrivacyDescription => - 'Es werden keine personenbezogenen Daten erhoben. Ihre Antworten bleiben auf diesem Gerät und nichts wird an einen Server oder in die Cloud gesendet.'; + 'Wir sammeln keine persönlichen Daten. Ihre Antworten bleiben auf diesem Gerät und gehen nicht in die Cloud.'; @override String get stepSelectRooms => 'Raum auswählen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 399e2be..198f811 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -23,7 +23,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get welcomePrivacyDescription => - 'No personal data is collected. Your answers stay on this device and nothing is sent to a server or cloud.'; + 'We do not collect personal data. Your answers stay on this device and are not sent to the cloud.'; @override String get stepSelectRooms => 'Select room'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index be4d8ac..b642f76 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -24,7 +24,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get welcomePrivacyDescription => - 'Aucune donnée personnelle n\'est collectée. Vos réponses restent sur cet appareil et rien n\'est envoyé vers un serveur ou le cloud.'; + 'Nous ne collectons pas de données personnelles. Vos réponses restent sur cet appareil et ne partent pas dans le cloud.'; @override String get stepSelectRooms => 'Choisir une pièce'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 867a699..77bd723 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -23,7 +23,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get welcomePrivacyDescription => - 'Er worden geen persoonsgegevens verzameld. Je antwoorden blijven op dit apparaat en er wordt niets naar een server of cloud gestuurd.'; + 'We verzamelen geen persoonsgegevens. Je antwoorden blijven op dit apparaat en gaan niet naar de cloud.'; @override String get stepSelectRooms => 'Kamer kiezen'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 01efdea..962fd85 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -23,7 +23,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String get welcomePrivacyDescription => - 'Nie są zbierane żadne dane osobowe. Twoje odpowiedzi pozostają na tym urządzeniu i nic nie jest wysyłane na serwer ani do chmury.'; + 'Nie zbieramy danych osobowych. Twoje odpowiedzi zostają na tym urządzeniu i nie trafiają do chmury.'; @override String get stepSelectRooms => 'Wybierz pokój'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index d42509e..aafbbd6 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -450,7 +450,7 @@ "websiteOpenFailed": "Kon website niet openen.", "welcomeDescription": "Leg je slimme apparaten kamer voor kamer vast en ontvang een privacybeoordeling met concrete aanbevelingen.", "welcomePrivacyTitle": "Privacy zonder cloud", - "welcomePrivacyDescription": "Er worden geen persoonsgegevens verzameld. Je antwoorden blijven op dit apparaat en er wordt niets naar een server of cloud gestuurd.", + "welcomePrivacyDescription": "We verzamelen geen persoonsgegevens. Je antwoorden blijven op dit apparaat en gaan niet naar de cloud.", "welcomeTitle": "Simplications\nSmart Home\nprivacycheck", "yes": "Ja" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 1e994ce..f8bfe9d 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -450,7 +450,7 @@ "websiteOpenFailed": "Nie można otworzyć strony internetowej.", "welcomeDescription": "Dodaj swoje inteligentne urządzenia pokój po pokoju i otrzymaj ocenę prywatności z konkretnymi zaleceniami.", "welcomePrivacyTitle": "Prywatność bez chmury", - "welcomePrivacyDescription": "Nie są zbierane żadne dane osobowe. Twoje odpowiedzi pozostają na tym urządzeniu i nic nie jest wysyłane na serwer ani do chmury.", + "welcomePrivacyDescription": "Nie zbieramy danych osobowych. Twoje odpowiedzi zostają na tym urządzeniu i nie trafiają do chmury.", "welcomeTitle": "Simplications\nKontrola prywatności\nSmart Home", "yes": "Tak" } diff --git a/pubspec.yaml b/pubspec.yaml index 3759168..dbd1217 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+29 +version: 1.0.0+30 environment: sdk: ^3.11.5 From e2e2d310c2af6e9ff03100159520b747348eb5eb Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 28 May 2026 11:13:47 +0200 Subject: [PATCH 04/16] feat: add 'resumeIncompleteDevice' localization and implement functionality in summary screen --- lib/l10n/app_cs.arb | 1 + lib/l10n/app_da.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_localizations.dart | 6 +++ lib/l10n/app_localizations_cs.dart | 4 ++ lib/l10n/app_localizations_da.dart | 3 ++ lib/l10n/app_localizations_de.dart | 4 ++ lib/l10n/app_localizations_en.dart | 3 ++ lib/l10n/app_localizations_fr.dart | 4 ++ lib/l10n/app_localizations_nl.dart | 4 ++ lib/l10n/app_localizations_pl.dart | 4 ++ lib/l10n/app_nl.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/screens/summary_screen.dart | 82 ++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 17 files changed, 122 insertions(+), 1 deletion(-) diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 610efdb..a86f599 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -384,6 +384,7 @@ "shareAsText": "Sdílet jako text", "shareResult": "Sdílet výsledek", "skippedDevicesHint": "{count} zařízení přeskočeno, protože dotazník nebyl dokončen.", + "resumeIncompleteDevice": "Pokračovat prvním nedokončeným zařízením", "sl_access_logging": "Přístupový protokol není k dispozici", "sl_account_required": "Vyžadován povinný účet u prodejce", "sl_base_risk": "Základní riziko typu zařízení", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index fbaab55..1ecbb99 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -384,6 +384,7 @@ "shareAsText": "Del som tekst", "shareResult": "Del resultat", "skippedDevicesHint": "{count} enheder er sprunget over, fordi spørgeskemaet ikke er afsluttet.", + "resumeIncompleteDevice": "Fortsæt med den første ufærdige enhed", "sl_access_logging": "Ingen adgangslog tilgængelig", "sl_account_required": "Obligatorisk leverandørkonto krævet", "sl_base_risk": "Baseline risiko for enhedstype", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7a97fb4..a2e4a31 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -143,6 +143,7 @@ "overview": "Übersicht", "evaluatedDevices": "Bewertete Geräte", "skippedDevicesHint": "{count} Geräte übersprungen, da der Fragebogen nicht abgeschlossen wurde.", + "resumeIncompleteDevice": "Mit erstem unvollständigen Gerät fortfahren", "overallRisk": "Gesamtrisiko", "generalRecommendations": "Allgemeine Empfehlungen", "generalRecommendationsHint": "Diese Maßnahmen gelten unabhängig von einzelnen Geräten für Ihr gesamtes Smart Home.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b0dca0b..7fe2715 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -143,6 +143,7 @@ "overview": "Overview", "evaluatedDevices": "Evaluated devices", "skippedDevicesHint": "{count} devices skipped because the questionnaire was not completed.", + "resumeIncompleteDevice": "Continue with first incomplete device", "overallRisk": "Overall risk", "generalRecommendations": "General recommendations", "generalRecommendationsHint": "These measures apply to your whole smart home regardless of individual devices.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 8703927..6b3a045 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -384,6 +384,7 @@ "shareAsText": "Partager en texte", "shareResult": "Partager le résultat", "skippedDevicesHint": "{count} appareils ignorés car le questionnaire n'a pas été terminé.", + "resumeIncompleteDevice": "Continuer avec le premier appareil non terminé", "sl_access_logging": "Aucun journal d'accès disponible", "sl_account_required": "Compte vendeur obligatoire requis", "sl_base_risk": "Risque de base du type d'appareil", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3c03e74..1284004 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -966,6 +966,12 @@ abstract class AppLocalizations { /// **'{count} devices skipped because the questionnaire was not completed.'** String skippedDevicesHint(Object count); + /// No description provided for @resumeIncompleteDevice. + /// + /// In en, this message translates to: + /// **'Continue with first incomplete device'** + String get resumeIncompleteDevice; + /// No description provided for @overallRisk. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_cs.dart b/lib/l10n/app_localizations_cs.dart index 1ce3cfe..210513b 100644 --- a/lib/l10n/app_localizations_cs.dart +++ b/lib/l10n/app_localizations_cs.dart @@ -472,6 +472,10 @@ class AppLocalizationsCs extends AppLocalizations { return '$count zařízení přeskočeno, protože dotazník nebyl dokončen.'; } + @override + String get resumeIncompleteDevice => + 'Pokračovat prvním nedokončeným zařízením'; + @override String get overallRisk => 'Celkové riziko'; diff --git a/lib/l10n/app_localizations_da.dart b/lib/l10n/app_localizations_da.dart index 96e88a3..38e2e5a 100644 --- a/lib/l10n/app_localizations_da.dart +++ b/lib/l10n/app_localizations_da.dart @@ -469,6 +469,9 @@ class AppLocalizationsDa extends AppLocalizations { return '$count enheder er sprunget over, fordi spørgeskemaet ikke er afsluttet.'; } + @override + String get resumeIncompleteDevice => 'Fortsæt med den første ufærdige enhed'; + @override String get overallRisk => 'Samlet risiko'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 286a8c9..2cbd7ef 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -471,6 +471,10 @@ class AppLocalizationsDe extends AppLocalizations { return '$count Geräte übersprungen, da der Fragebogen nicht abgeschlossen wurde.'; } + @override + String get resumeIncompleteDevice => + 'Mit erstem unvollständigen Gerät fortfahren'; + @override String get overallRisk => 'Gesamtrisiko'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 198f811..661bf41 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -467,6 +467,9 @@ class AppLocalizationsEn extends AppLocalizations { return '$count devices skipped because the questionnaire was not completed.'; } + @override + String get resumeIncompleteDevice => 'Continue with first incomplete device'; + @override String get overallRisk => 'Overall risk'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index b642f76..7706093 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -477,6 +477,10 @@ class AppLocalizationsFr extends AppLocalizations { return '$count appareils ignorés car le questionnaire n\'a pas été terminé.'; } + @override + String get resumeIncompleteDevice => + 'Continuer avec le premier appareil non terminé'; + @override String get overallRisk => 'Risque global'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 77bd723..a064935 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -471,6 +471,10 @@ class AppLocalizationsNl extends AppLocalizations { return '$count apparaten overgeslagen omdat de vragenlijst niet is voltooid.'; } + @override + String get resumeIncompleteDevice => + 'Ga verder met het eerste onvoltooide apparaat'; + @override String get overallRisk => 'Totaal risico'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 962fd85..bca23d2 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -471,6 +471,10 @@ class AppLocalizationsPl extends AppLocalizations { return 'Pominięto $count urządzeń, ponieważ kwestionariusz nie został ukończony.'; } + @override + String get resumeIncompleteDevice => + 'Kontynuuj od pierwszego nieukończonego urządzenia'; + @override String get overallRisk => 'Ryzyko całkowite'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index aafbbd6..8466c41 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -384,6 +384,7 @@ "shareAsText": "Delen als tekst", "shareResult": "Resultaat delen", "skippedDevicesHint": "{count} apparaten overgeslagen omdat de vragenlijst niet is voltooid.", + "resumeIncompleteDevice": "Ga verder met het eerste onvoltooide apparaat", "sl_access_logging": "Geen toegangslogboek beschikbaar", "sl_account_required": "Verplichte leveranciersaccount vereist", "sl_base_risk": "Basisrisico van apparaattype", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index f8bfe9d..0fc3799 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -384,6 +384,7 @@ "shareAsText": "Udostępnij jako tekst", "shareResult": "Udostępnij wynik", "skippedDevicesHint": "Pominięto {count} urządzeń, ponieważ kwestionariusz nie został ukończony.", + "resumeIncompleteDevice": "Kontynuuj od pierwszego nieukończonego urządzenia", "sl_access_logging": "Brak dostępnego dziennika dostępu", "sl_account_required": "Wymagane obowiązkowe konto dostawcy", "sl_base_risk": "Bazowe ryzyko typu urządzenia", diff --git a/lib/screens/summary_screen.dart b/lib/screens/summary_screen.dart index 1ee588c..8cec477 100644 --- a/lib/screens/summary_screen.dart +++ b/lib/screens/summary_screen.dart @@ -8,7 +8,9 @@ import '../data/catalog_data.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_key_resolver.dart'; import '../models/device.dart'; +import '../models/room.dart'; import '../models/survey_state.dart'; +import 'device_questionnaire_screen.dart'; const String _catalogUrl = 'https://tucmi.github.io/simplications-outreach/pages/massnahmenkatalog.html'; @@ -25,6 +27,51 @@ class SummaryScreen extends StatefulWidget { class _SummaryScreenState extends State { bool _isSharing = false; + DeviceInstance? _firstIncompleteDevice() { + for (final device in widget.state.devices) { + if (!device.isFullyAnswered) { + return device; + } + } + return null; + } + + Room? _roomForId(String roomId) { + for (final room in CatalogData.allRooms) { + if (room.id == roomId) { + return room; + } + } + for (final room in widget.state.customRooms) { + if (room.id == roomId) { + return room; + } + } + return null; + } + + void _openFirstIncompleteDevice() { + final device = _firstIncompleteDevice(); + if (device == null) { + return; + } + + final room = _roomForId(device.roomId); + if (room == null) { + return; + } + + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DeviceQuestionnaireScreen( + state: widget.state, + room: room, + instanceId: device.instanceId, + ), + ), + ); + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -35,6 +82,7 @@ class _SummaryScreenState extends State { final highRisk = report.highRisk; final medRisk = report.mediumRisk; final lowRisk = report.lowRisk; + final canContinueIncomplete = _firstIncompleteDevice() != null; return Scaffold( appBar: AppBar( @@ -109,6 +157,8 @@ class _SummaryScreenState extends State { highCount: highRisk.length, medCount: medRisk.length, lowCount: lowRisk.length, + canContinueIncomplete: canContinueIncomplete, + onContinueIncomplete: _openFirstIncompleteDevice, colors: colors, text: text, localizations: localizations, @@ -741,6 +791,8 @@ class _OverviewHeader extends StatelessWidget { final int highCount; final int medCount; final int lowCount; + final bool canContinueIncomplete; + final VoidCallback onContinueIncomplete; final ColorScheme colors; final TextTheme text; final AppLocalizations localizations; @@ -753,6 +805,8 @@ class _OverviewHeader extends StatelessWidget { required this.highCount, required this.medCount, required this.lowCount, + required this.canContinueIncomplete, + required this.onContinueIncomplete, required this.colors, required this.text, required this.localizations, @@ -835,6 +889,19 @@ class _OverviewHeader extends StatelessWidget { color: colors.onSurfaceVariant, ), ), + if (canContinueIncomplete) + TextButton.icon( + onPressed: onContinueIncomplete, + icon: const Icon( + Icons.play_arrow_rounded, + size: 18, + ), + label: Text(localizations.resumeIncompleteDevice), + style: TextButton.styleFrom( + padding: const EdgeInsets.only(top: 4, bottom: 2), + visualDensity: VisualDensity.compact, + ), + ), ], const SizedBox(height: 8), if (highCount > 0) @@ -856,6 +923,21 @@ class _OverviewHeader extends StatelessWidget { height: 1.4, ), ), + ] else if (skippedDevices > 0) ...[ + Text( + localizations.skippedDevicesHint(skippedDevices), + style: text.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + if (canContinueIncomplete) + TextButton.icon( + onPressed: onContinueIncomplete, + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: Text(localizations.resumeIncompleteDevice), + style: TextButton.styleFrom( + padding: const EdgeInsets.only(top: 6, bottom: 2), + visualDensity: VisualDensity.compact, + ), + ), ], ], ), diff --git a/pubspec.yaml b/pubspec.yaml index dbd1217..b8ba494 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+30 +version: 1.0.0+31 environment: sdk: ^3.11.5 From cc63fc9de210432af5c5c0be099fd311aac085dd Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 28 May 2026 11:27:49 +0200 Subject: [PATCH 05/16] feat: add 'noIncompleteDevicesLeft' localization for multiple languages and update summary screen functionality --- lib/l10n/app_cs.arb | 1 + lib/l10n/app_da.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_cs.dart | 3 + lib/l10n/app_localizations_da.dart | 3 + lib/l10n/app_localizations_de.dart | 3 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_fr.dart | 3 + lib/l10n/app_localizations_nl.dart | 3 + lib/l10n/app_localizations_pl.dart | 3 + lib/l10n/app_nl.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/screens/summary_screen.dart | 263 +++++++++++++++-------------- pubspec.yaml | 2 +- 17 files changed, 172 insertions(+), 127 deletions(-) diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index a86f599..d23f9e9 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -385,6 +385,7 @@ "shareResult": "Sdílet výsledek", "skippedDevicesHint": "{count} zařízení přeskočeno, protože dotazník nebyl dokončen.", "resumeIncompleteDevice": "Pokračovat prvním nedokončeným zařízením", + "noIncompleteDevicesLeft": "Žádná nedokončená zařízení nezbývají.", "sl_access_logging": "Přístupový protokol není k dispozici", "sl_account_required": "Vyžadován povinný účet u prodejce", "sl_base_risk": "Základní riziko typu zařízení", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index 1ecbb99..ba70e87 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -385,6 +385,7 @@ "shareResult": "Del resultat", "skippedDevicesHint": "{count} enheder er sprunget over, fordi spørgeskemaet ikke er afsluttet.", "resumeIncompleteDevice": "Fortsæt med den første ufærdige enhed", + "noIncompleteDevicesLeft": "Ingen ufærdige enheder tilbage.", "sl_access_logging": "Ingen adgangslog tilgængelig", "sl_account_required": "Obligatorisk leverandørkonto krævet", "sl_base_risk": "Baseline risiko for enhedstype", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a2e4a31..00cba65 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -144,6 +144,7 @@ "evaluatedDevices": "Bewertete Geräte", "skippedDevicesHint": "{count} Geräte übersprungen, da der Fragebogen nicht abgeschlossen wurde.", "resumeIncompleteDevice": "Mit erstem unvollständigen Gerät fortfahren", + "noIncompleteDevicesLeft": "Keine unvollständigen Geräte mehr.", "overallRisk": "Gesamtrisiko", "generalRecommendations": "Allgemeine Empfehlungen", "generalRecommendationsHint": "Diese Maßnahmen gelten unabhängig von einzelnen Geräten für Ihr gesamtes Smart Home.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7fe2715..ca773ef 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -144,6 +144,7 @@ "evaluatedDevices": "Evaluated devices", "skippedDevicesHint": "{count} devices skipped because the questionnaire was not completed.", "resumeIncompleteDevice": "Continue with first incomplete device", + "noIncompleteDevicesLeft": "No unfinished devices left.", "overallRisk": "Overall risk", "generalRecommendations": "General recommendations", "generalRecommendationsHint": "These measures apply to your whole smart home regardless of individual devices.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6b3a045..c07f226 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -385,6 +385,7 @@ "shareResult": "Partager le résultat", "skippedDevicesHint": "{count} appareils ignorés car le questionnaire n'a pas été terminé.", "resumeIncompleteDevice": "Continuer avec le premier appareil non terminé", + "noIncompleteDevicesLeft": "Il ne reste aucun appareil inachevé.", "sl_access_logging": "Aucun journal d'accès disponible", "sl_account_required": "Compte vendeur obligatoire requis", "sl_base_risk": "Risque de base du type d'appareil", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1284004..73d02ec 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -972,6 +972,12 @@ abstract class AppLocalizations { /// **'Continue with first incomplete device'** String get resumeIncompleteDevice; + /// No description provided for @noIncompleteDevicesLeft. + /// + /// In en, this message translates to: + /// **'No unfinished devices left.'** + String get noIncompleteDevicesLeft; + /// No description provided for @overallRisk. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_cs.dart b/lib/l10n/app_localizations_cs.dart index 210513b..15ba2b9 100644 --- a/lib/l10n/app_localizations_cs.dart +++ b/lib/l10n/app_localizations_cs.dart @@ -476,6 +476,9 @@ class AppLocalizationsCs extends AppLocalizations { String get resumeIncompleteDevice => 'Pokračovat prvním nedokončeným zařízením'; + @override + String get noIncompleteDevicesLeft => 'Žádná nedokončená zařízení nezbývají.'; + @override String get overallRisk => 'Celkové riziko'; diff --git a/lib/l10n/app_localizations_da.dart b/lib/l10n/app_localizations_da.dart index 38e2e5a..963fa3a 100644 --- a/lib/l10n/app_localizations_da.dart +++ b/lib/l10n/app_localizations_da.dart @@ -472,6 +472,9 @@ class AppLocalizationsDa extends AppLocalizations { @override String get resumeIncompleteDevice => 'Fortsæt med den første ufærdige enhed'; + @override + String get noIncompleteDevicesLeft => 'Ingen ufærdige enheder tilbage.'; + @override String get overallRisk => 'Samlet risiko'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2cbd7ef..0665474 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -475,6 +475,9 @@ class AppLocalizationsDe extends AppLocalizations { String get resumeIncompleteDevice => 'Mit erstem unvollständigen Gerät fortfahren'; + @override + String get noIncompleteDevicesLeft => 'Keine unvollständigen Geräte mehr.'; + @override String get overallRisk => 'Gesamtrisiko'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 661bf41..ec69d0e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -470,6 +470,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get resumeIncompleteDevice => 'Continue with first incomplete device'; + @override + String get noIncompleteDevicesLeft => 'No unfinished devices left.'; + @override String get overallRisk => 'Overall risk'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 7706093..9c89149 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -481,6 +481,9 @@ class AppLocalizationsFr extends AppLocalizations { String get resumeIncompleteDevice => 'Continuer avec le premier appareil non terminé'; + @override + String get noIncompleteDevicesLeft => 'Il ne reste aucun appareil inachevé.'; + @override String get overallRisk => 'Risque global'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index a064935..9065ed7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -475,6 +475,9 @@ class AppLocalizationsNl extends AppLocalizations { String get resumeIncompleteDevice => 'Ga verder met het eerste onvoltooide apparaat'; + @override + String get noIncompleteDevicesLeft => 'Geen onvoltooide apparaten meer.'; + @override String get overallRisk => 'Totaal risico'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index bca23d2..901540f 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -475,6 +475,9 @@ class AppLocalizationsPl extends AppLocalizations { String get resumeIncompleteDevice => 'Kontynuuj od pierwszego nieukończonego urządzenia'; + @override + String get noIncompleteDevicesLeft => 'Brak nieukończonych urządzeń.'; + @override String get overallRisk => 'Ryzyko całkowite'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 8466c41..3e6311a 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -385,6 +385,7 @@ "shareResult": "Resultaat delen", "skippedDevicesHint": "{count} apparaten overgeslagen omdat de vragenlijst niet is voltooid.", "resumeIncompleteDevice": "Ga verder met het eerste onvoltooide apparaat", + "noIncompleteDevicesLeft": "Geen onvoltooide apparaten meer.", "sl_access_logging": "Geen toegangslogboek beschikbaar", "sl_account_required": "Verplichte leveranciersaccount vereist", "sl_base_risk": "Basisrisico van apparaattype", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 0fc3799..0c0a25a 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -385,6 +385,7 @@ "shareResult": "Udostępnij wynik", "skippedDevicesHint": "Pominięto {count} urządzeń, ponieważ kwestionariusz nie został ukończony.", "resumeIncompleteDevice": "Kontynuuj od pierwszego nieukończonego urządzenia", + "noIncompleteDevicesLeft": "Brak nieukończonych urządzeń.", "sl_access_logging": "Brak dostępnego dziennika dostępu", "sl_account_required": "Wymagane obowiązkowe konto dostawcy", "sl_base_risk": "Bazowe ryzyko typu urządzenia", diff --git a/lib/screens/summary_screen.dart b/lib/screens/summary_screen.dart index 8cec477..cde27f9 100644 --- a/lib/screens/summary_screen.dart +++ b/lib/screens/summary_screen.dart @@ -50,16 +50,21 @@ class _SummaryScreenState extends State { return null; } + Room _fallbackRoomForDevice(DeviceInstance device) { + return Room(id: device.roomId, name: device.roomName, icon: Icons.home); + } + void _openFirstIncompleteDevice() { + final localizations = AppLocalizations.of(context)!; final device = _firstIncompleteDevice(); if (device == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(localizations.noIncompleteDevicesLeft)), + ); return; } - final room = _roomForId(device.roomId); - if (room == null) { - return; - } + final room = _roomForId(device.roomId) ?? _fallbackRoomForDevice(device); Navigator.of(context).push( MaterialPageRoute( @@ -77,12 +82,6 @@ class _SummaryScreenState extends State { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; final localizations = AppLocalizations.of(context)!; - final report = _SummaryReport.fromDevices(widget.state.devices); - final devices = report.devices; - final highRisk = report.highRisk; - final medRisk = report.mediumRisk; - final lowRisk = report.lowRisk; - final canContinueIncomplete = _firstIncompleteDevice() != null; return Scaffold( appBar: AppBar( @@ -145,137 +144,149 @@ class _SummaryScreenState extends State { ), ], ), - body: CustomScrollView( - slivers: [ - // ── Overview header ────────────────────────────────────────── - SliverToBoxAdapter( - child: _OverviewHeader( - devices: devices, - skippedDevices: report.skippedDevices, - overallScore: report.overallScore, - dontKnowAnswers: report.dontKnowAnswers, - highCount: highRisk.length, - medCount: medRisk.length, - lowCount: lowRisk.length, - canContinueIncomplete: canContinueIncomplete, - onContinueIncomplete: _openFirstIncompleteDevice, - colors: colors, - text: text, - localizations: localizations, - ), - ), + body: ListenableBuilder( + listenable: widget.state, + builder: (context, _) { + final report = _SummaryReport.fromDevices(widget.state.devices); + final devices = report.devices; + final highRisk = report.highRisk; + final medRisk = report.mediumRisk; + final lowRisk = report.lowRisk; + final canContinueIncomplete = _firstIncompleteDevice() != null; + + return CustomScrollView( + slivers: [ + // ── Overview header ────────────────────────────────────────── + SliverToBoxAdapter( + child: _OverviewHeader( + devices: devices, + skippedDevices: report.skippedDevices, + overallScore: report.overallScore, + dontKnowAnswers: report.dontKnowAnswers, + highCount: highRisk.length, + medCount: medRisk.length, + lowCount: lowRisk.length, + canContinueIncomplete: canContinueIncomplete, + onContinueIncomplete: _openFirstIncompleteDevice, + colors: colors, + text: text, + localizations: localizations, + ), + ), - // ── No devices ─────────────────────────────────────────────── - if (devices.isEmpty) - SliverPadding( - padding: const EdgeInsets.all(32), - sliver: SliverToBoxAdapter( - child: Center( - child: Column( - children: [ - Icon( - Icons.check_circle_outline, - size: 64, - color: colors.primary, - ), - const SizedBox(height: 16), - Text( - localizations.noDevicesCaptured, - style: text.titleMedium, - ), - const SizedBox(height: 8), - Text( - localizations.noDevicesHint, - style: text.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - textAlign: TextAlign.center, + // ── No devices ─────────────────────────────────────────────── + if (devices.isEmpty) + SliverPadding( + padding: const EdgeInsets.all(32), + sliver: SliverToBoxAdapter( + child: Center( + child: Column( + children: [ + Icon( + Icons.check_circle_outline, + size: 64, + color: colors.primary, + ), + const SizedBox(height: 16), + Text( + localizations.noDevicesCaptured, + style: text.titleMedium, + ), + const SizedBox(height: 8), + Text( + localizations.noDevicesHint, + style: text.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], ), - ], + ), ), ), - ), - ), - // ── Hohe Risiken ───────────────────────────────────────────── - if (highRisk.isNotEmpty) ...[ - SliverToBoxAdapter( - child: _SectionHeader( - label: localizations.highRisk, - count: highRisk.length, - color: _riskColor(RiskLevel.high), - icon: Icons.warning_rounded, - ), - ), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _DeviceResultCard(device: highRisk[i]), + // ── Hohe Risiken ───────────────────────────────────────────── + if (highRisk.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _SectionHeader( + label: localizations.highRisk, + count: highRisk.length, + color: _riskColor(RiskLevel.high), + icon: Icons.warning_rounded, ), - childCount: highRisk.length, ), - ), - ), - ], + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _DeviceResultCard(device: highRisk[i]), + ), + childCount: highRisk.length, + ), + ), + ), + ], - // ── Mittlere Risiken ───────────────────────────────────────── - if (medRisk.isNotEmpty) ...[ - SliverToBoxAdapter( - child: _SectionHeader( - label: localizations.mediumRisk, - count: medRisk.length, - color: _riskColor(RiskLevel.medium), - icon: Icons.info_rounded, - ), - ), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _DeviceResultCard(device: medRisk[i]), + // ── Mittlere Risiken ───────────────────────────────────────── + if (medRisk.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _SectionHeader( + label: localizations.mediumRisk, + count: medRisk.length, + color: _riskColor(RiskLevel.medium), + icon: Icons.info_rounded, ), - childCount: medRisk.length, ), - ), - ), - ], + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _DeviceResultCard(device: medRisk[i]), + ), + childCount: medRisk.length, + ), + ), + ), + ], - // ── Niedrige Risiken ───────────────────────────────────────── - if (lowRisk.isNotEmpty) ...[ - SliverToBoxAdapter( - child: _SectionHeader( - label: localizations.lowRisk, - count: lowRisk.length, - color: _riskColor(RiskLevel.low), - icon: Icons.check_circle_rounded, - ), - ), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _DeviceResultCard(device: lowRisk[i]), + // ── Niedrige Risiken ───────────────────────────────────────── + if (lowRisk.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _SectionHeader( + label: localizations.lowRisk, + count: lowRisk.length, + color: _riskColor(RiskLevel.low), + icon: Icons.check_circle_rounded, ), - childCount: lowRisk.length, ), - ), - ), - ], + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _DeviceResultCard(device: lowRisk[i]), + ), + childCount: lowRisk.length, + ), + ), + ), + ], - // ── General recommendations ────────────────────────────────── - SliverToBoxAdapter( - child: _GeneralRecommendations(colors: colors, text: text), - ), + // ── General recommendations ────────────────────────────────── + SliverToBoxAdapter( + child: _GeneralRecommendations(colors: colors, text: text), + ), - const SliverToBoxAdapter(child: SizedBox(height: 40)), - ], + const SliverToBoxAdapter(child: SizedBox(height: 40)), + ], + ); + }, ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index b8ba494..429f6db 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+31 +version: 1.0.0+33 environment: sdk: ^3.11.5 From eb8eb9c9e8d6e31995d41d30d09c4184659e6e48 Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 28 May 2026 11:40:32 +0200 Subject: [PATCH 06/16] test: add widget tests for About, Device Selection, and Room Selection screens --- test/about_screen_test.dart | 150 ++++++++++++++++ test/device_selection_screen_test.dart | 233 +++++++++++++++++++++++++ test/room_selection_screen_test.dart | 182 +++++++++++++++++++ 3 files changed, 565 insertions(+) create mode 100644 test/about_screen_test.dart create mode 100644 test/device_selection_screen_test.dart create mode 100644 test/room_selection_screen_test.dart diff --git a/test/about_screen_test.dart b/test/about_screen_test.dart new file mode 100644 index 0000000..176e338 --- /dev/null +++ b/test/about_screen_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:simplications/l10n/app_localizations.dart'; +import 'package:simplications/models/survey_state.dart'; +import 'package:simplications/screens/about_screen.dart'; + +Widget _buildTestApp(Widget child) { + return MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: child, + ); +} + +void _configureLargeViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1600, 2400); + tester.view.devicePixelRatio = 1.0; +} + +void main() { + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'canLaunch' || call.method == 'canLaunchUrl') { + return true; + } + if (call.method == 'launch' || call.method == 'launchUrl') { + return false; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + testWidgets('renders key sections and disabled controls with null state', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_buildTestApp(const AboutScreen(state: null))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + expect(find.text(localizations.aboutScreenTitle), findsOneWidget); + expect(find.text(localizations.projectPartners), findsOneWidget); + expect(find.text(localizations.coordinationFunding), findsOneWidget); + expect(find.text(localizations.expertModeTitle), findsOneWidget); + expect(find.text(localizations.dangerZone), findsOneWidget); + + final switchWidget = tester.widget(find.byType(Switch).first); + expect(switchWidget.onChanged, isNull); + + final deleteButton = tester.widget( + find.widgetWithText(FilledButton, localizations.deleteAllDataButton), + ); + expect(deleteButton.onPressed, isNull); + }); + + testWidgets('expert mode toggle updates survey state', (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(AboutScreen(state: state))); + await tester.pumpAndSettle(); + + expect(state.expertModeEnabled, isFalse); + + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + + expect(state.expertModeEnabled, isTrue); + }); + + testWidgets('delete all data confirmation clears state and shows snackbar', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + state.addCustomRoom('Temporary', Icons.home); + expect(state.hasAnyData, isTrue); + + await tester.pumpWidget(_buildTestApp(AboutScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap( + find.widgetWithText(FilledButton, localizations.deleteAllDataButton), + ); + await tester.pumpAndSettle(); + + expect(find.text(localizations.deleteAllDataTitle), findsOneWidget); + + await tester.tap( + find.widgetWithText(TextButton, localizations.deleteAllDataButton), + ); + await tester.pumpAndSettle(); + + expect(state.hasAnyData, isFalse); + expect(find.text(localizations.allDataDeleted), findsOneWidget); + }); + + testWidgets('failed website launch shows snackbar message', (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(AboutScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap(find.text(localizations.aboutWebsiteSimplicationsLabel)); + await tester.pumpAndSettle(); + + expect(find.text(localizations.websiteOpenFailed), findsOneWidget); + }); +} diff --git a/test/device_selection_screen_test.dart b/test/device_selection_screen_test.dart new file mode 100644 index 0000000..e6ca671 --- /dev/null +++ b/test/device_selection_screen_test.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:simplications/data/catalog_data.dart'; +import 'package:simplications/l10n/app_localizations.dart'; +import 'package:simplications/models/device.dart'; +import 'package:simplications/models/room.dart'; +import 'package:simplications/models/survey_state.dart'; +import 'package:simplications/screens/device_questionnaire_screen.dart'; +import 'package:simplications/screens/device_selection_screen.dart'; + +Widget _buildTestApp(Widget child) { + return MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: child, + ); +} + +void _configureLargeViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1600, 2400); + tester.view.devicePixelRatio = 1.0; +} + +Room _roomById(String id) => + CatalogData.allRooms.firstWhere((room) => room.id == id); + +void main() { + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('renders room header and device prompts', (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + expect(find.text(localizations.roomCheck), findsOneWidget); + expect(find.text(localizations.deviceQuestion), findsOneWidget); + expect(find.text(localizations.noDevice), findsOneWidget); + expect(find.text(localizations.addDevice), findsOneWidget); + }); + + testWidgets( + 'selecting a catalog device adds instance and opens questionnaire', + (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + final tvTemplate = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + final tvLabel = CatalogData.deviceName(localizations, tvTemplate); + + await tester.tap(find.text(tvLabel).first); + await tester.pumpAndSettle(); + + expect(state.isDeviceAdded(room.id, tvTemplate.id), isTrue); + expect(find.byType(DeviceQuestionnaireScreen), findsOneWidget); + }, + ); + + testWidgets('no-device flow marks room without devices after confirm', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('kitchen'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap(find.text(localizations.noDevice).first); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, localizations.confirm)); + await tester.pumpAndSettle(); + + expect(state.noDeviceRoomIds.contains(room.id), isTrue); + expect(state.completedRoomIds.contains(room.id), isTrue); + expect(state.devicesForRoom(room.id), isEmpty); + }); + + testWidgets( + 'custom room shows full catalog and supports custom device add/remove', + (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final customRoom = state.addCustomRoom('Studio', Icons.mic); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: customRoom)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + final irrigationTemplate = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_irrigation', + ); + final irrigationLabel = CatalogData.deviceName( + localizations, + irrigationTemplate, + ); + + expect(find.text(irrigationLabel), findsOneWidget); + + await tester.tap(find.text(localizations.addDevice).first); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'Custom Hub'); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, localizations.add)); + await tester.pumpAndSettle(); + + expect(state.customDevicesForRoom(customRoom.id), hasLength(1)); + + await tester.tap(find.byIcon(Icons.close).first); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, localizations.delete)); + await tester.pumpAndSettle(); + + expect(state.customDevicesForRoom(customRoom.id), isEmpty); + }, + ); + + testWidgets('results button is enabled when room has a finished device', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + FilledButton resultsButton() => tester.widget( + find.widgetWithText(FilledButton, localizations.results), + ); + + expect(resultsButton().onPressed, isNull); + + final template = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + state.addDevice(template, room.id, room.name); + final instance = state.devices.single; + for (final question in instance.questions) { + instance.setAnswer(question.id, QuestionAnswer.yes); + } + state.notifyUpdate(); + + await tester.pumpAndSettle(); + + expect(resultsButton().onPressed, isNotNull); + }); + + testWidgets('next-room action marks room completed', (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('office'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap( + find.widgetWithText(OutlinedButton, localizations.nextRoom), + ); + await tester.pumpAndSettle(); + + expect(state.completedRoomIds.contains(room.id), isTrue); + }); +} diff --git a/test/room_selection_screen_test.dart b/test/room_selection_screen_test.dart new file mode 100644 index 0000000..a6f4051 --- /dev/null +++ b/test/room_selection_screen_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:simplications/data/catalog_data.dart'; +import 'package:simplications/l10n/app_localizations.dart'; +import 'package:simplications/models/device.dart'; +import 'package:simplications/models/survey_state.dart'; +import 'package:simplications/screens/device_selection_screen.dart'; +import 'package:simplications/screens/room_selection_screen.dart'; +import 'package:simplications/screens/summary_screen.dart'; + +Widget _buildTestApp(Widget child) { + return MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: child, + ); +} + +void _configureLargeViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1600, 2400); + tester.view.devicePixelRatio = 1.0; +} + +void main() { + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + }); + + tearDown(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + testWidgets('renders catalog rooms and add-room action', (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(RoomSelectionScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + expect(find.text(localizations.roomSelectionTitle), findsOneWidget); + expect(find.text(localizations.addRoom), findsOneWidget); + expect(find.byIcon(Icons.weekend), findsAtLeastNWidgets(1)); + }); + + testWidgets('tapping a room opens device selection and marks room visited', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(RoomSelectionScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + final livingRoom = CatalogData.allRooms.firstWhere( + (room) => room.id == 'living', + ); + final livingRoomLabel = CatalogData.roomName(localizations, livingRoom); + + await tester.tap(find.text(livingRoomLabel).first); + await tester.pumpAndSettle(); + + expect(state.visitedRoomIds.contains('living'), isTrue); + expect(find.byType(DeviceSelectionScreen), findsOneWidget); + }); + + testWidgets('add custom room dialog adds room and navigates into it', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(RoomSelectionScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap(find.text(localizations.addRoom)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'Music Room'); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, localizations.add)); + await tester.pumpAndSettle(); + + expect(state.customRooms, hasLength(1)); + expect(state.customRooms.first.name, 'Music Room'); + expect(find.byType(DeviceSelectionScreen), findsOneWidget); + }); + + testWidgets('deleting a custom room removes it from state', (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + state.addCustomRoom('Temporary Room', Icons.sports_esports); + + await tester.pumpWidget(_buildTestApp(RoomSelectionScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + expect(state.customRooms, hasLength(1)); + + await tester.tap(find.byIcon(Icons.close).first); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, localizations.delete)); + await tester.pumpAndSettle(); + + expect(state.customRooms, isEmpty); + }); + + testWidgets('results button enables only when results are available', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(RoomSelectionScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + FilledButton resultsButton() => tester.widget( + find.widgetWithText(FilledButton, localizations.results), + ); + + expect(resultsButton().onPressed, isNull); + + final template = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + state.addDevice(template, 'living', 'room_living'); + final instance = state.devices.single; + for (final question in instance.questions) { + instance.setAnswer(question.id, QuestionAnswer.yes); + } + state.markRoomCompleted('living'); + state.notifyUpdate(); + + await tester.pumpAndSettle(); + + expect(resultsButton().onPressed, isNotNull); + + await tester.tap(find.widgetWithText(FilledButton, localizations.results)); + await tester.pumpAndSettle(); + + expect(find.byType(SummaryScreen), findsOneWidget); + }); +} From af484dc606a7bd72afabe5c2927855c550c67d85 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 9 Jun 2026 16:02:18 +0200 Subject: [PATCH 07/16] l10n: change words in en and de --- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 00cba65..39d07d7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -4,7 +4,7 @@ "welcomeTitle": "Der Simplications\nSmart Home\nPrivatsphäre-Check", "welcomeDescription": "Erfassen Sie Raum für Raum Ihre smarten Geräte und erhalten Sie eine Privatsphärebewertung mit konkreten Empfehlungen.", "welcomePrivacyTitle": "Datenschutz ohne Cloud", - "welcomePrivacyDescription": "Wir sammeln keine persönlichen Daten. Ihre Antworten bleiben auf diesem Gerät und gehen nicht in die Cloud.", + "welcomePrivacyDescription": "Wir sammeln keine persönlichen Daten. Ihre Antworten bleiben auf diesem Gerät und gehen nicht in eine Cloud.", "stepSelectRooms": "Raum auswählen", "stepCaptureDevices": "Geräte erfassen", "stepAnswerQuestions": "Fragen beantworten", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ca773ef..d9c23ae 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4,7 +4,7 @@ "welcomeTitle": "Simplications\nSmart Home\nPrivacy Check", "welcomeDescription": "Capture your smart devices room by room and receive a privacy assessment with concrete recommendations.", "welcomePrivacyTitle": "Private by design", - "welcomePrivacyDescription": "We do not collect personal data. Your answers stay on this device and are not sent to the cloud.", + "welcomePrivacyDescription": "We do not collect personal data. Your answers stay on this device and are not sent to a cloud.", "stepSelectRooms": "Select room", "stepCaptureDevices": "Capture devices", "stepAnswerQuestions": "Answer questions", From 3e674e649c922c1ab8e177685805ab803793f90d Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 10 Jun 2026 10:51:20 +0200 Subject: [PATCH 08/16] update wording in de/en --- lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_de.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 73d02ec..fbf2cd1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -135,7 +135,7 @@ abstract class AppLocalizations { /// No description provided for @welcomePrivacyDescription. /// /// In en, this message translates to: - /// **'We do not collect personal data. Your answers stay on this device and are not sent to the cloud.'** + /// **'We do not collect personal data. Your answers stay on this device and are not sent to a cloud.'** String get welcomePrivacyDescription; /// No description provided for @stepSelectRooms. diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 0665474..8a2678f 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -24,7 +24,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get welcomePrivacyDescription => - 'Wir sammeln keine persönlichen Daten. Ihre Antworten bleiben auf diesem Gerät und gehen nicht in die Cloud.'; + 'Wir sammeln keine persönlichen Daten. Ihre Antworten bleiben auf diesem Gerät und gehen nicht in eine Cloud.'; @override String get stepSelectRooms => 'Raum auswählen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ec69d0e..b426819 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -23,7 +23,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get welcomePrivacyDescription => - 'We do not collect personal data. Your answers stay on this device and are not sent to the cloud.'; + 'We do not collect personal data. Your answers stay on this device and are not sent to a cloud.'; @override String get stepSelectRooms => 'Select room'; From b8090f227caea218fbf7106de48d594a0fb38e6f Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 1 Jul 2026 11:24:58 +0200 Subject: [PATCH 09/16] feat: Enhance device management and results display - Allow multiple instances of the same device to be added to a room. - Introduce DeviceResultScreen to display results for each device instance. - Implement risk assessment features for devices and rooms, including risk levels and recommendations. - Update UI components to reflect device instance counts, completion status, and risk levels. - Add search functionality in DeviceSelectionScreen for easier device management. - Ensure proper navigation flow between device questionnaire and results screens. - Update tests to cover new functionalities and ensure reliability. --- lib/l10n/app_cs.arb | 2 + lib/l10n/app_da.arb | 2 + lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_cs.dart | 6 + lib/l10n/app_localizations_da.dart | 6 + lib/l10n/app_localizations_de.dart | 6 + lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_fr.dart | 6 + lib/l10n/app_localizations_nl.dart | 6 + lib/l10n/app_localizations_pl.dart | 6 + lib/l10n/app_nl.arb | 2 + lib/l10n/app_pl.arb | 2 + lib/models/survey_state.dart | 69 ++- lib/screens/device_questionnaire_screen.dart | 11 +- lib/screens/device_result_screen.dart | 372 +++++++++++++++ lib/screens/device_selection_screen.dart | 459 +++++++++++++++++-- lib/screens/room_selection_screen.dart | 72 ++- pubspec.yaml | 2 +- test/device_selection_screen_test.dart | 135 +++++- test/icon_serialization_test.dart | 12 + 23 files changed, 1131 insertions(+), 69 deletions(-) create mode 100644 lib/screens/device_result_screen.dart diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index d23f9e9..e0d09f1 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -172,9 +172,11 @@ "deviceNameHint": "např. Můj chytrý reproduktor", "deviceQuestion": "Která chytrá zařízení používáte v této místnosti?", "deviceQuestionHint": "Klepněte na zařízení a odpovězte na otázky.", + "searchDevicesHint": "Hledat zařízení", "devicesRated": "hodnocených zařízení", "deviceTitlePrefix": "Zařízení", "done": "Hotovo", + "checkAnotherDevice": "Zkontrolovat další zařízení", "dontKnow": "Nevím", "dontKnowHint": " {count, plural, one {1 odpověď byla označena jako \"Nevím\" - věnujte čas lepšímu poznání nastavení svých zařízení.} other {{count} odpovědi byly označeny jako \"Nevím\" - věnujte čas lepšímu poznání nastavení svých zařízení.}}", "evaluatedDevices": "Vyhodnocená zařízení", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index ba70e87..9eabd25 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -172,9 +172,11 @@ "deviceNameHint": "f.eks. Min smarte højttaler", "deviceQuestion": "Hvilke smarte enheder bruger du i dette rum?", "deviceQuestionHint": "Tryk på en enhed for at besvare spørgsmålene.", + "searchDevicesHint": "Søg enheder", "devicesRated": "vurderede enheder", "deviceTitlePrefix": "Enheder", "done": "Færdig", + "checkAnotherDevice": "Tjek en anden enhed", "dontKnow": "Ved ikke", "dontKnowHint": " {count, plural, one {1 svar er markeret som \"Ved ikke\" - brug tid på at lære dine enhedsindstillinger bedre at kende.} other {{count} svar er markeret som \"Ved ikke\" - brug tid på at lære dine enhedsindstillinger bedre at kende.}}", "evaluatedDevices": "Vurderede enheder", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 39d07d7..71673a7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -24,6 +24,7 @@ "roomCheck": "Raum-Check", "deviceQuestion": "Welche smarten Geräte nutzen Sie in diesem Raum?", "deviceQuestionHint": "Tippen Sie auf ein Gerät, um die Fragen dafür zu beantworten.", + "searchDevicesHint": "Geräte suchen", "noKnownDevices": "Keine bekannten Geräte für diesen Raum. Sie können ein eigenes Gerät hinzufügen.", "addDevice": "Gerät\nhinzufügen", "noDevice": "Kein Gerät", @@ -39,6 +40,7 @@ "questionnaireTitle": "Fragen", "connectedDevice": "Verbundenes Gerät", "done": "Fertig", + "checkAnotherDevice": "Weiteres Gerät prüfen", "yes": "Ja", "no": "Nein", "dontKnow": "Weiß ich nicht", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d9c23ae..82890f2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -24,6 +24,7 @@ "roomCheck": "Room check", "deviceQuestion": "Which smart devices do you use in this room?", "deviceQuestionHint": "Tap a device to answer its questions.", + "searchDevicesHint": "Search devices", "noKnownDevices": "No known devices for this room. You can add your own device.", "addDevice": "Add\ndevice", "noDevice": "No device", @@ -39,6 +40,7 @@ "questionnaireTitle": "Questions", "connectedDevice": "Connected device", "done": "Done", + "checkAnotherDevice": "Check another device", "yes": "Yes", "no": "No", "dontKnow": "I don't know", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c07f226..e4ca11a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -172,9 +172,11 @@ "deviceNameHint": "ex. Mon haut-parleur intelligent", "deviceQuestion": "Quels appareils intelligents utilisez-vous dans cette pièce ?", "deviceQuestionHint": "Touchez un appareil pour répondre aux questions.", + "searchDevicesHint": "Rechercher des appareils", "devicesRated": "appareils évalués", "deviceTitlePrefix": "Appareils", "done": "Terminé", + "checkAnotherDevice": "Vérifier un autre appareil", "dontKnow": "Je ne sais pas", "dontKnowHint": " {count, plural, one {1 réponse a été marquée \"Je ne sais pas\" - prenez le temps de mieux comprendre les paramètres de vos appareils.} other {{count} réponses ont été marquées \"Je ne sais pas\" - prenez le temps de mieux comprendre les paramètres de vos appareils.}}", "evaluatedDevices": "Appareils évalués", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index fbf2cd1..0ef0b2c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -252,6 +252,12 @@ abstract class AppLocalizations { /// **'Tap a device to answer its questions.'** String get deviceQuestionHint; + /// No description provided for @searchDevicesHint. + /// + /// In en, this message translates to: + /// **'Search devices'** + String get searchDevicesHint; + /// No description provided for @noKnownDevices. /// /// In en, this message translates to: @@ -342,6 +348,12 @@ abstract class AppLocalizations { /// **'Done'** String get done; + /// No description provided for @checkAnotherDevice. + /// + /// In en, this message translates to: + /// **'Check another device'** + String get checkAnotherDevice; + /// No description provided for @yes. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_cs.dart b/lib/l10n/app_localizations_cs.dart index 15ba2b9..7b3e24f 100644 --- a/lib/l10n/app_localizations_cs.dart +++ b/lib/l10n/app_localizations_cs.dart @@ -86,6 +86,9 @@ class AppLocalizationsCs extends AppLocalizations { String get deviceQuestionHint => 'Klepněte na zařízení a odpovězte na otázky.'; + @override + String get searchDevicesHint => 'Hledat zařízení'; + @override String get noKnownDevices => 'Pro tuto místnost nejsou známá žádná zařízení. Můžete přidat vlastní zařízení.'; @@ -135,6 +138,9 @@ class AppLocalizationsCs extends AppLocalizations { @override String get done => 'Hotovo'; + @override + String get checkAnotherDevice => 'Zkontrolovat další zařízení'; + @override String get yes => 'Ano'; diff --git a/lib/l10n/app_localizations_da.dart b/lib/l10n/app_localizations_da.dart index 963fa3a..828512e 100644 --- a/lib/l10n/app_localizations_da.dart +++ b/lib/l10n/app_localizations_da.dart @@ -84,6 +84,9 @@ class AppLocalizationsDa extends AppLocalizations { String get deviceQuestionHint => 'Tryk på en enhed for at besvare spørgsmålene.'; + @override + String get searchDevicesHint => 'Søg enheder'; + @override String get noKnownDevices => 'Ingen kendte enheder for dette rum. Du kan tilføje din egen enhed.'; @@ -133,6 +136,9 @@ class AppLocalizationsDa extends AppLocalizations { @override String get done => 'Færdig'; + @override + String get checkAnotherDevice => 'Tjek en anden enhed'; + @override String get yes => 'Ja'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 8a2678f..86e1245 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -86,6 +86,9 @@ class AppLocalizationsDe extends AppLocalizations { String get deviceQuestionHint => 'Tippen Sie auf ein Gerät, um die Fragen dafür zu beantworten.'; + @override + String get searchDevicesHint => 'Geräte suchen'; + @override String get noKnownDevices => 'Keine bekannten Geräte für diesen Raum. Sie können ein eigenes Gerät hinzufügen.'; @@ -135,6 +138,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get done => 'Fertig'; + @override + String get checkAnotherDevice => 'Weiteres Gerät prüfen'; + @override String get yes => 'Ja'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b426819..7be5142 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -83,6 +83,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get deviceQuestionHint => 'Tap a device to answer its questions.'; + @override + String get searchDevicesHint => 'Search devices'; + @override String get noKnownDevices => 'No known devices for this room. You can add your own device.'; @@ -132,6 +135,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get done => 'Done'; + @override + String get checkAnotherDevice => 'Check another device'; + @override String get yes => 'Yes'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 9c89149..3ae53de 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -88,6 +88,9 @@ class AppLocalizationsFr extends AppLocalizations { String get deviceQuestionHint => 'Touchez un appareil pour répondre aux questions.'; + @override + String get searchDevicesHint => 'Rechercher des appareils'; + @override String get noKnownDevices => 'Aucun appareil connu pour cette pièce. Vous pouvez ajouter votre propre appareil.'; @@ -137,6 +140,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get done => 'Terminé'; + @override + String get checkAnotherDevice => 'Vérifier un autre appareil'; + @override String get yes => 'Oui'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 9065ed7..5bc792d 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -85,6 +85,9 @@ class AppLocalizationsNl extends AppLocalizations { String get deviceQuestionHint => 'Tik op een apparaat om de vragen te beantwoorden.'; + @override + String get searchDevicesHint => 'Zoek apparaten'; + @override String get noKnownDevices => 'Geen bekende apparaten voor deze kamer. U kunt een eigen apparaat toevoegen.'; @@ -134,6 +137,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get done => 'Klaar'; + @override + String get checkAnotherDevice => 'Nog een apparaat controleren'; + @override String get yes => 'Ja'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 901540f..16480bd 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -85,6 +85,9 @@ class AppLocalizationsPl extends AppLocalizations { String get deviceQuestionHint => 'Stuknij urządzenie, aby odpowiedzieć na pytania.'; + @override + String get searchDevicesHint => 'Szukaj urządzeń'; + @override String get noKnownDevices => 'Brak znanych urządzeń dla tego pokoju. Możesz dodać własne urządzenie.'; @@ -134,6 +137,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get done => 'Gotowe'; + @override + String get checkAnotherDevice => 'Sprawdź kolejne urządzenie'; + @override String get yes => 'Tak'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 3e6311a..05eefef 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -172,9 +172,11 @@ "deviceNameHint": "bijv. Mijn slimme speaker", "deviceQuestion": "Welke slimme apparaten gebruikt u in deze kamer?", "deviceQuestionHint": "Tik op een apparaat om de vragen te beantwoorden.", + "searchDevicesHint": "Zoek apparaten", "devicesRated": "beoordeelde apparaten", "deviceTitlePrefix": "Apparaten", "done": "Klaar", + "checkAnotherDevice": "Nog een apparaat controleren", "dontKnow": "Weet ik niet", "dontKnowHint": " {count, plural, one {1 antwoord is gemarkeerd als \"Weet ik niet\" - neem de tijd om de instellingen van je apparaten beter te leren kennen.} other {{count} antwoorden zijn gemarkeerd als \"Weet ik niet\" - neem de tijd om de instellingen van je apparaten beter te leren kennen.}}", "evaluatedDevices": "Beoordeelde apparaten", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 0c0a25a..d1f6209 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -172,9 +172,11 @@ "deviceNameHint": "np. Mój inteligentny głośnik", "deviceQuestion": "Jakich inteligentnych urządzeń używasz w tym pokoju?", "deviceQuestionHint": "Stuknij urządzenie, aby odpowiedzieć na pytania.", + "searchDevicesHint": "Szukaj urządzeń", "devicesRated": "ocenionych urządzeń", "deviceTitlePrefix": "Urządzenia", "done": "Gotowe", + "checkAnotherDevice": "Sprawdź kolejne urządzenie", "dontKnow": "Nie wiem", "dontKnowHint": " {count, plural, one {1 odpowiedź oznaczono jako \"Nie wiem\" - poświęć czas na lepsze poznanie ustawień swoich urządzeń.} other {{count} odpowiedzi oznaczono jako \"Nie wiem\" - poświęć czas na lepsze poznanie ustawień swoich urządzeń.}}", "evaluatedDevices": "Ocenione urządzenia", diff --git a/lib/models/survey_state.dart b/lib/models/survey_state.dart index c11468a..1fe0088 100644 --- a/lib/models/survey_state.dart +++ b/lib/models/survey_state.dart @@ -120,25 +120,36 @@ class SurveyState extends ChangeNotifier { bool isRoomIncomplete(String roomId) => visitedRoomIds.contains(roomId) && !isRoomCompleted(roomId); - void addDevice(DeviceTemplate template, String roomId, String roomName) { - if (devices.any( - (d) => d.template.id == template.id && d.roomId == roomId, - )) { - return; - } + DeviceInstance addDevice( + DeviceTemplate template, + String roomId, + String roomName, + ) { visitedRoomIds.add(roomId); noDeviceRoomIds.remove(roomId); completedRoomIds.remove(roomId); - devices.add( - DeviceInstance( - instanceId: '${roomId}_${template.id}', - template: template, - roomId: roomId, - roomName: roomName, - expertModeEnabled: _expertModeEnabled, - ), + final instance = DeviceInstance( + instanceId: _nextInstanceId(roomId, template.id), + template: template, + roomId: roomId, + roomName: roomName, + expertModeEnabled: _expertModeEnabled, ); + devices.add(instance); _changed(); + return instance; + } + + String _nextInstanceId(String roomId, String templateId) { + final base = + '${roomId}_${templateId}_${DateTime.now().microsecondsSinceEpoch}'; + var candidate = base; + var suffix = 1; + while (devices.any((device) => device.instanceId == candidate)) { + candidate = '${base}_$suffix'; + suffix += 1; + } + return candidate; } void removeDevice(String instanceId) { @@ -155,6 +166,26 @@ class SurveyState extends ChangeNotifier { List devicesForRoom(String roomId) => devices.where((d) => d.roomId == roomId).toList(); + List evaluatedDevicesForRoom(String roomId) => + devicesForRoom(roomId).where((device) => device.isFullyAnswered).toList(); + + RiskLevel? worstRiskLevelForRoom(String roomId) { + final evaluated = evaluatedDevicesForRoom(roomId); + if (evaluated.isEmpty) { + return null; + } + if (evaluated.any((device) => device.riskLevel == RiskLevel.high)) { + return RiskLevel.high; + } + if (evaluated.any((device) => device.riskLevel == RiskLevel.medium)) { + return RiskLevel.medium; + } + return RiskLevel.low; + } + + int evaluatedDeviceCountForRoom(String roomId) => + evaluatedDevicesForRoom(roomId).length; + bool get hasAnyData => completedRoomIds.isNotEmpty || visitedRoomIds.isNotEmpty || @@ -373,7 +404,7 @@ class SurveyState extends ChangeNotifier { }; devices.clear(); - final restoredDeviceKeys = {}; + final restoredInstanceIds = {}; for (final dynamic item in (data['devices'] as List? ?? const [])) { if (item is! Map) { @@ -391,13 +422,15 @@ class SurveyState extends ChangeNotifier { continue; } final roomId = entry['roomId'] as String; - final restoredDeviceKey = '$roomId:${template.id}'; - if (!restoredDeviceKeys.add(restoredDeviceKey)) { + final restoredInstanceId = + entry['instanceId'] as String? ?? + _nextInstanceId(roomId, template.id); + if (!restoredInstanceIds.add(restoredInstanceId)) { continue; } final instance = DeviceInstance( - instanceId: entry['instanceId'] as String, + instanceId: restoredInstanceId, template: template, roomId: roomId, roomName: entry['roomName'] as String, diff --git a/lib/screens/device_questionnaire_screen.dart b/lib/screens/device_questionnaire_screen.dart index d5a2b4f..7c5e3ea 100644 --- a/lib/screens/device_questionnaire_screen.dart +++ b/lib/screens/device_questionnaire_screen.dart @@ -5,6 +5,7 @@ import '../l10n/app_localizations_key_resolver.dart'; import '../models/device.dart'; import '../models/room.dart'; import '../models/survey_state.dart'; +import 'device_result_screen.dart'; class DeviceQuestionnaireScreen extends StatefulWidget { final SurveyState state; @@ -49,7 +50,15 @@ class _DeviceQuestionnaireScreenState extends State { void _onNext(DeviceInstance device) { if (!device.isFullyAnswered) return; - Navigator.of(context).pop(); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => DeviceResultScreen( + state: widget.state, + room: widget.room, + instanceId: device.instanceId, + ), + ), + ); } @override diff --git a/lib/screens/device_result_screen.dart b/lib/screens/device_result_screen.dart new file mode 100644 index 0000000..e045f61 --- /dev/null +++ b/lib/screens/device_result_screen.dart @@ -0,0 +1,372 @@ +import 'package:flutter/material.dart'; + +import '../data/catalog_data.dart'; +import '../l10n/app_localizations.dart'; +import '../l10n/app_localizations_key_resolver.dart'; +import '../models/device.dart'; +import '../models/room.dart'; +import '../models/survey_state.dart'; +import 'summary_screen.dart'; + +class DeviceResultScreen extends StatelessWidget { + final SurveyState state; + final Room room; + final String instanceId; + + const DeviceResultScreen({ + super.key, + required this.state, + required this.room, + required this.instanceId, + }); + + DeviceInstance? _findDevice() { + for (final device in state.devices) { + if (device.instanceId == instanceId) { + return device; + } + } + return null; + } + + @override + Widget build(BuildContext context) { + final device = _findDevice(); + if (device == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + Navigator.of(context).maybePop(); + } + }); + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final colors = Theme.of(context).colorScheme; + final text = Theme.of(context).textTheme; + final localizations = AppLocalizations.of(context)!; + final riskColor = _riskColor(device.riskLevel); + final riskLabel = _riskLabel(localizations, device.riskLevel); + final actions = device.suggestedActions; + + return Scaffold( + appBar: AppBar(title: Text(localizations.results), centerTitle: false), + body: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + CatalogData.roomName(localizations, room), + style: text.labelSmall?.copyWith( + color: colors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + Card( + elevation: 0, + color: colors.surfaceContainerHighest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + device.template.icon, + size: 28, + color: colors.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + CatalogData.deviceName( + localizations, + device.template, + ), + style: text.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 14), + Card( + elevation: 0, + color: riskColor.withValues(alpha: 0.12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: riskColor.withValues(alpha: 0.45), + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizations.risk, + style: text.labelLarge?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Text( + '${device.riskScore}/100', + style: text.headlineSmall?.copyWith( + color: riskColor, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: riskColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + riskLabel, + style: text.labelLarge?.copyWith( + color: riskColor, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + _riskSummary(localizations, device.riskLevel), + style: text.bodyMedium, + ), + if (device.inherentRiskHint != null) ...[ + const SizedBox(height: 10), + Text( + localizations.resolveKey( + device.inherentRiskHint!, + fallback: '', + ), + style: text.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 8), + Text( + localizations.recommendations, + style: text.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + ], + ), + ), + ), + if (actions.isEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverToBoxAdapter( + child: Card( + elevation: 0, + color: colors.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Text( + _noActionText(localizations, device.riskLevel), + style: text.bodyMedium, + ), + ), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final action = actions[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Card( + elevation: 0, + color: colors.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizations.resolveKey( + action.title, + fallback: action.title, + ), + style: text.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 6), + Text( + localizations.resolveKey( + action.description, + fallback: action.description, + ), + style: text.bodyMedium, + ), + ], + ), + ), + ), + ); + }, childCount: actions.length), + ), + ), + ], + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: const Icon(Icons.devices_outlined), + label: Text( + localizations.checkAnotherDevice, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: () { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => SummaryScreen(state: state), + ), + ); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: const Icon(Icons.summarize_outlined), + label: Text( + localizations.results, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + String _riskLabel(AppLocalizations localizations, RiskLevel level) { + switch (level) { + case RiskLevel.high: + return localizations.highRisk; + case RiskLevel.medium: + return localizations.mediumRisk; + case RiskLevel.low: + return localizations.lowRisk; + } + } + + String _riskSummary(AppLocalizations localizations, RiskLevel level) { + switch (level) { + case RiskLevel.high: + return localizations.overallHigh; + case RiskLevel.medium: + return localizations.overallMedium; + case RiskLevel.low: + return localizations.overallLow; + } + } + + String _noActionText(AppLocalizations localizations, RiskLevel level) { + switch (level) { + case RiskLevel.high: + return localizations.noActionHigh; + case RiskLevel.medium: + return localizations.noActionMedium; + case RiskLevel.low: + return localizations.noActionLow; + } + } + + Color _riskColor(RiskLevel level) { + switch (level) { + case RiskLevel.high: + return const Color(0xFFC62828); + case RiskLevel.medium: + return const Color(0xFFF9A825); + case RiskLevel.low: + return const Color(0xFF2E7D32); + } + } +} diff --git a/lib/screens/device_selection_screen.dart b/lib/screens/device_selection_screen.dart index 4da39da..d84f81e 100644 --- a/lib/screens/device_selection_screen.dart +++ b/lib/screens/device_selection_screen.dart @@ -6,9 +6,10 @@ import '../models/room.dart'; import '../models/survey_state.dart'; import '../widgets/custom_add_dialogs.dart'; import 'device_questionnaire_screen.dart'; +import 'device_result_screen.dart'; import 'summary_screen.dart'; -class DeviceSelectionScreen extends StatelessWidget { +class DeviceSelectionScreen extends StatefulWidget { final SurveyState state; final Room room; @@ -18,6 +19,34 @@ class DeviceSelectionScreen extends StatelessWidget { required this.room, }); + @override + State createState() => _DeviceSelectionScreenState(); +} + +class _DeviceSelectionScreenState extends State { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List _filterDevices( + AppLocalizations localizations, + List devices, + ) { + final query = _searchQuery.trim().toLowerCase(); + if (query.isEmpty) { + return devices; + } + return devices.where((device) { + final name = CatalogData.deviceName(localizations, device).toLowerCase(); + return name.contains(query); + }).toList(); + } + Future _showAddDeviceDialog(BuildContext context) async { final result = await showDialog>( context: context, @@ -25,8 +54,8 @@ class DeviceSelectionScreen extends StatelessWidget { ); if (result != null && context.mounted) { - state.addCustomDevice( - room.id, + widget.state.addCustomDevice( + widget.room.id, result['name'] as String, result['icon'] as IconData, result['riskScore'] as int, @@ -38,7 +67,9 @@ class DeviceSelectionScreen extends StatelessWidget { Future _markNoDevice(BuildContext context) async { final localizations = AppLocalizations.of(context)!; - final hasExistingDevices = state.devicesForRoom(room.id).isNotEmpty; + final hasExistingDevices = widget.state + .devicesForRoom(widget.room.id) + .isNotEmpty; final shouldMarkNoDevice = await showDialog( context: context, builder: (context) => AlertDialog( @@ -65,19 +96,19 @@ class DeviceSelectionScreen extends StatelessWidget { return; } - state.markRoomWithoutDevices(room.id); + widget.state.markRoomWithoutDevices(widget.room.id); Navigator.of(context).pop(); } void _onNext(BuildContext context) { - state.markRoomCompleted(room.id); + widget.state.markRoomCompleted(widget.room.id); Navigator.of(context).pop(); } void _onFinish(BuildContext context) { - state.markRoomCompleted(room.id); + widget.state.markRoomCompleted(widget.room.id); Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => SummaryScreen(state: state)), + MaterialPageRoute(builder: (_) => SummaryScreen(state: widget.state)), ); } @@ -95,7 +126,34 @@ class DeviceSelectionScreen extends StatelessWidget { ), TextButton( onPressed: () { - state.removeCustomDevice(deviceId); + widget.state.removeCustomDevice(deviceId); + Navigator.pop(context); + }, + child: Text( + localizations.delete, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } + + void _removeDeviceInstance(BuildContext context, String instanceId) { + final localizations = AppLocalizations.of(context)!; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(localizations.deleteDeviceTitle), + content: Text(localizations.deleteDeviceBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(localizations.cancel), + ), + TextButton( + onPressed: () { + widget.state.removeDevice(instanceId); Navigator.pop(context); }, child: Text( @@ -113,25 +171,43 @@ class DeviceSelectionScreen extends StatelessWidget { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; final localizations = AppLocalizations.of(context)!; - final isCustomRoom = state.customRooms.any((r) => r.id == room.id); + final isCustomRoom = widget.state.customRooms.any( + (r) => r.id == widget.room.id, + ); final catalogDevices = isCustomRoom ? CatalogData.sortedDeviceTemplates(CatalogData.allDeviceTemplates) - : CatalogData.devicesForRoom(room.id); + : CatalogData.devicesForRoom(widget.room.id); return Scaffold( appBar: AppBar( title: Text( - '${localizations.deviceTitlePrefix}: ${CatalogData.roomName(localizations, room)}', + '${localizations.deviceTitlePrefix}: ${CatalogData.roomName(localizations, widget.room)}', style: const TextStyle(fontWeight: FontWeight.w600), ), centerTitle: false, ), body: ListenableBuilder( - listenable: state, + listenable: widget.state, builder: (context, _) { - final customDevices = state.customDevicesForRoom(room.id); - final allDevices = [...catalogDevices, ...customDevices]; - final isNoDeviceSelected = state.noDeviceRoomIds.contains(room.id); + final roomInstances = widget.state.devicesForRoom(widget.room.id); + final customDevices = widget.state.customDevicesForRoom( + widget.room.id, + ); + final filteredCatalogDevices = _filterDevices( + localizations, + catalogDevices, + ); + final filteredCustomDevices = _filterDevices( + localizations, + customDevices, + ); + final allDevices = [ + ...filteredCatalogDevices, + ...filteredCustomDevices, + ]; + final isNoDeviceSelected = widget.state.noDeviceRoomIds.contains( + widget.room.id, + ); return CustomScrollView( slivers: [ @@ -150,7 +226,7 @@ class DeviceSelectionScreen extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: Icon( - room.icon, + widget.room.icon, color: colors.primary, size: 24, ), @@ -167,7 +243,10 @@ class DeviceSelectionScreen extends StatelessWidget { ), ), Text( - CatalogData.roomName(localizations, room), + CatalogData.roomName( + localizations, + widget.room, + ), style: text.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), @@ -190,6 +269,37 @@ class DeviceSelectionScreen extends StatelessWidget { color: colors.onSurfaceVariant, ), ), + const SizedBox(height: 12), + TextField( + controller: _searchController, + onChanged: (value) { + if (value == _searchQuery) { + return; + } + setState(() { + _searchQuery = value; + }); + }, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + hintText: localizations.searchDevicesHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + isDense: true, + suffixIcon: _searchQuery.isEmpty + ? null + : IconButton( + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + icon: const Icon(Icons.clear), + ), + ), + ), const SizedBox(height: 16), ], ), @@ -232,27 +342,54 @@ class DeviceSelectionScreen extends StatelessWidget { } final device = allDevices[index]; - final isAdded = state.isDeviceAdded(room.id, device.id); - final instances = state - .devicesForRoom(room.id) + final instances = widget.state + .devicesForRoom(widget.room.id) .where((i) => i.template.id == device.id); - final isCompleted = - instances.isNotEmpty && instances.first.isFullyAnswered; - final isCustom = customDevices.contains(device); + final instanceList = instances.toList(); + final completedCount = instanceList + .where((instance) => instance.isFullyAnswered) + .length; + final evaluatedInstances = instanceList + .where((instance) => instance.isFullyAnswered) + .toList(); + RiskLevel? riskLevel; + if (evaluatedInstances.isNotEmpty) { + if (evaluatedInstances.any( + (instance) => instance.riskLevel == RiskLevel.high, + )) { + riskLevel = RiskLevel.high; + } else if (evaluatedInstances.any( + (instance) => instance.riskLevel == RiskLevel.medium, + )) { + riskLevel = RiskLevel.medium; + } else { + riskLevel = RiskLevel.low; + } + } + final hasIncomplete = instanceList.any( + (instance) => !instance.isFullyAnswered, + ); + final isCustom = filteredCustomDevices.contains(device); return _DeviceCard( device: device, - isAdded: isAdded, - isCompleted: isCompleted, + instanceCount: instanceList.length, + completedCount: completedCount, + hasIncomplete: hasIncomplete, + riskLevel: riskLevel, isCustom: isCustom, onTap: () { - state.addDevice(device, room.id, room.name); + final instance = widget.state.addDevice( + device, + widget.room.id, + widget.room.name, + ); Navigator.of(context).push( MaterialPageRoute( builder: (_) => DeviceQuestionnaireScreen( - state: state, - room: room, - instanceId: '${room.id}_${device.id}', + state: widget.state, + room: widget.room, + instanceId: instance.instanceId, ), ), ); @@ -264,6 +401,68 @@ class DeviceSelectionScreen extends StatelessWidget { }, childCount: allDevices.length + 2), ), ), + if (roomInstances.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 10), + sliver: SliverToBoxAdapter( + child: Text( + localizations.overview, + style: text.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + if (roomInstances.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final instance = roomInstances[index]; + final countForTemplate = roomInstances + .take(index + 1) + .where( + (device) => + device.template.id == instance.template.id, + ) + .length; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _DeviceInstanceCard( + instance: instance, + sequence: countForTemplate, + onOpen: () { + if (instance.isFullyAnswered) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DeviceResultScreen( + state: widget.state, + room: widget.room, + instanceId: instance.instanceId, + ), + ), + ); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DeviceQuestionnaireScreen( + state: widget.state, + room: widget.room, + instanceId: instance.instanceId, + ), + ), + ); + }, + onDelete: () => _removeDeviceInstance( + context, + instance.instanceId, + ), + ), + ); + }, childCount: roomInstances.length), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 100)), ], ); @@ -272,8 +471,119 @@ class DeviceSelectionScreen extends StatelessWidget { bottomNavigationBar: _BottomBar( onNext: () => _onNext(context), onFinish: () => _onFinish(context), - state: state, - currentRoomId: room.id, + state: widget.state, + currentRoomId: widget.room.id, + ), + ); + } +} + +class _DeviceInstanceCard extends StatelessWidget { + final DeviceInstance instance; + final int sequence; + final VoidCallback onOpen; + final VoidCallback onDelete; + + const _DeviceInstanceCard({ + required this.instance, + required this.sequence, + required this.onOpen, + required this.onDelete, + }); + + Color _riskColor() { + if (!instance.isFullyAnswered) { + return const Color(0xFFF9A825); + } + switch (instance.riskLevel) { + case RiskLevel.high: + return const Color(0xFFC62828); + case RiskLevel.medium: + return const Color(0xFFF9A825); + case RiskLevel.low: + return const Color(0xFF2E7D32); + } + } + + String _riskLabel(AppLocalizations localizations) { + if (!instance.isFullyAnswered) { + return localizations.notCompleted; + } + switch (instance.riskLevel) { + case RiskLevel.high: + return localizations.highRisk; + case RiskLevel.medium: + return localizations.mediumRisk; + case RiskLevel.low: + return localizations.lowRisk; + } + } + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + final localizations = AppLocalizations.of(context)!; + final riskColor = _riskColor(); + + return Card( + elevation: 0, + color: riskColor.withValues(alpha: 0.08), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: riskColor.withValues(alpha: 0.45)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Row( + children: [ + Icon(instance.template.icon, size: 20, color: riskColor), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${CatalogData.deviceName(localizations, instance.template)} #$sequence', + style: text.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + instance.isFullyAnswered + ? '${_riskLabel(localizations)} • ${instance.riskScore}/100' + : _riskLabel(localizations), + style: text.labelSmall?.copyWith( + color: riskColor, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + tooltip: instance.isFullyAnswered + ? localizations.results + : localizations.resumeIncompleteDevice, + onPressed: onOpen, + icon: Icon( + instance.isFullyAnswered + ? Icons.arrow_forward_ios + : Icons.play_circle_fill, + size: 18, + ), + ), + IconButton( + tooltip: localizations.delete, + onPressed: onDelete, + icon: const Icon(Icons.delete_outline, size: 18), + ), + ], + ), ), ); } @@ -281,26 +591,49 @@ class DeviceSelectionScreen extends StatelessWidget { class _DeviceCard extends StatelessWidget { final DeviceTemplate device; - final bool isAdded; - final bool isCompleted; + final int instanceCount; + final int completedCount; + final bool hasIncomplete; + final RiskLevel? riskLevel; final bool isCustom; final VoidCallback onTap; final VoidCallback? onRemove; const _DeviceCard({ required this.device, - required this.isAdded, - required this.isCompleted, + required this.instanceCount, + required this.completedCount, + required this.hasIncomplete, + required this.riskLevel, required this.isCustom, required this.onTap, this.onRemove, }); + Color _riskColor() { + switch (riskLevel) { + case RiskLevel.high: + return const Color(0xFFC62828); + case RiskLevel.medium: + return const Color(0xFFF9A825); + case RiskLevel.low: + return const Color(0xFF2E7D32); + case null: + return Colors.transparent; + } + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final localizations = AppLocalizations.of(context)!; - final contentColor = isCompleted + final hasInstances = instanceCount > 0; + final allCompleted = hasInstances && completedCount == instanceCount; + final riskColor = _riskColor(); + final hasRiskColor = riskLevel != null; + final contentColor = hasRiskColor + ? riskColor + : allCompleted ? colors.onSurface.withValues(alpha: 0.35) : colors.onSurfaceVariant; @@ -309,13 +642,19 @@ class _DeviceCard extends StatelessWidget { child: AnimatedContainer( duration: const Duration(milliseconds: 160), decoration: BoxDecoration( - color: isCompleted + color: hasRiskColor + ? riskColor.withValues(alpha: 0.08) + : allCompleted ? colors.surfaceContainerLow : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), border: Border.all( - color: isCompleted + color: hasRiskColor + ? riskColor + : allCompleted ? Colors.transparent + : hasIncomplete + ? colors.tertiary : (isCustom ? colors.tertiaryContainer : Colors.transparent), width: 2, ), @@ -331,7 +670,7 @@ class _DeviceCard extends StatelessWidget { children: [ Icon(device.icon, size: 20, color: contentColor), const Spacer(), - if (device.hasCamera && !isCompleted) + if (device.hasCamera && !allCompleted) Tooltip( message: localizations.camera, child: Icon( @@ -340,7 +679,7 @@ class _DeviceCard extends StatelessWidget { color: contentColor, ), ), - if (device.hasMicrophone && !isCompleted) + if (device.hasMicrophone && !allCompleted) Tooltip( message: localizations.microphone, child: Icon(Icons.mic, size: 12, color: contentColor), @@ -357,18 +696,52 @@ class _DeviceCard extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, ), + if (hasInstances) ...[ + const SizedBox(height: 4), + Text( + allCompleted + ? '$completedCount/$instanceCount ${localizations.done}' + : '$completedCount/$instanceCount', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: hasRiskColor + ? riskColor + : allCompleted + ? Colors.green.shade700 + : colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ], ), - if (isCompleted) + if (allCompleted && !hasRiskColor) const Positioned( top: 0, right: 0, child: Icon(Icons.check_circle, size: 16, color: Colors.green), ), + if (hasRiskColor) + Positioned( + top: 0, + right: 0, + child: Icon(Icons.shield_outlined, size: 16, color: riskColor), + ), + if (hasIncomplete) + Positioned( + top: 0, + right: hasRiskColor ? 22 : (allCompleted ? 22 : 0), + child: Icon(Icons.timelapse, size: 16, color: colors.tertiary), + ), if (isCustom && onRemove != null) Positioned( top: 0, - right: isCompleted ? 22 : 0, + right: hasIncomplete && hasRiskColor + ? 44 + : (allCompleted || hasIncomplete || hasRiskColor) + ? 22 + : 0, child: GestureDetector( onTap: onRemove, child: Container( diff --git a/lib/screens/room_selection_screen.dart b/lib/screens/room_selection_screen.dart index 79a9f87..591e71b 100644 --- a/lib/screens/room_selection_screen.dart +++ b/lib/screens/room_selection_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../data/catalog_data.dart'; import '../l10n/app_localizations.dart'; +import '../models/device.dart'; import '../models/room.dart'; import '../models/survey_state.dart'; import 'device_selection_screen.dart'; @@ -142,12 +143,19 @@ class RoomSelectionScreen extends StatelessWidget { final isIncomplete = state.isRoomIncomplete( room.id, ); + final roomRisk = state.worstRiskLevelForRoom( + room.id, + ); + final evaluatedCount = state + .evaluatedDeviceCountForRoom(room.id); final isCustom = state.customRooms.contains(room); return _RoomCard( room: room, isCompleted: isCompleted, isIncomplete: isIncomplete, + roomRisk: roomRisk, + evaluatedCount: evaluatedCount, isCustom: isCustom, onTap: () => _openRoom(context, room), onRemove: isCustom @@ -211,6 +219,8 @@ class _RoomCard extends StatelessWidget { final Room room; final bool isCompleted; final bool isIncomplete; + final RiskLevel? roomRisk; + final int evaluatedCount; final bool isCustom; final VoidCallback onTap; final VoidCallback? onRemove; @@ -219,15 +229,48 @@ class _RoomCard extends StatelessWidget { required this.room, required this.isCompleted, required this.isIncomplete, + required this.roomRisk, + required this.evaluatedCount, required this.isCustom, required this.onTap, this.onRemove, }); + Color _riskColor(ColorScheme colors) { + if (roomRisk == null) { + return isIncomplete ? colors.error : colors.outlineVariant; + } + switch (roomRisk!) { + case RiskLevel.high: + return const Color(0xFFC62828); + case RiskLevel.medium: + return const Color(0xFFF9A825); + case RiskLevel.low: + return const Color(0xFF2E7D32); + } + } + + String? _riskLabel(AppLocalizations localizations) { + if (roomRisk == null) { + return null; + } + switch (roomRisk!) { + case RiskLevel.high: + return localizations.highRisk; + case RiskLevel.medium: + return localizations.mediumRisk; + case RiskLevel.low: + return localizations.lowRisk; + } + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final localizations = AppLocalizations.of(context)!; + final riskColor = _riskColor(colors); + final riskLabel = _riskLabel(localizations); + final showRisk = riskLabel != null && evaluatedCount > 0; return GestureDetector( onTap: onTap, @@ -239,7 +282,9 @@ class _RoomCard extends StatelessWidget { : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16), border: Border.all( - color: isCompleted + color: showRisk + ? riskColor + : isCompleted ? Colors.transparent : isIncomplete ? colors.error @@ -259,14 +304,18 @@ class _RoomCard extends StatelessWidget { Icon( room.icon, size: 20, - color: isCompleted + color: showRisk + ? riskColor + : isCompleted ? colors.onSurface.withValues(alpha: 0.35) : isIncomplete ? colors.error : colors.onSurfaceVariant, ), const Spacer(), - if (isCompleted) + if (showRisk) + Icon(Icons.shield_outlined, size: 16, color: riskColor) + else if (isCompleted) const Icon( Icons.check_circle, size: 16, @@ -281,7 +330,9 @@ class _RoomCard extends StatelessWidget { CatalogData.roomName(localizations, room), style: Theme.of(context).textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w500, - color: isCompleted + color: showRisk + ? riskColor + : isCompleted ? colors.onSurface.withValues(alpha: 0.35) : isIncomplete ? colors.error @@ -290,7 +341,18 @@ class _RoomCard extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (isCompleted) ...[ + if (showRisk) ...[ + const SizedBox(height: 2), + Text( + '$riskLabel • $evaluatedCount', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: riskColor, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ] else if (isCompleted) ...[ const SizedBox(height: 2), Text( localizations.alreadyEvaluated, diff --git a/pubspec.yaml b/pubspec.yaml index 429f6db..8723f59 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+33 +version: 1.0.0+37 environment: sdk: ^3.11.5 diff --git a/test/device_selection_screen_test.dart b/test/device_selection_screen_test.dart index e6ca671..21ceb80 100644 --- a/test/device_selection_screen_test.dart +++ b/test/device_selection_screen_test.dart @@ -8,6 +8,7 @@ import 'package:simplications/l10n/app_localizations.dart'; import 'package:simplications/models/device.dart'; import 'package:simplications/models/room.dart'; import 'package:simplications/models/survey_state.dart'; +import 'package:simplications/screens/device_result_screen.dart'; import 'package:simplications/screens/device_questionnaire_screen.dart'; import 'package:simplications/screens/device_selection_screen.dart'; @@ -91,6 +92,130 @@ void main() { }, ); + testWidgets('same device can be added multiple times in one room', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + final tvTemplate = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + final tvLabel = CatalogData.deviceName(localizations, tvTemplate); + + await tester.tap(find.text(tvLabel).first); + await tester.pumpAndSettle(); + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.tap(find.text(tvLabel).first); + await tester.pumpAndSettle(); + + final roomInstances = state.devices + .where((instance) => instance.roomId == room.id) + .toList(); + expect(roomInstances, hasLength(2)); + expect(roomInstances[0].instanceId, isNot(roomInstances[1].instanceId)); + }); + + testWidgets('finishing questionnaire opens immediate device result screen', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + final tvTemplate = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + final tvLabel = CatalogData.deviceName(localizations, tvTemplate); + + await tester.tap(find.text(tvLabel).first); + await tester.pumpAndSettle(); + + final activeInstance = state.devices.last; + final yesButtons = find.widgetWithText(OutlinedButton, localizations.yes); + final yesCount = tester.widgetList(yesButtons).length; + for (var i = 0; i < yesCount; i++) { + await tester.tap(yesButtons.at(i)); + await tester.pump(); + } + for (final question in activeInstance.questions) { + expect(activeInstance.answerFor(question.id), equals(QuestionAnswer.yes)); + } + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, localizations.done)); + await tester.pumpAndSettle(); + + expect(find.byType(DeviceResultScreen), findsOneWidget); + }); + + testWidgets('room overview lists instances and allows resume/result open', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + final template = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + + final completed = state.addDevice(template, room.id, room.name); + for (final question in completed.questions) { + completed.setAnswer(question.id, QuestionAnswer.yes); + } + state.addDevice(template, room.id, room.name); + state.notifyUpdate(); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + expect(find.text(localizations.overview), findsOneWidget); + expect(find.textContaining('#1'), findsOneWidget); + expect(find.textContaining('#2'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.play_circle_fill)); + await tester.pumpAndSettle(); + expect(find.byType(DeviceQuestionnaireScreen), findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.arrow_forward_ios).first); + await tester.pumpAndSettle(); + expect(find.byType(DeviceResultScreen), findsOneWidget); + }); + testWidgets('no-device flow marks room without devices after confirm', ( tester, ) async { @@ -151,7 +276,15 @@ void main() { await tester.tap(find.text(localizations.addDevice).first); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField).first, 'Custom Hub'); + await tester.enterText( + find + .descendant( + of: find.byType(Dialog), + matching: find.byType(TextField), + ) + .first, + 'Custom Hub', + ); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(ElevatedButton, localizations.add)); diff --git a/test/icon_serialization_test.dart b/test/icon_serialization_test.dart index 96672ff..7e51025 100644 --- a/test/icon_serialization_test.dart +++ b/test/icon_serialization_test.dart @@ -202,6 +202,18 @@ void main() { expect(state.hasResultsAvailable, isTrue); }); + test('same template can be added multiple times to the same room', () { + final state = SurveyState(); + final room = CatalogData.allRooms.first; + final template = CatalogData.devicesForRoom(room.id).first; + + final first = state.addDevice(template, room.id, room.name); + final second = state.addDevice(template, room.id, room.name); + + expect(state.devicesForRoom(room.id), hasLength(2)); + expect(first.instanceId, isNot(second.instanceId)); + }); + test('completed room progress is restored after restart', () async { final state = SurveyState(); final room = CatalogData.allRooms.first; From 393004be3799021207985fc42993adda7440301e Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 3 Jul 2026 10:52:14 +0200 Subject: [PATCH 10/16] feat: Update release workflow and add Inno Setup installer configuration --- .github/installer/simplications.iss | 37 +++++ .github/workflows/release.yml | 68 +++----- lib/screens/device_selection_screen.dart | 190 +++++++++++++---------- lib/screens/summary_screen.dart | 8 +- pubspec.yaml | 2 +- 5 files changed, 168 insertions(+), 137 deletions(-) create mode 100644 .github/installer/simplications.iss diff --git a/.github/installer/simplications.iss b/.github/installer/simplications.iss new file mode 100644 index 0000000..ca0a79e --- /dev/null +++ b/.github/installer/simplications.iss @@ -0,0 +1,37 @@ +#ifndef AppVersion + #define AppVersion "0.0.0" +#endif + +[Setup] +AppId={{A6A0A6E8-3FCF-4D4D-9E2B-8E9C08EC8D56} +AppName=Simplications +AppVersion={#AppVersion} +AppPublisher=Simplications +DefaultDirName={autopf}\Simplications +DefaultGroupName=Simplications +UninstallDisplayIcon={app}\simplications.exe +OutputDir=build +OutputBaseFilename=simplications-{#AppVersion}-windows-setup +Compression=lzma +SolidCompression=yes +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 +PrivilegesRequired=admin +DisableProgramGroupPage=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional icons:" + +[Files] +Source: "build\\windows\\x64\\runner\\Release\\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion + +[Icons] +Name: "{autoprograms}\Simplications"; Filename: "{app}\simplications.exe" +Name: "{autodesktop}\Simplications"; Filename: "{app}\simplications.exe"; Tasks: desktopicon + +[Run] +Filename: "{app}\simplications.exe"; Description: "Launch Simplications"; Flags: nowait postinstall skipifsilent diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa1f81a..4e6bc30 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release APK and Windows MSIX +name: Release APK and Windows Installer on: release: @@ -58,7 +58,7 @@ jobs: with: files: ${{ steps.apk.outputs.apk_path }} - build-windows-msix: + build-windows-installer: runs-on: windows-latest steps: @@ -80,57 +80,33 @@ jobs: - name: Build release Windows app run: flutter build windows --release - - name: Import MSIX signing certificate - id: import-msix-certificate - shell: pwsh - env: - MSIX_CERT_PFX_BASE64: ${{ secrets.MSIX_CERT_PFX_BASE64 }} - run: | - if ([string]::IsNullOrWhiteSpace($env:MSIX_CERT_PFX_BASE64)) { - throw 'Missing required secret: MSIX_CERT_PFX_BASE64' - } - $certPath = Join-Path $env:RUNNER_TEMP 'msix-signing.pfx' - $cerPath = Join-Path $env:RUNNER_TEMP 'msix-signing.cer' - [System.IO.File]::WriteAllBytes($certPath, [System.Convert]::FromBase64String($env:MSIX_CERT_PFX_BASE64)) - $certPassword = ConvertTo-SecureString -String "${{ secrets.MSIX_CERT_PASSWORD }}" -AsPlainText -Force - $importedCert = Import-PfxCertificate -FilePath $certPath -Password $certPassword -CertStoreLocation 'Cert:\CurrentUser\My' - Export-Certificate -Cert $importedCert -FilePath $cerPath | Out-Null - "cert_path=$certPath" >> $env:GITHUB_OUTPUT - "cer_path=$cerPath" >> $env:GITHUB_OUTPUT - - - name: Create MSIX installer - shell: pwsh - env: - MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }} - run: | - if ([string]::IsNullOrWhiteSpace($env:MSIX_CERT_PASSWORD)) { - throw 'Missing required secret: MSIX_CERT_PASSWORD' - } - dart run msix:create --build-windows false --install-certificate false --certificate-path "${{ steps.import-msix-certificate.outputs.cert_path }}" --certificate-password "$env:MSIX_CERT_PASSWORD" + - name: Install Inno Setup + run: choco install innosetup --yes --no-progress - - name: Prepare versioned Windows MSIX filename - id: msix + - name: Create Inno Setup installer + id: inno shell: pwsh env: VERSION: ${{ github.event.release.tag_name || github.ref_name }} run: | $version = $env:VERSION.TrimStart('v') - $msixSrc = Get-ChildItem -Path 'build' -Filter '*.msix' -Recurse | Select-Object -First 1 - if (-not $msixSrc) { - throw 'MSIX package not found under build/' + if (-not (Test-Path 'build/windows/x64/runner/Release/simplications.exe')) { + throw 'Windows release executable not found under build/windows/x64/runner/Release/' + } + iscc "/DAppVersion=$version" ".github/installer/simplications.iss" + $installerTarget = "build/simplications-$version-windows-setup.exe" + if (-not (Test-Path $installerTarget)) { + $fallbackInstaller = Get-ChildItem -Path 'build' -Filter '*.exe' -Recurse | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if (-not $fallbackInstaller) { + throw 'Inno Setup installer was not generated under build/' + } + Copy-Item -Path $fallbackInstaller.FullName -Destination $installerTarget -Force } - $msixTarget = "build/simplications-$version-windows-release.msix" - Copy-Item -Path $msixSrc.FullName -Destination $msixTarget -Force - "msix_path=$msixTarget" >> $env:GITHUB_OUTPUT + "installer_path=$installerTarget" >> $env:GITHUB_OUTPUT - - name: Attach MSIX to release + - name: Attach Windows installer to release uses: softprops/action-gh-release@v2 with: - files: | - ${{ steps.msix.outputs.msix_path }} - ${{ steps.import-msix-certificate.outputs.cer_path }} - append_body: true - body: | - ## Internal Testing: Trust Install for Windows MSIX - If SmartScreen or certificate trust blocks install, follow the tester guide: - https://github.com/${{ github.repository }}/blob/main/docs/msix-trust-install.md + files: ${{ steps.inno.outputs.installer_path }} diff --git a/lib/screens/device_selection_screen.dart b/lib/screens/device_selection_screen.dart index d84f81e..e34c121 100644 --- a/lib/screens/device_selection_screen.dart +++ b/lib/screens/device_selection_screen.dart @@ -660,105 +660,123 @@ class _DeviceCard extends StatelessWidget { ), ), padding: const EdgeInsets.all(8), - child: Stack( - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, + child: LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(1); + final compact = constraints.maxHeight < 72 || textScale > 1.15; + final showProgress = hasInstances && !compact; + + return Stack( children: [ - Row( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(device.icon, size: 20, color: contentColor), - const Spacer(), - if (device.hasCamera && !allCompleted) - Tooltip( - message: localizations.camera, - child: Icon( - Icons.videocam, - size: 12, + Row( + children: [ + Icon(device.icon, size: 20, color: contentColor), + const Spacer(), + if (device.hasCamera && !allCompleted) + Tooltip( + message: localizations.camera, + child: Icon( + Icons.videocam, + size: 12, + color: contentColor, + ), + ), + if (device.hasMicrophone && !allCompleted) + Tooltip( + message: localizations.microphone, + child: Icon( + Icons.mic, + size: 12, + color: contentColor, + ), + ), + ], + ), + SizedBox(height: compact ? 2 : 4), + Flexible( + child: Text( + CatalogData.deviceName(localizations, device), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, color: contentColor, ), + maxLines: compact ? 1 : 2, + overflow: TextOverflow.ellipsis, ), - if (device.hasMicrophone && !allCompleted) - Tooltip( - message: localizations.microphone, - child: Icon(Icons.mic, size: 12, color: contentColor), + ), + if (showProgress) ...[ + const SizedBox(height: 4), + Text( + allCompleted + ? '$completedCount/$instanceCount ${localizations.done}' + : '$completedCount/$instanceCount', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: hasRiskColor + ? riskColor + : allCompleted + ? Colors.green.shade700 + : colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + ], ], ), - const SizedBox(height: 4), - Text( - CatalogData.deviceName(localizations, device), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - color: contentColor, + if (allCompleted && !hasRiskColor) + const Positioned( + top: 0, + right: 0, + child: Icon(Icons.check_circle, size: 16, color: Colors.green), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (hasInstances) ...[ - const SizedBox(height: 4), - Text( - allCompleted - ? '$completedCount/$instanceCount ${localizations.done}' - : '$completedCount/$instanceCount', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: hasRiskColor - ? riskColor - : allCompleted - ? Colors.green.shade700 - : colors.onSurfaceVariant, - fontWeight: FontWeight.w600, + if (hasRiskColor) + Positioned( + top: 0, + right: 0, + child: Icon( + Icons.shield_outlined, + size: 16, + color: riskColor, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ], - ], - ), - if (allCompleted && !hasRiskColor) - const Positioned( - top: 0, - right: 0, - child: Icon(Icons.check_circle, size: 16, color: Colors.green), - ), - if (hasRiskColor) - Positioned( - top: 0, - right: 0, - child: Icon(Icons.shield_outlined, size: 16, color: riskColor), - ), - if (hasIncomplete) - Positioned( - top: 0, - right: hasRiskColor ? 22 : (allCompleted ? 22 : 0), - child: Icon(Icons.timelapse, size: 16, color: colors.tertiary), - ), - if (isCustom && onRemove != null) - Positioned( - top: 0, - right: hasIncomplete && hasRiskColor - ? 44 - : (allCompleted || hasIncomplete || hasRiskColor) - ? 22 - : 0, - child: GestureDetector( - onTap: onRemove, - child: Container( - decoration: BoxDecoration( - color: Colors.red.shade400, - shape: BoxShape.circle, - ), - padding: const EdgeInsets.all(4), - child: const Icon( - Icons.close, - size: 14, - color: Colors.white, + if (hasIncomplete) + Positioned( + top: 0, + right: hasRiskColor ? 22 : (allCompleted ? 22 : 0), + child: Icon(Icons.timelapse, size: 16, color: colors.tertiary), + ), + if (isCustom && onRemove != null) + Positioned( + top: 0, + right: hasIncomplete && hasRiskColor + ? 44 + : (allCompleted || hasIncomplete || hasRiskColor) + ? 22 + : 0, + child: GestureDetector( + onTap: onRemove, + child: Container( + decoration: BoxDecoration( + color: Colors.red.shade400, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(4), + child: const Icon( + Icons.close, + size: 14, + color: Colors.white, + ), + ), ), ), - ), - ), - ], + ], + ); + }, ), ), ); diff --git a/lib/screens/summary_screen.dart b/lib/screens/summary_screen.dart index cde27f9..0ca947d 100644 --- a/lib/screens/summary_screen.dart +++ b/lib/screens/summary_screen.dart @@ -435,7 +435,7 @@ Color _riskColor(RiskLevel level) { case RiskLevel.high: return const Color(0xFFC62828); case RiskLevel.medium: - return const Color(0xFFE65100); + return const Color(0xFFF9A825); case RiskLevel.low: return const Color(0xFF2E7D32); } @@ -742,7 +742,7 @@ Color _riskBg(RiskLevel level) { case RiskLevel.high: return const Color(0xFFFFEBEE); case RiskLevel.medium: - return const Color(0xFFFFF3E0); + return const Color(0xFFFFF8E1); case RiskLevel.low: return const Color(0xFFE8F5E9); } @@ -1390,7 +1390,7 @@ class _FactorRow extends StatelessWidget { chipColor = const Color(0xFF5C6BC0); icon = Icons.device_hub_outlined; } else if (factor.isDontKnow) { - chipColor = const Color(0xFFE65100); + chipColor = const Color(0xFFF9A825); icon = Icons.help_outline; } else { chipColor = const Color(0xFFC62828); @@ -1450,7 +1450,7 @@ class _ActionTile extends StatelessWidget { final priorityColor = action.priority == ActionPriority.high ? const Color(0xFFC62828) : action.priority == ActionPriority.medium - ? const Color(0xFFE65100) + ? const Color(0xFFF9A825) : colors.primary; return Container( diff --git a/pubspec.yaml b/pubspec.yaml index 8723f59..a3d205f 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+37 +version: 1.0.0+39 environment: sdk: ^3.11.5 From 1735b58f4c7e22f06d434b205a8e6715a550c84e Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 3 Jul 2026 10:52:16 +0200 Subject: [PATCH 11/16] f --- lib/screens/device_selection_screen.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/screens/device_selection_screen.dart b/lib/screens/device_selection_screen.dart index e34c121..91b558b 100644 --- a/lib/screens/device_selection_screen.dart +++ b/lib/screens/device_selection_screen.dart @@ -732,7 +732,11 @@ class _DeviceCard extends StatelessWidget { const Positioned( top: 0, right: 0, - child: Icon(Icons.check_circle, size: 16, color: Colors.green), + child: Icon( + Icons.check_circle, + size: 16, + color: Colors.green, + ), ), if (hasRiskColor) Positioned( @@ -748,7 +752,11 @@ class _DeviceCard extends StatelessWidget { Positioned( top: 0, right: hasRiskColor ? 22 : (allCompleted ? 22 : 0), - child: Icon(Icons.timelapse, size: 16, color: colors.tertiary), + child: Icon( + Icons.timelapse, + size: 16, + color: colors.tertiary, + ), ), if (isCustom && onRemove != null) Positioned( From 0ecc1cbe2a8ca9aa42db7bac12c7260fc5ab6634 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 1 Sep 2026 16:08:03 +0200 Subject: [PATCH 12/16] feat: Refactor risk scoring logic and improve localization handling --- AGENTS.md | 16 ++- lib/models/device.dart | 145 ++++++++--------------- lib/models/survey_state.dart | 31 ++--- lib/screens/device_result_screen.dart | 13 +- lib/screens/device_selection_screen.dart | 37 +----- lib/screens/room_selection_screen.dart | 12 +- lib/screens/summary_screen.dart | 29 ++--- pubspec.yaml | 2 +- test/device_risk_scoring_test.dart | 26 ++++ 9 files changed, 122 insertions(+), 189 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 209eb14..363aa10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,10 +94,18 @@ test/ ### Localization - Never hardcode user-visible strings in widgets. -- All strings live in `lib/l10n/app_localizations.dart` in the - `_localizedValues` map, under every supported locale. -- When adding a string, add it under **all** locales (`de`, `en`, etc.). -- The fallback chain is: requested locale → `en` → `de` → fallback param → key. +- Uses standard Flutter `gen-l10n` codegen (see `l10n.yaml`), not a hand-rolled + lookup. Source of truth is the ARB files in `lib/l10n/` (`app_en.arb` is the + template); `lib/l10n/app_localizations*.dart` are generated output — do not + hand-edit them, edit the ARBs and run `flutter gen-l10n` (or `flutter build`/ + `flutter run` with `generate: true`, already set in `pubspec.yaml`). +- When adding a string, add the key under **all** locale ARB files (`app_de.arb`, + `app_en.arb`, etc.), then regenerate and commit the generated files together + (see the Localization workflow section in `README.md`). +- Access strings via `AppLocalizations.of(context)!.someKey`. For strings keyed + dynamically by a string id (e.g. catalog room/device names), use the + `resolveKey` extension in `lib/l10n/app_localizations_key_resolver.dart` + instead, and add new dynamic keys there too. ### Risk scoring diff --git a/lib/models/device.dart b/lib/models/device.dart index 1eabacb..0fa91a9 100644 --- a/lib/models/device.dart +++ b/lib/models/device.dart @@ -1,6 +1,48 @@ import 'package:flutter/material.dart'; -enum RiskLevel { low, medium, high } +enum RiskLevel { + low, + medium, + high; + + /// The single place that maps a 0-100 risk score to a [RiskLevel]. + static RiskLevel fromScore(int score) { + if (score <= 33) return RiskLevel.low; + if (score <= 66) return RiskLevel.medium; + return RiskLevel.high; + } +} + +extension RiskLevelColor on RiskLevel { + /// The traffic-light color used everywhere risk is visualized (badges, + /// bars, cards). Kept as a single source of truth so the palette can't + /// drift between screens. + Color get color { + switch (this) { + case RiskLevel.high: + return const Color(0xFFC62828); + case RiskLevel.medium: + return const Color(0xFFF9A825); + case RiskLevel.low: + return const Color(0xFF2E7D32); + } + } +} + +extension DeviceInstanceRiskSummary on Iterable { + /// The highest [RiskLevel] among these devices, or `null` if empty. + /// Relies on [RiskLevel] being declared low → medium → high. + RiskLevel? get worstRiskLevel { + RiskLevel? worst; + for (final device in this) { + final level = device.riskLevel; + if (worst == null || level.index > worst.index) { + worst = level; + } + } + return worst; + } +} enum ActionType { social, technical, security } @@ -794,88 +836,13 @@ class DeviceInstance { bool _hasQuestion(String questionId) => questions.any((question) => question.id == questionId); - int get riskScore { - int score = template.baseRiskScore; - if (roomId == _childBedroomRoomId) { - score += _childBedroomRiskBonus; - } - if (_hasQuestion('password')) { - score += _riskPenalty( - passwordChanged, - noPenalty: 20, - dontKnowPenalty: 10, - ); - } - if (_hasQuestion('updates')) { - score += _riskPenalty( - autoUpdatesEnabled, - noPenalty: 15, - dontKnowPenalty: 8, - ); - } - if (_hasQuestion('network')) { - score += _riskPenalty(separateNetwork, noPenalty: 10, dontKnowPenalty: 5); - } - if (_hasQuestion('informed')) { - score += _riskPenalty( - householdInformed, - noPenalty: 10, - dontKnowPenalty: 5, - ); - } - if (_hasQuestion('permissions')) { - score += _riskPenalty( - permissionsReduced, - noPenalty: 5, - dontKnowPenalty: 3, - ); - } - if (_hasQuestion('camera_consent')) { - score += _riskPenalty( - cameraConsentGiven, - noPenalty: 15, - dontKnowPenalty: 8, - ); - } - if (_hasQuestion('mic_active')) { - score += _riskPenalty( - micDeactivatedWhenUnused, - noPenalty: 10, - dontKnowPenalty: 5, - ); - } - - const baseIds = { - 'password', - 'updates', - 'network', - 'informed', - 'permissions', - 'camera_consent', - 'mic_active', - }; - - // Penalties for currently active device-specific questions. - for (final question in questions) { - if (baseIds.contains(question.id)) { - continue; - } - score += _riskPenalty( - answerFor(question.id), - noPenalty: 8, - dontKnowPenalty: 4, - ); - } + /// Derived from [scoringFactors] so the displayed score and its breakdown + /// (shown to the user via "how is this calculated") can never drift apart. + int get riskScore => scoringFactors + .fold(0, (sum, factor) => sum + factor.penalty) + .clamp(0, 100); - return score.clamp(0, 100); - } - - RiskLevel get riskLevel { - final s = riskScore; - if (s <= 33) return RiskLevel.low; - if (s <= 66) return RiskLevel.medium; - return RiskLevel.high; - } + RiskLevel get riskLevel => RiskLevel.fromScore(riskScore); String? get inherentRiskHint { if (!allAnswersPositive || riskLevel == RiskLevel.low) { @@ -1072,18 +1039,4 @@ class DeviceInstance { return factors; } - - int _riskPenalty( - QuestionAnswer? answer, { - required int noPenalty, - required int dontKnowPenalty, - }) { - if (answer == QuestionAnswer.no) { - return noPenalty; - } - if (answer == QuestionAnswer.dontKnow) { - return dontKnowPenalty; - } - return 0; - } } diff --git a/lib/models/survey_state.dart b/lib/models/survey_state.dart index 1fe0088..f1726ba 100644 --- a/lib/models/survey_state.dart +++ b/lib/models/survey_state.dart @@ -169,19 +169,8 @@ class SurveyState extends ChangeNotifier { List evaluatedDevicesForRoom(String roomId) => devicesForRoom(roomId).where((device) => device.isFullyAnswered).toList(); - RiskLevel? worstRiskLevelForRoom(String roomId) { - final evaluated = evaluatedDevicesForRoom(roomId); - if (evaluated.isEmpty) { - return null; - } - if (evaluated.any((device) => device.riskLevel == RiskLevel.high)) { - return RiskLevel.high; - } - if (evaluated.any((device) => device.riskLevel == RiskLevel.medium)) { - return RiskLevel.medium; - } - return RiskLevel.low; - } + RiskLevel? worstRiskLevelForRoom(String roomId) => + evaluatedDevicesForRoom(roomId).worstRiskLevel; int evaluatedDeviceCountForRoom(String roomId) => evaluatedDevicesForRoom(roomId).length; @@ -503,7 +492,7 @@ class SurveyState extends ChangeNotifier { customRooms.clear(); customDevices.clear(); _expertModeEnabled = false; - await clearStorage(); + await _enqueueWrite(clearStorage); notifyListeners(); } @@ -597,8 +586,20 @@ class SurveyState extends ChangeNotifier { return Icons.home; } + /// Chains persistence writes so overlapping calls can't finish out of + /// order. Without this, two rapid mutations could race: the newer write + /// starts and finishes first, then the older (now-stale) write finishes + /// last and overwrites it with outdated data. + Future? _pendingWrite; + + Future _enqueueWrite(Future Function() task) { + final next = (_pendingWrite ?? Future.value()).then((_) => task()); + _pendingWrite = next; + return next; + } + void _changed() { - unawaited(saveToStorage()); + unawaited(_enqueueWrite(saveToStorage)); notifyListeners(); } } diff --git a/lib/screens/device_result_screen.dart b/lib/screens/device_result_screen.dart index e045f61..87b1833 100644 --- a/lib/screens/device_result_screen.dart +++ b/lib/screens/device_result_screen.dart @@ -44,7 +44,7 @@ class DeviceResultScreen extends StatelessWidget { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; final localizations = AppLocalizations.of(context)!; - final riskColor = _riskColor(device.riskLevel); + final riskColor = device.riskLevel.color; final riskLabel = _riskLabel(localizations, device.riskLevel); final actions = device.suggestedActions; @@ -358,15 +358,4 @@ class DeviceResultScreen extends StatelessWidget { return localizations.noActionLow; } } - - Color _riskColor(RiskLevel level) { - switch (level) { - case RiskLevel.high: - return const Color(0xFFC62828); - case RiskLevel.medium: - return const Color(0xFFF9A825); - case RiskLevel.low: - return const Color(0xFF2E7D32); - } - } } diff --git a/lib/screens/device_selection_screen.dart b/lib/screens/device_selection_screen.dart index 91b558b..8d62e60 100644 --- a/lib/screens/device_selection_screen.dart +++ b/lib/screens/device_selection_screen.dart @@ -349,23 +349,9 @@ class _DeviceSelectionScreenState extends State { final completedCount = instanceList .where((instance) => instance.isFullyAnswered) .length; - final evaluatedInstances = instanceList + final riskLevel = instanceList .where((instance) => instance.isFullyAnswered) - .toList(); - RiskLevel? riskLevel; - if (evaluatedInstances.isNotEmpty) { - if (evaluatedInstances.any( - (instance) => instance.riskLevel == RiskLevel.high, - )) { - riskLevel = RiskLevel.high; - } else if (evaluatedInstances.any( - (instance) => instance.riskLevel == RiskLevel.medium, - )) { - riskLevel = RiskLevel.medium; - } else { - riskLevel = RiskLevel.low; - } - } + .worstRiskLevel; final hasIncomplete = instanceList.any( (instance) => !instance.isFullyAnswered, ); @@ -493,16 +479,9 @@ class _DeviceInstanceCard extends StatelessWidget { Color _riskColor() { if (!instance.isFullyAnswered) { - return const Color(0xFFF9A825); - } - switch (instance.riskLevel) { - case RiskLevel.high: - return const Color(0xFFC62828); - case RiskLevel.medium: - return const Color(0xFFF9A825); - case RiskLevel.low: - return const Color(0xFF2E7D32); + return RiskLevel.medium.color; } + return instance.riskLevel.color; } String _riskLabel(AppLocalizations localizations) { @@ -612,12 +591,8 @@ class _DeviceCard extends StatelessWidget { Color _riskColor() { switch (riskLevel) { - case RiskLevel.high: - return const Color(0xFFC62828); - case RiskLevel.medium: - return const Color(0xFFF9A825); - case RiskLevel.low: - return const Color(0xFF2E7D32); + case final RiskLevel level: + return level.color; case null: return Colors.transparent; } diff --git a/lib/screens/room_selection_screen.dart b/lib/screens/room_selection_screen.dart index 591e71b..f99df68 100644 --- a/lib/screens/room_selection_screen.dart +++ b/lib/screens/room_selection_screen.dart @@ -237,17 +237,11 @@ class _RoomCard extends StatelessWidget { }); Color _riskColor(ColorScheme colors) { - if (roomRisk == null) { + final risk = roomRisk; + if (risk == null) { return isIncomplete ? colors.error : colors.outlineVariant; } - switch (roomRisk!) { - case RiskLevel.high: - return const Color(0xFFC62828); - case RiskLevel.medium: - return const Color(0xFFF9A825); - case RiskLevel.low: - return const Color(0xFF2E7D32); - } + return risk.color; } String? _riskLabel(AppLocalizations localizations) { diff --git a/lib/screens/summary_screen.dart b/lib/screens/summary_screen.dart index 0ca947d..2db0b87 100644 --- a/lib/screens/summary_screen.dart +++ b/lib/screens/summary_screen.dart @@ -212,7 +212,7 @@ class _SummaryScreenState extends State { child: _SectionHeader( label: localizations.highRisk, count: highRisk.length, - color: _riskColor(RiskLevel.high), + color: RiskLevel.high.color, icon: Icons.warning_rounded, ), ), @@ -236,7 +236,7 @@ class _SummaryScreenState extends State { child: _SectionHeader( label: localizations.mediumRisk, count: medRisk.length, - color: _riskColor(RiskLevel.medium), + color: RiskLevel.medium.color, icon: Icons.info_rounded, ), ), @@ -260,7 +260,7 @@ class _SummaryScreenState extends State { child: _SectionHeader( label: localizations.lowRisk, count: lowRisk.length, - color: _riskColor(RiskLevel.low), + color: RiskLevel.low.color, icon: Icons.check_circle_rounded, ), ), @@ -430,17 +430,6 @@ class _SummaryReport { } } -Color _riskColor(RiskLevel level) { - switch (level) { - case RiskLevel.high: - return const Color(0xFFC62828); - case RiskLevel.medium: - return const Color(0xFFF9A825); - case RiskLevel.low: - return const Color(0xFF2E7D32); - } -} - String _priorityLabel(ActionPriority priority, AppLocalizations localizations) { switch (priority) { case ActionPriority.high: @@ -830,7 +819,7 @@ class _OverviewHeader extends StatelessWidget { : overallScore <= 66 ? RiskLevel.medium : RiskLevel.high; - final scoreColor = _riskColor(scoreLevel); + final scoreColor = scoreLevel.color; return Container( margin: const EdgeInsets.all(16), @@ -977,7 +966,7 @@ class _RiskCount extends StatelessWidget { @override Widget build(BuildContext context) { - final color = _riskColor(level); + final color = level.color; return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1079,7 +1068,7 @@ class _DeviceResultCardState extends State<_DeviceResultCard> { final text = Theme.of(context).textTheme; final device = widget.device; final level = device.riskLevel; - final color = _riskColor(level); + final color = level.color; final bg = _riskBg(level); final actions = device.suggestedActions; final inherentRiskHint = device.inherentRiskHint == null @@ -1088,9 +1077,7 @@ class _DeviceResultCardState extends State<_DeviceResultCard> { device.inherentRiskHint!, fallback: device.inherentRiskHint!, ); - final noActionColor = level == RiskLevel.low - ? _riskColor(RiskLevel.low) - : color; + final noActionColor = level == RiskLevel.low ? RiskLevel.low.color : color; final noActionIcon = level == RiskLevel.low ? Icons.check_circle : Icons.info_outline; @@ -1299,7 +1286,7 @@ class _ScoreBreakdownState extends State<_ScoreBreakdown> { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final riskColor = _riskColor(widget.device.riskLevel); + final riskColor = widget.device.riskLevel.color; return Container( margin: const EdgeInsets.fromLTRB(14, 0, 14, 10), diff --git a/pubspec.yaml b/pubspec.yaml index a3d205f..7856a94 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+39 +version: 1.0.0+40 environment: sdk: ^3.11.5 diff --git a/test/device_risk_scoring_test.dart b/test/device_risk_scoring_test.dart index 40727c8..3ec268a 100644 --- a/test/device_risk_scoring_test.dart +++ b/test/device_risk_scoring_test.dart @@ -213,5 +213,31 @@ void main() { ); expect(hasExpertFactor, isTrue); }); + + test('riskScore always equals the clamped sum of scoringFactors', () { + // Guards against riskScore and scoringFactors drifting apart into two + // independently-maintained penalty tables. + final devicesToCheck = [ + _instance('simple_sensor'), + _instance('smart_lock', roomId: 'child_bedroom'), + _instance('smart_speaker'), + _instance('smart_toy', expertMode: true, roomId: 'bedroom'), + ]; + + for (final device in devicesToCheck) { + _answerAll(device, QuestionAnswer.no); + final summedFactors = device.scoringFactors.fold( + 0, + (sum, factor) => sum + factor.penalty, + ); + expect( + device.riskScore, + summedFactors.clamp(0, 100), + reason: + 'riskScore must equal the clamped sum of scoringFactors for ' + '${device.template.id}', + ); + } + }); }); } From 1067dfcfe64d8c5690d3f2868958b818fd2ab881 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 8 Sep 2026 09:21:39 +0200 Subject: [PATCH 13/16] Refactor device type handling to use enum DeviceCategory - Updated CatalogData to replace string device types with DeviceCategory enum values for better type safety and maintainability. - Modified DeviceTemplate and DeviceInstance classes to accommodate the new DeviceCategory enum. - Adjusted various screens and dialogs to utilize the updated device type handling. - Introduced l10n_extensions.dart for easier localization access in the widget tree. - Enhanced the WelcomeScreen and other screens to pass the SurveyState instance correctly. - Updated widget tests to reflect changes in the app structure and ensure proper initialization. --- lib/data/catalog_data.dart | 48 +++++------ lib/l10n/l10n_extensions.dart | 11 +++ lib/main.dart | 24 +++++- lib/models/device.dart | 63 ++++++++++---- lib/models/survey_state.dart | 49 ++++++++++- lib/screens/about_screen.dart | 8 +- lib/screens/device_questionnaire_screen.dart | 6 +- lib/screens/device_result_screen.dart | 3 +- lib/screens/device_selection_screen.dart | 33 +++---- lib/screens/faq_screen.dart | 3 +- lib/screens/room_selection_screen.dart | 18 ++-- lib/screens/summary_screen.dart | 19 ++-- lib/screens/welcome_screen.dart | 91 +++++--------------- lib/widgets/custom_add_dialogs.dart | 85 +++++++----------- lib/widgets/language_switcher.dart | 5 +- pubspec.yaml | 2 +- test/widget_test.dart | 6 +- 17 files changed, 255 insertions(+), 219 deletions(-) create mode 100644 lib/l10n/l10n_extensions.dart diff --git a/lib/data/catalog_data.dart b/lib/data/catalog_data.dart index 0e7b7d6..5dcc6e3 100644 --- a/lib/data/catalog_data.dart +++ b/lib/data/catalog_data.dart @@ -115,7 +115,7 @@ class CatalogData { 'basement', 'whole_home', ], - deviceType: 'sensor', + deviceType: DeviceCategory.sensor, ), // ── Smart Speaker / Voice ────────────────────────────────────────── @@ -126,7 +126,7 @@ class CatalogData { baseRiskScore: 50, hasMicrophone: true, roomIds: ['living', 'kitchen', 'bedroom', 'office', 'hallway'], - deviceType: 'speaker', + deviceType: DeviceCategory.speaker, ), DeviceTemplate( id: 'smart_display', @@ -136,7 +136,7 @@ class CatalogData { hasCamera: true, hasMicrophone: true, roomIds: ['living', 'kitchen', 'bedroom', 'office'], - deviceType: 'speaker', + deviceType: DeviceCategory.speaker, ), // ── TV & Entertainment ──────────────────────────────────────────── @@ -146,7 +146,7 @@ class CatalogData { icon: Icons.tv, baseRiskScore: 40, roomIds: ['living', 'bedroom'], - deviceType: 'tv', + deviceType: DeviceCategory.tv, ), // ── Cameras ─────────────────────────────────────────────────────── @@ -157,7 +157,7 @@ class CatalogData { baseRiskScore: 60, hasCamera: true, roomIds: ['living', 'hallway', 'basement', 'bedroom', 'kitchen'], - deviceType: 'camera', + deviceType: DeviceCategory.camera, ), DeviceTemplate( id: 'outdoor_camera', @@ -166,7 +166,7 @@ class CatalogData { baseRiskScore: 60, hasCamera: true, roomIds: ['garden', 'hallway', 'whole_home'], - deviceType: 'camera', + deviceType: DeviceCategory.camera, ), DeviceTemplate( id: 'doorbell_camera', @@ -176,7 +176,7 @@ class CatalogData { hasCamera: true, hasMicrophone: true, roomIds: ['hallway', 'whole_home'], - deviceType: 'camera', + deviceType: DeviceCategory.camera, ), DeviceTemplate( id: 'baby_monitor', @@ -186,7 +186,7 @@ class CatalogData { hasCamera: true, hasMicrophone: true, roomIds: ['bedroom'], - deviceType: 'camera', + deviceType: DeviceCategory.camera, ), // ── Robots & Appliances ─────────────────────────────────────────── @@ -196,7 +196,7 @@ class CatalogData { icon: Icons.cleaning_services, baseRiskScore: 35, roomIds: ['living', 'kitchen', 'hallway', 'bedroom', 'whole_home'], - deviceType: 'robot', + deviceType: DeviceCategory.robot, ), DeviceTemplate( id: 'smart_fridge', @@ -204,7 +204,7 @@ class CatalogData { icon: Icons.kitchen, baseRiskScore: 30, roomIds: ['kitchen'], - deviceType: 'appliance', + deviceType: DeviceCategory.appliance, ), DeviceTemplate( id: 'smart_oven', @@ -212,7 +212,7 @@ class CatalogData { icon: Icons.microwave, baseRiskScore: 25, roomIds: ['kitchen'], - deviceType: 'appliance', + deviceType: DeviceCategory.appliance, ), DeviceTemplate( id: 'smart_coffee', @@ -220,7 +220,7 @@ class CatalogData { icon: Icons.coffee, baseRiskScore: 20, roomIds: ['kitchen'], - deviceType: 'appliance', + deviceType: DeviceCategory.appliance, ), DeviceTemplate( id: 'smart_washing', @@ -228,7 +228,7 @@ class CatalogData { icon: Icons.local_laundry_service, baseRiskScore: 20, roomIds: ['basement', 'bathroom', 'whole_home'], - deviceType: 'appliance', + deviceType: DeviceCategory.appliance, ), // ── Smart Home Controls ─────────────────────────────────────────── @@ -238,7 +238,7 @@ class CatalogData { icon: Icons.thermostat, baseRiskScore: 30, roomIds: ['living', 'bedroom', 'hallway', 'whole_home'], - deviceType: 'thermostat', + deviceType: DeviceCategory.thermostat, ), DeviceTemplate( id: 'smart_plug', @@ -254,7 +254,7 @@ class CatalogData { 'garden', 'basement', ], - deviceType: 'plug', + deviceType: DeviceCategory.plug, ), DeviceTemplate( id: 'smart_light', @@ -270,7 +270,7 @@ class CatalogData { 'hallway', 'garden', ], - deviceType: 'light', + deviceType: DeviceCategory.light, ), DeviceTemplate( id: 'smart_lock', @@ -278,7 +278,7 @@ class CatalogData { icon: Icons.lock, baseRiskScore: 50, roomIds: ['hallway', 'whole_home'], - deviceType: 'lock', + deviceType: DeviceCategory.lock, ), DeviceTemplate( id: 'smart_blind', @@ -286,7 +286,7 @@ class CatalogData { icon: Icons.window, baseRiskScore: 20, roomIds: ['living', 'bedroom', 'office', 'whole_home'], - deviceType: 'blind', + deviceType: DeviceCategory.blind, ), // ── Kids ────────────────────────────────────────────────────────── @@ -297,7 +297,7 @@ class CatalogData { baseRiskScore: 55, hasMicrophone: true, roomIds: ['bedroom', 'living'], - deviceType: 'toy', + deviceType: DeviceCategory.toy, ), // ── Network & Hub ───────────────────────────────────────────────── @@ -307,7 +307,7 @@ class CatalogData { icon: Icons.router, baseRiskScore: 40, roomIds: ['whole_home', 'office'], - deviceType: 'router', + deviceType: DeviceCategory.router, ), DeviceTemplate( id: 'smart_hub', @@ -315,7 +315,7 @@ class CatalogData { icon: Icons.hub, baseRiskScore: 35, roomIds: ['whole_home', 'living'], - deviceType: 'hub', + deviceType: DeviceCategory.hub, ), DeviceTemplate( id: 'smart_meter', @@ -323,7 +323,7 @@ class CatalogData { icon: Icons.bolt, baseRiskScore: 30, roomIds: ['whole_home', 'basement'], - deviceType: 'meter', + deviceType: DeviceCategory.meter, ), // ── Garden ──────────────────────────────────────────────────────── @@ -333,7 +333,7 @@ class CatalogData { icon: Icons.water_drop, baseRiskScore: 15, roomIds: ['garden'], - deviceType: 'outdoor', + deviceType: DeviceCategory.outdoor, ), // ── Office ──────────────────────────────────────────────────────── @@ -343,7 +343,7 @@ class CatalogData { icon: Icons.print, baseRiskScore: 30, roomIds: ['office'], - deviceType: 'printer', + deviceType: DeviceCategory.printer, ), ]; diff --git a/lib/l10n/l10n_extensions.dart b/lib/l10n/l10n_extensions.dart new file mode 100644 index 0000000..4e799f7 --- /dev/null +++ b/lib/l10n/l10n_extensions.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart'; + +import 'app_localizations.dart'; + +extension BuildContextL10n on BuildContext { + /// Shorthand for `AppLocalizations.of(context)!`. Safe to force-unwrap: + /// [AppLocalizations.delegate] is always registered in [MaterialApp], so a + /// null result here means the widget tree is misconfigured, not a normal + /// runtime state. + AppLocalizations get l10n => AppLocalizations.of(this)!; +} diff --git a/lib/main.dart b/lib/main.dart index 245f0b5..7576634 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,20 +1,36 @@ import 'package:flutter/material.dart'; import 'l10n/app_localizations.dart'; +import 'l10n/l10n_extensions.dart'; import 'l10n/language_controller.dart'; +import 'models/survey_state.dart'; import 'screens/welcome_screen.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); final languageController = LanguageController(); await languageController.init(); - runApp(SimplicationsApp(languageController: languageController)); + // Loaded once for the app's lifetime instead of per-screen, so every + // screen shares one instance and no screen needs to reload it from disk. + final surveyState = SurveyState(); + await surveyState.loadFromStorage(); + runApp( + SimplicationsApp( + languageController: languageController, + surveyState: surveyState, + ), + ); } class SimplicationsApp extends StatefulWidget { final LanguageController languageController; + final SurveyState surveyState; - const SimplicationsApp({super.key, required this.languageController}); + const SimplicationsApp({ + super.key, + required this.languageController, + required this.surveyState, + }); @override State createState() => _SimplicationsAppState(); @@ -30,9 +46,8 @@ class _SimplicationsAppState extends State { builder: (context, _) { return MaterialApp( navigatorKey: _navigatorKey, - onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle, + onGenerateTitle: (context) => context.l10n.appTitle, debugShowCheckedModeBanner: false, - navigatorObservers: [appRouteObserver], locale: widget.languageController.locale, supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -46,6 +61,7 @@ class _SimplicationsAppState extends State { home: WelcomeScreen( languageController: widget.languageController, navigatorKey: _navigatorKey, + state: widget.surveyState, ), ); }, diff --git a/lib/models/device.dart b/lib/models/device.dart index 0fa91a9..98ab652 100644 --- a/lib/models/device.dart +++ b/lib/models/device.dart @@ -125,6 +125,31 @@ class ScoringFactor { }); } +/// Which question set and scoring rules a [DeviceTemplate] uses. Categories +/// without a dedicated question set in [DeviceInstance._baseQuestions] (e.g. +/// [appliance], [router]) fall back to the generic question set. +enum DeviceCategory { + general, + custom, + sensor, + speaker, + camera, + tv, + thermostat, + plug, + light, + lock, + blind, + robot, + toy, + router, + hub, + meter, + outdoor, + printer, + appliance, +} + class DeviceTemplate { final String id; final String name; @@ -133,7 +158,7 @@ class DeviceTemplate { final bool hasCamera; final bool hasMicrophone; final List roomIds; - final String deviceType; // 'catalog' or 'custom' + final DeviceCategory deviceType; final bool isCustom; const DeviceTemplate({ @@ -145,7 +170,7 @@ class DeviceTemplate { this.hasMicrophone = false, required this.roomIds, // Default falls back to the generic question set in DeviceInstance.questions. - this.deviceType = 'general', + this.deviceType = DeviceCategory.general, this.isCustom = false, }); } @@ -512,7 +537,7 @@ class DeviceInstance { ); // ── Sensor: tailored set – no app/password/update concept ───────────────── - if (template.deviceType == 'sensor') { + if (template.deviceType == DeviceCategory.sensor) { return const [ DeviceQuestion( id: 'sensor_frequency', @@ -533,7 +558,7 @@ class DeviceInstance { } // ── Speaker ─────────────────────────────────────────────────────────────── - if (template.deviceType == 'speaker') { + if (template.deviceType == DeviceCategory.speaker) { return [ const DeviceQuestion( id: 'voice_history', @@ -555,7 +580,7 @@ class DeviceInstance { } // ── Camera ──────────────────────────────────────────────────────────────── - if (template.deviceType == 'camera') { + if (template.deviceType == DeviceCategory.camera) { return const [ DeviceQuestion( id: 'video_encryption', @@ -581,7 +606,7 @@ class DeviceInstance { } // ── Smart TV ────────────────────────────────────────────────────────────── - if (template.deviceType == 'tv') { + if (template.deviceType == DeviceCategory.tv) { return [ qUpdates, const DeviceQuestion( @@ -603,7 +628,7 @@ class DeviceInstance { } // ── Thermostat ──────────────────────────────────────────────────────────── - if (template.deviceType == 'thermostat') { + if (template.deviceType == DeviceCategory.thermostat) { return [ qUpdates, const DeviceQuestion( @@ -625,9 +650,9 @@ class DeviceInstance { } // ── Smart Light / Smart Plug / Motorised Blind ──────────────────────────── - if (template.deviceType == 'light' || - template.deviceType == 'plug' || - template.deviceType == 'blind') { + if (template.deviceType == DeviceCategory.light || + template.deviceType == DeviceCategory.plug || + template.deviceType == DeviceCategory.blind) { return [ qUpdates, const DeviceQuestion( @@ -649,7 +674,7 @@ class DeviceInstance { } // ── Smart Lock ──────────────────────────────────────────────────────────── - if (template.deviceType == 'lock') { + if (template.deviceType == DeviceCategory.lock) { return [ qPassword, const DeviceQuestion( @@ -671,7 +696,7 @@ class DeviceInstance { } // ── Robot Vacuum ────────────────────────────────────────────────────────── - if (template.deviceType == 'robot') { + if (template.deviceType == DeviceCategory.robot) { return [ qUpdates, const DeviceQuestion( @@ -693,7 +718,7 @@ class DeviceInstance { } // ── Connected Toy ───────────────────────────────────────────────────────── - if (template.deviceType == 'toy') { + if (template.deviceType == DeviceCategory.toy) { return [ qUpdates, const DeviceQuestion( @@ -767,7 +792,8 @@ class DeviceInstance { ), ); } - if (template.deviceType == 'toy' || roomId == _childBedroomRoomId) { + if (template.deviceType == DeviceCategory.toy || + roomId == _childBedroomRoomId) { questions.add( const DeviceQuestion( id: 'expert_child_data_protection', @@ -776,7 +802,7 @@ class DeviceInstance { ), ); } - if (template.deviceType == 'lock') { + if (template.deviceType == DeviceCategory.lock) { questions.add( const DeviceQuestion( id: 'expert_access_revocation', @@ -849,13 +875,14 @@ class DeviceInstance { return null; } - if (template.deviceType == 'camera' || template.hasCamera) { + if (template.deviceType == DeviceCategory.camera || template.hasCamera) { return 'risk_hint_camera'; } - if (template.deviceType == 'speaker' || template.hasMicrophone) { + if (template.deviceType == DeviceCategory.speaker || + template.hasMicrophone) { return 'risk_hint_mic'; } - if (template.deviceType == 'lock') { + if (template.deviceType == DeviceCategory.lock) { return 'risk_hint_lock'; } if (roomId == _childBedroomRoomId) { diff --git a/lib/models/survey_state.dart b/lib/models/survey_state.dart index f1726ba..dcbacff 100644 --- a/lib/models/survey_state.dart +++ b/lib/models/survey_state.dart @@ -65,6 +65,51 @@ final Map _iconKeyByFingerprint = { '${e.value.codePoint}:${e.value.fontFamily}': e.key, }; +/// The icon options offered by the "add custom room" picker, as +/// [kIconRegistry] keys — see [iconsForKeys]. +const List kRoomIconKeys = [ + 'weekend', + 'kitchen', + 'hotel', + 'bathtub', + 'computer', + 'meeting_room', + 'yard', + 'storage', + 'home', + 'living', + 'local_library', + 'fitness_center', + 'sports_esports', + 'roofing', +]; + +/// The icon options offered by the "add custom device" picker, as +/// [kIconRegistry] keys — see [iconsForKeys]. +const List kDeviceIconKeys = [ + 'speaker', + 'videocam', + 'tv', + 'thermostat', + 'lightbulb', + 'lock', + 'electrical_services', + 'window', + 'cleaning_services', + 'kitchen', + 'watch', + 'toys', + 'router', + 'print', +]; + +/// Resolves [keys] against [kIconRegistry]. Used to build icon-picker option +/// lists so every offered icon is guaranteed to have a registry entry — a +/// missing key throws immediately here instead of silently persisting as +/// `home` later in [SurveyState]. +List iconsForKeys(List keys) => + keys.map((key) => kIconRegistry[key]!).toList(growable: false); + class SurveyState extends ChangeNotifier { static const String _storageKey = 'survey_state_v1'; static const Map _legacyTemplateIdAliases = { @@ -263,7 +308,7 @@ class SurveyState extends ChangeNotifier { hasCamera: hasCamera, hasMicrophone: hasMicrophone, roomIds: [roomId], - deviceType: 'custom', + deviceType: DeviceCategory.custom, isCustom: true, ); customDevices.add(newDevice); @@ -352,7 +397,7 @@ class SurveyState extends ChangeNotifier { roomIds: (e['roomIds'] as List? ?? const []) .whereType() .toList(), - deviceType: 'custom', + deviceType: DeviceCategory.custom, isCustom: true, ), ), diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 42b5eaa..accb2c4 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../l10n/app_localizations.dart'; +import '../l10n/l10n_extensions.dart'; import '../models/survey_state.dart'; class AboutScreen extends StatelessWidget { @@ -10,7 +10,7 @@ class AboutScreen extends StatelessWidget { const AboutScreen({super.key, required this.state}); Future _openUrl(BuildContext context, String url) async { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final uri = Uri.parse(url); if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { if (!context.mounted) return; @@ -25,7 +25,7 @@ class AboutScreen extends StatelessWidget { return; } - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final shouldDelete = await showDialog( context: context, builder: (context) => AlertDialog( @@ -73,7 +73,7 @@ class AboutScreen extends StatelessWidget { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Scaffold( appBar: AppBar(title: Text(localizations.aboutScreenTitle)), diff --git a/lib/screens/device_questionnaire_screen.dart b/lib/screens/device_questionnaire_screen.dart index 7c5e3ea..816c459 100644 --- a/lib/screens/device_questionnaire_screen.dart +++ b/lib/screens/device_questionnaire_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../data/catalog_data.dart'; -import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_key_resolver.dart'; +import '../l10n/l10n_extensions.dart'; import '../models/device.dart'; import '../models/room.dart'; import '../models/survey_state.dart'; @@ -75,7 +75,7 @@ class _DeviceQuestionnaireScreenState extends State { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final questions = device.questions; final answered = questions .where((q) => device.answerFor(q.id) != null) @@ -336,7 +336,7 @@ class _QuestionCard extends StatelessWidget { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final answered = answer != null; final isNotApplicable = answer == QuestionAnswer.notApplicable; final accentColor = _answerAccentColor(colors, answer); diff --git a/lib/screens/device_result_screen.dart b/lib/screens/device_result_screen.dart index 87b1833..4ac6df9 100644 --- a/lib/screens/device_result_screen.dart +++ b/lib/screens/device_result_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../data/catalog_data.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_key_resolver.dart'; +import '../l10n/l10n_extensions.dart'; import '../models/device.dart'; import '../models/room.dart'; import '../models/survey_state.dart'; @@ -43,7 +44,7 @@ class DeviceResultScreen extends StatelessWidget { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final riskColor = device.riskLevel.color; final riskLabel = _riskLabel(localizations, device.riskLevel); final actions = device.suggestedActions; diff --git a/lib/screens/device_selection_screen.dart b/lib/screens/device_selection_screen.dart index 8d62e60..8b4e971 100644 --- a/lib/screens/device_selection_screen.dart +++ b/lib/screens/device_selection_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../data/catalog_data.dart'; import '../l10n/app_localizations.dart'; +import '../l10n/l10n_extensions.dart'; import '../models/device.dart'; import '../models/room.dart'; import '../models/survey_state.dart'; @@ -48,25 +49,25 @@ class _DeviceSelectionScreenState extends State { } Future _showAddDeviceDialog(BuildContext context) async { - final result = await showDialog>( + final result = await showDialog( context: context, - builder: (context) => const CustomDeviceDialog(), + builder: (context) => CustomDeviceDialog(), ); if (result != null && context.mounted) { widget.state.addCustomDevice( widget.room.id, - result['name'] as String, - result['icon'] as IconData, - result['riskScore'] as int, - hasCamera: result['hasCamera'] as bool, - hasMicrophone: result['hasMicrophone'] as bool, + result.name, + result.icon, + result.riskScore, + hasCamera: result.hasCamera, + hasMicrophone: result.hasMicrophone, ); } } Future _markNoDevice(BuildContext context) async { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final hasExistingDevices = widget.state .devicesForRoom(widget.room.id) .isNotEmpty; @@ -113,7 +114,7 @@ class _DeviceSelectionScreenState extends State { } void _removeCustomDevice(BuildContext context, String deviceId) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; showDialog( context: context, builder: (context) => AlertDialog( @@ -140,7 +141,7 @@ class _DeviceSelectionScreenState extends State { } void _removeDeviceInstance(BuildContext context, String instanceId) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; showDialog( context: context, builder: (context) => AlertDialog( @@ -170,7 +171,7 @@ class _DeviceSelectionScreenState extends State { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final isCustomRoom = widget.state.customRooms.any( (r) => r.id == widget.room.id, ); @@ -501,7 +502,7 @@ class _DeviceInstanceCard extends StatelessWidget { @override Widget build(BuildContext context) { final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final riskColor = _riskColor(); return Card( @@ -601,7 +602,7 @@ class _DeviceCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final hasInstances = instanceCount > 0; final allCompleted = hasInstances && completedCount == instanceCount; final riskColor = _riskColor(); @@ -774,7 +775,7 @@ class _AddDeviceCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return GestureDetector( onTap: onTap, @@ -817,7 +818,7 @@ class _NoDeviceCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return GestureDetector( onTap: onTap, @@ -874,7 +875,7 @@ class _BottomBar extends StatelessWidget { return ListenableBuilder( listenable: state, builder: (context, _) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final hasResultsAvailable = state.hasResultsAvailable || state.hasFinishedDeviceInRoom(currentRoomId); diff --git a/lib/screens/faq_screen.dart b/lib/screens/faq_screen.dart index 71540e8..c75a845 100644 --- a/lib/screens/faq_screen.dart +++ b/lib/screens/faq_screen.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import '../l10n/app_localizations.dart'; +import '../l10n/l10n_extensions.dart'; class FaqScreen extends StatelessWidget { const FaqScreen({super.key}); @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final text = Theme.of(context).textTheme; final entries = _faqEntries(localizations); diff --git a/lib/screens/room_selection_screen.dart b/lib/screens/room_selection_screen.dart index f99df68..02e52ea 100644 --- a/lib/screens/room_selection_screen.dart +++ b/lib/screens/room_selection_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../data/catalog_data.dart'; import '../l10n/app_localizations.dart'; +import '../l10n/l10n_extensions.dart'; import '../models/device.dart'; import '../models/room.dart'; import '../models/survey_state.dart'; @@ -23,22 +24,19 @@ class RoomSelectionScreen extends StatelessWidget { } Future _showAddRoomDialog(BuildContext context) async { - final result = await showDialog>( + final result = await showDialog( context: context, - builder: (context) => const CustomRoomDialog(), + builder: (context) => CustomRoomDialog(), ); if (result != null && context.mounted) { - final room = state.addCustomRoom( - result['name'] as String, - result['icon'] as IconData, - ); + final room = state.addCustomRoom(result.name, result.icon); _openRoom(context, room); } } void _removeCustomRoom(BuildContext context, String roomId) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; showDialog( context: context, builder: (context) => AlertDialog( @@ -74,7 +72,7 @@ class RoomSelectionScreen extends StatelessWidget { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Scaffold( appBar: AppBar( @@ -261,7 +259,7 @@ class _RoomCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final riskColor = _riskColor(colors); final riskLabel = _riskLabel(localizations); final showRisk = riskLabel != null && evaluatedCount > 0; @@ -404,7 +402,7 @@ class _AddRoomCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return GestureDetector( onTap: onTap, diff --git a/lib/screens/summary_screen.dart b/lib/screens/summary_screen.dart index 2db0b87..15a08a9 100644 --- a/lib/screens/summary_screen.dart +++ b/lib/screens/summary_screen.dart @@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart'; import '../data/catalog_data.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_key_resolver.dart'; +import '../l10n/l10n_extensions.dart'; import '../models/device.dart'; import '../models/room.dart'; import '../models/survey_state.dart'; @@ -55,7 +56,7 @@ class _SummaryScreenState extends State { } void _openFirstIncompleteDevice() { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final device = _firstIncompleteDevice(); if (device == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -81,7 +82,7 @@ class _SummaryScreenState extends State { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Scaffold( appBar: AppBar( @@ -292,7 +293,7 @@ class _SummaryScreenState extends State { } Future _shareSummary(_ShareFormat format) async { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; if (_isSharing) { return; } @@ -977,7 +978,7 @@ class _RiskCount extends StatelessWidget { ), const SizedBox(width: 6), Text( - '$count × ${_riskLabel(level, AppLocalizations.of(context)!)}', + '$count × ${_riskLabel(level, context.l10n)}', style: TextStyle( fontSize: 13, color: color, @@ -1063,7 +1064,7 @@ class _DeviceResultCardState extends State<_DeviceResultCard> { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; final device = widget.device; @@ -1280,7 +1281,7 @@ class _ScoreBreakdownState extends State<_ScoreBreakdown> { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final factors = widget.device.scoringFactors; if (factors.isEmpty) return const SizedBox.shrink(); @@ -1369,7 +1370,7 @@ class _FactorRow extends StatelessWidget { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final Color chipColor; final IconData icon; @@ -1431,7 +1432,7 @@ class _ActionTile extends StatelessWidget { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; final priorityColor = action.priority == ActionPriority.high @@ -1530,7 +1531,7 @@ class _GeneralRecommendations extends StatelessWidget { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Padding( padding: const EdgeInsets.all(16), child: Column( diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index 83c606d..85ef01c 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../l10n/app_localizations.dart'; +import '../l10n/l10n_extensions.dart'; import '../l10n/language_controller.dart'; import '../models/survey_state.dart'; import '../widgets/language_switcher.dart'; @@ -7,67 +7,23 @@ import 'about_screen.dart'; import 'faq_screen.dart'; import 'room_selection_screen.dart'; -/// App-wide route observer — provide this in [MaterialApp.navigatorObservers]. -final RouteObserver> appRouteObserver = - RouteObserver>(); - -class WelcomeScreen extends StatefulWidget { +class WelcomeScreen extends StatelessWidget { final LanguageController languageController; final GlobalKey? navigatorKey; + final SurveyState state; const WelcomeScreen({ super.key, required this.languageController, + required this.state, this.navigatorKey, }); - @override - State createState() => _WelcomeScreenState(); -} - -class _WelcomeScreenState extends State with RouteAware { - SurveyState? _surveyState; - - @override - void initState() { - super.initState(); - _loadSurveyState(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final route = ModalRoute.of(context); - if (route is PageRoute) { - appRouteObserver.subscribe(this, route); - } - } - - @override - void dispose() { - appRouteObserver.unsubscribe(this); - super.dispose(); - } - - /// Called when this screen is popped back to (e.g. after reset). - @override - void didPopNext() { - _loadSurveyState(); - } - - Future _loadSurveyState() async { - final surveyState = SurveyState(); - await surveyState.loadFromStorage(); - if (mounted) { - setState(() => _surveyState = surveyState); - } - } - @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Scaffold( backgroundColor: colors.surface, @@ -180,26 +136,20 @@ class _WelcomeScreenState extends State with RouteAware { Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Builder( - builder: (context) { - final surveyState = _surveyState; - final hasState = surveyState != null; - final hasProgress = - hasState && surveyState.hasAnyData; + ListenableBuilder( + listenable: state, + builder: (context, _) { + final hasProgress = state.hasAnyData; return FilledButton( - onPressed: hasState - ? () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - RoomSelectionScreen( - state: surveyState, - ), - ), - ); - } - : null, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + RoomSelectionScreen(state: state), + ), + ); + }, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( vertical: 16, @@ -245,8 +195,7 @@ class _WelcomeScreenState extends State with RouteAware { TextButton.icon( onPressed: () => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => - AboutScreen(state: _surveyState), + builder: (_) => AboutScreen(state: state), ), ), icon: const Icon( @@ -276,8 +225,8 @@ class _WelcomeScreenState extends State with RouteAware { right: 10, child: SafeArea( child: LanguageSwitcher( - controller: widget.languageController, - navigatorKey: widget.navigatorKey, + controller: languageController, + navigatorKey: navigatorKey, ), ), ), diff --git a/lib/widgets/custom_add_dialogs.dart b/lib/widgets/custom_add_dialogs.dart index f0d5e16..22c31da 100644 --- a/lib/widgets/custom_add_dialogs.dart +++ b/lib/widgets/custom_add_dialogs.dart @@ -1,29 +1,27 @@ import 'package:flutter/material.dart'; -import '../l10n/app_localizations.dart'; +import '../l10n/l10n_extensions.dart'; +import '../models/survey_state.dart'; + +/// Result of [CustomRoomDialog]. A typed record instead of a raw map so a +/// mismatched key/type is a compile error at the call site, not a runtime +/// cast failure. +typedef NewRoomResult = ({String name, IconData icon}); + +/// Result of [CustomDeviceDialog]. +typedef NewDeviceResult = ({ + String name, + IconData icon, + int riskScore, + bool hasCamera, + bool hasMicrophone, +}); class CustomRoomDialog extends StatefulWidget { final List availableIcons; - const CustomRoomDialog({ - super.key, - this.availableIcons = const [ - Icons.weekend, - Icons.kitchen, - Icons.hotel, - Icons.bathtub, - Icons.computer, - Icons.meeting_room, - Icons.yard, - Icons.storage, - Icons.home, - Icons.living, - Icons.local_library, - Icons.fitness_center, - Icons.sports_esports, - Icons.roofing, - ], - }); + CustomRoomDialog({super.key, List? availableIcons}) + : availableIcons = availableIcons ?? iconsForKeys(kRoomIconKeys); @override State createState() => _CustomRoomDialogState(); @@ -48,7 +46,7 @@ class _CustomRoomDialogState extends State { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 500), @@ -120,10 +118,10 @@ class _CustomRoomDialogState extends State { onPressed: _nameController.text.trim().isEmpty ? null : () { - Navigator.pop(context, { - 'name': _nameController.text.trim(), - 'icon': _selectedIcon, - }); + Navigator.pop(context, ( + name: _nameController.text.trim(), + icon: _selectedIcon, + )); }, child: Text(localizations.add), ), @@ -140,25 +138,8 @@ class _CustomRoomDialogState extends State { class CustomDeviceDialog extends StatefulWidget { final List availableIcons; - const CustomDeviceDialog({ - super.key, - this.availableIcons = const [ - Icons.speaker, - Icons.videocam, - Icons.tv, - Icons.thermostat, - Icons.lightbulb, - Icons.lock, - Icons.electrical_services, - Icons.window, - Icons.cleaning_services, - Icons.kitchen, - Icons.watch, - Icons.toys, - Icons.router, - Icons.print, - ], - }); + CustomDeviceDialog({super.key, List? availableIcons}) + : availableIcons = availableIcons ?? iconsForKeys(kDeviceIconKeys); @override State createState() => _CustomDeviceDialogState(); @@ -188,7 +169,7 @@ class _CustomDeviceDialogState extends State { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), @@ -295,13 +276,13 @@ class _CustomDeviceDialogState extends State { : () { final riskScore = int.tryParse(_riskScoreController.text) ?? 30; - Navigator.pop(context, { - 'name': _nameController.text.trim(), - 'icon': _selectedIcon, - 'riskScore': riskScore.clamp(0, 100), - 'hasCamera': _hasCamera, - 'hasMicrophone': _hasMicrophone, - }); + Navigator.pop(context, ( + name: _nameController.text.trim(), + icon: _selectedIcon, + riskScore: riskScore.clamp(0, 100), + hasCamera: _hasCamera, + hasMicrophone: _hasMicrophone, + )); }, child: Text(localizations.add), ), diff --git a/lib/widgets/language_switcher.dart b/lib/widgets/language_switcher.dart index 970867d..ff62708 100644 --- a/lib/widgets/language_switcher.dart +++ b/lib/widgets/language_switcher.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../l10n/app_localizations.dart'; +import '../l10n/l10n_extensions.dart'; import '../l10n/language_controller.dart'; class LanguageSwitcher extends StatelessWidget { @@ -15,7 +16,7 @@ class LanguageSwitcher extends StatelessWidget { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Tooltip( message: localizations.languageDialogTitle, @@ -42,7 +43,7 @@ class LanguageSwitcher extends StatelessWidget { Future _showLanguageDialog(BuildContext context) async { final dialogContext = navigatorKey?.currentContext ?? context; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final selected = await showDialog( context: dialogContext, useRootNavigator: true, diff --git a/pubspec.yaml b/pubspec.yaml index 7856a94..ce0db2d 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+40 +version: 1.0.0+41 environment: sdk: ^3.11.5 diff --git a/test/widget_test.dart b/test/widget_test.dart index 0998437..9887465 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -11,11 +11,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:simplications/l10n/app_localizations.dart'; import 'package:simplications/l10n/language_controller.dart'; import 'package:simplications/main.dart'; +import 'package:simplications/models/survey_state.dart'; void main() { testWidgets('App launches smoke test', (WidgetTester tester) async { await tester.pumpWidget( - SimplicationsApp(languageController: LanguageController()), + SimplicationsApp( + languageController: LanguageController(), + surveyState: SurveyState(), + ), ); await tester.pumpAndSettle(); From fe539b0deee5531c030bdee46fa66a9af6241ad0 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 14 Sep 2026 10:02:18 +0200 Subject: [PATCH 14/16] chore: remove development instructions document --- .instructions.md | 117 ----------------------------------------------- 1 file changed, 117 deletions(-) delete mode 100644 .instructions.md diff --git a/.instructions.md b/.instructions.md deleted file mode 100644 index 9841a15..0000000 --- a/.instructions.md +++ /dev/null @@ -1,117 +0,0 @@ -# Simplications Development Instructions - -## Project Overview - -**Simplications** is a Flutter smart-home privacy risk assessment tool that helps households evaluate and understand the privacy and security risks of their connected devices. Users answer structured questionnaires about devices, receive risk scores, and get tailored mitigation recommendations. - -- **Target platforms:** Android, iOS, Web, Windows, macOS, Linux -- **Supported languages:** German (de), English (en), Czech (cs), Polish (pl), French (fr), Dutch (nl), Danish (da) -- **Key features:** Device questionnaire wizard, risk scoring with penalties, household-level aggregation, PDF/text export -- **Repository:** https://github.com/tucmi/simplications - -## Tech Stack - -- **Framework:** Flutter 3.11.5+, Dart 3.1.0+ -- **Build system:** Gradle (Android), Xcode (iOS), CMake (Linux/macOS/Windows) -- **Key packages:** `pdf: ^3.11.3`, `share_plus: ^11.0.0`, custom localization -- **Versioning:** Semantic versioning (`major.minor.patch+buildNumber`) in `pubspec.yaml` - -## Development Guidelines - -### Code style and quality -- Run `flutter analyze` before committing — all issues must be resolved -- Follow Dart style guide; use meaningful variable and function names -- Prefer immutable data classes (use `final` fields) -- Add docstring comments to public classes and methods explaining purpose and key behavior - -### Localization -- All UI strings must use `AppLocalizations` (generated from `lib/l10n/app_localizations.dart`) -- When adding new user-facing text: - 1. Add German text to the `_deToEn` map in `lib/data/catalog_data.dart` or directly in the UI - 2. Use `AppLocalizations` instance to retrieve the localized value - 3. Run `flutter gen-l10n` to regenerate localizations - 4. Verify translations exist in all 7 supported locales before merging -- Special characters: Use only ASCII-safe characters or ensure PDF fonts support them (see below) - -### PDF export -- All text rendered to PDF must use characters covered by Helvetica (Latin-1, ISO 8859-1) -- Avoid en-dashes (`–`), em-dashes (`—`), and non-Latin accented characters in strings used in PDF export -- If internationalization requires unsupported characters, consider embedding a Unicode font (e.g., `Roboto`) - -### Testing -- Run `flutter test` before opening a PR — all tests must pass -- Write widget tests for complex UI components (dialogs, forms, risk displays) -- Test localization changes with at least two locales (preferably one with special characters) - -### Commits and PRs -- Use conventional commit format: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:` -- Reference related issues with `Fixes #…` or `Closes #…` in PR descriptions -- Split logical changes into separate commits; avoid unrelated refactors in feature PRs -- All PRs must pass CI (analyze, build APK release) - -## Version Management and Build Numbers - -### Versioning model -- **Format:** `X.Y.Z+N` in `pubspec.yaml` (e.g., `1.2.0+42`) - - `X.Y.Z`: Semantic version (public version name) - - `N`: Build/internal number (Android `versionCode`, iOS `CFBundleVersion`) - -### Build number increment workflow -**Every completed task/PR should increment the build number by 1:** - -1. After merging a feature, fix, or refactor to `main`: - - Edit `pubspec.yaml` and bump the build number: `version: X.Y.Z+N` → `version: X.Y.Z+(N+1)` - - Example: `1.0.0+1` → `1.0.0+2` - - Do not change the semantic version unless explicitly planning a release - -2. When releasing to stores: - - Bump semantic version (e.g., `1.0.0` → `1.1.0` for feature release, `1.1.1` for hotfix) - - Commit: `git commit -m "chore: bump version to X.Y.Z"` - - Tag: `git tag vX.Y.Z` (tag format does not include build number) - - Push with tags: `git push origin main --tags` - - CI workflow (on `v*` tag) will validate pubspec version matches tag and create GitHub Release - -### Example increment sequence -``` -Task 1: Add device filter feature → version: 1.0.0+2 (merged, bumped) -Task 2: Fix PDF export bug → version: 1.0.0+3 (merged, bumped) -Task 3: Localize to French → version: 1.0.0+4 (merged, bumped) -Release 1.1.0: Prepare release → version: 1.1.0+4 (semantic bump), tag v1.1.0 -Task 4: Refactor risk scoring → version: 1.1.0+5 (merged, bumped) -``` - -## Folder Structure - -- **`lib/`**: Dart source code - - `main.dart`: App entry point - - `data/catalog_data.dart`: Device catalog, recommendations, translations - - `models/`: Core data models (Device, Room, risk scoring logic) - - `screens/`: UI screens (Welcome, Room/Device Selection, Questionnaire, Summary) - - `widgets/`: Reusable UI components - - `l10n/`: Localization utilities -- **`test/`**: Widget and unit tests -- **`android/`, `ios/`, `web/`, `linux/`, `windows/`, `macos/`**: Platform-specific code -- **`.github/workflows/`**: CI/CD pipelines -- **`.github/ISSUE_TEMPLATE/`**: Issue and PR templates - -## Key Codebase Knowledge - -- **Risk scoring:** Defined in `lib/models/device.dart`; each device receives a score based on security/privacy penalties; overall household score is the mean of device scores -- **Device types:** Categorized in `lib/data/catalog_data.dart` (e.g., Smartphone, SmartSpeaker, SmartTV); each has tailored questionnaire -- **Questionnaire flow:** Users select devices → device-type-specific questions → summary with scores and recommendations -- **Exports:** PDF (styled, multi-page) and plain text; generated on-the-fly from `_SummaryReport` data -- **Localization:** Bidirectional (German ↔ English) string maps in `catalog_data.dart`; generated localizations extend this - -## CI/CD - -- **`check.yml`**: Runs on every push to any branch - - Builds APK release: `flutter build apk --release` - - Uploads APK artifact for download -- **Release workflow** (on `v*` tags): Validates version match, builds, creates GitHub Release (to be implemented) - -## Getting Help - -Refer to: -- `README.md`: Overview and quick start -- `CONTRIBUTING.md`: Setup and developer commands -- `lib/models/RISK_SCORING_README.md`: Risk scoring algorithm details From 0f390c5919a603b3d2e98e34a61b164d96aabd3b Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 14 Sep 2026 10:02:20 +0200 Subject: [PATCH 15/16] update --- .llmrc | 35 ++++++++++++++++++++++++++--------- AGENTS.md | 44 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/.llmrc b/.llmrc index 8f130bb..90186c1 100644 --- a/.llmrc +++ b/.llmrc @@ -30,8 +30,10 @@ This file provides essential context for LLMs working on the Simplications proje ### Device-Specific Features -The app dynamically changes questions based on device type: +The app dynamically changes questions based on `DeviceTemplate.deviceType` +(the `DeviceCategory` enum in `lib/models/device.dart`): +- **sensor**: Sampling frequency, data deletion, local processing (3 device-specific Q's) - **speaker**: Voice history, local processing, app permissions (3 device-specific Q's) - **camera**: Encryption, storage, sharing controls, motion detection (4 Q's) - **tv**: Account required, tracking disabled, offline mode (3 Q's) @@ -39,8 +41,9 @@ The app dynamically changes questions based on device type: - **lock**: Offline unlock, access logging, 2FA (3 Q's) - **robot**: Map privacy, cloud requirement, vision data (3 Q's) - **toy**: Parental control, child data limits, recording disable (3 Q's) -- **wearable**: Health data sharing, location tracking (2 Q's) - **light/plug/blind**: Local control, usage tracking, offline fallback (3 Q's) +- **general/custom/router/hub/meter/outdoor/printer/appliance**: no dedicated + question set — fall back to the generic questions below - All devices: 5 universal base questions (password, updates, network, informed, permissions) - Conditional: Camera/microphone consent questions if applicable @@ -74,6 +77,9 @@ lib/ │ │ ├── enum RiskLevel { low, medium, high } │ │ ├── enum ActionType { social, technical, security } │ │ ├── enum ActionPriority { high, medium, low } +│ │ ├── enum DeviceCategory { general, custom, sensor, speaker, camera, +│ │ │ tv, thermostat, plug, light, lock, blind, robot, toy, router, +│ │ │ hub, meter, outdoor, printer, appliance } │ │ ├── class PrivacyAction │ │ ├── class DeviceTemplate │ │ ├── class DeviceQuestion(id, text, hint) @@ -104,7 +110,7 @@ lib/ ### Device-Specific Logic -- `DeviceTemplate.deviceType` (string) determines question set +- `DeviceTemplate.deviceType` (`DeviceCategory` enum, `lib/models/device.dart`) determines question set - `DeviceInstance.questions` property returns conditional question list - `CatalogData.getDeviceSpecificQuestions(template)` provides question definitions - `CatalogData.getDeviceSpecificActions(template)` provides action recommendations @@ -163,9 +169,9 @@ lib/ ### To Add Support for a New Device Type -1. Add device type string (e.g., `'dishwasher'`) to device constants +1. Add a new value to `enum DeviceCategory` in `lib/models/device.dart` (e.g., `dishwasher`) 2. Create 3-4 device-specific questions in `catalog_data.dart` -3. Add conditional questions in `DeviceInstance.questions` getter (if device type == 'dishwasher') +3. Add conditional questions in `DeviceInstance.questions` getter (`if (template.deviceType == DeviceCategory.dishwasher)`) 4. Add 3-5 device-specific actions in `CatalogData.getDeviceSpecificActions()` 5. Test with custom device creation or add to predefined devices list @@ -206,7 +212,7 @@ lib/ - Variable/method names: camelCase - Class names: PascalCase - Constants: camelCase with const keyword -- Device type strings: lowercase single word (speaker, camera, tv, lock, robot, toy, wearable, thermostat, light, plug, blind, router, hub, meter, printer, appliance, outdoor) +- `DeviceCategory` enum values (camelCase, `lib/models/device.dart`): general, custom, sensor, speaker, camera, tv, thermostat, plug, light, lock, blind, robot, toy, router, hub, meter, outdoor, printer, appliance - Question IDs: lowercase with underscores (e.g., 'password', 'voice_history', 'video_encryption') ## File Locations & Purposes @@ -249,9 +255,9 @@ Before submitting changes: ### Device-specific questions not appearing -- Check `DeviceTemplate.deviceType` matches string used in `DeviceInstance.questions` +- Check `DeviceTemplate.deviceType` matches the `DeviceCategory` value checked in `DeviceInstance.questions` - Verify question added to `CatalogData.getDeviceSpecificQuestions()` -- Check conditional logic: `if (template.deviceType == 'speaker')` +- Check conditional logic: `if (template.deviceType == DeviceCategory.speaker)` ### Custom devices not showing in grid @@ -259,15 +265,26 @@ Before submitting changes: - Check grid logic: `allDevices.length + state.customDevices.length + 1` - Ensure `isCustom` flag is properly set +## CI/CD + +- **`check.yml`**: Runs on every push to any branch; builds APK release + (`flutter build apk --release`) and uploads it as an artifact. +- **Release workflow** (on `v*` tags): Validates the tag matches the + `pubspec.yaml` version and creates the GitHub Release — see the release + tagging steps in `AGENTS.md`. + ## Resources - **Flutter Docs**: - **Material Design 3**: - **Dart Language**: - **BSI Smart Home Security** (reference): German Federal Office for Information Security guidelines +- **`lib/models/RISK_SCORING_README.md`**: Risk scoring algorithm details -## Recent Changes (April 2026) +## Recent Changes +- ✅ (2026-09) Refactored `DeviceTemplate.deviceType` from a raw string to the + `DeviceCategory` enum (`lib/models/device.dart`) - ✅ Added custom room and device support with full UI - ✅ Implemented 40+ device-specific questions across 12+ device types - ✅ Added 30+ BSI-based security actions mapped to device types diff --git a/AGENTS.md b/AGENTS.md index 363aa10..d64648e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,20 @@ version: 1.0.0+4 Never skip numbers. Never decrease the build number. +### Release tagging (semantic version) + +This is separate from the per-edit build-number bump above and only applies +when actually cutting a release, not on ordinary task/PR work: + +1. Bump the semantic version in `pubspec.yaml` (e.g. `1.0.0+41` → `1.1.0+41` + for a feature release, `1.1.1+41` for a hotfix) — leave the build number + as-is unless a core file also changed. +2. Commit: `chore: bump version to X.Y.Z`. +3. Tag: `git tag vX.Y.Z` (the tag does not include the build number). +4. Push with tags: `git push origin main --tags`. +5. The `v*`-tag release workflow validates the tag matches `pubspec.yaml` and + creates the GitHub Release. + --- ## Project Overview @@ -55,8 +69,11 @@ Never skip numbers. Never decrease the build number. their smart-home rooms, select devices, answer privacy questions, and receive a risk score with concrete action recommendations. -- **Platform targets**: Android, iOS, Web, Windows -- **Languages**: Dart / Flutter +- **Repository**: +- **Platform targets**: Android, iOS, Web, Windows, macOS, Linux +- **Languages**: Dart / Flutter (Flutter 3.11.5+, Dart 3.1.0+) +- **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` - **State persistence**: `SharedPreferences` via `SurveyState` - **No backend** – all data stays on-device @@ -107,6 +124,15 @@ test/ `resolveKey` extension in `lib/l10n/app_localizations_key_resolver.dart` instead, and add new dynamic keys there too. +### PDF export + +- All text rendered to PDF must use characters covered by Helvetica (Latin-1 / + 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. + ### Risk scoring - Base risk is set per `DeviceTemplate.baseRiskScore` in `catalog_data.dart`. @@ -121,18 +147,27 @@ test/ - Run `flutter analyze` to catch static issues. - When changing risk scoring logic, update `test/device_risk_scoring_test.dart` with the new expected values. +- Write widget tests for complex UI components (dialogs, forms, risk displays). +- Test localization changes against at least two locales, preferably one with + non-ASCII/special characters (e.g. `cs`, `pl`, `fr`). ### Code style - 2-space indentation, Dart conventions (PascalCase classes, camelCase members). - Group imports: dart → flutter → package → relative. - Zero analyzer errors and warnings required. +- Add short docstring comments to public classes and methods explaining their + purpose and key behavior. -### Pull request titles +### Commits and pull requests +- Use conventional commit format: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`. - Pull request titles should follow this template: `: `. - Use a lowercase type prefix such as `feat`, `fix`, `docs`, `refactor`, `test`, or `chore`. - Examples: `feat: add summary export`, `fix: preserve selected room state`. +- Reference related issues with `Fixes #…` or `Closes #…` in PR descriptions. +- Split logical changes into separate commits; avoid unrelated refactors in feature PRs. +- All PRs must pass CI (`flutter analyze`, release APK build). --- @@ -143,3 +178,6 @@ test/ - [ ] `flutter analyze` passes (zero errors/warnings) - [ ] Localization keys added in all locales if UI text was added/changed - [ ] No hardcoded user-visible strings in widgets +- [ ] PDF-export strings stay Latin-1/Helvetica-safe (no en/em dashes, no + unsupported accented characters) +- [ ] Widget tests added for complex UI components touched by the change From 263d8239ae7459c89c4eba4990009c6095e00af7 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 14 Sep 2026 11:01:02 +0200 Subject: [PATCH 16/16] feat: Enhance device question handling and improve room completion logic --- .llmrc | 9 +- lib/models/device.dart | 50 +++++++- lib/models/survey_state.dart | 143 ++++++++++++----------- lib/screens/device_result_screen.dart | 8 ++ lib/screens/device_selection_screen.dart | 14 ++- lib/screens/summary_screen.dart | 18 ++- pubspec.yaml | 2 +- test/device_selection_screen_test.dart | 47 +++++++- 8 files changed, 204 insertions(+), 87 deletions(-) diff --git a/.llmrc b/.llmrc index 90186c1..9508041 100644 --- a/.llmrc +++ b/.llmrc @@ -43,9 +43,12 @@ The app dynamically changes questions based on `DeviceTemplate.deviceType` - **toy**: Parental control, child data limits, recording disable (3 Q's) - **light/plug/blind**: Local control, usage tracking, offline fallback (3 Q's) - **general/custom/router/hub/meter/outdoor/printer/appliance**: no dedicated - question set — fall back to the generic questions below -- All devices: 5 universal base questions (password, updates, network, informed, permissions) -- Conditional: Camera/microphone consent questions if applicable + question set — fall back to `password`/`updates`/`network`/`informed` +- Cross-cutting, appended on top of the category's own list (see + `_questionsWithCrossCuttingOnes` in `lib/models/device.dart`): every device + except `sensor` also gets an app-`permissions` question; any device with + `hasCamera`/`hasMicrophone` also gets a camera-consent / mic-active question + if its category list didn't already ask one ### Risk Scoring diff --git a/lib/models/device.dart b/lib/models/device.dart index 98ab652..27ec3e2 100644 --- a/lib/models/device.dart +++ b/lib/models/device.dart @@ -496,8 +496,27 @@ class DeviceInstance { this.expertModeEnabled = false, }); + /// Cross-cutting questions that apply based on device properties rather + /// than [DeviceCategory] — asked in addition to whatever [_baseQuestions] + /// returns for the device's category. + static const DeviceQuestion _qPermissions = DeviceQuestion( + id: 'permissions', + text: 'q_permissions_text', + hint: 'q_permissions_hint', + ); + static const DeviceQuestion _qCameraConsent = DeviceQuestion( + id: 'camera_consent', + text: 'q_camera_consent_text', + hint: 'q_camera_consent_hint', + ); + static const DeviceQuestion _qMicActive = DeviceQuestion( + id: 'mic_active', + text: 'q_mic_active_text', + hint: 'q_mic_active_hint', + ); + List get questions { - final baseQuestions = _baseQuestions; + final baseQuestions = _questionsWithCrossCuttingOnes(_baseQuestions); if (!expertModeEnabled) { return baseQuestions; } @@ -508,6 +527,29 @@ class DeviceInstance { ]; } + /// Appends [_qCameraConsent] / [_qMicActive] when the device has that + /// capability, and [_qPermissions] for every device with a companion app + /// (i.e. everything except bare sensors, which have none) — skipping any + /// that a category's [_baseQuestions] list already asked explicitly. + List _questionsWithCrossCuttingOnes( + List base, + ) { + final questions = [...base]; + bool has(String id) => questions.any((q) => q.id == id); + + if (template.deviceType != DeviceCategory.sensor && + !has(_qPermissions.id)) { + questions.add(_qPermissions); + } + if (template.hasCamera && !has(_qCameraConsent.id)) { + questions.add(_qCameraConsent); + } + if (template.hasMicrophone && !has(_qMicActive.id)) { + questions.add(_qMicActive); + } + return questions; + } + List get _baseQuestions { // ── Base question definitions (referenced selectively per device type) ───── const qPassword = DeviceQuestion( @@ -530,11 +572,7 @@ class DeviceInstance { text: 'q_informed_text', hint: 'q_informed_hint', ); - const qMicActive = DeviceQuestion( - id: 'mic_active', - text: 'q_mic_active_text', - hint: 'q_mic_active_hint', - ); + const qMicActive = _qMicActive; // ── Sensor: tailored set – no app/password/update concept ───────────────── if (template.deviceType == DeviceCategory.sensor) { diff --git a/lib/models/survey_state.dart b/lib/models/survey_state.dart index dcbacff..a752fa0 100644 --- a/lib/models/survey_state.dart +++ b/lib/models/survey_state.dart @@ -441,76 +441,85 @@ class SurveyState extends ChangeNotifier { final restoredInstanceIds = {}; for (final dynamic item in (data['devices'] as List? ?? const [])) { - if (item is! Map) { - continue; - } - final entry = Map.from(item); - final templateId = entry['templateId'] as String?; - if (templateId == null) { - continue; - } - final normalizedTemplateId = - _legacyTemplateIdAliases[templateId] ?? templateId; - final template = templateById[normalizedTemplateId]; - if (template == null) { - continue; - } - final roomId = entry['roomId'] as String; - final restoredInstanceId = - entry['instanceId'] as String? ?? - _nextInstanceId(roomId, template.id); - if (!restoredInstanceIds.add(restoredInstanceId)) { - continue; - } - - final instance = DeviceInstance( - instanceId: restoredInstanceId, - template: template, - roomId: roomId, - roomName: entry['roomName'] as String, - expertModeEnabled: _expertModeEnabled, - ); - - instance.passwordChanged = questionAnswerFromStored( - entry['passwordChanged'], - ); - instance.autoUpdatesEnabled = questionAnswerFromStored( - entry['autoUpdatesEnabled'], - ); - instance.separateNetwork = questionAnswerFromStored( - entry['separateNetwork'], - ); - instance.householdInformed = questionAnswerFromStored( - entry['householdInformed'], - ); - instance.permissionsReduced = questionAnswerFromStored( - entry['permissionsReduced'], - ); - instance.cameraConsentGiven = questionAnswerFromStored( - entry['cameraConsentGiven'], - ); - instance.micDeactivatedWhenUnused = questionAnswerFromStored( - entry['micDeactivatedWhenUnused'], - ); + // One malformed device entry shouldn't cost the user every other + // already-answered device in the same save file. + try { + if (item is! Map) { + continue; + } + final entry = Map.from(item); + final templateId = entry['templateId'] as String?; + if (templateId == null) { + continue; + } + final normalizedTemplateId = + _legacyTemplateIdAliases[templateId] ?? templateId; + final template = templateById[normalizedTemplateId]; + if (template == null) { + continue; + } + final roomId = entry['roomId'] as String?; + if (roomId == null) { + continue; + } + final restoredInstanceId = + entry['instanceId'] as String? ?? + _nextInstanceId(roomId, template.id); + if (!restoredInstanceIds.add(restoredInstanceId)) { + continue; + } + + final instance = DeviceInstance( + instanceId: restoredInstanceId, + template: template, + roomId: roomId, + roomName: entry['roomName'] as String? ?? '', + expertModeEnabled: _expertModeEnabled, + ); - final storedSpecific = Map.from( - entry['deviceSpecificAnswers'] as Map? ?? const {}, - ); - instance.deviceSpecificAnswers - ..clear() - ..addEntries( - storedSpecific.entries - .map( - (entry) => MapEntry( - entry.key, - questionAnswerFromStored(entry.value), - ), - ) - .where((entry) => entry.value != null) - .map((entry) => MapEntry(entry.key, entry.value!)), + instance.passwordChanged = questionAnswerFromStored( + entry['passwordChanged'], + ); + instance.autoUpdatesEnabled = questionAnswerFromStored( + entry['autoUpdatesEnabled'], + ); + instance.separateNetwork = questionAnswerFromStored( + entry['separateNetwork'], + ); + instance.householdInformed = questionAnswerFromStored( + entry['householdInformed'], + ); + instance.permissionsReduced = questionAnswerFromStored( + entry['permissionsReduced'], + ); + instance.cameraConsentGiven = questionAnswerFromStored( + entry['cameraConsentGiven'], + ); + instance.micDeactivatedWhenUnused = questionAnswerFromStored( + entry['micDeactivatedWhenUnused'], ); - devices.add(instance); + final storedSpecific = Map.from( + entry['deviceSpecificAnswers'] as Map? ?? const {}, + ); + instance.deviceSpecificAnswers + ..clear() + ..addEntries( + storedSpecific.entries + .map( + (entry) => MapEntry( + entry.key, + questionAnswerFromStored(entry.value), + ), + ) + .where((entry) => entry.value != null) + .map((entry) => MapEntry(entry.key, entry.value!)), + ); + + devices.add(instance); + } catch (_) { + // Skip just this entry; keep restoring the rest of the list. + } } notifyListeners(); diff --git a/lib/screens/device_result_screen.dart b/lib/screens/device_result_screen.dart index 4ac6df9..3a5525d 100644 --- a/lib/screens/device_result_screen.dart +++ b/lib/screens/device_result_screen.dart @@ -298,6 +298,14 @@ class DeviceResultScreen extends StatelessWidget { Expanded( child: FilledButton.icon( onPressed: () { + // Reaching the summary directly from a finished device + // should unlock the room-list "Results" gate too, not + // only the in-room shortcut — otherwise backing out to + // room selection shows Results as unavailable despite + // having just viewed it. + if (state.hasFinishedDeviceInRoom(room.id)) { + state.markRoomCompleted(room.id); + } Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => SummaryScreen(state: state), diff --git a/lib/screens/device_selection_screen.dart b/lib/screens/device_selection_screen.dart index 8b4e971..d36bbef 100644 --- a/lib/screens/device_selection_screen.dart +++ b/lib/screens/device_selection_screen.dart @@ -101,13 +101,23 @@ class _DeviceSelectionScreenState extends State { Navigator.of(context).pop(); } + // Only the room the user actually finished a device in gets marked + // completed — otherwise tapping through an untouched room (or one where + // results are only available because a *different* room was finished) + // would falsely show it as done on the room list. + void _markCompletedIfFinished() { + if (widget.state.hasFinishedDeviceInRoom(widget.room.id)) { + widget.state.markRoomCompleted(widget.room.id); + } + } + void _onNext(BuildContext context) { - widget.state.markRoomCompleted(widget.room.id); + _markCompletedIfFinished(); Navigator.of(context).pop(); } void _onFinish(BuildContext context) { - widget.state.markRoomCompleted(widget.room.id); + _markCompletedIfFinished(); Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => SummaryScreen(state: widget.state)), ); diff --git a/lib/screens/summary_screen.dart b/lib/screens/summary_screen.dart index 15a08a9..4c52d5c 100644 --- a/lib/screens/summary_screen.dart +++ b/lib/screens/summary_screen.dart @@ -857,12 +857,18 @@ class _OverviewHeader extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - '$overallScore', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: scoreColor, + // FittedBox keeps a 3-digit score (the maximum + // possible, 100) on one line instead of wrapping and + // overflowing this fixed-size circle. + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '$overallScore', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: scoreColor, + ), ), ), Text( diff --git a/pubspec.yaml b/pubspec.yaml index ce0db2d..21313bf 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+41 +version: 1.0.0+42 environment: sdk: ^3.11.5 diff --git a/test/device_selection_screen_test.dart b/test/device_selection_screen_test.dart index 21ceb80..732eaf3 100644 --- a/test/device_selection_screen_test.dart +++ b/test/device_selection_screen_test.dart @@ -340,7 +340,9 @@ void main() { expect(resultsButton().onPressed, isNotNull); }); - testWidgets('next-room action marks room completed', (tester) async { + testWidgets('next-room action does not complete an untouched room', ( + tester, + ) async { _configureLargeViewport(tester); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); @@ -361,6 +363,47 @@ void main() { ); await tester.pumpAndSettle(); - expect(state.completedRoomIds.contains(room.id), isTrue); + // Leaving via "next room" without adding or finishing a device must not + // falsely mark the room as assessed — that misleads the room list into + // showing it as done when nothing was actually checked. + expect(state.completedRoomIds.contains(room.id), isFalse); + expect(state.isRoomCompleted(room.id), isFalse); }); + + testWidgets( + 'next-room action marks room completed once a device is finished', + (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('office'); + + final template = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + state.addDevice(template, room.id, room.name); + final instance = state.devices.single; + for (final question in instance.questions) { + instance.setAnswer(question.id, QuestionAnswer.yes); + } + state.notifyUpdate(); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap( + find.widgetWithText(OutlinedButton, localizations.nextRoom), + ); + await tester.pumpAndSettle(); + + expect(state.completedRoomIds.contains(room.id), isTrue); + }, + ); }