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/.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
diff --git a/.llmrc b/.llmrc
index 8f130bb..9508041 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,10 +41,14 @@ 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)
-- All devices: 5 universal base questions (password, updates, network, informed, permissions)
-- Conditional: Camera/microphone consent questions if applicable
+- **general/custom/router/hub/meter/outdoor/printer/appliance**: no dedicated
+ 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
@@ -74,6 +80,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 +113,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 +172,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 +215,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 +258,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 +268,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 209eb14..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
@@ -94,10 +111,27 @@ 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.
+
+### 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
@@ -113,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).
---
@@ -135,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
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
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/app_cs.arb b/lib/l10n/app_cs.arb
index 76a7211..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í",
@@ -384,6 +386,8 @@
"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",
+ "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í",
@@ -450,7 +454,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..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",
@@ -384,6 +386,8 @@
"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",
+ "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",
@@ -450,7 +454,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..71673a7 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 eine Cloud.",
"stepSelectRooms": "Raum auswählen",
"stepCaptureDevices": "Geräte erfassen",
"stepAnswerQuestions": "Fragen beantworten",
@@ -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",
@@ -143,6 +145,8 @@
"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",
+ "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 b734163..82890f2 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 a cloud.",
"stepSelectRooms": "Select room",
"stepCaptureDevices": "Capture devices",
"stepAnswerQuestions": "Answer questions",
@@ -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",
@@ -143,6 +145,8 @@
"overview": "Overview",
"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 1247748..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",
@@ -384,6 +386,8 @@
"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é",
+ "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",
@@ -450,7 +454,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..0ef0b2c 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 a cloud.'**
String get welcomePrivacyDescription;
/// No description provided for @stepSelectRooms.
@@ -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:
@@ -966,6 +978,18 @@ 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 @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 d5030ee..7b3e24f 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';
@@ -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';
@@ -472,6 +478,13 @@ 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 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 8b4107a..828512e 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';
@@ -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';
@@ -469,6 +475,12 @@ 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 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 93e4c42..86e1245 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 eine Cloud.';
@override
String get stepSelectRooms => 'Raum auswählen';
@@ -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';
@@ -471,6 +477,13 @@ 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 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 399e2be..7be5142 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 a cloud.';
@override
String get stepSelectRooms => 'Select room';
@@ -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';
@@ -467,6 +473,12 @@ 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 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 be4d8ac..3ae53de 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';
@@ -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';
@@ -477,6 +483,13 @@ 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 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 867a699..5bc792d 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';
@@ -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';
@@ -471,6 +477,13 @@ 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 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 01efdea..16480bd 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';
@@ -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';
@@ -471,6 +477,13 @@ 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 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 d42509e..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",
@@ -384,6 +386,8 @@
"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",
+ "noIncompleteDevicesLeft": "Geen onvoltooide apparaten meer.",
"sl_access_logging": "Geen toegangslogboek beschikbaar",
"sl_account_required": "Verplichte leveranciersaccount vereist",
"sl_base_risk": "Basisrisico van apparaattype",
@@ -450,7 +454,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..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",
@@ -384,6 +386,8 @@
"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",
+ "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",
@@ -450,7 +454,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/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 1eabacb..27ec3e2 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 }
@@ -83,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;
@@ -91,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({
@@ -103,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,
});
}
@@ -429,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;
}
@@ -441,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(
@@ -463,14 +572,10 @@ 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 == 'sensor') {
+ if (template.deviceType == DeviceCategory.sensor) {
return const [
DeviceQuestion(
id: 'sensor_frequency',
@@ -491,7 +596,7 @@ class DeviceInstance {
}
// ── Speaker ───────────────────────────────────────────────────────────────
- if (template.deviceType == 'speaker') {
+ if (template.deviceType == DeviceCategory.speaker) {
return [
const DeviceQuestion(
id: 'voice_history',
@@ -513,7 +618,7 @@ class DeviceInstance {
}
// ── Camera ────────────────────────────────────────────────────────────────
- if (template.deviceType == 'camera') {
+ if (template.deviceType == DeviceCategory.camera) {
return const [
DeviceQuestion(
id: 'video_encryption',
@@ -539,7 +644,7 @@ class DeviceInstance {
}
// ── Smart TV ──────────────────────────────────────────────────────────────
- if (template.deviceType == 'tv') {
+ if (template.deviceType == DeviceCategory.tv) {
return [
qUpdates,
const DeviceQuestion(
@@ -561,7 +666,7 @@ class DeviceInstance {
}
// ── Thermostat ────────────────────────────────────────────────────────────
- if (template.deviceType == 'thermostat') {
+ if (template.deviceType == DeviceCategory.thermostat) {
return [
qUpdates,
const DeviceQuestion(
@@ -583,9 +688,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(
@@ -607,7 +712,7 @@ class DeviceInstance {
}
// ── Smart Lock ────────────────────────────────────────────────────────────
- if (template.deviceType == 'lock') {
+ if (template.deviceType == DeviceCategory.lock) {
return [
qPassword,
const DeviceQuestion(
@@ -629,7 +734,7 @@ class DeviceInstance {
}
// ── Robot Vacuum ──────────────────────────────────────────────────────────
- if (template.deviceType == 'robot') {
+ if (template.deviceType == DeviceCategory.robot) {
return [
qUpdates,
const DeviceQuestion(
@@ -651,7 +756,7 @@ class DeviceInstance {
}
// ── Connected Toy ─────────────────────────────────────────────────────────
- if (template.deviceType == 'toy') {
+ if (template.deviceType == DeviceCategory.toy) {
return [
qUpdates,
const DeviceQuestion(
@@ -725,7 +830,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',
@@ -734,7 +840,7 @@ class DeviceInstance {
),
);
}
- if (template.deviceType == 'lock') {
+ if (template.deviceType == DeviceCategory.lock) {
questions.add(
const DeviceQuestion(
id: 'expert_access_revocation',
@@ -794,101 +900,27 @@ 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,
- );
- }
-
- return score.clamp(0, 100);
- }
+ /// 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);
- 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) {
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) {
@@ -1072,18 +1104,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 c11468a..a752fa0 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 = {
@@ -120,25 +165,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 +211,15 @@ 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) =>
+ evaluatedDevicesForRoom(roomId).worstRiskLevel;
+
+ int evaluatedDeviceCountForRoom(String roomId) =>
+ evaluatedDevicesForRoom(roomId).length;
+
bool get hasAnyData =>
completedRoomIds.isNotEmpty ||
visitedRoomIds.isNotEmpty ||
@@ -243,7 +308,7 @@ class SurveyState extends ChangeNotifier {
hasCamera: hasCamera,
hasMicrophone: hasMicrophone,
roomIds: [roomId],
- deviceType: 'custom',
+ deviceType: DeviceCategory.custom,
isCustom: true,
);
customDevices.add(newDevice);
@@ -332,7 +397,7 @@ class SurveyState extends ChangeNotifier {
roomIds: (e['roomIds'] as List? ?? const [])
.whereType()
.toList(),
- deviceType: 'custom',
+ deviceType: DeviceCategory.custom,
isCustom: true,
),
),
@@ -373,77 +438,88 @@ class SurveyState extends ChangeNotifier {
};
devices.clear();
- final restoredDeviceKeys = {};
+ 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 restoredDeviceKey = '$roomId:${template.id}';
- if (!restoredDeviceKeys.add(restoredDeviceKey)) {
- continue;
- }
-
- final instance = DeviceInstance(
- instanceId: entry['instanceId'] as String,
- 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();
@@ -470,7 +546,7 @@ class SurveyState extends ChangeNotifier {
customRooms.clear();
customDevices.clear();
_expertModeEnabled = false;
- await clearStorage();
+ await _enqueueWrite(clearStorage);
notifyListeners();
}
@@ -564,8 +640,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/about_screen.dart b/lib/screens/about_screen.dart
index d35236c..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,13 +10,13 @@ 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;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(localizations.websiteOpenFailed)),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(localizations.websiteOpenFailed)));
}
}
@@ -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)),
@@ -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..816c459 100644
--- a/lib/screens/device_questionnaire_screen.dart
+++ b/lib/screens/device_questionnaire_screen.dart
@@ -1,10 +1,11 @@
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';
+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
@@ -66,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)
@@ -327,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);
@@ -443,13 +452,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_result_screen.dart b/lib/screens/device_result_screen.dart
new file mode 100644
index 0000000..3a5525d
--- /dev/null
+++ b/lib/screens/device_result_screen.dart
@@ -0,0 +1,370 @@
+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';
+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 = context.l10n;
+ final riskColor = device.riskLevel.color;
+ 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: () {
+ // 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),
+ ),
+ );
+ },
+ 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;
+ }
+ }
+}
diff --git a/lib/screens/device_selection_screen.dart b/lib/screens/device_selection_screen.dart
index 06f572c..d36bbef 100644
--- a/lib/screens/device_selection_screen.dart
+++ b/lib/screens/device_selection_screen.dart
@@ -1,14 +1,16 @@
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';
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,27 +20,57 @@ 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