diff --git a/.llmrc b/.llmrc index f90863a..8f130bb 100644 --- a/.llmrc +++ b/.llmrc @@ -2,11 +2,17 @@ This file provides essential context for LLMs working on the Simplications project. +## Instruction Priority + +- `AGENTS.md` is the authoritative policy file for AI agent behavior. +- Use this `.llmrc` as implementation context and project guidance. +- If `.llmrc` and `AGENTS.md` differ, follow `AGENTS.md`. + ## Project Overview **Simplications** is a Flutter application that helps users evaluate privacy and security risks in their smart home, room-by-room. Users select a room, add devices, answer targeted security questions, and receive actionable recommendations based on BSI (German Federal Office for Information Security) guidelines. -**Language**: German (Deutsch) - all UI text is in German +**Language**: Multi-language UI with German default (`de`, `en`, `cs`, `pl`, `fr`, `nl`, `da`) **Framework**: Flutter 3.11.5+, Dart **Platform**: Cross-platform (Android, iOS, Web, macOS, Windows, Linux) **Target Users**: German-speaking users concerned about smart home privacy @@ -114,8 +120,15 @@ lib/ ### Data Persistence -- **Current**: Transient (lost on app restart) -- **Planned Enhancement**: SharedPreferences or local SQLite storage +- **Current**: Local persistence via SharedPreferences (`SurveyState`) +- **Planned Enhancement**: Optional richer local storage/migrations if needed + +### Build Number Policy (LLM Work) + +- Any LLM edit to core app files under `lib/` must bump the build number in `pubspec.yaml`. +- Bump only the integer after `+` in `version` (for example, `1.0.0+2` -> `1.0.0+3`). +- Changes limited to non-core paths like `test/` and platform folders do not require a bump. +- Full policy details are in `AGENTS.md`. ## Important Code Patterns @@ -138,7 +151,7 @@ lib/ - Material Design 3 - Primary color: Teal (#00695C) - Theme accessed via `Theme.of(context).colorScheme` -- All text in German - search for `const Text` to find strings for localization +- Keep German-first UX, but preserve and maintain all supported locales in `lib/l10n/app_localizations.dart` ### Screen Navigation @@ -180,7 +193,7 @@ lib/ ## Naming Conventions -### German Text (All UI) +### German Text (Primary UX) - Room names: "Wohnzimmer", "KΓΌche", "Schlafzimmer" - Device names: "Sprachassistent", "Smarte Kamera", "Intelligenter Thermostat" @@ -219,7 +232,7 @@ Before submitting changes: - [ ] Device-specific questions appear for tested device types - [ ] Risk score updates when questions are answered - [ ] Device-specific actions appear in evaluation screen -- [ ] UI text is German throughout +- [ ] German UX remains correct and localized keys are preserved across all supported locales - [ ] Material 3 teal theme is applied consistently ## Troubleshooting @@ -260,8 +273,8 @@ Before submitting changes: - βœ… Added 30+ BSI-based security actions mapped to device types - βœ… Fixed all lint warnings (0 issues in `flutter analyze`) - βœ… Added device-specific evaluation logic -- 🟑 Future: Add persistence layer (SharedPreferences/SQLite) -- 🟑 Future: Add multi-language support +- βœ… SharedPreferences-based persistence is active +- βœ… Multi-language support is active --- diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c8211da --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,132 @@ +# Simplications – AI Agent Instructions + +This file is the single source of truth for any LLM or AI coding agent +(GitHub Copilot, Codex, Claude, Gemini, etc.) working in this repository. +Read this file before making any changes. Follow these rules strictly. + +`.llmrc` may provide additional implementation context, but if there is any +conflict, this file (`AGENTS.md`) takes precedence. + +--- + +## Build Number Policy + +**Rule: every LLM edit to a core app file MUST increment the build number +in `pubspec.yaml` before (or as part of) the same commit/change set.** + +### What counts as a core file? + +Any file under `lib/` (recursively), including: + +| File | Description | +|---|---| +| `lib/main.dart` | App entry point | +| `lib/data/catalog_data.dart` | Static catalog: rooms, devices, questions, actions | +| `lib/models/device.dart` | Risk scoring, question logic, action recommendations | +| `lib/models/survey_state.dart` | Assessment state and persistence | +| `lib/models/room.dart` | Room model | +| `lib/l10n/app_localizations.dart` | Localization (all supported languages) | +| `lib/l10n/language_controller.dart` | Language switch persistence | +| `lib/screens/*.dart` | All wizard and result screens | +| `lib/widgets/*.dart` | Reusable UI components | + +`test/`, `android/`, `ios/`, `web/`, `windows/`, `linux/`, `macos/` are NOT +core files. Changing only those files does not require a build number bump. + +### How to bump + +In `pubspec.yaml`, increase the integer after `+` in the `version` line: + +```yaml +# Before +version: 1.0.0+3 + +# After (any LLM change to a core file) +version: 1.0.0+4 +``` + +Never skip numbers. Never decrease the build number. + +--- + +## Project Overview + +**Simplications** is a Flutter privacy-assessment app. Users walk through +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 +- **Supported locales**: `de` (default), `en`, `cs`, `pl`, `fr`, `nl`, `da` +- **State persistence**: `SharedPreferences` via `SurveyState` +- **No backend** – all data stays on-device + +--- + +## Architecture Summary + +``` +lib/ + main.dart β€” App entry, theming, locale wiring + data/catalog_data.dart β€” Catalog of rooms, device templates, localisation helpers + models/ + device.dart β€” DeviceTemplate, DeviceInstance, risk engine, actions + survey_state.dart β€” Central state + SharedPreferences persistence + room.dart β€” Room model + l10n/ + app_localizations.dart β€” All UI strings for all locales (source of truth) + language_controller.dart β€” User language preference + persistence + screens/ β€” Wizard flow: welcome β†’ rooms β†’ devices β†’ questionnaire β†’ summary + widgets/ β€” Shared UI components (dialogs, language switcher, …) +test/ + widget_test.dart β€” App launch smoke test + icon_serialization_test.dart β€” Icon persistence + survey state round-trips + device_risk_scoring_test.dart β€” Risk engine unit tests + app_localizations_test.dart β€” Localization fallback and interpolation tests + language_controller_test.dart β€” Language persistence tests + summary_screen_test.dart β€” Summary screen rendering tests +``` + +--- + +## Key Conventions + +### 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. + +### Risk scoring +- Base risk is set per `DeviceTemplate.baseRiskScore` in `catalog_data.dart`. +- Per-question penalties are defined in `DeviceInstance.riskScore` (device.dart). +- Score is clamped to `[0, 100]`. +- Child-bedroom room adds a 10-point bonus. +- Dont-know answers use a reduced penalty (roughly half of the "no" penalty). + +### Testing +- Run `flutter test` before finishing any task involving core files. +- Run `flutter analyze` to catch static issues. +- When changing risk scoring logic, update `test/device_risk_scoring_test.dart` + with the new expected values. + +### Code style +- 2-space indentation, Dart conventions (PascalCase classes, camelCase members). +- Group imports: dart β†’ flutter β†’ package β†’ relative. +- Zero analyzer errors and warnings required. + +### Pull request titles +- 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`. + +--- + +## Checklist Before Finishing Any Task + +- [ ] Build number bumped if any `lib/` file was changed +- [ ] `flutter test` passes (all tests green) +- [ ] `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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 873cdfe..246b01e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,6 +93,22 @@ lib/ - Assessment flow is state-driven via `SurveyState`. - Progress and selected settings are persisted locally with SharedPreferences. +## Build Number Policy + +Every change β€” by a human contributor or an AI agent β€” to any **core app file** +(`lib/` and its subdirectories) must include a build number increment in +`pubspec.yaml` as part of the same commit. + +```yaml +# Increment the integer after + +version: 1.0.0+3 β†’ version: 1.0.0+4 +``` + +Files outside `lib/` (`test/`, platform directories, `pubspec.yaml` itself +when only bumping the build number, docs) do not require a bump. + +> AI agents: this rule is also enforced in `AGENTS.md` at the repo root. + ## Workflow 1. Create a feature branch: diff --git a/pubspec.yaml b/pubspec.yaml index 1370d47..6b0cf0a 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+1 +version: 1.0.0+2 environment: sdk: ^3.11.5 diff --git a/test/app_localizations_test.dart b/test/app_localizations_test.dart new file mode 100644 index 0000000..c971944 --- /dev/null +++ b/test/app_localizations_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:simplications/l10n/app_localizations.dart'; + +void main() { + group('AppLocalizations.translate', () { + test('falls back to English when locale is unsupported', () { + final value = AppLocalizations.translate( + 'start', + locale: const Locale('xx'), + ); + + expect(value, 'Start'); + }); + + test('replaces parameter placeholders', () { + final value = AppLocalizations.translate( + 'dontKnowHint', + locale: const Locale('en'), + params: {'count': '2', 'suffix': 's'}, + ); + + expect(value.contains('2 answer'), isTrue); + expect(value.contains('"I don\'t know"'), isTrue); + }); + + test('uses explicit fallback for unknown keys', () { + final value = AppLocalizations.translate( + 'missing_key_example', + locale: const Locale('en'), + fallback: 'fallback-value', + ); + + expect(value, 'fallback-value'); + }); + + test('returns key when unknown key has no fallback', () { + final value = AppLocalizations.translate( + 'missing_key_example', + locale: const Locale('en'), + ); + + expect(value, 'missing_key_example'); + }); + }); + + group('AppLocalizations.activate', () { + test('updates active language code', () { + AppLocalizations.activate(const Locale('pl')); + expect(AppLocalizations.activeLanguageCode, 'pl'); + + AppLocalizations.activate(const Locale('de')); + expect(AppLocalizations.activeLanguageCode, 'de'); + }); + }); +} diff --git a/test/device_risk_scoring_test.dart b/test/device_risk_scoring_test.dart new file mode 100644 index 0000000..267255b --- /dev/null +++ b/test/device_risk_scoring_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:simplications/data/catalog_data.dart'; +import 'package:simplications/models/device.dart'; + +DeviceTemplate _template(String id) { + return CatalogData.allDeviceTemplates.firstWhere((t) => t.id == id); +} + +DeviceInstance _instance( + String templateId, { + String roomId = 'living', + bool expertMode = false, +}) { + final template = _template(templateId); + return DeviceInstance( + instanceId: '${roomId}_$templateId', + template: template, + roomId: roomId, + roomName: roomId, + expertModeEnabled: expertMode, + ); +} + +void _answerAll(DeviceInstance instance, QuestionAnswer answer) { + for (final question in instance.questions) { + instance.setAnswer(question.id, answer); + } +} + +void main() { + group('Device risk scoring', () { + test('sensor with all no answers applies expected penalties', () { + final sensor = _instance('humidity_sensor'); + _answerAll(sensor, QuestionAnswer.no); + + expect(sensor.riskScore, 47); + expect(sensor.riskLevel, RiskLevel.medium); + + final labels = sensor.scoringFactors.map((f) => f.label).toSet(); + expect(labels.contains('sl_base_risk'), isTrue); + expect(labels.contains('sl_sensor_frequency'), isTrue); + expect(labels.contains('sl_sensor_data_deletion'), isTrue); + expect(labels.contains('sl_sensor_granularity'), isTrue); + expect(labels.contains('sl_sensor_local'), isTrue); + }); + + test('score is clamped to 100 and child room bonus is applied', () { + final lockInChildRoom = _instance('smart_lock', roomId: 'child_bedroom'); + _answerAll(lockInChildRoom, QuestionAnswer.no); + + expect(lockInChildRoom.riskScore, 100); + expect(lockInChildRoom.riskLevel, RiskLevel.high); + + final childBonus = lockInChildRoom.scoringFactors.firstWhere( + (f) => f.label == 'sl_child_room_bonus', + ); + expect(childBonus.penalty, 10); + expect(childBonus.isBaseRisk, isTrue); + }); + + test('dont know uses partial penalty and adds learning action', () { + final speaker = _instance('smart_speaker'); + _answerAll(speaker, QuestionAnswer.yes); + speaker.setAnswer('voice_history', QuestionAnswer.dontKnow); + + expect(speaker.riskScore, 54); + expect(speaker.dontKnowAnswerCount, 1); + + final hasDontKnowFactor = speaker.scoringFactors.any( + (f) => f.label == 'sl_voice_history' && f.penalty == 4 && f.isDontKnow, + ); + expect(hasDontKnowFactor, isTrue); + + final actionTitles = speaker.suggestedActions.map((a) => a.title).toSet(); + expect(actionTitles.contains('a_dont_know_title'), isTrue); + expect(actionTitles.contains('a_voice_history_title'), isFalse); + }); + + test('all positive answers on camera returns inherent camera hint', () { + final camera = _instance('indoor_camera', roomId: 'hallway'); + _answerAll(camera, QuestionAnswer.yes); + + expect(camera.allAnswersPositive, isTrue); + expect(camera.inherentRiskHint, 'risk_hint_camera'); + }); + + test('expert mode adds expert questions and expert scoring factors', () { + final wearable = _instance('fitness_tracker', expertMode: true); + + final ids = wearable.questions.map((q) => q.id).toSet(); + expect(ids.contains('expert_data_retention_duration'), isTrue); + expect(ids.contains('expert_sensitive_inference_controls'), isTrue); + + _answerAll(wearable, QuestionAnswer.yes); + wearable.setAnswer( + 'expert_sensitive_inference_controls', + QuestionAnswer.no, + ); + + final hasExpertFactor = wearable.scoringFactors.any( + (f) => + f.label == 'sl_expert_sensitive_inference_controls' && + f.penalty == 8, + ); + expect(hasExpertFactor, isTrue); + }); + }); +} diff --git a/test/language_controller_test.dart b/test/language_controller_test.dart new file mode 100644 index 0000000..ee4319d --- /dev/null +++ b/test/language_controller_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:simplications/l10n/language_controller.dart'; + +void main() { + group('LanguageController', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('init loads persisted supported locale', () async { + SharedPreferences.setMockInitialValues({'selected_language_code': 'en'}); + + final controller = LanguageController(); + await controller.init(); + + expect(controller.locale, const Locale('en')); + }); + + test( + 'setLocale updates locale, notifies listeners, and persists', + () async { + final controller = LanguageController(); + await controller.init(); + + var notifyCount = 0; + controller.addListener(() { + notifyCount += 1; + }); + + await controller.setLocale(const Locale('fr')); + + expect(controller.locale, const Locale('fr')); + expect(notifyCount, 1); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('selected_language_code'), 'fr'); + }, + ); + + test('setLocale ignores unsupported languages', () async { + final controller = LanguageController(); + await controller.init(); + final before = controller.locale; + + await controller.setLocale(const Locale('es')); + + expect(controller.locale, before); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('selected_language_code'), isNull); + }); + }); +} diff --git a/test/summary_screen_test.dart b/test/summary_screen_test.dart new file mode 100644 index 0000000..9ca7778 --- /dev/null +++ b/test/summary_screen_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.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/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, + ); +} + +DeviceTemplate _template(String id) { + return CatalogData.allDeviceTemplates.firstWhere((t) => t.id == id); +} + +void main() { + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + testWidgets('shows empty-state text when there are no evaluated devices', ( + tester, + ) async { + tester.view.physicalSize = const Size(1600, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + await tester.pumpWidget(_buildTestApp(SummaryScreen(state: state))); + await tester.pumpAndSettle(); + + expect(find.text('No devices captured.'), findsOneWidget); + expect( + find.text('Restart and add devices to receive an assessment.'), + findsOneWidget, + ); + }); + + testWidgets('renders risk sections and skipped-device hint', (tester) async { + tester.view.physicalSize = const Size(1600, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final state = SurveyState(); + + final highRiskTemplate = _template('indoor_camera'); + state.addDevice(highRiskTemplate, 'hallway', 'Hallway'); + final highRiskDevice = state.devices.first; + for (final question in highRiskDevice.questions) { + highRiskDevice.setAnswer(question.id, QuestionAnswer.no); + } + + final skippedTemplate = _template('smart_tv'); + state.addDevice(skippedTemplate, 'living', 'Living room'); + + await tester.pumpWidget(_buildTestApp(SummaryScreen(state: state))); + await tester.pumpAndSettle(); + + expect(find.text('High risk'), findsAtLeastNWidgets(1)); + expect( + find.textContaining( + 'skipped because the questionnaire was not completed.', + ), + findsOneWidget, + ); + }); +}