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>( + final result = await showDialog( context: context, - builder: (context) => const CustomDeviceDialog(), + builder: (context) => CustomDeviceDialog(), ); if (result != null && context.mounted) { - state.addCustomDevice( - room.id, - result['name'] as String, - result['icon'] as IconData, - result['riskScore'] as int, - hasCamera: result['hasCamera'] as bool, - hasMicrophone: result['hasMicrophone'] as bool, + widget.state.addCustomDevice( + widget.room.id, + result.name, + result.icon, + result.riskScore, + hasCamera: result.hasCamera, + hasMicrophone: result.hasMicrophone, ); } } Future _markNoDevice(BuildContext context) async { - final localizations = AppLocalizations.of(context)!; - final hasExistingDevices = state.devicesForRoom(room.id).isNotEmpty; + final localizations = context.l10n; + final hasExistingDevices = widget.state + .devicesForRoom(widget.room.id) + .isNotEmpty; final shouldMarkNoDevice = await showDialog( context: context, builder: (context) => AlertDialog( @@ -65,24 +97,34 @@ class DeviceSelectionScreen extends StatelessWidget { return; } - state.markRoomWithoutDevices(room.id); + widget.state.markRoomWithoutDevices(widget.room.id); Navigator.of(context).pop(); } + // Only the room the user actually finished a device in gets marked + // completed — otherwise tapping through an untouched room (or one where + // results are only available because a *different* room was finished) + // would falsely show it as done on the room list. + void _markCompletedIfFinished() { + if (widget.state.hasFinishedDeviceInRoom(widget.room.id)) { + widget.state.markRoomCompleted(widget.room.id); + } + } + void _onNext(BuildContext context) { - state.markRoomCompleted(room.id); + _markCompletedIfFinished(); Navigator.of(context).pop(); } void _onFinish(BuildContext context) { - state.markRoomCompleted(room.id); + _markCompletedIfFinished(); Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => SummaryScreen(state: state)), + MaterialPageRoute(builder: (_) => SummaryScreen(state: widget.state)), ); } void _removeCustomDevice(BuildContext context, String deviceId) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; showDialog( context: context, builder: (context) => AlertDialog( @@ -95,7 +137,34 @@ class DeviceSelectionScreen extends StatelessWidget { ), TextButton( onPressed: () { - state.removeCustomDevice(deviceId); + widget.state.removeCustomDevice(deviceId); + Navigator.pop(context); + }, + child: Text( + localizations.delete, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } + + void _removeDeviceInstance(BuildContext context, String instanceId) { + final localizations = context.l10n; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(localizations.deleteDeviceTitle), + content: Text(localizations.deleteDeviceBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(localizations.cancel), + ), + TextButton( + onPressed: () { + widget.state.removeDevice(instanceId); Navigator.pop(context); }, child: Text( @@ -112,26 +181,44 @@ class DeviceSelectionScreen extends StatelessWidget { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; - final isCustomRoom = state.customRooms.any((r) => r.id == room.id); + final localizations = context.l10n; + final isCustomRoom = widget.state.customRooms.any( + (r) => r.id == widget.room.id, + ); final catalogDevices = isCustomRoom ? CatalogData.sortedDeviceTemplates(CatalogData.allDeviceTemplates) - : CatalogData.devicesForRoom(room.id); + : CatalogData.devicesForRoom(widget.room.id); return Scaffold( appBar: AppBar( title: Text( - '${localizations.deviceTitlePrefix}: ${CatalogData.roomName(localizations, room)}', + '${localizations.deviceTitlePrefix}: ${CatalogData.roomName(localizations, widget.room)}', style: const TextStyle(fontWeight: FontWeight.w600), ), centerTitle: false, ), body: ListenableBuilder( - listenable: state, + listenable: widget.state, builder: (context, _) { - final customDevices = state.customDevicesForRoom(room.id); - final allDevices = [...catalogDevices, ...customDevices]; - final isNoDeviceSelected = state.noDeviceRoomIds.contains(room.id); + final roomInstances = widget.state.devicesForRoom(widget.room.id); + final customDevices = widget.state.customDevicesForRoom( + widget.room.id, + ); + final filteredCatalogDevices = _filterDevices( + localizations, + catalogDevices, + ); + final filteredCustomDevices = _filterDevices( + localizations, + customDevices, + ); + final allDevices = [ + ...filteredCatalogDevices, + ...filteredCustomDevices, + ]; + final isNoDeviceSelected = widget.state.noDeviceRoomIds.contains( + widget.room.id, + ); return CustomScrollView( slivers: [ @@ -150,7 +237,7 @@ class DeviceSelectionScreen extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: Icon( - room.icon, + widget.room.icon, color: colors.primary, size: 24, ), @@ -167,7 +254,10 @@ class DeviceSelectionScreen extends StatelessWidget { ), ), Text( - CatalogData.roomName(localizations, room), + CatalogData.roomName( + localizations, + widget.room, + ), style: text.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), @@ -190,6 +280,37 @@ class DeviceSelectionScreen extends StatelessWidget { color: colors.onSurfaceVariant, ), ), + const SizedBox(height: 12), + TextField( + controller: _searchController, + onChanged: (value) { + if (value == _searchQuery) { + return; + } + setState(() { + _searchQuery = value; + }); + }, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + hintText: localizations.searchDevicesHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + isDense: true, + suffixIcon: _searchQuery.isEmpty + ? null + : IconButton( + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + icon: const Icon(Icons.clear), + ), + ), + ), const SizedBox(height: 16), ], ), @@ -210,8 +331,8 @@ class DeviceSelectionScreen extends StatelessWidget { SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 220, childAspectRatio: 2.2, crossAxisSpacing: 12, mainAxisSpacing: 12, @@ -232,27 +353,40 @@ class DeviceSelectionScreen extends StatelessWidget { } final device = allDevices[index]; - final isAdded = state.isDeviceAdded(room.id, device.id); - final instances = state - .devicesForRoom(room.id) + final instances = widget.state + .devicesForRoom(widget.room.id) .where((i) => i.template.id == device.id); - final isCompleted = - instances.isNotEmpty && instances.first.isFullyAnswered; - final isCustom = customDevices.contains(device); + final instanceList = instances.toList(); + final completedCount = instanceList + .where((instance) => instance.isFullyAnswered) + .length; + final riskLevel = instanceList + .where((instance) => instance.isFullyAnswered) + .worstRiskLevel; + final hasIncomplete = instanceList.any( + (instance) => !instance.isFullyAnswered, + ); + final isCustom = filteredCustomDevices.contains(device); return _DeviceCard( device: device, - isAdded: isAdded, - isCompleted: isCompleted, + instanceCount: instanceList.length, + completedCount: completedCount, + hasIncomplete: hasIncomplete, + riskLevel: riskLevel, isCustom: isCustom, onTap: () { - state.addDevice(device, room.id, room.name); + final instance = widget.state.addDevice( + device, + widget.room.id, + widget.room.name, + ); Navigator.of(context).push( MaterialPageRoute( builder: (_) => DeviceQuestionnaireScreen( - state: state, - room: room, - instanceId: '${room.id}_${device.id}', + state: widget.state, + room: widget.room, + instanceId: instance.instanceId, ), ), ); @@ -264,6 +398,68 @@ class DeviceSelectionScreen extends StatelessWidget { }, childCount: allDevices.length + 2), ), ), + if (roomInstances.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 10), + sliver: SliverToBoxAdapter( + child: Text( + localizations.overview, + style: text.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + if (roomInstances.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final instance = roomInstances[index]; + final countForTemplate = roomInstances + .take(index + 1) + .where( + (device) => + device.template.id == instance.template.id, + ) + .length; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _DeviceInstanceCard( + instance: instance, + sequence: countForTemplate, + onOpen: () { + if (instance.isFullyAnswered) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DeviceResultScreen( + state: widget.state, + room: widget.room, + instanceId: instance.instanceId, + ), + ), + ); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DeviceQuestionnaireScreen( + state: widget.state, + room: widget.room, + instanceId: instance.instanceId, + ), + ), + ); + }, + onDelete: () => _removeDeviceInstance( + context, + instance.instanceId, + ), + ), + ); + }, childCount: roomInstances.length), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 100)), ], ); @@ -272,8 +468,112 @@ class DeviceSelectionScreen extends StatelessWidget { bottomNavigationBar: _BottomBar( onNext: () => _onNext(context), onFinish: () => _onFinish(context), - state: state, - currentRoomId: room.id, + state: widget.state, + currentRoomId: widget.room.id, + ), + ); + } +} + +class _DeviceInstanceCard extends StatelessWidget { + final DeviceInstance instance; + final int sequence; + final VoidCallback onOpen; + final VoidCallback onDelete; + + const _DeviceInstanceCard({ + required this.instance, + required this.sequence, + required this.onOpen, + required this.onDelete, + }); + + Color _riskColor() { + if (!instance.isFullyAnswered) { + return RiskLevel.medium.color; + } + return instance.riskLevel.color; + } + + String _riskLabel(AppLocalizations localizations) { + if (!instance.isFullyAnswered) { + return localizations.notCompleted; + } + switch (instance.riskLevel) { + case RiskLevel.high: + return localizations.highRisk; + case RiskLevel.medium: + return localizations.mediumRisk; + case RiskLevel.low: + return localizations.lowRisk; + } + } + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + final localizations = context.l10n; + final riskColor = _riskColor(); + + return Card( + elevation: 0, + color: riskColor.withValues(alpha: 0.08), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: riskColor.withValues(alpha: 0.45)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Row( + children: [ + Icon(instance.template.icon, size: 20, color: riskColor), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${CatalogData.deviceName(localizations, instance.template)} #$sequence', + style: text.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + instance.isFullyAnswered + ? '${_riskLabel(localizations)} • ${instance.riskScore}/100' + : _riskLabel(localizations), + style: text.labelSmall?.copyWith( + color: riskColor, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + tooltip: instance.isFullyAnswered + ? localizations.results + : localizations.resumeIncompleteDevice, + onPressed: onOpen, + icon: Icon( + instance.isFullyAnswered + ? Icons.arrow_forward_ios + : Icons.play_circle_fill, + size: 18, + ), + ), + IconButton( + tooltip: localizations.delete, + onPressed: onDelete, + icon: const Icon(Icons.delete_outline, size: 18), + ), + ], + ), ), ); } @@ -281,26 +581,45 @@ class DeviceSelectionScreen extends StatelessWidget { class _DeviceCard extends StatelessWidget { final DeviceTemplate device; - final bool isAdded; - final bool isCompleted; + final int instanceCount; + final int completedCount; + final bool hasIncomplete; + final RiskLevel? riskLevel; final bool isCustom; final VoidCallback onTap; final VoidCallback? onRemove; const _DeviceCard({ required this.device, - required this.isAdded, - required this.isCompleted, + required this.instanceCount, + required this.completedCount, + required this.hasIncomplete, + required this.riskLevel, required this.isCustom, required this.onTap, this.onRemove, }); + Color _riskColor() { + switch (riskLevel) { + case final RiskLevel level: + return level.color; + case null: + return Colors.transparent; + } + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final localizations = AppLocalizations.of(context)!; - final contentColor = isCompleted + final localizations = context.l10n; + final hasInstances = instanceCount > 0; + final allCompleted = hasInstances && completedCount == instanceCount; + final riskColor = _riskColor(); + final hasRiskColor = riskLevel != null; + final contentColor = hasRiskColor + ? riskColor + : allCompleted ? colors.onSurface.withValues(alpha: 0.35) : colors.onSurfaceVariant; @@ -309,83 +628,149 @@ class _DeviceCard extends StatelessWidget { child: AnimatedContainer( duration: const Duration(milliseconds: 160), decoration: BoxDecoration( - color: isCompleted + color: hasRiskColor + ? riskColor.withValues(alpha: 0.08) + : allCompleted ? colors.surfaceContainerLow : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), border: Border.all( - color: isCompleted + color: hasRiskColor + ? riskColor + : allCompleted ? Colors.transparent + : hasIncomplete + ? colors.tertiary : (isCustom ? colors.tertiaryContainer : Colors.transparent), width: 2, ), ), padding: const EdgeInsets.all(8), - child: Stack( - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, + child: LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(1); + final compact = constraints.maxHeight < 72 || textScale > 1.15; + final showProgress = hasInstances && !compact; + + return Stack( children: [ - Row( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(device.icon, size: 20, color: contentColor), - const Spacer(), - if (device.hasCamera && !isCompleted) - Tooltip( - message: localizations.camera, - child: Icon( - Icons.videocam, - size: 12, + Row( + children: [ + Icon(device.icon, size: 20, color: contentColor), + const Spacer(), + if (device.hasCamera && !allCompleted) + Tooltip( + message: localizations.camera, + child: Icon( + Icons.videocam, + size: 12, + color: contentColor, + ), + ), + if (device.hasMicrophone && !allCompleted) + Tooltip( + message: localizations.microphone, + child: Icon( + Icons.mic, + size: 12, + color: contentColor, + ), + ), + ], + ), + SizedBox(height: compact ? 2 : 4), + Flexible( + child: Text( + CatalogData.deviceName(localizations, device), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, color: contentColor, ), + maxLines: compact ? 1 : 2, + overflow: TextOverflow.ellipsis, ), - if (device.hasMicrophone && !isCompleted) - Tooltip( - message: localizations.microphone, - child: Icon(Icons.mic, size: 12, color: contentColor), + ), + if (showProgress) ...[ + const SizedBox(height: 4), + Text( + allCompleted + ? '$completedCount/$instanceCount ${localizations.done}' + : '$completedCount/$instanceCount', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: hasRiskColor + ? riskColor + : allCompleted + ? Colors.green.shade700 + : colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + ], ], ), - const SizedBox(height: 4), - Text( - CatalogData.deviceName(localizations, device), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - color: contentColor, + if (allCompleted && !hasRiskColor) + const Positioned( + top: 0, + right: 0, + child: Icon( + Icons.check_circle, + size: 16, + color: Colors.green, + ), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - if (isCompleted) - const Positioned( - top: 0, - right: 0, - child: Icon(Icons.check_circle, size: 16, color: Colors.green), - ), - if (isCustom && onRemove != null) - Positioned( - top: 0, - right: isCompleted ? 22 : 0, - child: GestureDetector( - onTap: onRemove, - child: Container( - decoration: BoxDecoration( - color: Colors.red.shade400, - shape: BoxShape.circle, + if (hasRiskColor) + Positioned( + top: 0, + right: 0, + child: Icon( + Icons.shield_outlined, + size: 16, + color: riskColor, ), - padding: const EdgeInsets.all(4), - child: const Icon( - Icons.close, - size: 14, - color: Colors.white, + ), + if (hasIncomplete) + Positioned( + top: 0, + right: hasRiskColor ? 22 : (allCompleted ? 22 : 0), + child: Icon( + Icons.timelapse, + size: 16, + color: colors.tertiary, ), ), - ), - ), - ], + if (isCustom && onRemove != null) + Positioned( + top: 0, + right: hasIncomplete && hasRiskColor + ? 44 + : (allCompleted || hasIncomplete || hasRiskColor) + ? 22 + : 0, + child: GestureDetector( + onTap: onRemove, + child: Container( + decoration: BoxDecoration( + color: Colors.red.shade400, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(4), + child: const Icon( + Icons.close, + size: 14, + color: Colors.white, + ), + ), + ), + ), + ], + ); + }, ), ), ); @@ -400,7 +785,7 @@ class _AddDeviceCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return GestureDetector( onTap: onTap, @@ -443,7 +828,7 @@ class _NoDeviceCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return GestureDetector( onTap: onTap, @@ -500,7 +885,7 @@ class _BottomBar extends StatelessWidget { return ListenableBuilder( listenable: state, builder: (context, _) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final hasResultsAvailable = state.hasResultsAvailable || state.hasFinishedDeviceInRoom(currentRoomId); diff --git a/lib/screens/faq_screen.dart b/lib/screens/faq_screen.dart index 71540e8..c75a845 100644 --- a/lib/screens/faq_screen.dart +++ b/lib/screens/faq_screen.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import '../l10n/app_localizations.dart'; +import '../l10n/l10n_extensions.dart'; class FaqScreen extends StatelessWidget { const FaqScreen({super.key}); @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final text = Theme.of(context).textTheme; final entries = _faqEntries(localizations); diff --git a/lib/screens/room_selection_screen.dart b/lib/screens/room_selection_screen.dart index dfc2f48..02e52ea 100644 --- a/lib/screens/room_selection_screen.dart +++ b/lib/screens/room_selection_screen.dart @@ -1,6 +1,8 @@ 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 'device_selection_screen.dart'; @@ -22,22 +24,19 @@ class RoomSelectionScreen extends StatelessWidget { } Future _showAddRoomDialog(BuildContext context) async { - final result = await showDialog>( + final result = await showDialog( context: context, - builder: (context) => const CustomRoomDialog(), + builder: (context) => CustomRoomDialog(), ); if (result != null && context.mounted) { - final room = state.addCustomRoom( - result['name'] as String, - result['icon'] as IconData, - ); + final room = state.addCustomRoom(result.name, result.icon); _openRoom(context, room); } } void _removeCustomRoom(BuildContext context, String roomId) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; showDialog( context: context, builder: (context) => AlertDialog( @@ -73,7 +72,7 @@ class RoomSelectionScreen extends StatelessWidget { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Scaffold( appBar: AppBar( @@ -116,8 +115,8 @@ class RoomSelectionScreen extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 220, childAspectRatio: 2.2, crossAxisSpacing: 12, mainAxisSpacing: 12, @@ -142,12 +141,19 @@ class RoomSelectionScreen extends StatelessWidget { final isIncomplete = state.isRoomIncomplete( room.id, ); + final roomRisk = state.worstRiskLevelForRoom( + room.id, + ); + final evaluatedCount = state + .evaluatedDeviceCountForRoom(room.id); final isCustom = state.customRooms.contains(room); return _RoomCard( room: room, isCompleted: isCompleted, isIncomplete: isIncomplete, + roomRisk: roomRisk, + evaluatedCount: evaluatedCount, isCustom: isCustom, onTap: () => _openRoom(context, room), onRemove: isCustom @@ -211,6 +217,8 @@ class _RoomCard extends StatelessWidget { final Room room; final bool isCompleted; final bool isIncomplete; + final RiskLevel? roomRisk; + final int evaluatedCount; final bool isCustom; final VoidCallback onTap; final VoidCallback? onRemove; @@ -219,15 +227,42 @@ class _RoomCard extends StatelessWidget { required this.room, required this.isCompleted, required this.isIncomplete, + required this.roomRisk, + required this.evaluatedCount, required this.isCustom, required this.onTap, this.onRemove, }); + Color _riskColor(ColorScheme colors) { + final risk = roomRisk; + if (risk == null) { + return isIncomplete ? colors.error : colors.outlineVariant; + } + return risk.color; + } + + String? _riskLabel(AppLocalizations localizations) { + if (roomRisk == null) { + return null; + } + switch (roomRisk!) { + case RiskLevel.high: + return localizations.highRisk; + case RiskLevel.medium: + return localizations.mediumRisk; + case RiskLevel.low: + return localizations.lowRisk; + } + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; + final riskColor = _riskColor(colors); + final riskLabel = _riskLabel(localizations); + final showRisk = riskLabel != null && evaluatedCount > 0; return GestureDetector( onTap: onTap, @@ -239,7 +274,9 @@ class _RoomCard extends StatelessWidget { : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16), border: Border.all( - color: isCompleted + color: showRisk + ? riskColor + : isCompleted ? Colors.transparent : isIncomplete ? colors.error @@ -259,14 +296,18 @@ class _RoomCard extends StatelessWidget { Icon( room.icon, size: 20, - color: isCompleted + color: showRisk + ? riskColor + : isCompleted ? colors.onSurface.withValues(alpha: 0.35) : isIncomplete ? colors.error : colors.onSurfaceVariant, ), const Spacer(), - if (isCompleted) + if (showRisk) + Icon(Icons.shield_outlined, size: 16, color: riskColor) + else if (isCompleted) const Icon( Icons.check_circle, size: 16, @@ -281,7 +322,9 @@ class _RoomCard extends StatelessWidget { CatalogData.roomName(localizations, room), style: Theme.of(context).textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w500, - color: isCompleted + color: showRisk + ? riskColor + : isCompleted ? colors.onSurface.withValues(alpha: 0.35) : isIncomplete ? colors.error @@ -290,7 +333,18 @@ class _RoomCard extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (isCompleted) ...[ + if (showRisk) ...[ + const SizedBox(height: 2), + Text( + '$riskLabel • $evaluatedCount', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: riskColor, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ] else if (isCompleted) ...[ const SizedBox(height: 2), Text( localizations.alreadyEvaluated, @@ -348,7 +402,7 @@ class _AddRoomCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return GestureDetector( onTap: onTap, diff --git a/lib/screens/summary_screen.dart b/lib/screens/summary_screen.dart index 1ee588c..4c52d5c 100644 --- a/lib/screens/summary_screen.dart +++ b/lib/screens/summary_screen.dart @@ -7,8 +7,11 @@ import 'package:share_plus/share_plus.dart'; import '../data/catalog_data.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_key_resolver.dart'; +import '../l10n/l10n_extensions.dart'; import '../models/device.dart'; +import '../models/room.dart'; import '../models/survey_state.dart'; +import 'device_questionnaire_screen.dart'; const String _catalogUrl = 'https://tucmi.github.io/simplications-outreach/pages/massnahmenkatalog.html'; @@ -25,16 +28,61 @@ class SummaryScreen extends StatefulWidget { class _SummaryScreenState extends State { bool _isSharing = false; + DeviceInstance? _firstIncompleteDevice() { + for (final device in widget.state.devices) { + if (!device.isFullyAnswered) { + return device; + } + } + return null; + } + + Room? _roomForId(String roomId) { + for (final room in CatalogData.allRooms) { + if (room.id == roomId) { + return room; + } + } + for (final room in widget.state.customRooms) { + if (room.id == roomId) { + return room; + } + } + return null; + } + + Room _fallbackRoomForDevice(DeviceInstance device) { + return Room(id: device.roomId, name: device.roomName, icon: Icons.home); + } + + void _openFirstIncompleteDevice() { + final localizations = context.l10n; + final device = _firstIncompleteDevice(); + if (device == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(localizations.noIncompleteDevicesLeft)), + ); + return; + } + + final room = _roomForId(device.roomId) ?? _fallbackRoomForDevice(device); + + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DeviceQuestionnaireScreen( + state: widget.state, + room: room, + instanceId: device.instanceId, + ), + ), + ); + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; - final report = _SummaryReport.fromDevices(widget.state.devices); - final devices = report.devices; - final highRisk = report.highRisk; - final medRisk = report.mediumRisk; - final lowRisk = report.lowRisk; + final localizations = context.l10n; return Scaffold( appBar: AppBar( @@ -97,141 +145,155 @@ class _SummaryScreenState extends State { ), ], ), - body: CustomScrollView( - slivers: [ - // ── Overview header ────────────────────────────────────────── - SliverToBoxAdapter( - child: _OverviewHeader( - devices: devices, - skippedDevices: report.skippedDevices, - overallScore: report.overallScore, - dontKnowAnswers: report.dontKnowAnswers, - highCount: highRisk.length, - medCount: medRisk.length, - lowCount: lowRisk.length, - colors: colors, - text: text, - localizations: localizations, - ), - ), + body: ListenableBuilder( + listenable: widget.state, + builder: (context, _) { + final report = _SummaryReport.fromDevices(widget.state.devices); + final devices = report.devices; + final highRisk = report.highRisk; + final medRisk = report.mediumRisk; + final lowRisk = report.lowRisk; + final canContinueIncomplete = _firstIncompleteDevice() != null; + + return CustomScrollView( + slivers: [ + // ── Overview header ────────────────────────────────────────── + SliverToBoxAdapter( + child: _OverviewHeader( + devices: devices, + skippedDevices: report.skippedDevices, + overallScore: report.overallScore, + dontKnowAnswers: report.dontKnowAnswers, + highCount: highRisk.length, + medCount: medRisk.length, + lowCount: lowRisk.length, + canContinueIncomplete: canContinueIncomplete, + onContinueIncomplete: _openFirstIncompleteDevice, + colors: colors, + text: text, + localizations: localizations, + ), + ), - // ── No devices ─────────────────────────────────────────────── - if (devices.isEmpty) - SliverPadding( - padding: const EdgeInsets.all(32), - sliver: SliverToBoxAdapter( - child: Center( - child: Column( - children: [ - Icon( - Icons.check_circle_outline, - size: 64, - color: colors.primary, - ), - const SizedBox(height: 16), - Text( - localizations.noDevicesCaptured, - style: text.titleMedium, - ), - const SizedBox(height: 8), - Text( - localizations.noDevicesHint, - style: text.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - textAlign: TextAlign.center, + // ── No devices ─────────────────────────────────────────────── + if (devices.isEmpty) + SliverPadding( + padding: const EdgeInsets.all(32), + sliver: SliverToBoxAdapter( + child: Center( + child: Column( + children: [ + Icon( + Icons.check_circle_outline, + size: 64, + color: colors.primary, + ), + const SizedBox(height: 16), + Text( + localizations.noDevicesCaptured, + style: text.titleMedium, + ), + const SizedBox(height: 8), + Text( + localizations.noDevicesHint, + style: text.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], ), - ], + ), ), ), - ), - ), - // ── Hohe Risiken ───────────────────────────────────────────── - if (highRisk.isNotEmpty) ...[ - SliverToBoxAdapter( - child: _SectionHeader( - label: localizations.highRisk, - count: highRisk.length, - color: _riskColor(RiskLevel.high), - icon: Icons.warning_rounded, - ), - ), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _DeviceResultCard(device: highRisk[i]), + // ── Hohe Risiken ───────────────────────────────────────────── + if (highRisk.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _SectionHeader( + label: localizations.highRisk, + count: highRisk.length, + color: RiskLevel.high.color, + icon: Icons.warning_rounded, ), - childCount: highRisk.length, ), - ), - ), - ], + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _DeviceResultCard(device: highRisk[i]), + ), + childCount: highRisk.length, + ), + ), + ), + ], - // ── Mittlere Risiken ───────────────────────────────────────── - if (medRisk.isNotEmpty) ...[ - SliverToBoxAdapter( - child: _SectionHeader( - label: localizations.mediumRisk, - count: medRisk.length, - color: _riskColor(RiskLevel.medium), - icon: Icons.info_rounded, - ), - ), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _DeviceResultCard(device: medRisk[i]), + // ── Mittlere Risiken ───────────────────────────────────────── + if (medRisk.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _SectionHeader( + label: localizations.mediumRisk, + count: medRisk.length, + color: RiskLevel.medium.color, + icon: Icons.info_rounded, ), - childCount: medRisk.length, ), - ), - ), - ], + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _DeviceResultCard(device: medRisk[i]), + ), + childCount: medRisk.length, + ), + ), + ), + ], - // ── Niedrige Risiken ───────────────────────────────────────── - if (lowRisk.isNotEmpty) ...[ - SliverToBoxAdapter( - child: _SectionHeader( - label: localizations.lowRisk, - count: lowRisk.length, - color: _riskColor(RiskLevel.low), - icon: Icons.check_circle_rounded, - ), - ), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _DeviceResultCard(device: lowRisk[i]), + // ── Niedrige Risiken ───────────────────────────────────────── + if (lowRisk.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _SectionHeader( + label: localizations.lowRisk, + count: lowRisk.length, + color: RiskLevel.low.color, + icon: Icons.check_circle_rounded, ), - childCount: lowRisk.length, ), - ), - ), - ], + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _DeviceResultCard(device: lowRisk[i]), + ), + childCount: lowRisk.length, + ), + ), + ), + ], - // ── General recommendations ────────────────────────────────── - SliverToBoxAdapter( - child: _GeneralRecommendations(colors: colors, text: text), - ), + // ── General recommendations ────────────────────────────────── + SliverToBoxAdapter( + child: _GeneralRecommendations(colors: colors, text: text), + ), - const SliverToBoxAdapter(child: SizedBox(height: 40)), - ], + const SliverToBoxAdapter(child: SizedBox(height: 40)), + ], + ); + }, ), ); } Future _shareSummary(_ShareFormat format) async { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; if (_isSharing) { return; } @@ -369,17 +431,6 @@ class _SummaryReport { } } -Color _riskColor(RiskLevel level) { - switch (level) { - case RiskLevel.high: - return const Color(0xFFC62828); - case RiskLevel.medium: - return const Color(0xFFE65100); - case RiskLevel.low: - return const Color(0xFF2E7D32); - } -} - String _priorityLabel(ActionPriority priority, AppLocalizations localizations) { switch (priority) { case ActionPriority.high: @@ -681,7 +732,7 @@ Color _riskBg(RiskLevel level) { case RiskLevel.high: return const Color(0xFFFFEBEE); case RiskLevel.medium: - return const Color(0xFFFFF3E0); + return const Color(0xFFFFF8E1); case RiskLevel.low: return const Color(0xFFE8F5E9); } @@ -741,6 +792,8 @@ class _OverviewHeader extends StatelessWidget { final int highCount; final int medCount; final int lowCount; + final bool canContinueIncomplete; + final VoidCallback onContinueIncomplete; final ColorScheme colors; final TextTheme text; final AppLocalizations localizations; @@ -753,6 +806,8 @@ class _OverviewHeader extends StatelessWidget { required this.highCount, required this.medCount, required this.lowCount, + required this.canContinueIncomplete, + required this.onContinueIncomplete, required this.colors, required this.text, required this.localizations, @@ -765,7 +820,7 @@ class _OverviewHeader extends StatelessWidget { : overallScore <= 66 ? RiskLevel.medium : RiskLevel.high; - final scoreColor = _riskColor(scoreLevel); + final scoreColor = scoreLevel.color; return Container( margin: const EdgeInsets.all(16), @@ -802,12 +857,18 @@ class _OverviewHeader extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - '$overallScore', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: scoreColor, + // FittedBox keeps a 3-digit score (the maximum + // possible, 100) on one line instead of wrapping and + // overflowing this fixed-size circle. + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '$overallScore', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: scoreColor, + ), ), ), Text( @@ -835,6 +896,19 @@ class _OverviewHeader extends StatelessWidget { color: colors.onSurfaceVariant, ), ), + if (canContinueIncomplete) + TextButton.icon( + onPressed: onContinueIncomplete, + icon: const Icon( + Icons.play_arrow_rounded, + size: 18, + ), + label: Text(localizations.resumeIncompleteDevice), + style: TextButton.styleFrom( + padding: const EdgeInsets.only(top: 4, bottom: 2), + visualDensity: VisualDensity.compact, + ), + ), ], const SizedBox(height: 8), if (highCount > 0) @@ -856,6 +930,21 @@ class _OverviewHeader extends StatelessWidget { height: 1.4, ), ), + ] else if (skippedDevices > 0) ...[ + Text( + localizations.skippedDevicesHint(skippedDevices), + style: text.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + if (canContinueIncomplete) + TextButton.icon( + onPressed: onContinueIncomplete, + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: Text(localizations.resumeIncompleteDevice), + style: TextButton.styleFrom( + padding: const EdgeInsets.only(top: 6, bottom: 2), + visualDensity: VisualDensity.compact, + ), + ), ], ], ), @@ -884,7 +973,7 @@ class _RiskCount extends StatelessWidget { @override Widget build(BuildContext context) { - final color = _riskColor(level); + final color = level.color; return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -895,7 +984,7 @@ class _RiskCount extends StatelessWidget { ), const SizedBox(width: 6), Text( - '$count × ${_riskLabel(level, AppLocalizations.of(context)!)}', + '$count × ${_riskLabel(level, context.l10n)}', style: TextStyle( fontSize: 13, color: color, @@ -981,12 +1070,12 @@ class _DeviceResultCardState extends State<_DeviceResultCard> { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; final device = widget.device; final level = device.riskLevel; - final color = _riskColor(level); + final color = level.color; final bg = _riskBg(level); final actions = device.suggestedActions; final inherentRiskHint = device.inherentRiskHint == null @@ -995,9 +1084,7 @@ class _DeviceResultCardState extends State<_DeviceResultCard> { device.inherentRiskHint!, fallback: device.inherentRiskHint!, ); - final noActionColor = level == RiskLevel.low - ? _riskColor(RiskLevel.low) - : color; + final noActionColor = level == RiskLevel.low ? RiskLevel.low.color : color; final noActionIcon = level == RiskLevel.low ? Icons.check_circle : Icons.info_outline; @@ -1200,13 +1287,13 @@ class _ScoreBreakdownState extends State<_ScoreBreakdown> { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final factors = widget.device.scoringFactors; if (factors.isEmpty) return const SizedBox.shrink(); final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final riskColor = _riskColor(widget.device.riskLevel); + final riskColor = widget.device.riskLevel.color; return Container( margin: const EdgeInsets.fromLTRB(14, 0, 14, 10), @@ -1289,7 +1376,7 @@ class _FactorRow extends StatelessWidget { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final Color chipColor; final IconData icon; @@ -1297,7 +1384,7 @@ class _FactorRow extends StatelessWidget { chipColor = const Color(0xFF5C6BC0); icon = Icons.device_hub_outlined; } else if (factor.isDontKnow) { - chipColor = const Color(0xFFE65100); + chipColor = const Color(0xFFF9A825); icon = Icons.help_outline; } else { chipColor = const Color(0xFFC62828); @@ -1351,13 +1438,13 @@ class _ActionTile extends StatelessWidget { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; final priorityColor = action.priority == ActionPriority.high ? const Color(0xFFC62828) : action.priority == ActionPriority.medium - ? const Color(0xFFE65100) + ? const Color(0xFFF9A825) : colors.primary; return Container( @@ -1450,7 +1537,7 @@ class _GeneralRecommendations extends StatelessWidget { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Padding( padding: const EdgeInsets.all(16), child: Column( diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index 83c606d..85ef01c 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../l10n/app_localizations.dart'; +import '../l10n/l10n_extensions.dart'; import '../l10n/language_controller.dart'; import '../models/survey_state.dart'; import '../widgets/language_switcher.dart'; @@ -7,67 +7,23 @@ import 'about_screen.dart'; import 'faq_screen.dart'; import 'room_selection_screen.dart'; -/// App-wide route observer — provide this in [MaterialApp.navigatorObservers]. -final RouteObserver> appRouteObserver = - RouteObserver>(); - -class WelcomeScreen extends StatefulWidget { +class WelcomeScreen extends StatelessWidget { final LanguageController languageController; final GlobalKey? navigatorKey; + final SurveyState state; const WelcomeScreen({ super.key, required this.languageController, + required this.state, this.navigatorKey, }); - @override - State createState() => _WelcomeScreenState(); -} - -class _WelcomeScreenState extends State with RouteAware { - SurveyState? _surveyState; - - @override - void initState() { - super.initState(); - _loadSurveyState(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final route = ModalRoute.of(context); - if (route is PageRoute) { - appRouteObserver.subscribe(this, route); - } - } - - @override - void dispose() { - appRouteObserver.unsubscribe(this); - super.dispose(); - } - - /// Called when this screen is popped back to (e.g. after reset). - @override - void didPopNext() { - _loadSurveyState(); - } - - Future _loadSurveyState() async { - final surveyState = SurveyState(); - await surveyState.loadFromStorage(); - if (mounted) { - setState(() => _surveyState = surveyState); - } - } - @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final text = Theme.of(context).textTheme; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Scaffold( backgroundColor: colors.surface, @@ -180,26 +136,20 @@ class _WelcomeScreenState extends State with RouteAware { Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Builder( - builder: (context) { - final surveyState = _surveyState; - final hasState = surveyState != null; - final hasProgress = - hasState && surveyState.hasAnyData; + ListenableBuilder( + listenable: state, + builder: (context, _) { + final hasProgress = state.hasAnyData; return FilledButton( - onPressed: hasState - ? () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - RoomSelectionScreen( - state: surveyState, - ), - ), - ); - } - : null, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + RoomSelectionScreen(state: state), + ), + ); + }, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( vertical: 16, @@ -245,8 +195,7 @@ class _WelcomeScreenState extends State with RouteAware { TextButton.icon( onPressed: () => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => - AboutScreen(state: _surveyState), + builder: (_) => AboutScreen(state: state), ), ), icon: const Icon( @@ -276,8 +225,8 @@ class _WelcomeScreenState extends State with RouteAware { right: 10, child: SafeArea( child: LanguageSwitcher( - controller: widget.languageController, - navigatorKey: widget.navigatorKey, + controller: languageController, + navigatorKey: navigatorKey, ), ), ), diff --git a/lib/widgets/custom_add_dialogs.dart b/lib/widgets/custom_add_dialogs.dart index f0d5e16..22c31da 100644 --- a/lib/widgets/custom_add_dialogs.dart +++ b/lib/widgets/custom_add_dialogs.dart @@ -1,29 +1,27 @@ import 'package:flutter/material.dart'; -import '../l10n/app_localizations.dart'; +import '../l10n/l10n_extensions.dart'; +import '../models/survey_state.dart'; + +/// Result of [CustomRoomDialog]. A typed record instead of a raw map so a +/// mismatched key/type is a compile error at the call site, not a runtime +/// cast failure. +typedef NewRoomResult = ({String name, IconData icon}); + +/// Result of [CustomDeviceDialog]. +typedef NewDeviceResult = ({ + String name, + IconData icon, + int riskScore, + bool hasCamera, + bool hasMicrophone, +}); class CustomRoomDialog extends StatefulWidget { final List availableIcons; - const CustomRoomDialog({ - super.key, - this.availableIcons = const [ - Icons.weekend, - Icons.kitchen, - Icons.hotel, - Icons.bathtub, - Icons.computer, - Icons.meeting_room, - Icons.yard, - Icons.storage, - Icons.home, - Icons.living, - Icons.local_library, - Icons.fitness_center, - Icons.sports_esports, - Icons.roofing, - ], - }); + CustomRoomDialog({super.key, List? availableIcons}) + : availableIcons = availableIcons ?? iconsForKeys(kRoomIconKeys); @override State createState() => _CustomRoomDialogState(); @@ -48,7 +46,7 @@ class _CustomRoomDialogState extends State { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 500), @@ -120,10 +118,10 @@ class _CustomRoomDialogState extends State { onPressed: _nameController.text.trim().isEmpty ? null : () { - Navigator.pop(context, { - 'name': _nameController.text.trim(), - 'icon': _selectedIcon, - }); + Navigator.pop(context, ( + name: _nameController.text.trim(), + icon: _selectedIcon, + )); }, child: Text(localizations.add), ), @@ -140,25 +138,8 @@ class _CustomRoomDialogState extends State { class CustomDeviceDialog extends StatefulWidget { final List availableIcons; - const CustomDeviceDialog({ - super.key, - this.availableIcons = const [ - Icons.speaker, - Icons.videocam, - Icons.tv, - Icons.thermostat, - Icons.lightbulb, - Icons.lock, - Icons.electrical_services, - Icons.window, - Icons.cleaning_services, - Icons.kitchen, - Icons.watch, - Icons.toys, - Icons.router, - Icons.print, - ], - }); + CustomDeviceDialog({super.key, List? availableIcons}) + : availableIcons = availableIcons ?? iconsForKeys(kDeviceIconKeys); @override State createState() => _CustomDeviceDialogState(); @@ -188,7 +169,7 @@ class _CustomDeviceDialogState extends State { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), @@ -295,13 +276,13 @@ class _CustomDeviceDialogState extends State { : () { final riskScore = int.tryParse(_riskScoreController.text) ?? 30; - Navigator.pop(context, { - 'name': _nameController.text.trim(), - 'icon': _selectedIcon, - 'riskScore': riskScore.clamp(0, 100), - 'hasCamera': _hasCamera, - 'hasMicrophone': _hasMicrophone, - }); + Navigator.pop(context, ( + name: _nameController.text.trim(), + icon: _selectedIcon, + riskScore: riskScore.clamp(0, 100), + hasCamera: _hasCamera, + hasMicrophone: _hasMicrophone, + )); }, child: Text(localizations.add), ), diff --git a/lib/widgets/language_switcher.dart b/lib/widgets/language_switcher.dart index 970867d..ff62708 100644 --- a/lib/widgets/language_switcher.dart +++ b/lib/widgets/language_switcher.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../l10n/app_localizations.dart'; +import '../l10n/l10n_extensions.dart'; import '../l10n/language_controller.dart'; class LanguageSwitcher extends StatelessWidget { @@ -15,7 +16,7 @@ class LanguageSwitcher extends StatelessWidget { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; return Tooltip( message: localizations.languageDialogTitle, @@ -42,7 +43,7 @@ class LanguageSwitcher extends StatelessWidget { Future _showLanguageDialog(BuildContext context) async { final dialogContext = navigatorKey?.currentContext ?? context; - final localizations = AppLocalizations.of(context)!; + final localizations = context.l10n; final selected = await showDialog( context: dialogContext, useRootNavigator: true, diff --git a/pubspec.yaml b/pubspec.yaml index 6935922..21313bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: 'none' # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+26 +version: 1.0.0+42 environment: sdk: ^3.11.5 diff --git a/test/about_screen_test.dart b/test/about_screen_test.dart new file mode 100644 index 0000000..176e338 --- /dev/null +++ b/test/about_screen_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:simplications/l10n/app_localizations.dart'; +import 'package:simplications/models/survey_state.dart'; +import 'package:simplications/screens/about_screen.dart'; + +Widget _buildTestApp(Widget child) { + return MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: child, + ); +} + +void _configureLargeViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1600, 2400); + tester.view.devicePixelRatio = 1.0; +} + +void main() { + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'canLaunch' || call.method == 'canLaunchUrl') { + return true; + } + if (call.method == 'launch' || call.method == 'launchUrl') { + return false; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + testWidgets('renders key sections and disabled controls with null state', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_buildTestApp(const AboutScreen(state: null))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + expect(find.text(localizations.aboutScreenTitle), findsOneWidget); + expect(find.text(localizations.projectPartners), findsOneWidget); + expect(find.text(localizations.coordinationFunding), findsOneWidget); + expect(find.text(localizations.expertModeTitle), findsOneWidget); + expect(find.text(localizations.dangerZone), findsOneWidget); + + final switchWidget = tester.widget(find.byType(Switch).first); + expect(switchWidget.onChanged, isNull); + + final deleteButton = tester.widget( + find.widgetWithText(FilledButton, localizations.deleteAllDataButton), + ); + expect(deleteButton.onPressed, isNull); + }); + + testWidgets('expert mode toggle updates survey state', (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(AboutScreen(state: state))); + await tester.pumpAndSettle(); + + expect(state.expertModeEnabled, isFalse); + + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + + expect(state.expertModeEnabled, isTrue); + }); + + testWidgets('delete all data confirmation clears state and shows snackbar', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + state.addCustomRoom('Temporary', Icons.home); + expect(state.hasAnyData, isTrue); + + await tester.pumpWidget(_buildTestApp(AboutScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap( + find.widgetWithText(FilledButton, localizations.deleteAllDataButton), + ); + await tester.pumpAndSettle(); + + expect(find.text(localizations.deleteAllDataTitle), findsOneWidget); + + await tester.tap( + find.widgetWithText(TextButton, localizations.deleteAllDataButton), + ); + await tester.pumpAndSettle(); + + expect(state.hasAnyData, isFalse); + expect(find.text(localizations.allDataDeleted), findsOneWidget); + }); + + testWidgets('failed website launch shows snackbar message', (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(AboutScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap(find.text(localizations.aboutWebsiteSimplicationsLabel)); + await tester.pumpAndSettle(); + + expect(find.text(localizations.websiteOpenFailed), findsOneWidget); + }); +} diff --git a/test/device_risk_scoring_test.dart b/test/device_risk_scoring_test.dart index 40727c8..3ec268a 100644 --- a/test/device_risk_scoring_test.dart +++ b/test/device_risk_scoring_test.dart @@ -213,5 +213,31 @@ void main() { ); expect(hasExpertFactor, isTrue); }); + + test('riskScore always equals the clamped sum of scoringFactors', () { + // Guards against riskScore and scoringFactors drifting apart into two + // independently-maintained penalty tables. + final devicesToCheck = [ + _instance('simple_sensor'), + _instance('smart_lock', roomId: 'child_bedroom'), + _instance('smart_speaker'), + _instance('smart_toy', expertMode: true, roomId: 'bedroom'), + ]; + + for (final device in devicesToCheck) { + _answerAll(device, QuestionAnswer.no); + final summedFactors = device.scoringFactors.fold( + 0, + (sum, factor) => sum + factor.penalty, + ); + expect( + device.riskScore, + summedFactors.clamp(0, 100), + reason: + 'riskScore must equal the clamped sum of scoringFactors for ' + '${device.template.id}', + ); + } + }); }); } diff --git a/test/device_selection_screen_test.dart b/test/device_selection_screen_test.dart new file mode 100644 index 0000000..732eaf3 --- /dev/null +++ b/test/device_selection_screen_test.dart @@ -0,0 +1,409 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:simplications/data/catalog_data.dart'; +import 'package:simplications/l10n/app_localizations.dart'; +import 'package:simplications/models/device.dart'; +import 'package:simplications/models/room.dart'; +import 'package:simplications/models/survey_state.dart'; +import 'package:simplications/screens/device_result_screen.dart'; +import 'package:simplications/screens/device_questionnaire_screen.dart'; +import 'package:simplications/screens/device_selection_screen.dart'; + +Widget _buildTestApp(Widget child) { + return MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: child, + ); +} + +void _configureLargeViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1600, 2400); + tester.view.devicePixelRatio = 1.0; +} + +Room _roomById(String id) => + CatalogData.allRooms.firstWhere((room) => room.id == id); + +void main() { + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('renders room header and device prompts', (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + expect(find.text(localizations.roomCheck), findsOneWidget); + expect(find.text(localizations.deviceQuestion), findsOneWidget); + expect(find.text(localizations.noDevice), findsOneWidget); + expect(find.text(localizations.addDevice), findsOneWidget); + }); + + testWidgets( + 'selecting a catalog device adds instance and opens questionnaire', + (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + final tvTemplate = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + final tvLabel = CatalogData.deviceName(localizations, tvTemplate); + + await tester.tap(find.text(tvLabel).first); + await tester.pumpAndSettle(); + + expect(state.isDeviceAdded(room.id, tvTemplate.id), isTrue); + expect(find.byType(DeviceQuestionnaireScreen), findsOneWidget); + }, + ); + + testWidgets('same device can be added multiple times in one room', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + final tvTemplate = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + final tvLabel = CatalogData.deviceName(localizations, tvTemplate); + + await tester.tap(find.text(tvLabel).first); + await tester.pumpAndSettle(); + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.tap(find.text(tvLabel).first); + await tester.pumpAndSettle(); + + final roomInstances = state.devices + .where((instance) => instance.roomId == room.id) + .toList(); + expect(roomInstances, hasLength(2)); + expect(roomInstances[0].instanceId, isNot(roomInstances[1].instanceId)); + }); + + testWidgets('finishing questionnaire opens immediate device result screen', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + final tvTemplate = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + final tvLabel = CatalogData.deviceName(localizations, tvTemplate); + + await tester.tap(find.text(tvLabel).first); + await tester.pumpAndSettle(); + + final activeInstance = state.devices.last; + final yesButtons = find.widgetWithText(OutlinedButton, localizations.yes); + final yesCount = tester.widgetList(yesButtons).length; + for (var i = 0; i < yesCount; i++) { + await tester.tap(yesButtons.at(i)); + await tester.pump(); + } + for (final question in activeInstance.questions) { + expect(activeInstance.answerFor(question.id), equals(QuestionAnswer.yes)); + } + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, localizations.done)); + await tester.pumpAndSettle(); + + expect(find.byType(DeviceResultScreen), findsOneWidget); + }); + + testWidgets('room overview lists instances and allows resume/result open', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + final template = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + + final completed = state.addDevice(template, room.id, room.name); + for (final question in completed.questions) { + completed.setAnswer(question.id, QuestionAnswer.yes); + } + state.addDevice(template, room.id, room.name); + state.notifyUpdate(); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + expect(find.text(localizations.overview), findsOneWidget); + expect(find.textContaining('#1'), findsOneWidget); + expect(find.textContaining('#2'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.play_circle_fill)); + await tester.pumpAndSettle(); + expect(find.byType(DeviceQuestionnaireScreen), findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.arrow_forward_ios).first); + await tester.pumpAndSettle(); + expect(find.byType(DeviceResultScreen), findsOneWidget); + }); + + testWidgets('no-device flow marks room without devices after confirm', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('kitchen'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap(find.text(localizations.noDevice).first); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, localizations.confirm)); + await tester.pumpAndSettle(); + + expect(state.noDeviceRoomIds.contains(room.id), isTrue); + expect(state.completedRoomIds.contains(room.id), isTrue); + expect(state.devicesForRoom(room.id), isEmpty); + }); + + testWidgets( + 'custom room shows full catalog and supports custom device add/remove', + (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final customRoom = state.addCustomRoom('Studio', Icons.mic); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: customRoom)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + final irrigationTemplate = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_irrigation', + ); + final irrigationLabel = CatalogData.deviceName( + localizations, + irrigationTemplate, + ); + + expect(find.text(irrigationLabel), findsOneWidget); + + await tester.tap(find.text(localizations.addDevice).first); + await tester.pumpAndSettle(); + + await tester.enterText( + find + .descendant( + of: find.byType(Dialog), + matching: find.byType(TextField), + ) + .first, + 'Custom Hub', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, localizations.add)); + await tester.pumpAndSettle(); + + expect(state.customDevicesForRoom(customRoom.id), hasLength(1)); + + await tester.tap(find.byIcon(Icons.close).first); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, localizations.delete)); + await tester.pumpAndSettle(); + + expect(state.customDevicesForRoom(customRoom.id), isEmpty); + }, + ); + + testWidgets('results button is enabled when room has a finished device', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('living'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + FilledButton resultsButton() => tester.widget( + find.widgetWithText(FilledButton, localizations.results), + ); + + expect(resultsButton().onPressed, isNull); + + final template = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + state.addDevice(template, room.id, room.name); + final instance = state.devices.single; + for (final question in instance.questions) { + instance.setAnswer(question.id, QuestionAnswer.yes); + } + state.notifyUpdate(); + + await tester.pumpAndSettle(); + + expect(resultsButton().onPressed, isNotNull); + }); + + testWidgets('next-room action does not complete an untouched room', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('office'); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap( + find.widgetWithText(OutlinedButton, localizations.nextRoom), + ); + await tester.pumpAndSettle(); + + // Leaving via "next room" without adding or finishing a device must not + // falsely mark the room as assessed — that misleads the room list into + // showing it as done when nothing was actually checked. + expect(state.completedRoomIds.contains(room.id), isFalse); + expect(state.isRoomCompleted(room.id), isFalse); + }); + + testWidgets( + 'next-room action marks room completed once a device is finished', + (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + final room = _roomById('office'); + + final template = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + state.addDevice(template, room.id, room.name); + final instance = state.devices.single; + for (final question in instance.questions) { + instance.setAnswer(question.id, QuestionAnswer.yes); + } + state.notifyUpdate(); + + await tester.pumpWidget( + _buildTestApp(DeviceSelectionScreen(state: state, room: room)), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap( + find.widgetWithText(OutlinedButton, localizations.nextRoom), + ); + await tester.pumpAndSettle(); + + expect(state.completedRoomIds.contains(room.id), isTrue); + }, + ); +} diff --git a/test/icon_serialization_test.dart b/test/icon_serialization_test.dart index 96672ff..7e51025 100644 --- a/test/icon_serialization_test.dart +++ b/test/icon_serialization_test.dart @@ -202,6 +202,18 @@ void main() { expect(state.hasResultsAvailable, isTrue); }); + test('same template can be added multiple times to the same room', () { + final state = SurveyState(); + final room = CatalogData.allRooms.first; + final template = CatalogData.devicesForRoom(room.id).first; + + final first = state.addDevice(template, room.id, room.name); + final second = state.addDevice(template, room.id, room.name); + + expect(state.devicesForRoom(room.id), hasLength(2)); + expect(first.instanceId, isNot(second.instanceId)); + }); + test('completed room progress is restored after restart', () async { final state = SurveyState(); final room = CatalogData.allRooms.first; diff --git a/test/room_selection_screen_test.dart b/test/room_selection_screen_test.dart new file mode 100644 index 0000000..a6f4051 --- /dev/null +++ b/test/room_selection_screen_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:simplications/data/catalog_data.dart'; +import 'package:simplications/l10n/app_localizations.dart'; +import 'package:simplications/models/device.dart'; +import 'package:simplications/models/survey_state.dart'; +import 'package:simplications/screens/device_selection_screen.dart'; +import 'package:simplications/screens/room_selection_screen.dart'; +import 'package:simplications/screens/summary_screen.dart'; + +Widget _buildTestApp(Widget child) { + return MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: child, + ); +} + +void _configureLargeViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1600, 2400); + tester.view.devicePixelRatio = 1.0; +} + +void main() { + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + }); + + tearDown(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + testWidgets('renders catalog rooms and add-room action', (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(RoomSelectionScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + expect(find.text(localizations.roomSelectionTitle), findsOneWidget); + expect(find.text(localizations.addRoom), findsOneWidget); + expect(find.byIcon(Icons.weekend), findsAtLeastNWidgets(1)); + }); + + testWidgets('tapping a room opens device selection and marks room visited', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(RoomSelectionScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + final livingRoom = CatalogData.allRooms.firstWhere( + (room) => room.id == 'living', + ); + final livingRoomLabel = CatalogData.roomName(localizations, livingRoom); + + await tester.tap(find.text(livingRoomLabel).first); + await tester.pumpAndSettle(); + + expect(state.visitedRoomIds.contains('living'), isTrue); + expect(find.byType(DeviceSelectionScreen), findsOneWidget); + }); + + testWidgets('add custom room dialog adds room and navigates into it', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(RoomSelectionScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + await tester.tap(find.text(localizations.addRoom)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'Music Room'); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, localizations.add)); + await tester.pumpAndSettle(); + + expect(state.customRooms, hasLength(1)); + expect(state.customRooms.first.name, 'Music Room'); + expect(find.byType(DeviceSelectionScreen), findsOneWidget); + }); + + testWidgets('deleting a custom room removes it from state', (tester) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + state.addCustomRoom('Temporary Room', Icons.sports_esports); + + await tester.pumpWidget(_buildTestApp(RoomSelectionScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + expect(state.customRooms, hasLength(1)); + + await tester.tap(find.byIcon(Icons.close).first); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, localizations.delete)); + await tester.pumpAndSettle(); + + expect(state.customRooms, isEmpty); + }); + + testWidgets('results button enables only when results are available', ( + tester, + ) async { + _configureLargeViewport(tester); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(RoomSelectionScreen(state: state))); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(Scaffold).first); + final localizations = AppLocalizations.of(context)!; + + FilledButton resultsButton() => tester.widget( + find.widgetWithText(FilledButton, localizations.results), + ); + + expect(resultsButton().onPressed, isNull); + + final template = CatalogData.allDeviceTemplates.firstWhere( + (device) => device.id == 'smart_tv', + ); + state.addDevice(template, 'living', 'room_living'); + final instance = state.devices.single; + for (final question in instance.questions) { + instance.setAnswer(question.id, QuestionAnswer.yes); + } + state.markRoomCompleted('living'); + state.notifyUpdate(); + + await tester.pumpAndSettle(); + + expect(resultsButton().onPressed, isNotNull); + + await tester.tap(find.widgetWithText(FilledButton, localizations.results)); + await tester.pumpAndSettle(); + + expect(find.byType(SummaryScreen), findsOneWidget); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 0998437..9887465 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -11,11 +11,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:simplications/l10n/app_localizations.dart'; import 'package:simplications/l10n/language_controller.dart'; import 'package:simplications/main.dart'; +import 'package:simplications/models/survey_state.dart'; void main() { testWidgets('App launches smoke test', (WidgetTester tester) async { await tester.pumpWidget( - SimplicationsApp(languageController: LanguageController()), + SimplicationsApp( + languageController: LanguageController(), + surveyState: SurveyState(), + ), ); await tester.pumpAndSettle();