Compare commits

19 Commits

Author SHA1 Message Date
TrochtaOndrej ab0acf60b4 docs: document fatal-log and training-log sync endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 03:08:12 +02:00
TrochtaOndrej 17b1171e9b feat: add web platform support and fix smoke test
- Update .metadata to include web platform (replaces android/ios entries)
- Enable core library desugaring for Android (Java 8+ API support)
- Fix widget_test smoke test with proper provider overrides

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:39:13 +01:00
TrochtaOndrej 303a62b95e Merge NM-App-405-fix-gitignore-add-claude-web-claire into dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 01:00:47 +01:00
TrochtaOndrej 5fa10a13f0 docs: document .gitignore AITM artifact exclusions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:17:38 +01:00
TrochtaOndrej eb64c0f60a fix: add .claude/, web/, .claire/ to .gitignore
Replace narrow .claude/worktrees/ pattern with .claude/ and add
web/ (Flutter web build output) and .claire/ (AITM worktree files).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:14:38 +01:00
TrochtaOndrej 55be1e7e4e Merge NM-App-335-add-app-skin-theme-system-with-7-switchable-skins into dev 2026-03-21 18:27:46 +01:00
TrochtaOndrej fd30dfb07f docs: rewrite skin-theme-system docs to reflect SkinType/SkinColors/SkinTypography architecture
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:27:35 +01:00
TrochtaOndrej 326a9dbcee feat: implement 7-skin theme system with persistent selection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:11:47 +01:00
TrochtaOndrej 6e0a7e9760 Merge NM-App-335-more-skin into dev 2026-03-20 22:19:14 +01:00
TrochtaOndrej 214b697145 docs: document app skin/theme system with 7 switchable skins
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:18:59 +01:00
TrochtaOndrej 0d0d518678 Merge NM-App-333-add-i18n-with-external-json-resource-files-en-cz into dev 2026-03-20 21:33:42 +01:00
TrochtaOndrej c8d69f7b9b docs: document i18n implementation with external JSON resource files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:33:31 +01:00
TrochtaOndrej be75f129c9 feat: add i18n with external JSON resource files (EN + CS)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:23:59 +01:00
TrochtaOndrej b4513f78d7 Merge NM-App-302-run-as-service into dev 2026-03-20 01:04:35 +01:00
TrochtaOndrej f15d1985aa docs: ServiceLicence systemd integration with AITM dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 01:04:23 +01:00
TrochtaOndrej 7b8af7a101 Merge NM-App-245-fix-pet-state-persistence-and-dart-io-web-crash into dev 2026-03-19 23:43:15 +01:00
TrochtaOndrej 0f4d016ce3 docs: pet state persistence and web crash fix documentation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:43:04 +01:00
TrochtaOndrej 9a0f44288a fix: set energyToday: 2 when inserting initial pet state during onboarding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:05:08 +01:00
TrochtaOndrej ef67280298 fix: pet state persistence and dart:io web crash
- Replace dart:io Platform with flutter/foundation defaultTargetPlatform in onboarding
- Add petRepositoryProvider to task_providers.dart
- Add petStateStreamProvider; rewrite PetStateNotifier.build() to load from DB
- Add _persist() to PetStateNotifier, call from incrementTasksDone()
- Insert PetStateEntity during onboarding after createUser()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:04:01 +01:00
46 changed files with 3844 additions and 542 deletions
+4 -1
View File
@@ -54,4 +54,7 @@ app.*.map.json
# Generated Drift/Riverpod files — keep .g.dart in VCS so CI doesn't need codegen
# (do NOT add *.g.dart here)
.claude/worktrees/
# AITM / build artifacts
.claude/
.claire/
web/
+1 -4
View File
@@ -15,10 +15,7 @@ migration:
- platform: root
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: android
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: ios
- platform: web
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
+20
View File
@@ -63,11 +63,31 @@ assets/
API base URL: `http://localhost:8090/api/v1/`
## Internationalization (i18n)
- **Translation files**: `assets/i18n/en.json` (default/fallback) and `assets/i18n/cs.json` — flat key-value JSON with `_meta` block
- **Service**: `lib/core/i18n/translation_service.dart` — singleton `TranslationService`, loads EN first as fallback then overlays current locale
- **Providers**: `lib/core/i18n/i18n_provider.dart``localeProvider` (persisted via SharedPreferences), `tProvider` (shortcut to `t()`)
- **Usage**: `final t = ref.watch(tProvider); t('key', params: {'name': 'value'})` — named params support different word order per language
- **Language picker**: `lib/shared/widgets/language_picker.dart` — flag buttons (🇬🇧/🇨🇿), available in onboarding and profile screen
- **Validator**: `lib/core/i18n/i18n_validator.dart` — used in tests; checks key completeness, param parity, malformed placeholders
- **Default locale**: `'cs'` — loaded from SharedPreferences on startup
## Skin / Theme System
- **`AppSkin` enum**: `lib/core/theme/app_skin.dart` — 7 skins, each provides `buildTheme()`, `buildColorScheme()`, `previewPrimary`, `previewSurface`, `isDark`, `fontScale`
- **`skinProvider`**: `lib/core/theme/skin_provider.dart``NotifierProvider<SkinNotifier, AppSkin>`; call `setSkin()` to switch and persist, `loadSaved()` at startup
- **Persistence**: SharedPreferences key `'app_skin'`; `AppSkin.fromKey()` for safe deserialization
- **Dark skins**: `epic` and `future` use `ColorScheme.dark()`; all others are light
- **Font scale**: `senior` = 1.25×, `kids` = 1.1×, all others = 1.0× (applied via Google Fonts text theme)
## Widget API Patterns
- **Dual-param widgets**: `shared/widgets/` accept both entity param (e.g. `task: TaskEntity?`) AND individual raw params — private getters resolve which to use. Keep backward compat when extending.
- **`PetType` canonical location**: `domain/entities/pet_type.dart` — never redeclare locally.
- **`todayTasksProvider`** is a top-level alias for `tasksProvider` (no date filter in MVP).
- **Pet identity vs. pet state**: `petType`/`petName` belong to `userProvider`; `petStateProvider` tracks energy/streak/rewards only.
- **`petRepositoryProvider`**: lives in `features/tasks/providers/task_providers.dart` (alongside `userRepositoryProvider`).
- **`petStateStreamProvider`**: `StreamProvider<PetStateEntity?>` in `pet_providers.dart` — source of truth for `PetStateNotifier.build()`; returns `null` when no user/state exists.
- **Onboarding creates PetState**: `_completeOnboarding()` inserts a `PetStateEntity` (energyToday: 2, zeros elsewhere) immediately after `createUser()`.
- **Web platform**: use `flutter/foundation.dart` + `defaultTargetPlatform` instead of `dart:io` + `Platform` anywhere platform detection is needed.
## Naming Conventions
- **Files**: `snake_case.dart`
@@ -0,0 +1,133 @@
# Fatal Log & Training Log Sync Endpoints
## What was implemented
Two new REST endpoints on the LicenseServer to receive and store crash/training data from AITM clients.
## Files changed (LicenseServer project)
| File | Change |
|------|--------|
| `src/shared-types.ts` | Added `FatalLogRecord`, `TrainingLogRecord`, `FatalLogRow`, `TrainingLogRow` types |
| `src/constants.ts` | Added `FATAL_LOG_DEDUP_WINDOW_MS = 5 * 60_000` (5-minute dedup window) |
| `src/db/GlobalDatabase.ts` | Added `fatal_logs` + `training_logs` tables; methods: `insertFatalLog`, `checkFatalDuplicate`, `insertTrainingLog`, `insertTrainingLogs` |
| `src/rest/authMiddleware.ts` | New: JWT Bearer token extraction middleware — extracts `licenseId` from payload via base64url decode (no signature verification) |
| `src/rest/SyncApi.ts` | New: `POST /api/v1/sync/fatal` and `POST /api/v1/sync/logs` handlers |
| `src/server.ts` | Wired `setupSyncApi(app, ctx)` after `setupRestApi` |
| `src/__tests__/SyncApi.test.ts` | 11 unit tests (all passing) |
## Endpoints
### POST /api/v1/sync/fatal
Receives automatic fatal crash reports from AITM clients.
**Auth**: JWT Bearer token in `Authorization` header — `licenseId` extracted from payload.
**Request body** (`FatalLogRecord`):
```json
{
"fatalError": "TypeError: Cannot read property 'x' of undefined",
"fatalSource": "architect",
"fatalTimestamp": "2026-03-30T00:00:00Z",
"contextWindow": [...],
"environment": {
"nodeVersion": "v20.0.0",
"processUptime": 3600,
"memoryUsage": 512
}
}
```
**Response**:
```json
{ "ok": true, "id": "<uuid>" }
```
**Deduplication**: if the same `fatalError` + `fatalSource` arrives from the same `license_id` within 5 minutes, the record is stored with `is_duplicate = true`.
---
### POST /api/v1/sync/logs
Receives user-submitted training logs (for AI model improvement).
**Auth**: JWT Bearer token (same as above).
**Request body**:
```json
{
"logs": [
{
"projectId": "cr-fe",
"taskNumber": 42,
"logType": "prompt",
"content": "<base64-encoded bytes>",
"outcome": "success",
"relatedFiles": ["src/foo.ts"]
}
]
}
```
**Response**:
```json
{ "ok": true, "count": 3 }
```
## Database Schema
### `fatal_logs`
```sql
CREATE TABLE fatal_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID REFERENCES licenses(id),
project_id TEXT,
task_number INTEGER,
fatal_error TEXT NOT NULL,
fatal_source TEXT NOT NULL,
fatal_timestamp TIMESTAMPTZ NOT NULL,
context_window JSONB NOT NULL,
context_size INTEGER NOT NULL,
node_version TEXT,
process_uptime INTEGER,
memory_usage INTEGER,
active_task_ids JSONB,
root_cause TEXT,
resolution TEXT,
category TEXT,
is_duplicate BOOLEAN DEFAULT false,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_fatal_license ON fatal_logs(license_id);
CREATE INDEX idx_fatal_source ON fatal_logs(fatal_source);
```
### `training_logs`
```sql
CREATE TABLE training_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID REFERENCES licenses(id),
project_id TEXT NOT NULL,
task_number INTEGER,
log_type TEXT NOT NULL,
content BYTEA NOT NULL,
outcome TEXT,
related_files JSONB,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
## Key design decisions
- **No JWT signature verification** — the license server trusts its own issued tokens; `licenseId` is extracted by base64url-decoding the payload only. Full verification is handled by the auth layer.
- **`contextWindow` as JSONB** — stored as JSON string (TEXT in SQLite dev, JSONB in PostgreSQL prod) for flexible querying.
- **`content` as BLOB** — training log content stored as raw bytes (`BYTEA` in PostgreSQL).
- **`globalDb` unavailability** — if the global database is not configured, both endpoints respond with `503 Service Unavailable` (non-crashing, with a startup warning).
- **Dedup window**: `FATAL_LOG_DEDUP_WINDOW_MS = 300_000` (5 minutes), indexed by `(license_id, fatal_error, fatal_source)`.
## Limitations
- No request body size limit is configured. Large `contextWindow` arrays may exceed Express's 100 KB default. Add `express.json({ limit: '5mb' })` to sync routes if payloads grow.
+21
View File
@@ -0,0 +1,21 @@
# .gitignore — AITM/Build Artifact Exclusions
## What was done
Extended `.gitignore` to exclude three directories that should never be committed:
| Pattern | Purpose |
|---------|---------|
| `.claude/` | AITM internal database and worktree files |
| `.claire/` | AITM worktree metadata files |
| `web/` | Flutter web build output |
## Files changed
- `.gitignore` — replaced narrow `.claude/worktrees/` entry with broader `.claude/`, and added `.claire/` and `web/`
## Key details
The previous entry `.claude/worktrees/` only excluded the worktrees subdirectory. The replacement `.claude/` covers the entire AITM internal directory (including the database and any future subdirectories).
All three patterns were added under a shared `# AITM / build artifacts` comment block.
## Tests
`test/gitignore_test.dart` — 3 tests verifying each of the three entries is present in `.gitignore`.
+115
View File
@@ -0,0 +1,115 @@
# Internationalization (i18n) — External JSON Resource Files
## Overview
Added full i18n support to KittieMobile using external JSON translation files stored in `assets/i18n/`. All user-facing strings are now decoupled from Dart code. Supported locales: **English (en)** and **Czech (cs)**. Default locale is `cs`.
## Files Added / Modified
### New files
| File | Purpose |
|------|---------|
| `assets/i18n/en.json` | English translations (111 keys, default/fallback) |
| `assets/i18n/cs.json` | Czech translations (111 keys) |
| `assets/i18n/README.md` | Translator guide (format, rules, how to add new language) |
| `lib/core/i18n/translation_service.dart` | Singleton service: load, fallback, param substitution |
| `lib/core/i18n/i18n_provider.dart` | Riverpod providers: `localeProvider`, `tProvider`, `translationServiceProvider` |
| `lib/core/i18n/i18n_validator.dart` | Validation utility for tests and CI |
| `lib/shared/widgets/language_picker.dart` | Flag-based EN/CS switcher widget |
| `lib/shared/utils/czech_date.dart` | `localizedDateHeader()` and `localizedGreeting()` helpers |
| `test/i18n_test.dart` | 18 tests covering JSON validity, key completeness, param substitution |
### Modified files
- `pubspec.yaml` — added `assets/i18n/` asset bundle entry
- `lib/main.dart` — async `TranslationService.init()` before `runApp`, reads saved locale from SharedPreferences
- `lib/domain/entities/pet_type.dart``localizedName()` method uses `t()`
- All UI screens and shared widgets — hardcoded strings replaced with `t()` calls
## Architecture
### JSON format
Flat key-value structure with dot-notation grouping. The `_meta` block is required but skipped during lookup.
```json
{
"_meta": { "language": "English", "locale": "en", "version": "1.0.0", "author": "KittieMobile Team" },
"onboarding.welcome_title": "Welcome to KittieMobile!",
"tasks.error": "Error: {error}",
"tasks.count": "{count} tasks completed"
}
```
Czech can reorder named params:
```json
{
"tasks.count": "Dokončeno {count} úkolů"
}
```
### TranslationService (`lib/core/i18n/translation_service.dart`)
- Singleton: `TranslationService.instance`
- `init({String locale})` — loads `en.json` as fallback, then overlays requested locale
- `setLocale(String locale)` — hot-switch locale, notifies listeners via `ChangeNotifier`
- `t(String key, {Map<String, String>? params})` — lookup chain: current locale → EN fallback → key itself (never returns null, never crashes)
- Sanitizes loaded values: strips control chars (U+0000U+001F except `\n`) and zero-width chars (U+200B/C/D, U+FEFF)
### Parameter substitution rules
| Situation | Behavior |
|-----------|----------|
| `{paramName}` with matching key in params | Replaced with value |
| `{paramName}` with no matching key | Left as-is in output |
| `{param` (unclosed `}`) | Written as-is, then ` value` appended at end |
| `}` without opening `{` | Treated as literal text |
### Riverpod providers (`lib/core/i18n/i18n_provider.dart`)
- `translationServiceProvider``Provider<TranslationService>` exposing singleton
- `localeProvider``NotifierProvider<LocaleNotifier, String>` — holds current locale string, persists to SharedPreferences on change
- `tProvider``Provider<String Function(String, {Map<String, String>? params})>` — watches `localeProvider` so widgets rebuild on locale change
### Language picker (`lib/shared/widgets/language_picker.dart`)
`LanguagePicker` is a `ConsumerWidget` rendering two flag buttons (🇬🇧 / 🇨🇿). Active locale gets a highlighted border. Calling `ref.read(localeProvider.notifier).setLocale(locale)` persists and propagates the change.
Placed in:
- Onboarding screen (first step)
- Profile screen (settings area)
### I18nValidator (`lib/core/i18n/i18n_validator.dart`)
Used in `test/i18n_test.dart` for CI validation:
- `validateTranslationFile(String json)` — checks valid JSON, string-only values, no bad characters, `_meta` presence, well-formed `{param}` placeholders, valid param names `[a-zA-Z0-9_]`
- `compareTranslations(Map en, Map other)` — returns `missingKeys`, `extraKeys`, `emptyValues`, `mismatchedParams`
## Usage in widgets
```dart
// In ConsumerWidget / ConsumerStatefulWidget
final t = ref.watch(tProvider);
Text(t('onboarding.welcome_title'))
Text(t('tasks.count', params: {'count': '5'}))
Text(t('tasks.error', params: {'error': e.toString()}))
```
For widgets without `ref` access (e.g. `initState`):
```dart
TranslationService.instance.t('key')
```
## Tests (`test/i18n_test.dart`)
18 tests — all passing:
- `en.json` and `cs.json` are valid JSON
- `cs.json` has all keys from `en.json`, no extras, no empty values
- No control/zero-width chars in values
- Param placeholders in `cs.json` match those in `en.json`
- Fallback: missing key → key string, missing locale → English
- Param substitution: correct, missing closing `}`, missing param, different word order
- `I18nValidator`: invalid JSON, missing `_meta`, non-string values, unclosed placeholder, invalid param name, `extractParams`
Run: `flutter test test/i18n_test.dart`
## Adding a new language
1. Copy `assets/i18n/en.json``assets/i18n/<locale>.json`
2. Translate all values (keep `{param}` placeholders unchanged)
3. Add locale code to `TranslationService._availableLocales` in `translation_service.dart`
4. Add a flag button in `language_picker.dart`
5. Run `flutter test test/i18n_test.dart` to validate
@@ -0,0 +1,87 @@
# Pet State Persistence & Web Crash Fix
**Branch:** `NM-App-245-fix-pet-state-persistence-and-dart-io-web-crash`
**Commits:** `ef67280`, `9a0f442`
## What Was Done
Three critical bugs were fixed:
1. **`dart:io` crash on web** — `onboarding_screen.dart` used `Platform.operatingSystem` which is unavailable on Flutter Web and caused a runtime crash.
2. **Hardcoded pet state**`PetStateNotifier.build()` returned static fake data instead of reading from the Drift database.
3. **Missing PetState after onboarding** — completing onboarding only created a `User` row; the `pet_state` table was left empty, so pet state was never persisted.
---
## Files Changed
### `lib/features/tasks/providers/task_providers.dart`
- Added `import` for `PetRepository`.
- Added `petRepositoryProvider` (`Provider<PetRepository>`) alongside the existing `userRepositoryProvider`.
### `lib/features/tasks/providers/pet_providers.dart`
- Added `petStateStreamProvider` (`StreamProvider<PetStateEntity?>`):
- Watches the current user from `currentUserProvider`.
- Returns `Stream.value(null)` when no user is loaded.
- Calls `petRepositoryProvider.watchPetState(user.id)` to stream DB changes.
- Rewrote `PetStateNotifier.build()`:
- Watches `petStateStreamProvider`.
- Returns the DB entity if present; otherwise a zero-default `PetStateEntity` with `id: ''` / `userId: ''`.
- Added `_persist()` private method:
- Guard: no-ops if `state.id.isEmpty` (prevents spurious writes before the DB record exists).
- Calls `petRepositoryProvider.updatePetState(state)`.
- `incrementTasksDone()` now also stamps `updatedAt: DateTime.now()` and calls `_persist()`.
### `lib/features/onboarding/screens/onboarding_screen.dart`
- Replaced `import 'dart:io'``import 'package:flutter/foundation.dart'`.
- Replaced `Platform.operatingSystem``defaultTargetPlatform.name.toLowerCase()`.
- Added `import` for `PetStateEntity`.
- After `repo.createUser(...)`, reads `petRepositoryProvider` and calls `insertPetState` with:
- `id`: new UUID
- `userId`: the just-created user's ID
- `energyToday: 2` (medium default)
- `streakDays: 0`, `totalTasksDone: 0`, `unlockedItems: ''` (via entity defaults)
- `updatedAt: DateTime.now()`
---
## Key Implementation Details
### Provider chain for pet state
```
currentUserProvider (FutureProvider<UserEntity?>)
└─► petStateStreamProvider (StreamProvider<PetStateEntity?>)
└─► PetStateNotifier.build() → petStateProvider (NotifierProvider)
```
### Guard pattern in `_persist()`
`PetStateNotifier` uses an empty-string `id` as a sentinel for "not yet in DB." This prevents `updatePetState` from being called with a phantom record before onboarding completes.
```dart
void _persist() {
if (state.id.isEmpty) return; // not yet in DB — skip
ref.read(petRepositoryProvider).updatePetState(state);
}
```
### Web-safe platform detection
```dart
// Before (crashes on web):
import 'dart:io';
final deviceId = '${Platform.operatingSystem}-${uuid.v4()}';
// After (works everywhere):
import 'package:flutter/foundation.dart';
final deviceId = '${defaultTargetPlatform.name.toLowerCase()}-${uuid.v4()}';
```
---
## Tests Added
| File | Tests |
|------|-------|
| `test/domain/entities/pet_state_entity_test.dart` | 16 — defaults, `copyWith`, field storage |
| `test/features/tasks/providers/pet_providers_test.dart` | 22 — provider wiring, DB loading, increment, mood/level providers |
All 38 new tests pass. Full suite: 109 passed, 1 pre-existing failure (`widget_test.dart` timer assertion, unrelated).
@@ -0,0 +1,91 @@
# ServiceLicence — Systemd Integration with AITM Dashboard
## What Was Done
Registered the `ServiceLicence` .NET license server as a systemd user service and surfaced it in the AITM dashboard (Test/Services tab) with start/stop/status controls.
## Files Changed
### Outside this repo — AITM project (`/home/zuf/dev/code/AITM/`)
| File | Change |
|------|--------|
| `src/shared-types.ts` | Added `managedBy?: string`, `systemdUnit?: string` to `TestAppConfig`; `rootOverride?: string` to `TestProjectConfig` |
| `src/types.ts` | Added `systemdUnit?: string` field; allowed `process: null` in `TestProcess` |
| `src/test-environment/TestEnvironmentService.ts` | Systemd-aware branches in `startTestApp`, `stopTestApp`, `getTestStatus`; `rootOverride` support in `testProjectDir()` |
| `src/socket/TestSocketHandlers.ts` | Skip dotnet build for systemd-managed apps in `test:build-and-start` and `test:start-all` |
| `config.json` | Added `service-licence` project entry with `rootOverride` and `managedBy: "systemd"` |
### System file
| File | Change |
|------|--------|
| `~/.config/systemd/user/servicelicence.service` | New systemd user unit for ServiceLicence; `NODE_ENV=development`, `DATABASE_URL` set; `Restart=on-failure` |
## Key Implementation Details
### Systemd Unit (`servicelicence.service`)
```ini
[Unit]
Description=ServiceLicence License Server
After=network.target
[Service]
WorkingDirectory=<path to LicenseServer project>
ExecStart=dotnet run --project <path>
Environment=NODE_ENV=development
Environment=DATABASE_URL=<connection string>
Restart=on-failure
[Install]
WantedBy=default.target
```
`NODE_ENV=development` was required (not `production`) so that the dev-secret default for `JWT_SECRET` is picked up — production mode requires an explicit `JWT_SECRET` env var.
### AITM Config Entry
```json
{
"id": "service-licence",
"name": "ServiceLicence",
"managedBy": "systemd",
"systemdUnit": "servicelicence",
"rootOverride": "/absolute/path/to/LicenseServer"
}
```
`rootOverride` is acceptable in `config.json` (config file, not source code). `managedBy: "systemd"` gates the new code branches.
### Backend Systemd-Aware Logic (`TestEnvironmentService.ts`)
Three functions were extended with a systemd branch:
- **`startTestApp(id)`** — runs `systemctl --user start <unit>` instead of spawning a child process
- **`stopTestApp(id)`** — runs `systemctl --user stop <unit>`
- **`getTestStatus(id)`** — parses `systemctl --user is-active <unit>` → maps `active``running`, anything else → `stopped`; survives AITM restarts (no in-memory process reference needed)
### Build Skip (`TestSocketHandlers.ts`)
`test:build-and-start` and `test:start-all` handlers skip `dotnet build` for entries with `managedBy: "systemd"` — the service manages its own lifecycle.
## Usage
```bash
# Check status
systemctl --user status servicelicence
# Restart
systemctl --user restart servicelicence
# Via AITM API
curl -s http://localhost:3333/api/test/status | jq '.[] | select(.id == "service-licence")'
```
## Acceptance Criteria Met
- `systemctl --user status servicelicence``active (running)`
- `curl -s http://localhost:3333/api/test/status | jq .` includes `service-licence` with valid status
- Start/Stop buttons in AITM Test tab work for ServiceLicence
- `npm run check:frontend` passes
+117
View File
@@ -0,0 +1,117 @@
# Skin / Theme System
## What was implemented
A switchable skin system allowing users to choose from 7 distinct visual themes. The active skin is persisted via SharedPreferences and restored on app startup. The system is split into three distinct layers: color palette (`SkinColors`), typography (`SkinTypography`), and theme assembly (`KittieTheme.fromSkin`).
## Architecture overview
```
SkinType (enum)
└─ .colors → SkinColors (abstract + 7 concrete impls)
└─ used by SkinTypography (font + scale)
skinProvider (NotifierProvider<SkinNotifier, SkinType>)
skinColorsProvider → Provider<SkinColors> (derived from skinProvider)
skinTypographyProvider → Provider<SkinTypography> (derived from both above)
KittieTheme.fromSkin(SkinColors, SkinTypography) → ThemeData
```
## Files created
| File | Purpose |
|------|---------|
| `lib/core/theme/skin_type.dart` | `SkinType` enum + `SkinTypeExtension` (i18n keys, storage key, `.colors` factory) |
| `lib/core/theme/skin_colors.dart` | Abstract `SkinColors` + 7 concrete implementations |
| `lib/core/theme/skin_typography.dart` | `SkinTypography` — font family, scale factor, TextTheme |
| `lib/shared/widgets/skin_picker.dart` | `SkinPicker` ConsumerWidget — 7 tap-to-select tiles |
| `test/core/theme/skin_type_test.dart` | 21 tests for `SkinType` enum and extension |
| `test/core/theme/skin_colors_test.dart` | 73 tests for color palettes |
| `test/core/theme/skin_typography_test.dart` | 25 widget tests for typography |
## Files modified
| File | Change |
|------|--------|
| `lib/core/theme/skin_provider.dart` | Replaced with `setInitialSkin()` + derived `skinColorsProvider` / `skinTypographyProvider` |
| `lib/core/theme/kittie_theme.dart` | Added `fromSkin(SkinColors, SkinTypography)` factory |
| `lib/main.dart` | Reads persisted skin before `runApp()`, calls `setInitialSkin()` |
| `lib/shared/widgets/profile_screen.dart` | Added `SkinPicker` section, ported to skin providers |
| 11 widget/screen files | Converted from `KittieColors.*` / `KittieTypography.*` to `skinColorsProvider` / `skinTypographyProvider` |
| `assets/i18n/en.json` + `assets/i18n/cs.json` | Added 16 skin i18n keys each (`skin.title`, `skin.<name>`, `skin.<name>_desc`) |
## Skins
| Key | Name | Mode | Font | Scale | Surface | Primary |
|-----|------|------|------|-------|---------|---------|
| `defaultSkin` | Default | Light | Lexend | 1.0× | `#FFF8E7` (parchment) | `#6B4A2A` (brown) |
| `accessibility` | Accessible | Light | Atkinson Hyperlegible | 1.15× | `#FFFFFF` | `#0055CC` (blue) |
| `senior` | Senior | Light | Atkinson Hyperlegible | 1.25× | `#FAFAFA` | `#2C4A7C` (navy) |
| `modern` | Modern | Light | Lexend | 1.0× | `#F8F9FA` | `#0D9488` (teal) |
| `kids` | Kids | Light | Lexend | 1.1× | `#F0E6FF` (lavender) | `#E91E8F` (pink) |
| `epic` | Epic | **Dark** | Lexend | 1.0× | `#1A1425` | `#7C3AED` (purple) |
| `future` | Future | **Dark** | Lexend | 1.0× | `#0A0E17` | `#2979FF` (neon blue) |
## Key design decisions
### Three-layer separation
`SkinColors` (data) → `SkinTypography` (computed styles) → `KittieTheme.fromSkin()` (assembled ThemeData). Each layer is independently testable.
### `setInitialSkin()` pattern (no async Notifier.build)
The persisted skin is read synchronously in `main()` before `runApp()`. `setInitialSkin()` sets a package-level variable that `SkinNotifier.build()` reads as its initial state — avoids the `AsyncValue` complexity that would come from loading SharedPreferences inside the notifier.
### Dark skins override `toColorScheme()` with `ColorScheme.dark()`
`EpicSkinColors` and `FutureSkinColors` override `SkinColors.toColorScheme()` to return `ColorScheme.dark(...)`. Without this, Flutter's Material theme defaults to light brightness, resulting in invisible text on dark surfaces.
### `DefaultSkinColors` mirrors `KittieColors` exactly
Ensures no visual regression when switching away from the old static approach. `KittieTheme.light` now delegates to `fromSkin(DefaultSkinColors(), ...)` for backward compatibility.
### Derived providers avoid re-computation
`skinColorsProvider` and `skinTypographyProvider` are simple `Provider` wrappers derived from `skinProvider`. Widgets watch these directly — they only rebuild when the skin actually changes.
## Usage
### Watch colors/typography in a widget
```dart
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Text('Hello', style: skinTypo.h2.copyWith(color: skinColors.brownDark));
}
}
```
### Switch skin
```dart
ref.read(skinProvider.notifier).setSkin(SkinType.epic);
```
### Persist on startup (already wired in `main.dart`)
```dart
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString('app_skin') ?? 'defaultSkin';
setInitialSkin(SkinTypeExtension.fromStorageKey(saved));
runApp(const ProviderScope(child: KittieApp()));
```
### Apply theme in `MaterialApp` (already wired in `KittieApp`)
```dart
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
MaterialApp(theme: KittieTheme.fromSkin(skinColors, skinTypo), ...)
```
## Tests
```
test/core/theme/skin_type_test.dart — 21 tests
test/core/theme/skin_colors_test.dart — 73 tests
test/core/theme/skin_typography_test.dart — 25 widget tests
test/core/theme/app_skin_test.dart — retained (legacy AppSkin)
test/core/theme/skin_provider_test.dart — updated for SkinType
```
Total: 197 passing tests. Covers enum round-trip, WCAG AAA contrast for accessibility skin, dark/light brightness, energy color distinctness, scale factors, font family per skin, TextTheme slot mapping, and no-null fontSize for all skins × all slots.
+5
View File
@@ -11,6 +11,7 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -39,6 +40,10 @@ android {
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
flutter {
source = "../.."
}
+61
View File
@@ -0,0 +1,61 @@
# KittieMobile i18n Translation Files
## Format
Each translation file is a flat JSON with string key-value pairs and a `_meta` header:
```json
{
"_meta": {
"language": "English",
"locale": "en",
"version": "1.0.0",
"author": "KittieMobile Team"
},
"key.subkey": "Translated string"
}
```
## Rules
1. All keys from `en.json` must exist in every translation file.
2. All values must be plain strings — no arrays, numbers, or nested objects.
3. The `_meta` object is required with fields: `language`, `locale`, `version`, `author`.
4. Do not add keys that are not present in `en.json`.
## Variable substitution syntax
Use `{paramName}` placeholders for dynamic values:
```json
"onboarding.ready_subtitle": "{emoji} {petName} is ready to help you."
```
Rules for parameters:
- Always close with `}` — unclosed placeholders cause runtime fallback.
- Param names must match `[a-zA-Z0-9_]` only (no spaces, dashes, dots).
- Keep the same param names as in `en.json` — only translate surrounding text.
## Adding a new language
1. Copy `en.json` to a new file named `<locale>.json` (e.g., `de.json`).
2. Update the `_meta.language` and `_meta.locale` fields.
3. Translate all string values. Keep `{paramName}` placeholders intact.
4. Register the locale in `lib/core/i18n/translation_service.dart` in `_availableLocales`.
5. Add a button for the locale in `lib/shared/widgets/language_picker.dart`.
## Validation
Run the i18n test suite to verify all translation files:
```bash
flutter test test/i18n_test.dart
```
The tests check:
- Valid JSON syntax
- Presence of `_meta` header
- All keys from `en.json` present
- No extra keys beyond `en.json`
- No empty values
- Matching `{param}` names between locales
+138
View File
@@ -0,0 +1,138 @@
{
"_meta": {
"language": "Czech",
"locale": "cs",
"version": "1.0.0",
"author": "KittieMobile Team"
},
"onboarding.welcome_title": "Vítej v KittieMobile!",
"onboarding.welcome_subtitle": "Tvůj mazlíček ti pomůže zvládnout úkoly.",
"onboarding.start": "Začít",
"onboarding.choose_pet": "Vyber si mazlíčka",
"onboarding.pet_dog": "Pes",
"onboarding.pet_cat": "Kočka",
"onboarding.pet_capybara": "Kapybara",
"onboarding.pet_name_label": "Jak se bude jmenovat?",
"onboarding.pet_name_hint": "Míša",
"onboarding.continue": "Pokračovat",
"onboarding.ready_title": "Vše je připraveno!",
"onboarding.ready_subtitle": "{emoji} {petName} je připraven/a ti pomáhat.",
"onboarding.start_using": "Začít používat",
"tasks.section_today": "DNEŠNÍ ÚKOLY",
"tasks.error": "Chyba: {error}",
"tasks.empty_title": "Žádné úkoly na dnes!",
"tasks.empty_subtitle": "Užij si volný den ☕",
"tasks.new_task": "Nový úkol",
"tasks.title_label": "NÁZEV",
"tasks.title_hint": "Co potřebuješ udělat?",
"tasks.note_label": "POZNÁMKA",
"tasks.note_hint": "Volitelné...",
"tasks.when_title": "Kdy to chceš udělat?",
"tasks.day_today": "Dnes",
"tasks.day_tomorrow": "Zítra",
"tasks.day_other": "Jindy",
"tasks.day_someday": "Někdy",
"tasks.time_label": "Čas",
"tasks.energy_question": "Kolik energie to bude stát?",
"tasks.energy_low": "Lehké",
"tasks.energy_medium": "Střední",
"tasks.energy_high": "Náročné",
"tasks.save_task": "Uložit úkol",
"tasks.task_saved": "Úkol uložen!",
"tasks.back_to_list": "Zpět na seznam",
"tasks.add_another": "Přidat další úkol",
"zen.done": "Hotovo",
"zen.skip": "Přeskočit",
"zen.all_done_title": "Vše hotovo.\nOdpočívej.",
"nav.tasks": "úkoly",
"nav.pet": "mazlíček",
"nav.profile": "profil",
"pet.companion_label": "Tvůj společník — {petType}",
"pet.stat_streak": "Streak",
"pet.stat_streak_value": "{days} dní",
"pet.stat_tasks_done": "Hotové úkoly",
"pet.stat_self_care": "Péče o sebe",
"pet.stat_level": "Úroveň",
"pet.unlocked_items": "Odemčené předměty",
"pet.item_bow": "Mašle",
"pet.item_bed": "Pelíšek",
"pet.item_crown": "Koruna",
"pet.item_wings": "Křídla",
"pet.mood_happy": "{petName} je šťastná! Jde ti to skvěle",
"pet.mood_neutral": "{petName} tě čeká! Co dnes podnikneme?",
"pet.mood_sad": "{petName} tě podporuje. Zkus jeden úkol",
"pet.type_dog": "Pejsek",
"pet.type_cat": "Kočička",
"pet.type_capybara": "Kapybara",
"profile.title": "Profil",
"profile.name_label": "Jméno",
"profile.pet_name_label": "Jméno mazlíčka",
"profile.pet_type_label": "Typ mazlíčka",
"profile.edit_name_title": "Upravit jméno",
"profile.edit_pet_name_title": "Upravit jméno mazlíčka",
"profile.stats_title": "Statistiky",
"profile.export_data": "Exportovat data",
"profile.export_preparing": "Připravujeme",
"profile.version": "KittieMobile v1.0.0",
"energy.label": "ENERGIE",
"energy.low": "Lehké",
"energy.medium": "Střední",
"energy.high": "Náročné",
"self_care.tag": "Péče o sebe",
"streak.day_mon": "Po",
"streak.day_tue": "Ut",
"streak.day_wed": "St",
"streak.day_thu": "Čt",
"streak.day_fri": "Pá",
"streak.day_sat": "So",
"streak.day_sun": "Ne",
"date.day_monday": "PONDĚLÍ",
"date.day_tuesday": "ÚTERÝ",
"date.day_wednesday": "STŘEDA",
"date.day_thursday": "ČTVRTEK",
"date.day_friday": "PÁTEK",
"date.day_saturday": "SOBOTA",
"date.day_sunday": "NEDĚLE",
"date.month_january": "LEDNA",
"date.month_february": "ÚNORA",
"date.month_march": "BŘEZNA",
"date.month_april": "DUBNA",
"date.month_may": "KVĚTNA",
"date.month_june": "ČERVNA",
"date.month_july": "ČERVENCE",
"date.month_august": "SRPNA",
"date.month_september": "ZÁŘÍ",
"date.month_october": "ŘÍJNA",
"date.month_november": "LISTOPADU",
"date.month_december": "PROSINCE",
"date.header_format": "{day}, {date}. {month}",
"greeting.morning": "Dobré ráno",
"greeting.afternoon": "Dobrý den",
"greeting.evening": "Dobrý večer",
"greeting.night": "Dobrou noc",
"common.save": "Uložit",
"common.cancel": "Zrušit",
"common.back": "Zpět",
"common.delete": "Smazat",
"common.edit": "Upravit",
"common.loading": "Načítám...",
"common.retry": "Zkusit znovu",
"language.title": "Jazyk",
"language.en": "Angličtina",
"language.cs": "Čeština",
"skin.title": "Vzhled aplikace",
"skin.defaultSkin": "Výchozí",
"skin.defaultSkin_desc": "Teplý pergamen",
"skin.accessibility": "Přístupný",
"skin.accessibility_desc": "Vysoký kontrast",
"skin.senior": "Senior",
"skin.senior_desc": "Velký text",
"skin.modern": "Moderní",
"skin.modern_desc": "Čistý a minimální",
"skin.kids": "Dětský",
"skin.kids_desc": "Barevný a hravý",
"skin.epic": "Epický",
"skin.epic_desc": "Tmavý a výrazný",
"skin.future": "Budoucnost",
"skin.future_desc": "Neonové vibrace"
}
+138
View File
@@ -0,0 +1,138 @@
{
"_meta": {
"language": "English",
"locale": "en",
"version": "1.0.0",
"author": "KittieMobile Team"
},
"onboarding.welcome_title": "Welcome to KittieMobile!",
"onboarding.welcome_subtitle": "Your pet will help you tackle tasks.",
"onboarding.start": "Start",
"onboarding.choose_pet": "Choose your pet",
"onboarding.pet_dog": "Dog",
"onboarding.pet_cat": "Cat",
"onboarding.pet_capybara": "Capybara",
"onboarding.pet_name_label": "What's your pet's name?",
"onboarding.pet_name_hint": "Buddy",
"onboarding.continue": "Continue",
"onboarding.ready_title": "All set!",
"onboarding.ready_subtitle": "{emoji} {petName} is ready to help you.",
"onboarding.start_using": "Start using",
"tasks.section_today": "TODAY'S TASKS",
"tasks.error": "Error: {error}",
"tasks.empty_title": "No tasks for today!",
"tasks.empty_subtitle": "Enjoy your free day",
"tasks.new_task": "New task",
"tasks.title_label": "TITLE",
"tasks.title_hint": "What do you need to do?",
"tasks.note_label": "NOTE",
"tasks.note_hint": "Optional...",
"tasks.when_title": "When do you want to do it?",
"tasks.day_today": "Today",
"tasks.day_tomorrow": "Tomorrow",
"tasks.day_other": "Other",
"tasks.day_someday": "Someday",
"tasks.time_label": "Time",
"tasks.energy_question": "How much energy will it cost?",
"tasks.energy_low": "Light",
"tasks.energy_medium": "Medium",
"tasks.energy_high": "Hard",
"tasks.save_task": "Save task",
"tasks.task_saved": "Task saved!",
"tasks.back_to_list": "Back to list",
"tasks.add_another": "Add another task",
"zen.done": "Done",
"zen.skip": "Skip",
"zen.all_done_title": "All done.\nRest up.",
"nav.tasks": "tasks",
"nav.pet": "pet",
"nav.profile": "profile",
"pet.companion_label": "Your companion — {petType}",
"pet.stat_streak": "Streak",
"pet.stat_streak_value": "{days} days",
"pet.stat_tasks_done": "Tasks done",
"pet.stat_self_care": "Self-care",
"pet.stat_level": "Level",
"pet.unlocked_items": "Unlocked items",
"pet.item_bow": "Bow",
"pet.item_bed": "Bed",
"pet.item_crown": "Crown",
"pet.item_wings": "Wings",
"pet.mood_happy": "{petName} is happy! You're doing great",
"pet.mood_neutral": "{petName} is waiting! What will we do today?",
"pet.mood_sad": "{petName} supports you. Try one task",
"pet.type_dog": "Dog",
"pet.type_cat": "Cat",
"pet.type_capybara": "Capybara",
"profile.title": "Profile",
"profile.name_label": "Name",
"profile.pet_name_label": "Pet name",
"profile.pet_type_label": "Pet type",
"profile.edit_name_title": "Edit name",
"profile.edit_pet_name_title": "Edit pet name",
"profile.stats_title": "Statistics",
"profile.export_data": "Export data",
"profile.export_preparing": "Preparing",
"profile.version": "KittieMobile v1.0.0",
"energy.label": "ENERGY",
"energy.low": "Light",
"energy.medium": "Medium",
"energy.high": "Hard",
"self_care.tag": "Self-care",
"streak.day_mon": "Mo",
"streak.day_tue": "Tu",
"streak.day_wed": "We",
"streak.day_thu": "Th",
"streak.day_fri": "Fr",
"streak.day_sat": "Sa",
"streak.day_sun": "Su",
"date.day_monday": "MONDAY",
"date.day_tuesday": "TUESDAY",
"date.day_wednesday": "WEDNESDAY",
"date.day_thursday": "THURSDAY",
"date.day_friday": "FRIDAY",
"date.day_saturday": "SATURDAY",
"date.day_sunday": "SUNDAY",
"date.month_january": "JANUARY",
"date.month_february": "FEBRUARY",
"date.month_march": "MARCH",
"date.month_april": "APRIL",
"date.month_may": "MAY",
"date.month_june": "JUNE",
"date.month_july": "JULY",
"date.month_august": "AUGUST",
"date.month_september": "SEPTEMBER",
"date.month_october": "OCTOBER",
"date.month_november": "NOVEMBER",
"date.month_december": "DECEMBER",
"date.header_format": "{day}, {date}. {month}",
"greeting.morning": "Good morning",
"greeting.afternoon": "Good afternoon",
"greeting.evening": "Good evening",
"greeting.night": "Good night",
"common.save": "Save",
"common.cancel": "Cancel",
"common.back": "Back",
"common.delete": "Delete",
"common.edit": "Edit",
"common.loading": "Loading...",
"common.retry": "Retry",
"language.title": "Language",
"language.en": "English",
"language.cs": "Czech",
"skin.title": "App skin",
"skin.defaultSkin": "Default",
"skin.defaultSkin_desc": "Warm parchment",
"skin.accessibility": "Accessible",
"skin.accessibility_desc": "High contrast",
"skin.senior": "Senior",
"skin.senior_desc": "Large text",
"skin.modern": "Modern",
"skin.modern_desc": "Clean & minimal",
"skin.kids": "Kids",
"skin.kids_desc": "Colorful & fun",
"skin.epic": "Epic",
"skin.epic_desc": "Dark & bold",
"skin.future": "Future",
"skin.future_desc": "Neon vibes"
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'translation_service.dart';
final translationServiceProvider = Provider<TranslationService>((ref) {
return TranslationService.instance;
});
final localeProvider = NotifierProvider<LocaleNotifier, String>(LocaleNotifier.new);
class LocaleNotifier extends Notifier<String> {
@override
String build() => TranslationService.instance.currentLocale;
Future<void> setLocale(String locale) async {
await TranslationService.instance.setLocale(locale);
state = locale;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('app_locale', locale);
}
}
final tProvider =
Provider<String Function(String, {Map<String, String>? params})>((ref) {
ref.watch(localeProvider);
return TranslationService.instance.t;
});
+134
View File
@@ -0,0 +1,134 @@
import 'dart:convert';
class I18nValidationResult {
final List<String> errors;
bool get isValid => errors.isEmpty;
const I18nValidationResult(this.errors);
}
class I18nCompareResult {
final List<String> missingKeys;
final List<String> extraKeys;
final List<String> emptyValues;
final List<String> mismatchedParams;
const I18nCompareResult({
required this.missingKeys,
required this.extraKeys,
required this.emptyValues,
required this.mismatchedParams,
});
}
class I18nValidator {
static I18nValidationResult validateTranslationFile(String jsonString) {
final errors = <String>[];
Map<String, dynamic> parsed;
try {
parsed = json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
return I18nValidationResult(['Invalid JSON: $e']);
}
// Check _meta
if (!parsed.containsKey('_meta')) {
errors.add('Missing _meta object');
} else if (parsed['_meta'] is Map) {
final meta = parsed['_meta'] as Map<String, dynamic>;
for (final field in ['language', 'locale', 'version', 'author']) {
if (!meta.containsKey(field)) {
errors.add('_meta missing required field: $field');
}
}
}
// Check all values are strings (skip _meta)
final controlChars = RegExp(r'[\x00-\x09\x0B-\x1F]');
final zeroWidth = RegExp(r'[\u200B\u200C\u200D\uFEFF]');
final validParamName = RegExp(r'^[a-zA-Z0-9_]+$');
for (final entry in parsed.entries) {
if (entry.key == '_meta') continue;
if (entry.value is! String) {
errors.add('Key "${entry.key}" has non-string value');
continue;
}
final value = entry.value as String;
if (controlChars.hasMatch(value)) {
errors.add('Key "${entry.key}" contains control characters');
}
if (zeroWidth.hasMatch(value)) {
errors.add('Key "${entry.key}" contains zero-width characters');
}
// Check placeholders
int idx = 0;
while (idx < value.length) {
if (value[idx] == '{') {
final closing = value.indexOf('}', idx);
if (closing == -1) {
errors.add(
'Key "${entry.key}" has unclosed placeholder starting at index $idx');
break;
}
final paramName = value.substring(idx + 1, closing);
if (!validParamName.hasMatch(paramName)) {
errors.add(
'Key "${entry.key}" has invalid param name "$paramName" (use only [a-zA-Z0-9_])');
}
idx = closing + 1;
} else {
idx++;
}
}
}
return I18nValidationResult(errors);
}
static I18nCompareResult compareTranslations(
Map<String, dynamic> en,
Map<String, dynamic> other,
) {
final enKeys = en.keys.where((k) => k != '_meta').toSet();
final otherKeys = other.keys.where((k) => k != '_meta').toSet();
final missingKeys = enKeys.difference(otherKeys).toList()..sort();
final extraKeys = otherKeys.difference(enKeys).toList()..sort();
final emptyValues = <String>[];
for (final key in otherKeys) {
if (other[key] is String && (other[key] as String).isEmpty) {
emptyValues.add(key);
}
}
final mismatchedParams = <String>[];
for (final key in enKeys.intersection(otherKeys)) {
if (en[key] is String && other[key] is String) {
final enParams = extractParams(en[key] as String);
final otherParams = extractParams(other[key] as String);
if (!_setsEqual(enParams, otherParams)) {
mismatchedParams.add(key);
}
}
}
return I18nCompareResult(
missingKeys: missingKeys,
extraKeys: extraKeys,
emptyValues: emptyValues,
mismatchedParams: mismatchedParams,
);
}
static Set<String> extractParams(String value) {
final regex = RegExp(r'\{([a-zA-Z0-9_]+)\}');
return regex.allMatches(value).map((m) => m.group(1)!).toSet();
}
static bool _setsEqual(Set<String> a, Set<String> b) {
if (a.length != b.length) return false;
return a.containsAll(b);
}
}
+99
View File
@@ -0,0 +1,99 @@
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/foundation.dart';
class TranslationService extends ChangeNotifier {
TranslationService._();
static final TranslationService instance = TranslationService._();
Map<String, String> _fallback = {};
Map<String, String> _current = {};
String _currentLocale = 'en';
final List<String> _availableLocales = ['en', 'cs'];
String get currentLocale => _currentLocale;
List<String> get availableLocales => List.unmodifiable(_availableLocales);
Future<void> init({String locale = 'cs'}) async {
_fallback = await _loadJson('en');
await setLocale(locale);
}
Future<void> setLocale(String locale) async {
if (!_availableLocales.contains(locale)) locale = 'en';
_current = locale == 'en' ? _fallback : await _loadJson(locale);
_currentLocale = locale;
notifyListeners();
}
String t(String key, {Map<String, String>? params}) {
String value = _current[key] ?? _fallback[key] ?? key;
if (params != null && params.isNotEmpty) {
value = _substituteParams(value, params);
}
return value;
}
@visibleForTesting
static String substituteParams(String template, Map<String, String> params) {
return _substituteParams(template, params);
}
static String _substituteParams(String template, Map<String, String> params) {
final result = StringBuffer();
int i = 0;
while (i < template.length) {
if (template[i] == '{') {
final closingIndex = template.indexOf('}', i);
if (closingIndex == -1) {
// Unclosed placeholder — write as-is then append param value at end
final paramName = template.substring(i + 1).trim();
result.write(template.substring(i));
if (params.containsKey(paramName)) {
result.write(' ');
result.write(params[paramName]);
}
i = template.length;
} else {
final paramName = template.substring(i + 1, closingIndex);
if (RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(paramName) &&
params.containsKey(paramName)) {
result.write(params[paramName]);
} else {
result.write(template.substring(i, closingIndex + 1));
}
i = closingIndex + 1;
}
} else {
result.write(template[i]);
i++;
}
}
return result.toString();
}
Future<Map<String, String>> _loadJson(String locale) async {
try {
final jsonStr = await rootBundle.loadString('assets/i18n/$locale.json');
final Map<String, dynamic> parsed = json.decode(jsonStr);
final result = <String, String>{};
for (final entry in parsed.entries) {
if (entry.key == '_meta') continue;
if (entry.value is String) {
result[entry.key] = _sanitize(entry.value as String);
}
}
return result;
} catch (e) {
debugPrint('TranslationService: Failed to load $locale.json: $e');
return {};
}
}
static String _sanitize(String value) {
return value.replaceAll(
RegExp(r'[\x00-\x09\x0B-\x1F\u200B\u200C\u200D\uFEFF]'), '');
}
}
+345
View File
@@ -0,0 +1,345 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../constants/app_constants.dart';
import 'kittie_colors.dart';
import 'kittie_typography.dart';
/// All available app skins.
///
/// The [key] field is used for SharedPreferences persistence.
enum AppSkin {
defaultSkin('default'),
accessible('accessible'),
senior('senior'),
modern('modern'),
kids('kids'),
epic('epic'),
future('future');
const AppSkin(this.key);
/// Stable string key for SharedPreferences persistence.
final String key;
/// Returns the [AppSkin] for [key], defaulting to [defaultSkin].
static AppSkin fromKey(String key) => AppSkin.values.firstWhere(
(s) => s.key == key,
orElse: () => AppSkin.defaultSkin,
);
/// Whether this skin uses a dark color scheme.
bool get isDark => this == AppSkin.epic || this == AppSkin.future;
/// Font scale factor relative to default (1.0).
double get fontScale => switch (this) {
AppSkin.senior => 1.25,
AppSkin.kids => 1.1,
_ => 1.0,
};
/// Primary swatch color for skin-picker preview tiles.
Color get previewPrimary => switch (this) {
AppSkin.defaultSkin => KittieColors.brown,
AppSkin.accessible => const Color(0xFF000000),
AppSkin.senior => const Color(0xFF1A237E),
AppSkin.modern => const Color(0xFF00796B),
AppSkin.kids => const Color(0xFFE91E8C),
AppSkin.epic => const Color(0xFFFFD700),
AppSkin.future => const Color(0xFF00E5FF),
};
/// Surface/background color for skin-picker preview tiles.
Color get previewSurface => switch (this) {
AppSkin.defaultSkin => KittieColors.cream,
AppSkin.accessible => const Color(0xFFFFFFFF),
AppSkin.senior => const Color(0xFFFAF8F0),
AppSkin.modern => const Color(0xFFF5F5F5),
AppSkin.kids => const Color(0xFFF3E5F5),
AppSkin.epic => const Color(0xFF1A0030),
AppSkin.future => const Color(0xFF050D1A),
};
/// Returns only the [ColorScheme] for this skin — no Google Fonts calls.
///
/// Useful for tests that need to verify color values without triggering
/// async font-load network requests.
ColorScheme buildColorScheme() => switch (this) {
AppSkin.defaultSkin => _colorSchemeDefault(),
AppSkin.accessible => _colorSchemeAccessible(),
AppSkin.senior => _colorSchemeSenior(),
AppSkin.modern => _colorSchemeModern(),
AppSkin.kids => _colorSchemeKids(),
AppSkin.epic => _colorSchemeEpic(),
AppSkin.future => _colorSchemeFuture(),
};
/// Builds the full [ThemeData] for this skin.
ThemeData buildTheme() => switch (this) {
AppSkin.defaultSkin => _buildDefault(),
AppSkin.accessible => _buildAccessible(),
AppSkin.senior => _buildSenior(),
AppSkin.modern => _buildModern(),
AppSkin.kids => _buildKids(),
AppSkin.epic => _buildEpic(),
AppSkin.future => _buildFuture(),
};
// ── Default (Warm Parchment) ────────────────────────────────────────────
ColorScheme _colorSchemeDefault() => ColorScheme.light(
surface: KittieColors.cream,
onSurface: KittieColors.brownDark,
primary: KittieColors.brown,
onPrimary: KittieColors.cream,
secondary: KittieColors.amber,
onSecondary: KittieColors.brownDark,
tertiary: KittieColors.sageGreen,
onTertiary: KittieColors.cream,
error: KittieColors.red,
onError: KittieColors.cream,
);
ThemeData _buildDefault() =>
_base(_colorSchemeDefault(), KittieTypography.textTheme);
// ── Accessible (WCAG AAA High-Contrast) ────────────────────────────────
ColorScheme _colorSchemeAccessible() => const ColorScheme.light(
surface: Color(0xFFFFFFFF),
onSurface: Color(0xFF000000),
primary: Color(0xFF000000),
onPrimary: Color(0xFFFFFFFF),
secondary: Color(0xFF0052CC),
onSecondary: Color(0xFFFFFFFF),
tertiary: Color(0xFF005C2E),
onTertiary: Color(0xFFFFFFFF),
error: Color(0xFFB71C1C),
onError: Color(0xFFFFFFFF),
);
ThemeData _buildAccessible() {
final cs = _colorSchemeAccessible();
return _base(cs, _atkinsonTextTheme(1.0, cs.onSurface));
}
// ── Senior (1.25x font, Atkinson Hyperlegible, navy/gold) ──────────────
ColorScheme _colorSchemeSenior() => ColorScheme.light(
surface: const Color(0xFFFAF8F0),
onSurface: const Color(0xFF0D1226),
primary: const Color(0xFF1A237E),
onPrimary: Colors.white,
secondary: const Color(0xFFFFC107),
onSecondary: const Color(0xFF0D1226),
tertiary: const Color(0xFF2E7D32),
onTertiary: Colors.white,
error: const Color(0xFFC62828),
onError: Colors.white,
);
ThemeData _buildSenior() {
final cs = _colorSchemeSenior();
return _base(cs, _atkinsonTextTheme(1.25, cs.onSurface));
}
// ── Modern (clean gray, teal primary) ──────────────────────────────────
ColorScheme _colorSchemeModern() => ColorScheme.light(
surface: const Color(0xFFF5F5F5),
onSurface: const Color(0xFF212121),
primary: const Color(0xFF00796B),
onPrimary: Colors.white,
secondary: const Color(0xFF546E7A),
onSecondary: Colors.white,
tertiary: const Color(0xFF558B2F),
onTertiary: Colors.white,
error: const Color(0xFFD32F2F),
onError: Colors.white,
);
ThemeData _buildModern() {
final cs = _colorSchemeModern();
return _base(cs, _lexendTextTheme(1.0, cs.onSurface));
}
// ── Kids (lavender, bright pink, 1.1x font) ────────────────────────────
ColorScheme _colorSchemeKids() => ColorScheme.light(
surface: const Color(0xFFF3E5F5),
onSurface: const Color(0xFF2D0A2E),
primary: const Color(0xFFE91E8C),
onPrimary: Colors.white,
secondary: const Color(0xFF7B1FA2),
onSecondary: Colors.white,
tertiary: const Color(0xFF00897B),
onTertiary: Colors.white,
error: const Color(0xFFD32F2F),
onError: Colors.white,
);
ThemeData _buildKids() {
final cs = _colorSchemeKids();
return _base(cs, _lexendTextTheme(1.1, cs.onSurface));
}
// ── Epic (dark purple, gold, dark scheme) ──────────────────────────────
ColorScheme _colorSchemeEpic() => const ColorScheme.dark(
surface: Color(0xFF1A0030),
onSurface: Color(0xFFF5E6FF),
primary: Color(0xFFFFD700),
onPrimary: Color(0xFF1A0030),
secondary: Color(0xFFCE93D8),
onSecondary: Color(0xFF1A0030),
tertiary: Color(0xFF80CBC4),
onTertiary: Color(0xFF1A0030),
error: Color(0xFFEF9A9A),
onError: Color(0xFF1A0030),
);
ThemeData _buildEpic() {
final cs = _colorSchemeEpic();
return _base(cs, _lexendTextTheme(1.0, cs.onSurface));
}
// ── Future (neon cyan/blue on very dark, dark scheme) ──────────────────
ColorScheme _colorSchemeFuture() => const ColorScheme.dark(
surface: Color(0xFF050D1A),
onSurface: Color(0xFFE0F7FA),
primary: Color(0xFF00E5FF),
onPrimary: Color(0xFF001F2C),
secondary: Color(0xFF64B5F6),
onSecondary: Color(0xFF001F2C),
tertiary: Color(0xFF69FF47),
onTertiary: Color(0xFF001F2C),
error: Color(0xFFFF5252),
onError: Color(0xFF001F2C),
);
ThemeData _buildFuture() {
final cs = _colorSchemeFuture();
return _base(cs, _lexendTextTheme(1.0, cs.onSurface));
}
// ── Typography helpers ──────────────────────────────────────────────────
TextTheme _atkinsonTextTheme(double scale, Color color) {
TextStyle s(double size, FontWeight w) => GoogleFonts.atkinsonHyperlegible(
fontSize: size * scale,
fontWeight: w,
color: color,
);
return TextTheme(
headlineLarge: s(28, FontWeight.w700),
headlineMedium: s(22, FontWeight.w600),
headlineSmall: s(18, FontWeight.w600),
bodyLarge: s(16, FontWeight.w400),
bodyMedium: s(14, FontWeight.w400),
bodySmall: s(13, FontWeight.w400),
labelLarge: s(14, FontWeight.w500),
labelMedium: s(12, FontWeight.w500),
labelSmall: s(11, FontWeight.w500),
titleLarge: s(22, FontWeight.w600),
titleMedium: s(18, FontWeight.w600),
titleSmall: s(14, FontWeight.w500),
);
}
TextTheme _lexendTextTheme(double scale, Color color) {
TextStyle s(double size, FontWeight w) => GoogleFonts.lexend(
fontSize: size * scale,
fontWeight: w,
color: color,
);
return TextTheme(
headlineLarge: s(28, FontWeight.w700),
headlineMedium: s(22, FontWeight.w600),
headlineSmall: s(18, FontWeight.w600),
bodyLarge: s(16, FontWeight.w400),
bodyMedium: s(14, FontWeight.w400),
bodySmall: s(13, FontWeight.w400),
labelLarge: s(14, FontWeight.w500),
labelMedium: s(12, FontWeight.w500),
labelSmall: s(11, FontWeight.w500),
titleLarge: s(22, FontWeight.w600),
titleMedium: s(18, FontWeight.w600),
titleSmall: s(14, FontWeight.w500),
);
}
// ── Base ThemeData builder ──────────────────────────────────────────────
ThemeData _base(ColorScheme cs, TextTheme typo) => ThemeData(
useMaterial3: true,
colorScheme: cs,
textTheme: typo,
scaffoldBackgroundColor: cs.surface,
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: typo.headlineMedium,
iconTheme: IconThemeData(color: cs.onSurface),
),
cardTheme: CardThemeData(
color: cs.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
side: BorderSide(
color: cs.onSurface.withValues(alpha: 0.12),
),
),
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: cs.surface,
selectedItemColor: cs.primary,
unselectedItemColor: cs.onSurface.withValues(alpha: 0.5),
elevation: 0,
type: BottomNavigationBarType.fixed,
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: cs.primary,
foregroundColor: cs.onPrimary,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: cs.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppConstants.bottomSheetBorderRadius),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.primary, width: 1.5),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: cs.primary,
foregroundColor: cs.onPrimary,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: typo.labelLarge,
elevation: 0,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: cs.primary,
textStyle: typo.labelLarge,
),
),
);
}
+39 -40
View File
@@ -1,30 +1,31 @@
import 'package:flutter/material.dart';
import 'kittie_colors.dart';
import 'kittie_typography.dart';
import 'skin_colors.dart';
import 'skin_type.dart';
import 'skin_typography.dart';
import '../constants/app_constants.dart';
/// Material 3 theme for KittieMobile.
abstract final class KittieTheme {
static ThemeData get light {
final colorScheme = ColorScheme.light(
surface: KittieColors.cream,
onSurface: KittieColors.brownDark,
primary: KittieColors.brown,
onPrimary: KittieColors.cream,
secondary: KittieColors.amber,
onSecondary: KittieColors.brownDark,
tertiary: KittieColors.sageGreen,
onTertiary: KittieColors.cream,
error: KittieColors.red,
onError: KittieColors.cream,
);
/// Legacy light theme — uses the default parchment colors.
static ThemeData get light => fromSkin(
DefaultSkinColors(),
SkinTypography(
colors: DefaultSkinColors(),
skinType: SkinType.defaultSkin,
),
);
/// Builds a full [ThemeData] from [SkinColors] and [SkinTypography].
static ThemeData fromSkin(SkinColors colors, SkinTypography typography) {
final cs = colors.toColorScheme();
final typo = typography.textTheme;
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
textTheme: KittieTypography.textTheme,
scaffoldBackgroundColor: KittieColors.cream,
colorScheme: cs,
textTheme: typo,
scaffoldBackgroundColor: cs.surface,
// AppBar
appBarTheme: AppBarTheme(
@@ -32,35 +33,33 @@ abstract final class KittieTheme {
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: KittieTypography.h2.copyWith(
color: KittieColors.brownDark,
),
iconTheme: const IconThemeData(color: KittieColors.brownDark),
titleTextStyle: typo.headlineMedium,
iconTheme: IconThemeData(color: cs.onSurface),
),
// Card
cardTheme: CardThemeData(
color: KittieColors.cream,
color: cs.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
side: const BorderSide(color: KittieColors.creamBorder),
side: BorderSide(color: cs.onSurface.withValues(alpha: 0.12)),
),
),
// Bottom Navigation Bar
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: KittieColors.cream,
selectedItemColor: KittieColors.brown,
unselectedItemColor: KittieColors.brownLight,
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: cs.surface,
selectedItemColor: cs.primary,
unselectedItemColor: cs.onSurface.withValues(alpha: 0.5),
elevation: 0,
type: BottomNavigationBarType.fixed,
),
// FAB
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: KittieColors.brown,
foregroundColor: KittieColors.cream,
backgroundColor: cs.primary,
foregroundColor: cs.onPrimary,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
@@ -68,9 +67,9 @@ abstract final class KittieTheme {
),
// Bottom Sheet
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: KittieColors.cream,
shape: RoundedRectangleBorder(
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: cs.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppConstants.bottomSheetBorderRadius),
),
@@ -80,14 +79,14 @@ abstract final class KittieTheme {
// Input Decoration
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: KittieColors.creamDark,
fillColor: colors.creamDark,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: KittieColors.brown, width: 1.5),
borderSide: BorderSide(color: cs.primary, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -98,13 +97,13 @@ abstract final class KittieTheme {
// Elevated Button
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.brown,
foregroundColor: KittieColors.cream,
backgroundColor: cs.primary,
foregroundColor: cs.onPrimary,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: KittieTypography.labelLarge,
textStyle: typo.labelLarge,
elevation: 0,
),
),
@@ -112,8 +111,8 @@ abstract final class KittieTheme {
// Text Button
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: KittieColors.brown,
textStyle: KittieTypography.labelLarge,
foregroundColor: cs.primary,
textStyle: typo.labelLarge,
),
),
);
+511
View File
@@ -0,0 +1,511 @@
import 'package:flutter/material.dart';
/// Abstract base class for all skin color palettes.
///
/// Each skin provides concrete color values used throughout the app.
/// Dark skins (Epic, Future) MUST override [toColorScheme] with
/// [ColorScheme.dark] to prevent white-on-white text bugs.
abstract class SkinColors {
// ── Surface / cream ────────────────────────────────────────────────────────
Color get cream;
Color get creamDark;
Color get creamBorder;
// ── Brown / primary ────────────────────────────────────────────────────────
Color get brown;
Color get brownLight;
Color get brownDark;
Color get brownMid;
// ── Sage green / tertiary ──────────────────────────────────────────────────
Color get sageGreen;
Color get greenLight;
Color get greenDark;
// ── Amber / secondary ──────────────────────────────────────────────────────
Color get amber;
// ── Purple / accent ────────────────────────────────────────────────────────
Color get purple;
Color get purpleLight;
// ── Red / error ────────────────────────────────────────────────────────────
Color get red;
Color get redLight;
// ── Orange / warning ───────────────────────────────────────────────────────
Color get orange;
Color get orangeLight;
// ── Energy level colors ────────────────────────────────────────────────────
Color get energyLow;
Color get energyMedium;
Color get energyHigh;
/// Builds the [ColorScheme] for this skin.
///
/// Light skins use [ColorScheme.light]; dark skins (epic, future)
/// MUST override with [ColorScheme.dark].
ColorScheme toColorScheme() => ColorScheme.light(
surface: cream,
onSurface: brownDark,
primary: brown,
onPrimary: cream,
secondary: amber,
onSecondary: brownDark,
tertiary: sageGreen,
onTertiary: cream,
error: red,
onError: cream,
);
}
// ── Default (Warm Parchment) ─────────────────────────────────────────────────
class DefaultSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFFF5F2EC);
@override
Color get creamDark => const Color(0xFFEDE8DF);
@override
Color get creamBorder => const Color(0xFFE0DBD0);
@override
Color get brown => const Color(0xFF6B5A48);
@override
Color get brownLight => const Color(0xFF8C8070);
@override
Color get brownDark => const Color(0xFF2C2820);
@override
Color get brownMid => const Color(0xFF3D3830);
@override
Color get sageGreen => const Color(0xFF7BAE82);
@override
Color get greenLight => const Color(0xFFEEF4EF);
@override
Color get greenDark => const Color(0xFF3A6040);
@override
Color get amber => const Color(0xFFC4A882);
@override
Color get purple => const Color(0xFF9070A0);
@override
Color get purpleLight => const Color(0xFFF0E8F8);
@override
Color get red => const Color(0xFFC45858);
@override
Color get redLight => const Color(0xFFFAF0F0);
@override
Color get orange => const Color(0xFFC4943A);
@override
Color get orangeLight => const Color(0xFFFBF3E8);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => orange;
@override
Color get energyHigh => red;
}
// ── Accessibility (WCAG AAA High-Contrast) ───────────────────────────────────
class AccessibilitySkinColors extends SkinColors {
@override
Color get cream => const Color(0xFFFFFFFF);
@override
Color get creamDark => const Color(0xFFF0F0F0);
@override
Color get creamBorder => const Color(0xFFCCCCCC);
@override
Color get brown => const Color(0xFF0055CC);
@override
Color get brownLight => const Color(0xFF444444);
@override
Color get brownDark => const Color(0xFF1A1A1A);
@override
Color get brownMid => const Color(0xFF333333);
@override
Color get sageGreen => const Color(0xFF006600);
@override
Color get greenLight => const Color(0xFFE0F0E0);
@override
Color get greenDark => const Color(0xFF004400);
@override
Color get amber => const Color(0xFF996600);
@override
Color get purple => const Color(0xFF660099);
@override
Color get purpleLight => const Color(0xFFEEDDFF);
@override
Color get red => const Color(0xFFCC0000);
@override
Color get redLight => const Color(0xFFFFEEEE);
@override
Color get orange => const Color(0xFFCC5500);
@override
Color get orangeLight => const Color(0xFFFFEEDD);
@override
Color get energyLow => const Color(0xFF006600);
@override
Color get energyMedium => const Color(0xFFCC5500);
@override
Color get energyHigh => const Color(0xFFCC0000);
@override
ColorScheme toColorScheme() => const ColorScheme.light(
surface: Color(0xFFFFFFFF),
onSurface: Color(0xFF1A1A1A),
primary: Color(0xFF0055CC),
onPrimary: Color(0xFFFFFFFF),
secondary: Color(0xFF996600),
onSecondary: Color(0xFFFFFFFF),
tertiary: Color(0xFF006600),
onTertiary: Color(0xFFFFFFFF),
error: Color(0xFFCC0000),
onError: Color(0xFFFFFFFF),
);
}
// ── Senior (off-white, navy, gold, 1.25x) ────────────────────────────────────
class SeniorSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFFFAFAFA);
@override
Color get creamDark => const Color(0xFFF0EFE8);
@override
Color get creamBorder => const Color(0xFFDDDDD5);
@override
Color get brown => const Color(0xFF2C4A7C);
@override
Color get brownLight => const Color(0xFF6B7A99);
@override
Color get brownDark => const Color(0xFF1C1C1C);
@override
Color get brownMid => const Color(0xFF3A3A3A);
@override
Color get sageGreen => const Color(0xFF3A7D44);
@override
Color get greenLight => const Color(0xFFE8F5E9);
@override
Color get greenDark => const Color(0xFF1B5E20);
@override
Color get amber => const Color(0xFFB8942C);
@override
Color get purple => const Color(0xFF6A3D9A);
@override
Color get purpleLight => const Color(0xFFF0E6FF);
@override
Color get red => const Color(0xFFB71C1C);
@override
Color get redLight => const Color(0xFFFFEBEE);
@override
Color get orange => const Color(0xFFE65100);
@override
Color get orangeLight => const Color(0xFFFFF3E0);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => amber;
@override
Color get energyHigh => red;
@override
ColorScheme toColorScheme() => ColorScheme.light(
surface: cream,
onSurface: brownDark,
primary: brown,
onPrimary: Colors.white,
secondary: amber,
onSecondary: brownDark,
tertiary: sageGreen,
onTertiary: Colors.white,
error: red,
onError: Colors.white,
);
}
// ── Modern (cool gray, teal) ──────────────────────────────────────────────────
class ModernSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFFF8F9FA);
@override
Color get creamDark => const Color(0xFFECEFF1);
@override
Color get creamBorder => const Color(0xFFCFD8DC);
@override
Color get brown => const Color(0xFF0D9488);
@override
Color get brownLight => const Color(0xFF64748B);
@override
Color get brownDark => const Color(0xFF212529);
@override
Color get brownMid => const Color(0xFF374151);
@override
Color get sageGreen => const Color(0xFF10B981);
@override
Color get greenLight => const Color(0xFFD1FAE5);
@override
Color get greenDark => const Color(0xFF065F46);
@override
Color get amber => const Color(0xFFF59E0B);
@override
Color get purple => const Color(0xFF8B5CF6);
@override
Color get purpleLight => const Color(0xFFEDE9FE);
@override
Color get red => const Color(0xFFEF4444);
@override
Color get redLight => const Color(0xFFFEE2E2);
@override
Color get orange => const Color(0xFFF97316);
@override
Color get orangeLight => const Color(0xFFFFEDD5);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => amber;
@override
Color get energyHigh => red;
@override
ColorScheme toColorScheme() => ColorScheme.light(
surface: cream,
onSurface: brownDark,
primary: brown,
onPrimary: Colors.white,
secondary: amber,
onSecondary: brownDark,
tertiary: sageGreen,
onTertiary: Colors.white,
error: red,
onError: Colors.white,
);
}
// ── Kids (lavender, pink, sunny yellow, 1.1x) ────────────────────────────────
class KidsSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFFF0E6FF);
@override
Color get creamDark => const Color(0xFFE3D5F5);
@override
Color get creamBorder => const Color(0xFFD1B8F0);
@override
Color get brown => const Color(0xFFE91E8F);
@override
Color get brownLight => const Color(0xFF9966CC);
@override
Color get brownDark => const Color(0xFF3D1F6E);
@override
Color get brownMid => const Color(0xFF5D3091);
@override
Color get sageGreen => const Color(0xFF4CAF50);
@override
Color get greenLight => const Color(0xFFE8F5E9);
@override
Color get greenDark => const Color(0xFF1B5E20);
@override
Color get amber => const Color(0xFFFFD93D);
@override
Color get purple => const Color(0xFF9C27B0);
@override
Color get purpleLight => const Color(0xFFF3E5F5);
@override
Color get red => const Color(0xFFF44336);
@override
Color get redLight => const Color(0xFFFFEBEE);
@override
Color get orange => const Color(0xFFFF9800);
@override
Color get orangeLight => const Color(0xFFFFF3E0);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => amber;
@override
Color get energyHigh => red;
@override
ColorScheme toColorScheme() => ColorScheme.light(
surface: cream,
onSurface: brownDark,
primary: brown,
onPrimary: Colors.white,
secondary: amber,
onSecondary: brownDark,
tertiary: sageGreen,
onTertiary: Colors.white,
error: red,
onError: Colors.white,
);
}
// ── Epic (dark purple, gold) ──────────────────────────────────────────────────
class EpicSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFF1A1425);
@override
Color get creamDark => const Color(0xFF241C35);
@override
Color get creamBorder => const Color(0xFF3D3050);
@override
Color get brown => const Color(0xFF7C3AED);
@override
Color get brownLight => const Color(0xFFAA88DD);
@override
Color get brownDark => const Color(0xFFE8E0F0);
@override
Color get brownMid => const Color(0xFFCCBBEE);
@override
Color get sageGreen => const Color(0xFF10B981);
@override
Color get greenLight => const Color(0xFF1A2E28);
@override
Color get greenDark => const Color(0xFF6EE7B7);
@override
Color get amber => const Color(0xFFF59E0B);
@override
Color get purple => const Color(0xFFA855F7);
@override
Color get purpleLight => const Color(0xFF2D1B4E);
@override
Color get red => const Color(0xFFF87171);
@override
Color get redLight => const Color(0xFF2D1414);
@override
Color get orange => const Color(0xFFFB923C);
@override
Color get orangeLight => const Color(0xFF2D1F0A);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => amber;
@override
Color get energyHigh => red;
/// Epic is a dark skin — MUST use [ColorScheme.dark].
@override
ColorScheme toColorScheme() => const ColorScheme.dark(
surface: Color(0xFF1A1425),
onSurface: Color(0xFFE8E0F0),
primary: Color(0xFF7C3AED),
onPrimary: Color(0xFFFFFFFF),
secondary: Color(0xFFF59E0B),
onSecondary: Color(0xFF1A1425),
tertiary: Color(0xFF10B981),
onTertiary: Color(0xFF1A1425),
error: Color(0xFFF87171),
onError: Color(0xFF1A1425),
);
}
// ── Future (neon cyan/blue, very dark) ───────────────────────────────────────
class FutureSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFF0A0E17);
@override
Color get creamDark => const Color(0xFF111827);
@override
Color get creamBorder => const Color(0xFF1F2D3D);
@override
Color get brown => const Color(0xFF2979FF);
@override
Color get brownLight => const Color(0xFF5599FF);
@override
Color get brownDark => const Color(0xFF00E5FF);
@override
Color get brownMid => const Color(0xFF99CCFF);
@override
Color get sageGreen => const Color(0xFF00E5FF);
@override
Color get greenLight => const Color(0xFF0A1A1F);
@override
Color get greenDark => const Color(0xFF00BCD4);
@override
Color get amber => const Color(0xFFFF6D00);
@override
Color get purple => const Color(0xFFFF1744);
@override
Color get purpleLight => const Color(0xFF1A0A10);
@override
Color get red => const Color(0xFFFF1744);
@override
Color get redLight => const Color(0xFF1A0A10);
@override
Color get orange => const Color(0xFFFF6D00);
@override
Color get orangeLight => const Color(0xFF1A100A);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => amber;
@override
Color get energyHigh => red;
/// Future is a dark skin — MUST use [ColorScheme.dark].
@override
ColorScheme toColorScheme() => const ColorScheme.dark(
surface: Color(0xFF0A0E17),
onSurface: Color(0xFF00E5FF),
primary: Color(0xFF2979FF),
onPrimary: Color(0xFF0A0E17),
secondary: Color(0xFFFF6D00),
onSecondary: Color(0xFF0A0E17),
tertiary: Color(0xFF00E5FF),
onTertiary: Color(0xFF0A0E17),
error: Color(0xFFFF1744),
onError: Color(0xFF0A0E17),
);
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'skin_colors.dart';
import 'skin_type.dart';
import 'skin_typography.dart';
const _kSkinKey = 'app_skin';
/// Module-level initial skin — set before [runApp] via [setInitialSkin].
SkinType _initialSkin = SkinType.defaultSkin;
/// Sets the initial skin from persisted preferences before [runApp].
void setInitialSkin(SkinType skin) => _initialSkin = skin;
/// Provides the currently active [SkinType].
///
/// Initialised to the value set by [setInitialSkin] (or [SkinType.defaultSkin]
/// if not called). Use [SkinNotifier.setSkin] to switch and persist.
final skinProvider = NotifierProvider<SkinNotifier, SkinType>(SkinNotifier.new);
class SkinNotifier extends Notifier<SkinType> {
@override
SkinType build() => _initialSkin;
/// Switches to [skin] and persists the choice.
Future<void> setSkin(SkinType skin) async {
state = skin;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kSkinKey, skin.storageKey);
}
}
/// Provides the [SkinColors] for the currently active skin.
final skinColorsProvider = Provider<SkinColors>(
(ref) => ref.watch(skinProvider).colors,
);
/// Provides the [SkinTypography] for the currently active skin.
final skinTypographyProvider = Provider<SkinTypography>((ref) {
final skinType = ref.watch(skinProvider);
final colors = ref.watch(skinColorsProvider);
return SkinTypography(colors: colors, skinType: skinType);
});
+40
View File
@@ -0,0 +1,40 @@
import 'skin_colors.dart';
/// All available app skins.
enum SkinType {
defaultSkin,
accessibility,
senior,
modern,
kids,
epic,
future;
}
extension SkinTypeExtension on SkinType {
/// i18n key for the skin name.
String get nameKey => 'skin.$name';
/// i18n key for the skin description.
String get descriptionKey => 'skin.${name}_desc';
/// Stable storage key for SharedPreferences persistence.
String get storageKey => name;
/// Parses a storage key back to [SkinType], defaulting to [SkinType.defaultSkin].
static SkinType fromStorageKey(String key) => SkinType.values.firstWhere(
(s) => s.storageKey == key,
orElse: () => SkinType.defaultSkin,
);
/// Returns the [SkinColors] instance for this skin.
SkinColors get colors => switch (this) {
SkinType.defaultSkin => DefaultSkinColors(),
SkinType.accessibility => AccessibilitySkinColors(),
SkinType.senior => SeniorSkinColors(),
SkinType.modern => ModernSkinColors(),
SkinType.kids => KidsSkinColors(),
SkinType.epic => EpicSkinColors(),
SkinType.future => FutureSkinColors(),
};
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'skin_colors.dart';
import 'skin_type.dart';
/// Typography system parameterized by [SkinColors] and [SkinType].
///
/// Font family:
/// - accessibility, senior → Atkinson Hyperlegible
/// - all others → Lexend
///
/// Scale factors:
/// - senior: 1.25×, kids: 1.1×, accessibility: 1.15×, others: 1.0×
class SkinTypography {
SkinTypography({required this.colors, required this.skinType});
final SkinColors colors;
final SkinType skinType;
double get _scale => switch (skinType) {
SkinType.accessibility => 1.15,
SkinType.senior => 1.25,
SkinType.kids => 1.1,
_ => 1.0,
};
TextStyle _s(double size, FontWeight w) {
final scaled = size * _scale;
return switch (skinType) {
SkinType.accessibility || SkinType.senior =>
GoogleFonts.atkinsonHyperlegible(
fontSize: scaled,
fontWeight: w,
color: colors.brownDark,
),
_ => GoogleFonts.lexend(
fontSize: scaled,
fontWeight: w,
color: colors.brownDark,
),
};
}
// ── Headings ────────────────────────────────────────────────────────────────
TextStyle get h1 => _s(28, FontWeight.w700);
TextStyle get h2 => _s(22, FontWeight.w600);
TextStyle get h3 => _s(18, FontWeight.w600);
// ── Body ────────────────────────────────────────────────────────────────────
TextStyle get bodyLarge => _s(16, FontWeight.w400);
TextStyle get bodyMedium => _s(14, FontWeight.w400);
TextStyle get bodySmall => _s(13, FontWeight.w400);
// ── Labels ──────────────────────────────────────────────────────────────────
TextStyle get labelLarge => _s(14, FontWeight.w500);
TextStyle get labelMedium => _s(12, FontWeight.w500);
TextStyle get labelSmall => _s(11, FontWeight.w500);
/// Full [TextTheme] for Material 3.
TextTheme get textTheme => TextTheme(
headlineLarge: h1,
headlineMedium: h2,
headlineSmall: h3,
bodyLarge: bodyLarge,
bodyMedium: bodyMedium,
bodySmall: bodySmall,
labelLarge: labelLarge,
labelMedium: labelMedium,
labelSmall: labelSmall,
titleLarge: h2,
titleMedium: h3,
titleSmall: labelLarge,
);
}
@@ -1,13 +1,15 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:uuid/uuid.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/theme/kittie_typography.dart';
import '../../../core/i18n/i18n_provider.dart';
import '../../../core/i18n/translation_service.dart';
import '../../../core/theme/skin_provider.dart';
import '../../../domain/entities/pet_state_entity.dart';
import '../../../domain/entities/pet_type.dart';
import '../../../shared/widgets/language_picker.dart';
import '../../tasks/providers/task_providers.dart';
import '../providers/onboarding_providers.dart';
@@ -26,7 +28,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
void initState() {
super.initState();
_pageController = PageController();
_nameController = TextEditingController(text: 'Míša');
_nameController = TextEditingController(
text: TranslationService.instance.t('onboarding.pet_name_hint'),
);
}
@override
@@ -52,7 +56,8 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
const uuid = Uuid();
final userId = uuid.v4();
final deviceId = '${Platform.operatingSystem}-${uuid.v4()}';
final deviceId =
'${defaultTargetPlatform.name.toLowerCase()}-${uuid.v4()}';
await repo.createUser(
id: userId,
@@ -62,6 +67,14 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
petName: petName,
);
final petRepo = ref.read(petRepositoryProvider);
await petRepo.insertPetState(PetStateEntity(
id: uuid.v4(),
userId: userId,
energyToday: 2,
updatedAt: DateTime.now(),
));
ref.invalidate(currentUserProvider);
if (mounted) {
@@ -72,33 +85,17 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
@override
Widget build(BuildContext context) {
final step = ref.watch(onboardingStepProvider);
final skinColors = ref.watch(skinColorsProvider);
return Scaffold(
backgroundColor: KittieColors.brownDark,
backgroundColor: skinColors.brownDark,
body: SafeArea(
child: Column(
children: [
// Progress dots
Padding(
padding: const EdgeInsets.only(top: 24, bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (i) {
final isActive = i == step;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive
? KittieColors.amber
: KittieColors.brownLight,
borderRadius: BorderRadius.circular(4),
),
);
}),
),
child: _ProgressDots(step: step, skinColors: skinColors),
),
// Pages
@@ -130,31 +127,62 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
}
}
class _ProgressDots extends StatelessWidget {
const _ProgressDots({required this.step, required this.skinColors});
final int step;
final dynamic skinColors;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (i) {
final isActive = i == step;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive ? skinColors.amber : skinColors.brownLight,
borderRadius: BorderRadius.circular(4),
),
);
}),
);
}
}
// ─── Step 1: Welcome ────────────────────────────────────────────────────────
class _WelcomeStep extends StatelessWidget {
class _WelcomeStep extends ConsumerWidget {
const _WelcomeStep({required this.onNext});
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const LanguagePicker(),
const SizedBox(height: 24),
const Text('🐱', style: TextStyle(fontSize: 80)),
const SizedBox(height: 24),
Text(
'Vítej v KittieMobile',
style: KittieTypography.h1.copyWith(color: KittieColors.cream),
t('onboarding.welcome_title'),
style: skinTypo.h1.copyWith(color: skinColors.cream),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Tvůj mazlíček ti pomůže zvládnout úkoly.',
style:
KittieTypography.bodyLarge.copyWith(color: KittieColors.amber),
t('onboarding.welcome_subtitle'),
style: skinTypo.bodyLarge.copyWith(color: skinColors.amber),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
@@ -163,10 +191,10 @@ class _WelcomeStep extends StatelessWidget {
child: ElevatedButton(
onPressed: onNext,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.amber,
foregroundColor: KittieColors.brownDark,
backgroundColor: skinColors.amber,
foregroundColor: skinColors.brownDark,
),
child: const Text('Začít'),
child: Text(t('onboarding.start')),
),
),
],
@@ -188,15 +216,18 @@ class _PetSelectionStep extends ConsumerWidget {
final VoidCallback onNext;
final VoidCallback onBack;
static const _pets = [
(type: PetType.dog, emoji: '🐶', label: 'Pes'),
(type: PetType.cat, emoji: '🐱', label: 'Kočka'),
(type: PetType.capybara, emoji: '🦫', label: 'Kapybara'),
static const _petOptions = [
(type: PetType.dog, emoji: '🐶', labelKey: 'onboarding.pet_dog'),
(type: PetType.cat, emoji: '🐱', labelKey: 'onboarding.pet_cat'),
(type: PetType.capybara, emoji: '🦫', labelKey: 'onboarding.pet_capybara'),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(selectedPetTypeProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -204,15 +235,15 @@ class _PetSelectionStep extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Vyber si mazlíčka',
style: KittieTypography.h2.copyWith(color: KittieColors.cream),
t('onboarding.choose_pet'),
style: skinTypo.h2.copyWith(color: skinColors.cream),
),
const SizedBox(height: 32),
// Pet grid
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _pets.map((pet) {
children: _petOptions.map((pet) {
final isSelected = selected == pet.type;
return GestureDetector(
onTap: () {
@@ -224,11 +255,12 @@ class _PetSelectionStep extends ConsumerWidget {
height: 80,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: KittieColors.brownMid,
color: skinColors.brownMid,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color:
isSelected ? KittieColors.amber : Colors.transparent,
color: isSelected
? skinColors.amber
: Colors.transparent,
width: 2,
),
),
@@ -238,9 +270,10 @@ class _PetSelectionStep extends ConsumerWidget {
Text(pet.emoji, style: const TextStyle(fontSize: 32)),
const SizedBox(height: 4),
Text(
pet.label,
style: KittieTypography.labelSmall
.copyWith(color: KittieColors.cream),
t(pet.labelKey),
style: skinTypo.labelSmall.copyWith(
color: skinColors.cream,
),
),
],
),
@@ -253,21 +286,21 @@ class _PetSelectionStep extends ConsumerWidget {
// Name input
Text(
'Jak se bude jmenovat?',
style:
KittieTypography.bodyLarge.copyWith(color: KittieColors.cream),
t('onboarding.pet_name_label'),
style: skinTypo.bodyLarge.copyWith(color: skinColors.cream),
),
const SizedBox(height: 12),
TextField(
controller: nameController,
style: KittieTypography.bodyLarge
.copyWith(color: KittieColors.brownDark),
style:
skinTypo.bodyLarge.copyWith(color: skinColors.brownDark),
textAlign: TextAlign.center,
decoration: InputDecoration(
fillColor: KittieColors.creamDark,
hintText: 'Míša',
hintStyle: KittieTypography.bodyLarge
.copyWith(color: KittieColors.brownLight),
fillColor: skinColors.creamDark,
hintText: t('onboarding.pet_name_hint'),
hintStyle: skinTypo.bodyLarge.copyWith(
color: skinColors.brownLight,
),
),
onChanged: (value) {
ref.read(petNameProvider.notifier).update(value);
@@ -282,19 +315,20 @@ class _PetSelectionStep extends ConsumerWidget {
TextButton(
onPressed: onBack,
child: Text(
'Zpět',
style: KittieTypography.labelLarge
.copyWith(color: KittieColors.brownLight),
t('common.back'),
style: skinTypo.labelLarge.copyWith(
color: skinColors.brownLight,
),
),
),
const Spacer(),
ElevatedButton(
onPressed: onNext,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.amber,
foregroundColor: KittieColors.brownDark,
backgroundColor: skinColors.amber,
foregroundColor: skinColors.brownDark,
),
child: const Text('Pokračovat'),
child: Text(t('onboarding.continue')),
),
],
),
@@ -315,6 +349,9 @@ class _ReadyStep extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final petName = ref.watch(petNameProvider);
final petType = ref.watch(selectedPetTypeProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
final emoji = switch (petType) {
PetType.dog => '🐶',
@@ -327,22 +364,22 @@ class _ReadyStep extends ConsumerWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.check_circle_rounded,
color: KittieColors.sageGreen,
color: skinColors.sageGreen,
size: 80,
),
const SizedBox(height: 24),
Text(
'Vše je připraveno!',
style: KittieTypography.h1.copyWith(color: KittieColors.cream),
t('onboarding.ready_title'),
style: skinTypo.h1.copyWith(color: skinColors.cream),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'$emoji $petName je připraven/a ti pomáhat.',
style:
KittieTypography.bodyLarge.copyWith(color: KittieColors.amber),
t('onboarding.ready_subtitle',
params: {'emoji': emoji, 'petName': petName}),
style: skinTypo.bodyLarge.copyWith(color: skinColors.amber),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
@@ -351,19 +388,20 @@ class _ReadyStep extends ConsumerWidget {
TextButton(
onPressed: onBack,
child: Text(
'Zpět',
style: KittieTypography.labelLarge
.copyWith(color: KittieColors.brownLight),
t('common.back'),
style: skinTypo.labelLarge.copyWith(
color: skinColors.brownLight,
),
),
),
const Spacer(),
ElevatedButton(
onPressed: onComplete,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.sageGreen,
foregroundColor: KittieColors.cream,
backgroundColor: skinColors.sageGreen,
foregroundColor: skinColors.cream,
),
child: const Text('Začít používat'),
child: Text(t('onboarding.start_using')),
),
],
),
+28 -10
View File
@@ -57,23 +57,41 @@ final userProvider =
// ── Pet state ──
/// Watches pet state from the Drift database for the current user.
final petStateStreamProvider = StreamProvider<PetStateEntity?>((ref) {
final user = ref.watch(currentUserProvider).value;
if (user == null) return Stream.value(null);
final repo = ref.watch(petRepositoryProvider);
return repo.watchPetState(user.id);
});
class PetStateNotifier extends Notifier<PetStateEntity> {
@override
PetStateEntity build() => PetStateEntity(
id: '1',
userId: '1',
energyToday: 2,
streakDays: 3,
totalTasksDone: 12,
unlockedItems: 'masle,pelisek',
updatedAt: DateTime.now(),
);
PetStateEntity build() {
final dbState = ref.watch(petStateStreamProvider).value;
if (dbState != null) return dbState;
return PetStateEntity(
id: '',
userId: '',
updatedAt: DateTime.now(),
);
}
void incrementTasksDone() {
state = state.copyWith(totalTasksDone: state.totalTasksDone + 1);
state = state.copyWith(
totalTasksDone: state.totalTasksDone + 1,
updatedAt: DateTime.now(),
);
_persist();
}
void onTaskCompleted() => incrementTasksDone();
void _persist() {
if (state.id.isEmpty) return;
final repo = ref.read(petRepositoryProvider);
repo.updatePetState(state);
}
}
final petStateProvider =
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/db/app_database.dart';
import '../../../data/repositories/task_repository.dart';
import '../../../data/repositories/pet_repository.dart';
import '../../../data/repositories/user_repository.dart';
import '../../../domain/entities/task_entity.dart';
import '../../../domain/entities/task_status.dart';
@@ -26,6 +27,11 @@ final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository(ref.watch(appDatabaseProvider));
});
/// Pet repository backed by Drift.
final petRepositoryProvider = Provider<PetRepository>((ref) {
return PetRepository(ref.watch(appDatabaseProvider));
});
/// Current user loaded from the database. Null if onboarding not complete.
final currentUserProvider = FutureProvider<UserEntity?>((ref) {
final repo = ref.watch(userRepositoryProvider);
+127 -90
View File
@@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/theme/kittie_typography.dart';
import '../../../core/i18n/i18n_provider.dart';
import '../../../core/theme/skin_provider.dart';
import '../../../domain/entities/task_entity.dart';
import '../providers/add_task_providers.dart';
import '../providers/pet_providers.dart';
@@ -80,8 +80,8 @@ class _AddTaskSheetState extends ConsumerState<_AddTaskSheet> {
final int minute = time?.minute ?? 0;
return switch (day) {
TaskDay.today => DateTime(now.year, now.month, now.day, hour, minute),
TaskDay.tomorrow => DateTime(
now.year, now.month, now.day + 1, hour, minute),
TaskDay.tomorrow =>
DateTime(now.year, now.month, now.day + 1, hour, minute),
_ => null,
};
}
@@ -113,11 +113,12 @@ class _AddTaskSheetState extends ConsumerState<_AddTaskSheet> {
Widget build(BuildContext context) {
final step = ref.watch(addTaskStepProvider);
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final skinColors = ref.watch(skinColorsProvider);
return Container(
decoration: const BoxDecoration(
color: KittieColors.cream,
borderRadius: BorderRadius.vertical(
decoration: BoxDecoration(
color: skinColors.cream,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppConstants.bottomSheetBorderRadius),
),
),
@@ -135,34 +136,14 @@ class _AddTaskSheetState extends ConsumerState<_AddTaskSheet> {
width: 40,
height: 4,
decoration: BoxDecoration(
color: KittieColors.creamBorder,
color: skinColors.creamBorder,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
// Step dots
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(4, (i) {
final isActive = i == step;
final isPast = i < step;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 20 : 6,
height: 6,
decoration: BoxDecoration(
color: isActive
? KittieColors.brown
: isPast
? KittieColors.sageGreen
: KittieColors.creamBorder,
borderRadius: BorderRadius.circular(3),
),
);
}),
),
_StepDots(step: step),
const SizedBox(height: 20),
// Step content
@@ -187,8 +168,7 @@ class _AddTaskSheetState extends ConsumerState<_AddTaskSheet> {
),
1 => _Step2When(
key: const ValueKey(1),
onNext: () =>
ref.read(addTaskStepProvider.notifier).next(),
onNext: () => ref.read(addTaskStepProvider.notifier).next(),
onBack: () =>
ref.read(addTaskStepProvider.notifier).previous(),
),
@@ -212,9 +192,41 @@ class _AddTaskSheetState extends ConsumerState<_AddTaskSheet> {
}
}
class _StepDots extends ConsumerWidget {
const _StepDots({required this.step});
final int step;
@override
Widget build(BuildContext context, WidgetRef ref) {
final skinColors = ref.watch(skinColorsProvider);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(4, (i) {
final isActive = i == step;
final isPast = i < step;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 20 : 6,
height: 6,
decoration: BoxDecoration(
color: isActive
? skinColors.brown
: isPast
? skinColors.sageGreen
: skinColors.creamBorder,
borderRadius: BorderRadius.circular(3),
),
);
}),
);
}
}
// ─── Step 1: Title & Note ───────────────────────────────────────────────────
class _Step1Title extends StatelessWidget {
class _Step1Title extends ConsumerWidget {
const _Step1Title({
super.key,
required this.titleController,
@@ -227,37 +239,39 @@ class _Step1Title extends StatelessWidget {
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Nový úkol', style: KittieTypography.h2),
Text(t('tasks.new_task'), style: skinTypo.h2),
const SizedBox(height: 20),
Text('NÁZEV', style: KittieTypography.labelMedium),
Text(t('tasks.title_label'), style: skinTypo.labelMedium),
const SizedBox(height: 8),
TextField(
controller: titleController,
autofocus: true,
style: KittieTypography.bodyLarge
.copyWith(color: KittieColors.brownDark),
decoration: const InputDecoration(hintText: 'Co potřebuješ udělat?'),
style: skinTypo.bodyLarge.copyWith(color: skinColors.brownDark),
decoration: InputDecoration(hintText: t('tasks.title_hint')),
),
const SizedBox(height: 16),
Text('POZNÁMKA', style: KittieTypography.labelMedium),
Text(t('tasks.note_label'), style: skinTypo.labelMedium),
const SizedBox(height: 8),
TextField(
controller: noteController,
style: KittieTypography.bodyMedium
.copyWith(color: KittieColors.brownDark),
style: skinTypo.bodyMedium.copyWith(color: skinColors.brownDark),
maxLines: 3,
decoration: const InputDecoration(hintText: 'Volitelné...'),
decoration: InputDecoration(hintText: t('tasks.note_hint')),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onNext,
child: const Text('Pokračovat'),
child: Text(t('onboarding.continue')),
),
),
],
@@ -277,11 +291,11 @@ class _Step2When extends ConsumerWidget {
final VoidCallback onNext;
final VoidCallback onBack;
static const _days = [
(day: TaskDay.today, label: 'Dnes'),
(day: TaskDay.tomorrow, label: 'Zítra'),
(day: TaskDay.other, label: 'Jindy'),
(day: TaskDay.someday, label: 'Někdy'),
static const _dayOptions = [
(day: TaskDay.today, labelKey: 'tasks.day_today'),
(day: TaskDay.tomorrow, labelKey: 'tasks.day_tomorrow'),
(day: TaskDay.other, labelKey: 'tasks.day_other'),
(day: TaskDay.someday, labelKey: 'tasks.day_someday'),
];
static const _timePresets = [8, 10, 14, 18];
@@ -290,30 +304,32 @@ class _Step2When extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final selectedDay = ref.watch(selectedTaskDayProvider);
final selectedTime = ref.watch(selectedTaskTimeProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Kdy to chceš udělat?', style: KittieTypography.h2),
Text(t('tasks.when_title'), style: skinTypo.h2),
const SizedBox(height: 20),
// Day chips
Wrap(
spacing: 8,
runSpacing: 8,
children: _days.map((d) {
children: _dayOptions.map((d) {
final isSelected = selectedDay == d.day;
return ChoiceChip(
label: Text(d.label),
label: Text(t(d.labelKey)),
selected: isSelected,
onSelected: (_) {
ref.read(selectedTaskDayProvider.notifier).select(d.day);
},
selectedColor: KittieColors.amber,
backgroundColor: KittieColors.creamDark,
labelStyle: KittieTypography.labelLarge.copyWith(
color:
isSelected ? KittieColors.brownDark : KittieColors.brown,
selectedColor: skinColors.amber,
backgroundColor: skinColors.creamDark,
labelStyle: skinTypo.labelLarge.copyWith(
color: isSelected ? skinColors.brownDark : skinColors.brown,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -327,7 +343,7 @@ class _Step2When extends ConsumerWidget {
const SizedBox(height: 24),
// Time presets
Text('Čas', style: KittieTypography.labelMedium),
Text(t('tasks.time_label'), style: skinTypo.labelMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
@@ -341,11 +357,10 @@ class _Step2When extends ConsumerWidget {
onSelected: (_) {
ref.read(selectedTaskTimeProvider.notifier).select(time);
},
selectedColor: KittieColors.amber,
backgroundColor: KittieColors.creamDark,
labelStyle: KittieTypography.labelLarge.copyWith(
color:
isSelected ? KittieColors.brownDark : KittieColors.brown,
selectedColor: skinColors.amber,
backgroundColor: skinColors.creamDark,
labelStyle: skinTypo.labelLarge.copyWith(
color: isSelected ? skinColors.brownDark : skinColors.brown,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -361,12 +376,12 @@ class _Step2When extends ConsumerWidget {
children: [
TextButton(
onPressed: onBack,
child: const Text('Zpět'),
child: Text(t('common.back')),
),
const Spacer(),
ElevatedButton(
onPressed: onNext,
child: const Text('Pokračovat'),
child: Text(t('onboarding.continue')),
),
],
),
@@ -387,15 +402,33 @@ class _Step3Energy extends ConsumerWidget {
final VoidCallback onSave;
final VoidCallback onBack;
static const _energyOptions = [
(level: 1, label: 'Lehké', emoji: '🍃', color: KittieColors.sageGreen),
(level: 2, label: 'Střední', emoji: '', color: KittieColors.orange),
(level: 3, label: 'Náročné', emoji: '🔥', color: KittieColors.red),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(selectedEnergyProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
final energyOptions = [
(
level: 1,
labelKey: 'tasks.energy_low',
emoji: '🍃',
color: skinColors.sageGreen
),
(
level: 2,
labelKey: 'tasks.energy_medium',
emoji: '',
color: skinColors.orange
),
(
level: 3,
labelKey: 'tasks.energy_high',
emoji: '🔥',
color: skinColors.red
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -404,7 +437,7 @@ class _Step3Energy extends ConsumerWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: KittieColors.creamDark,
color: skinColors.creamDark,
borderRadius: BorderRadius.circular(16),
),
child: Row(
@@ -413,8 +446,8 @@ class _Step3Energy extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'Kolik energie to bude stát?',
style: KittieTypography.bodyLarge,
t('tasks.energy_question'),
style: skinTypo.bodyLarge,
),
),
],
@@ -424,7 +457,7 @@ class _Step3Energy extends ConsumerWidget {
// Energy picker
Row(
children: _energyOptions.map((opt) {
children: energyOptions.map((opt) {
final isSelected = selected == opt.level;
return Expanded(
child: GestureDetector(
@@ -438,7 +471,7 @@ class _Step3Energy extends ConsumerWidget {
decoration: BoxDecoration(
color: isSelected
? opt.color.withValues(alpha: 0.15)
: KittieColors.creamDark,
: skinColors.creamDark,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected ? opt.color : Colors.transparent,
@@ -450,9 +483,9 @@ class _Step3Energy extends ConsumerWidget {
Text(opt.emoji, style: const TextStyle(fontSize: 28)),
const SizedBox(height: 8),
Text(
opt.label,
style: KittieTypography.labelLarge.copyWith(
color: isSelected ? opt.color : KittieColors.brown,
t(opt.labelKey),
style: skinTypo.labelLarge.copyWith(
color: isSelected ? opt.color : skinColors.brown,
),
),
],
@@ -468,17 +501,17 @@ class _Step3Energy extends ConsumerWidget {
children: [
TextButton(
onPressed: onBack,
child: const Text('Zpět'),
child: Text(t('common.back')),
),
const Spacer(),
ElevatedButton(
onPressed: selected != null ? onSave : null,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.sageGreen,
foregroundColor: KittieColors.cream,
disabledBackgroundColor: KittieColors.creamDark,
backgroundColor: skinColors.sageGreen,
foregroundColor: skinColors.cream,
disabledBackgroundColor: skinColors.creamDark,
),
child: const Text('Uložit úkol'),
child: Text(t('tasks.save_task')),
),
],
),
@@ -489,7 +522,7 @@ class _Step3Energy extends ConsumerWidget {
// ─── Step 4: Confirmation ───────────────────────────────────────────────────
class _Step4Confirmation extends StatelessWidget {
class _Step4Confirmation extends ConsumerWidget {
const _Step4Confirmation({
super.key,
required this.onClose,
@@ -500,22 +533,26 @@ class _Step4Confirmation extends StatelessWidget {
final VoidCallback onAddAnother;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Column(
children: [
const Icon(
Icon(
Icons.check_circle_rounded,
color: KittieColors.sageGreen,
color: skinColors.sageGreen,
size: 64,
),
const SizedBox(height: 16),
Text('Úkol uložen!', style: KittieTypography.h2),
Text(t('tasks.task_saved'), style: skinTypo.h2),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onClose,
child: const Text('Zpět na seznam'),
child: Text(t('tasks.back_to_list')),
),
),
const SizedBox(height: 12),
@@ -523,7 +560,7 @@ class _Step4Confirmation extends StatelessWidget {
width: double.infinity,
child: TextButton(
onPressed: onAddAnother,
child: const Text('Přidat další úkol'),
child: Text(t('tasks.add_another')),
),
),
],
+60 -43
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/theme/kittie_typography.dart';
import '../../../core/i18n/i18n_provider.dart';
import '../../../core/theme/skin_provider.dart';
import '../providers/pet_providers.dart';
import '../providers/task_providers.dart';
@@ -17,12 +17,16 @@ class PetScreen extends ConsumerWidget {
final mood = ref.watch(petMoodProvider);
final level = ref.watch(petLevelProvider);
final selfCareCount = ref.watch(selfCareCountProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
final unlocked = petState.unlockedItems.isEmpty
? <String>[]
: petState.unlockedItems.split(',');
return Scaffold(
backgroundColor: KittieColors.cream,
backgroundColor: skinColors.cream,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
@@ -31,13 +35,14 @@ class PetScreen extends ConsumerWidget {
// Pet name header
Text(
user.petName,
style: KittieTypography.h1.copyWith(fontSize: 24),
style: skinTypo.h1.copyWith(fontSize: 24),
),
const SizedBox(height: 4),
Text(
'Tvůj společník — ${user.petType.label}',
style: KittieTypography.bodyMedium.copyWith(
color: KittieColors.brownLight,
t('pet.companion_label',
params: {'petType': user.petType.label}),
style: skinTypo.bodyMedium.copyWith(
color: skinColors.brownLight,
),
),
const SizedBox(height: 24),
@@ -51,9 +56,9 @@ class PetScreen extends ConsumerWidget {
height: 130,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: KittieColors.creamDark,
color: skinColors.creamDark,
border: Border.all(
color: KittieColors.creamBorder,
color: skinColors.creamBorder,
width: 3,
),
),
@@ -71,10 +76,10 @@ class PetScreen extends ConsumerWidget {
width: 36,
height: 36,
decoration: BoxDecoration(
color: _moodColor(mood),
color: _moodColor(mood, skinColors),
shape: BoxShape.circle,
border: Border.all(
color: KittieColors.cream,
color: skinColors.cream,
width: 2,
),
),
@@ -101,27 +106,32 @@ class PetScreen extends ConsumerWidget {
children: [
_StatTile(
icon: Icons.local_fire_department_rounded,
label: 'Streak',
value: '${petState.streakDays} dni',
color: KittieColors.orange,
label: t('pet.stat_streak'),
value: t('pet.stat_streak_value',
params: {'days': '${petState.streakDays}'}),
color: skinColors.orange,
skinTypo: skinTypo,
),
_StatTile(
icon: Icons.check_circle_rounded,
label: 'Hotové úkoly',
label: t('pet.stat_tasks_done'),
value: '${petState.totalTasksDone}',
color: KittieColors.sageGreen,
color: skinColors.sageGreen,
skinTypo: skinTypo,
),
_StatTile(
icon: Icons.favorite_rounded,
label: 'Péče o sebe',
label: t('pet.stat_self_care'),
value: '$selfCareCount',
color: KittieColors.purple,
color: skinColors.purple,
skinTypo: skinTypo,
),
_StatTile(
icon: Icons.star_rounded,
label: 'Úroveň',
label: t('pet.stat_level'),
value: '$level',
color: KittieColors.amber,
color: skinColors.amber,
skinTypo: skinTypo,
),
],
),
@@ -130,8 +140,7 @@ class PetScreen extends ConsumerWidget {
// Unlocked items header
Align(
alignment: Alignment.centerLeft,
child: Text('Odemčené předměty',
style: KittieTypography.h3),
child: Text(t('pet.unlocked_items'), style: skinTypo.h3),
),
const SizedBox(height: 12),
@@ -142,24 +151,32 @@ class PetScreen extends ConsumerWidget {
scrollDirection: Axis.horizontal,
children: [
_ItemCard(
name: 'Mašle',
name: t('pet.item_bow'),
icon: Icons.catching_pokemon_rounded,
unlocked: unlocked.contains('masle'),
skinColors: skinColors,
skinTypo: skinTypo,
),
_ItemCard(
name: 'Pelíšek',
name: t('pet.item_bed'),
icon: Icons.bed_rounded,
unlocked: unlocked.contains('pelisek'),
skinColors: skinColors,
skinTypo: skinTypo,
),
_ItemCard(
name: 'Koruna',
name: t('pet.item_crown'),
icon: Icons.auto_awesome_rounded,
unlocked: unlocked.contains('koruna'),
skinColors: skinColors,
skinTypo: skinTypo,
),
_ItemCard(
name: 'Křídla',
name: t('pet.item_wings'),
icon: Icons.flight_rounded,
unlocked: unlocked.contains('kridla'),
skinColors: skinColors,
skinTypo: skinTypo,
),
],
),
@@ -173,10 +190,10 @@ class PetScreen extends ConsumerWidget {
);
}
Color _moodColor(PetMood mood) => switch (mood) {
PetMood.happy => KittieColors.sageGreen,
PetMood.neutral => KittieColors.amber,
PetMood.sad => KittieColors.red,
Color _moodColor(PetMood mood, dynamic skinColors) => switch (mood) {
PetMood.happy => skinColors.sageGreen,
PetMood.neutral => skinColors.amber,
PetMood.sad => skinColors.red,
};
}
@@ -187,12 +204,14 @@ class _StatTile extends StatelessWidget {
final String label;
final String value;
final Color color;
final dynamic skinTypo;
const _StatTile({
required this.icon,
required this.label,
required this.value,
required this.color,
required this.skinTypo,
});
@override
@@ -209,14 +228,8 @@ class _StatTile extends StatelessWidget {
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 8),
Text(
value,
style: KittieTypography.h2.copyWith(color: color),
),
Text(
label,
style: KittieTypography.bodySmall.copyWith(color: color),
),
Text(value, style: skinTypo.h2.copyWith(color: color)),
Text(label, style: skinTypo.bodySmall.copyWith(color: color)),
],
),
);
@@ -227,11 +240,15 @@ class _ItemCard extends StatelessWidget {
final String name;
final IconData icon;
final bool unlocked;
final dynamic skinColors;
final dynamic skinTypo;
const _ItemCard({
required this.name,
required this.icon,
required this.unlocked,
required this.skinColors,
required this.skinTypo,
});
@override
@@ -242,9 +259,9 @@ class _ItemCard extends StatelessWidget {
width: 80,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
color: KittieColors.creamDark,
color: skinColors.creamDark,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KittieColors.creamBorder),
border: Border.all(color: skinColors.creamBorder),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -252,19 +269,19 @@ class _ItemCard extends StatelessWidget {
Icon(
icon,
size: 32,
color: unlocked ? KittieColors.amber : KittieColors.brownLight,
color: unlocked ? skinColors.amber : skinColors.brownLight,
),
const SizedBox(height: 6),
Text(
name,
style: KittieTypography.labelSmall,
style: skinTypo.labelSmall,
textAlign: TextAlign.center,
),
if (!unlocked)
Icon(
Icons.lock_rounded,
size: 12,
color: KittieColors.brownLight,
color: skinColors.brownLight,
),
],
),
+43 -45
View File
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/i18n/i18n_provider.dart';
import '../../../core/theme/skin_provider.dart';
import '../screens/add_task_screen.dart';
import '../../../domain/entities/pet_type.dart';
import '../../../domain/entities/task_status.dart';
@@ -27,17 +27,19 @@ class TasksScreen extends ConsumerWidget {
final userState = ref.watch(userProvider);
final petMood = ref.watch(petMoodProvider);
final userName = ref.watch(userNameProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Scaffold(
body: SafeArea(
child: tasksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Chyba: $e')),
error: (e, _) =>
Center(child: Text(t('tasks.error', params: {'error': '$e'}))),
data: (tasks) {
final pendingTasks =
tasks.where((t) => !t.isCompleted).toList();
final completedTasks =
tasks.where((t) => t.isCompleted).toList();
final pendingTasks = tasks.where((t) => !t.isCompleted).toList();
final completedTasks = tasks.where((t) => t.isCompleted).toList();
final pendingCount = pendingTasks.length;
if (tasks.isEmpty) {
@@ -51,11 +53,9 @@ class TasksScreen extends ConsumerWidget {
children: [
// 1. Date header
Text(
czechDateHeader(DateTime.now()),
style: GoogleFonts.lexend(
fontSize: 12,
fontWeight: FontWeight.w500,
color: KittieColors.brownLight,
localizedDateHeader(DateTime.now(), t),
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
letterSpacing: 1.2,
),
),
@@ -63,17 +63,18 @@ class TasksScreen extends ConsumerWidget {
// 2. Greeting
Text(
'${czechGreeting()}, $userName',
style: GoogleFonts.lexend(
fontSize: 22,
fontWeight: FontWeight.w600,
color: KittieColors.brownDark,
'${localizedGreeting(t)}, $userName',
style: skinTypo.h2.copyWith(
color: skinColors.brownDark,
),
),
const SizedBox(height: 20),
// 3. Energy bar
EnergyBar(energyLevel: petState.energyToday),
EnergyBar(
energyLevel: petState.energyToday,
label: t('energy.label'),
),
const SizedBox(height: 20),
// 4. Pet area
@@ -81,7 +82,7 @@ class TasksScreen extends ConsumerWidget {
petType: userState.petType,
petName: userState.petName,
mood: petMood.emoji,
message: _petMessage(petMood, userState.petName),
message: _petMessage(petMood, userState.petName, t),
),
const SizedBox(height: 16),
@@ -93,11 +94,9 @@ class TasksScreen extends ConsumerWidget {
Row(
children: [
Text(
'DNEŠNÍ ÚKOLY',
style: GoogleFonts.lexend(
fontSize: 12,
fontWeight: FontWeight.w600,
color: KittieColors.brownLight,
t('tasks.section_today'),
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
letterSpacing: 1.2,
),
),
@@ -105,15 +104,14 @@ class TasksScreen extends ConsumerWidget {
Container(
width: 22,
height: 22,
decoration: const BoxDecoration(
color: KittieColors.amber,
decoration: BoxDecoration(
color: skinColors.amber,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$pendingCount',
style: GoogleFonts.lexend(
fontSize: 11,
style: skinTypo.labelSmall.copyWith(
fontWeight: FontWeight.w700,
color: Colors.white,
),
@@ -168,23 +166,30 @@ class TasksScreen extends ConsumerWidget {
);
}
static String _petMessage(PetMood mood, String petName) {
static String _petMessage(
PetMood mood,
String petName,
String Function(String, {Map<String, String>? params}) t,
) {
return switch (mood) {
PetMood.happy => '$petName je šťastná! Jde ti to skvěle \u{1F44F}',
PetMood.neutral => '$petName tě čeká! Co dnes zvládneme? \u{2728}',
PetMood.sad => '$petName tě podporuje. Zkus jeden úkol \u{1F495}',
PetMood.happy => t('pet.mood_happy', params: {'petName': petName}),
PetMood.neutral => t('pet.mood_neutral', params: {'petName': petName}),
PetMood.sad => t('pet.mood_sad', params: {'petName': petName}),
};
}
}
class _EmptyState extends StatelessWidget {
class _EmptyState extends ConsumerWidget {
final PetType petType;
const _EmptyState({required this.petType});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final emoji = petType.emoji;
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Center(
child: Column(
@@ -193,20 +198,13 @@ class _EmptyState extends StatelessWidget {
Text(emoji, style: const TextStyle(fontSize: 64)),
const SizedBox(height: 16),
Text(
'Žádné úkoly na dnes!',
style: GoogleFonts.lexend(
fontSize: 18,
fontWeight: FontWeight.w600,
color: KittieColors.brownLight,
),
t('tasks.empty_title'),
style: skinTypo.h3.copyWith(color: skinColors.brownLight),
),
const SizedBox(height: 8),
Text(
'Užij si volný den \u{2615}',
style: GoogleFonts.lexend(
fontSize: 14,
color: KittieColors.brownLight,
),
t('tasks.empty_subtitle'),
style: skinTypo.bodyMedium.copyWith(color: skinColors.brownLight),
),
],
),
+48 -25
View File
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/theme/kittie_typography.dart';
import '../../../core/i18n/i18n_provider.dart';
import '../../../core/theme/skin_provider.dart';
import '../../../shared/widgets/energy_badge.dart';
import '../../../domain/entities/task_entity.dart';
import '../../tasks/providers/task_providers.dart';
@@ -19,9 +19,12 @@ class ZenScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final task = ref.watch(nextZenTaskProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Scaffold(
backgroundColor: KittieColors.creamDark,
backgroundColor: skinColors.creamDark,
body: SafeArea(
child: Column(
children: [
@@ -32,7 +35,7 @@ class ZenScreen extends ConsumerWidget {
padding: const EdgeInsets.only(left: 8, top: 8),
child: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
color: KittieColors.brown,
color: skinColors.brown,
onPressed: () => context.pop(),
),
),
@@ -42,8 +45,8 @@ class ZenScreen extends ConsumerWidget {
Expanded(
child: Center(
child: task == null
? _buildEmpty()
: _buildTask(context, ref, task),
? _buildEmpty(t, skinColors, skinTypo)
: _buildTask(context, ref, task, t, skinColors, skinTypo),
),
),
@@ -56,20 +59,27 @@ class ZenScreen extends ConsumerWidget {
Expanded(
child: ElevatedButton(
onPressed: () {
ref.read(taskRepositoryProvider).completeTask(task.id);
ref.read(petStateProvider.notifier).incrementTasksDone();
ref
.read(taskRepositoryProvider)
.completeTask(task.id);
ref
.read(petStateProvider.notifier)
.incrementTasksDone();
},
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.sageGreen,
backgroundColor: skinColors.sageGreen,
foregroundColor: Colors.white,
minimumSize: const Size(0, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text('Hotovo',
style: KittieTypography.labelLarge
.copyWith(color: Colors.white)),
child: Text(
t('zen.done'),
style: skinTypo.labelLarge.copyWith(
color: Colors.white,
),
),
),
),
const SizedBox(width: 12),
@@ -78,22 +88,24 @@ class ZenScreen extends ConsumerWidget {
onPressed: () {
ref.read(taskRepositoryProvider).updateTask(
task.copyWith(
sortOrder: task.sortOrder + 100),
sortOrder: task.sortOrder + 100,
),
);
},
style: TextButton.styleFrom(
foregroundColor: KittieColors.brownLight,
foregroundColor: skinColors.brownLight,
minimumSize: const Size(0, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(
color: KittieColors.creamBorder,
),
side: BorderSide(color: skinColors.creamBorder),
),
),
child: Text(
t('zen.skip'),
style: skinTypo.labelLarge.copyWith(
color: skinColors.brownLight,
),
),
child: Text('Přeskočit',
style: KittieTypography.labelLarge
.copyWith(color: KittieColors.brownLight)),
),
),
],
@@ -105,22 +117,33 @@ class ZenScreen extends ConsumerWidget {
);
}
Widget _buildEmpty() {
Widget _buildEmpty(
String Function(String, {Map<String, String>? params}) t,
dynamic skinColors,
dynamic skinTypo,
) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('\u{2728}', style: TextStyle(fontSize: 48)),
const SizedBox(height: 16),
Text(
'Vše hotovo.\nOdpočívej.',
t('zen.all_done_title'),
textAlign: TextAlign.center,
style: KittieTypography.h2.copyWith(color: KittieColors.brown),
style: skinTypo.h2.copyWith(color: skinColors.brown),
),
],
);
}
Widget _buildTask(BuildContext context, WidgetRef ref, TaskEntity task) {
Widget _buildTask(
BuildContext context,
WidgetRef ref,
TaskEntity task,
String Function(String, {Map<String, String>? params}) t,
dynamic skinColors,
dynamic skinTypo,
) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
@@ -129,7 +152,7 @@ class ZenScreen extends ConsumerWidget {
Text(
task.title,
textAlign: TextAlign.center,
style: KittieTypography.h1.copyWith(fontSize: 28),
style: skinTypo.h1.copyWith(fontSize: 28),
),
const SizedBox(height: 16),
EnergyBadge(energyLevel: task.energyLevel),
+21 -2
View File
@@ -1,10 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'core/i18n/i18n_provider.dart';
import 'core/i18n/translation_service.dart';
import 'core/router/app_router.dart';
import 'core/theme/kittie_theme.dart';
import 'core/theme/skin_provider.dart';
import 'core/theme/skin_type.dart';
void main() {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
// Restore saved skin before runApp so the first frame uses the right theme.
final savedSkinKey = prefs.getString('app_skin');
if (savedSkinKey != null) {
setInitialSkin(SkinTypeExtension.fromStorageKey(savedSkinKey));
}
final savedLocale = prefs.getString('app_locale') ?? 'cs';
await TranslationService.instance.init(locale: savedLocale);
runApp(const ProviderScope(child: KittieApp()));
}
@@ -13,11 +29,14 @@ class KittieApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(localeProvider);
final router = ref.watch(appRouterProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return MaterialApp.router(
title: 'KittieMobile',
theme: KittieTheme.light,
theme: KittieTheme.fromSkin(skinColors, skinTypo),
routerConfig: router,
debugShowCheckedModeBanner: false,
);
+46
View File
@@ -38,3 +38,49 @@ String czechGreeting() {
if (hour >= 18 && hour < 22) return 'Dobrý večer';
return 'Dobrou noc';
}
/// Returns a locale-aware date header using the provided [t] function.
/// Example output (cs): "STŘEDA, 17. BŘEZNA" / (en): "WEDNESDAY, 17. MARCH"
String localizedDateHeader(
DateTime date,
String Function(String, {Map<String, String>? params}) t,
) {
const dayKeys = [
'date.day_monday',
'date.day_tuesday',
'date.day_wednesday',
'date.day_thursday',
'date.day_friday',
'date.day_saturday',
'date.day_sunday',
];
const monthKeys = [
'date.month_january',
'date.month_february',
'date.month_march',
'date.month_april',
'date.month_may',
'date.month_june',
'date.month_july',
'date.month_august',
'date.month_september',
'date.month_october',
'date.month_november',
'date.month_december',
];
final day = t(dayKeys[date.weekday - 1]);
final month = t(monthKeys[date.month - 1]);
return t('date.header_format',
params: {'day': day, 'date': date.day.toString(), 'month': month});
}
/// Returns a locale-aware time-based greeting using the provided [t] function.
String localizedGreeting(
String Function(String, {Map<String, String>? params}) t,
) {
final hour = DateTime.now().hour;
if (hour >= 5 && hour < 12) return t('greeting.morning');
if (hour >= 12 && hour < 18) return t('greeting.afternoon');
if (hour >= 18 && hour < 22) return t('greeting.evening');
return t('greeting.night');
}
+33 -14
View File
@@ -1,26 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/skin_provider.dart';
import '../../core/constants/app_constants.dart';
/// Bottom navigation shell wrapping the main tabs.
class AppShell extends StatelessWidget {
class AppShell extends ConsumerWidget {
final StatefulNavigationShell navigationShell;
const AppShell({super.key, required this.navigationShell});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Scaffold(
body: navigationShell,
bottomNavigationBar: Container(
height: AppConstants.bottomNavHeight,
decoration: const BoxDecoration(
color: KittieColors.cream,
decoration: BoxDecoration(
color: skinColors.cream,
border: Border(
top: BorderSide(color: KittieColors.creamBorder, width: 0.5),
top: BorderSide(color: skinColors.creamBorder, width: 0.5),
),
),
child: Row(
@@ -28,21 +33,27 @@ class AppShell extends StatelessWidget {
children: [
_NavItem(
icon: Icons.grid_view_rounded,
label: 'úkoly',
label: t('nav.tasks'),
isActive: navigationShell.currentIndex == 0,
onTap: () => navigationShell.goBranch(0),
skinColors: skinColors,
skinTypo: skinTypo,
),
_NavItem(
icon: Icons.pets_rounded,
label: 'mazlíček',
label: t('nav.pet'),
isActive: navigationShell.currentIndex == 1,
onTap: () => navigationShell.goBranch(1),
skinColors: skinColors,
skinTypo: skinTypo,
),
_NavItem(
icon: Icons.person_rounded,
label: 'profil',
label: t('nav.profile'),
isActive: navigationShell.currentIndex == 2,
onTap: () => navigationShell.goBranch(2),
skinColors: skinColors,
skinTypo: skinTypo,
),
],
),
@@ -56,17 +67,22 @@ class _NavItem extends StatelessWidget {
final String label;
final bool isActive;
final VoidCallback onTap;
final dynamic skinColors;
final dynamic skinTypo;
const _NavItem({
required this.icon,
required this.label,
required this.isActive,
required this.onTap,
required this.skinColors,
required this.skinTypo,
});
@override
Widget build(BuildContext context) {
final color = isActive ? KittieColors.brown : KittieColors.brownLight;
final color =
isActive ? skinColors.brown : skinColors.brownLight;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
@@ -78,14 +94,17 @@ class _NavItem extends StatelessWidget {
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 2),
Text(label, style: KittieTypography.labelSmall.copyWith(color: color)),
Text(
label,
style: skinTypo.labelSmall.copyWith(color: color),
),
const SizedBox(height: 4),
if (isActive)
Container(
width: 4,
height: 4,
decoration: const BoxDecoration(
color: KittieColors.amber,
decoration: BoxDecoration(
color: skinColors.amber,
shape: BoxShape.circle,
),
),
+27 -11
View File
@@ -1,14 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/theme/skin_provider.dart';
/// Reusable bottom sheet with drag handle, progress dots, title, content,
/// and action buttons.
///
/// Use with [showModalBottomSheet] or as a child of [DraggableScrollableSheet].
class BottomSheetScaffold extends StatelessWidget {
class BottomSheetScaffold extends ConsumerWidget {
const BottomSheetScaffold({
super.key,
required this.title,
@@ -38,7 +38,10 @@ class BottomSheetScaffold extends StatelessWidget {
final VoidCallback? onSecondaryPressed;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
@@ -50,7 +53,7 @@ class BottomSheetScaffold extends StatelessWidget {
width: 40,
height: 4,
decoration: BoxDecoration(
color: KittieColors.creamBorder,
color: skinColors.creamBorder,
borderRadius: BorderRadius.circular(2),
),
),
@@ -58,20 +61,28 @@ class BottomSheetScaffold extends StatelessWidget {
// Progress dots
if (totalSteps != null && currentStep != null) ...[
_ProgressDots(total: totalSteps!, current: currentStep!),
_ProgressDots(
total: totalSteps!,
current: currentStep!,
skinColors: skinColors,
),
const SizedBox(height: 16),
],
// Title
Text(title, style: KittieTypography.h2, textAlign: TextAlign.center),
Text(
title,
style: skinTypo.h2,
textAlign: TextAlign.center,
),
// Subtitle
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: KittieTypography.bodySmall.copyWith(
color: KittieColors.brownLight,
style: skinTypo.bodySmall.copyWith(
color: skinColors.brownLight,
),
textAlign: TextAlign.center,
),
@@ -110,10 +121,15 @@ class BottomSheetScaffold extends StatelessWidget {
}
class _ProgressDots extends StatelessWidget {
const _ProgressDots({required this.total, required this.current});
const _ProgressDots({
required this.total,
required this.current,
required this.skinColors,
});
final int total;
final int current;
final dynamic skinColors;
@override
Widget build(BuildContext context) {
@@ -128,7 +144,7 @@ class _ProgressDots extends StatelessWidget {
margin: EdgeInsets.only(left: i > 0 ? 6 : 0),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive ? KittieColors.amber : KittieColors.creamBorder,
color: isActive ? skinColors.amber : skinColors.creamBorder,
),
);
}),
+35 -31
View File
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/skin_provider.dart';
/// Energy level for the badge display.
enum EnergyLevel {
@@ -23,10 +24,10 @@ enum EnergyLevel {
/// Small colored pill showing the energy level of a task.
///
/// - low: green bg, green text, leaf icon — "Lehke"
/// - medium: orange bg, orange text, sun icon — "Stredni"
/// - high: red bg, red text, fire icon — "Narocne"
class EnergyBadge extends StatelessWidget {
/// - low: green bg, green text, leaf icon — localized label
/// - medium: orange bg, orange text, sun icon — localized label
/// - high: red bg, red text, fire icon — localized label
class EnergyBadge extends ConsumerWidget {
const EnergyBadge({super.key, this.level, this.energyLevel})
: assert(level != null || energyLevel != null,
'Provide either level or energyLevel');
@@ -44,8 +45,33 @@ class EnergyBadge extends StatelessWidget {
level ?? EnergyLevel.fromInt(energyLevel ?? 1);
@override
Widget build(BuildContext context) {
final (bgColor, textColor, icon, label) = _props;
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
final (bgColor, textColor, icon, labelKey) = switch (_effectiveLevel) {
EnergyLevel.low => (
skinColors.greenLight,
skinColors.greenDark,
Icons.eco_outlined,
'energy.low',
),
EnergyLevel.medium => (
skinColors.orangeLight,
skinColors.orange,
Icons.wb_sunny_outlined,
'energy.medium',
),
EnergyLevel.high => (
skinColors.redLight,
skinColors.red,
Icons.local_fire_department_outlined,
'energy.high',
),
};
final label = t(labelKey);
return Container(
height: 24,
@@ -61,32 +87,10 @@ class EnergyBadge extends StatelessWidget {
const SizedBox(width: 4),
Text(
label,
style: KittieTypography.labelSmall.copyWith(color: textColor),
style: skinTypo.labelSmall.copyWith(color: textColor),
),
],
),
);
}
(Color bg, Color text, IconData icon, String label) get _props =>
switch (_effectiveLevel) {
EnergyLevel.low => (
KittieColors.greenLight,
KittieColors.greenDark,
Icons.eco_outlined,
'Lehké',
),
EnergyLevel.medium => (
KittieColors.orangeLight,
KittieColors.orange,
Icons.wb_sunny_outlined,
'Střední',
),
EnergyLevel.high => (
KittieColors.redLight,
KittieColors.red,
Icons.local_fire_department_outlined,
'Náročné',
),
};
}
+12 -9
View File
@@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/theme/skin_provider.dart';
/// Horizontal energy bar with "ENERGIE" label.
/// Horizontal energy bar with label.
///
/// Shows a track with creamDark background and amber fill
/// that animates to the current [fraction] (0.01.0).
/// Alternatively, pass [energyLevel] (13) to compute the fraction.
class EnergyBar extends StatelessWidget {
class EnergyBar extends ConsumerWidget {
const EnergyBar({
super.key,
this.fraction,
@@ -33,15 +33,18 @@ class EnergyBar extends StatelessWidget {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.brownLight,
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
letterSpacing: 1.2,
),
),
@@ -57,14 +60,14 @@ class EnergyBar extends StatelessWidget {
// Track background
Container(
width: constraints.maxWidth,
color: KittieColors.creamDark,
color: skinColors.creamDark,
),
// Amber fill
AnimatedContainer(
duration: AppConstants.animationNormal,
curve: Curves.easeInOut,
width: constraints.maxWidth * _effectiveFraction,
color: KittieColors.amber,
color: skinColors.amber,
),
],
);
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/i18n/i18n_provider.dart';
class LanguagePicker extends ConsumerWidget {
const LanguagePicker({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentLocale = ref.watch(localeProvider);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_LocaleButton(
label: '🇬🇧',
locale: 'en',
isActive: currentLocale == 'en',
onTap: () => ref.read(localeProvider.notifier).setLocale('en'),
),
const SizedBox(width: 8),
_LocaleButton(
label: '🇨🇿',
locale: 'cs',
isActive: currentLocale == 'cs',
onTap: () => ref.read(localeProvider.notifier).setLocale('cs'),
),
],
);
}
}
class _LocaleButton extends StatelessWidget {
const _LocaleButton({
required this.label,
required this.locale,
required this.isActive,
required this.onTap,
});
final String label;
final String locale;
final bool isActive;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isActive
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.15)
: Colors.transparent,
border: Border.all(
color: isActive
? Theme.of(context).colorScheme.primary
: Colors.grey.withValues(alpha: 0.3),
),
),
child: Text(label, style: const TextStyle(fontSize: 20)),
),
);
}
}
+16 -12
View File
@@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/theme/skin_provider.dart';
import '../../domain/entities/pet_type.dart';
/// Pet area card with a speech bubble.
///
/// Shows the pet emoji on the left and a white speech bubble on the right
/// Shows the pet emoji on the left and a speech bubble on the right
/// containing the pet name, mood, and a message.
class PetBubble extends StatelessWidget {
class PetBubble extends ConsumerWidget {
const PetBubble({
super.key,
required this.petType,
@@ -24,11 +24,14 @@ class PetBubble extends StatelessWidget {
final String message;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: KittieColors.creamDark,
color: skinColors.creamDark,
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
),
child: Row(
@@ -45,8 +48,9 @@ class PetBubble extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
color: skinColors.cream,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: skinColors.creamBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -56,13 +60,13 @@ class PetBubble extends StatelessWidget {
children: [
Text(
petName,
style: KittieTypography.labelLarge,
style: skinTypo.labelLarge,
),
const SizedBox(width: 6),
Text(
mood,
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.brownLight,
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
),
),
],
@@ -70,8 +74,8 @@ class PetBubble extends StatelessWidget {
const SizedBox(height: 4),
Text(
message,
style: KittieTypography.bodySmall.copyWith(
color: KittieColors.brownLight,
style: skinTypo.bodySmall.copyWith(
color: skinColors.brownLight,
),
),
],
+108 -48
View File
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/skin_provider.dart';
import '../../features/tasks/providers/pet_providers.dart';
import '../widgets/language_picker.dart';
import '../widgets/skin_picker.dart';
/// User profile screen — name, pet info, stats, export.
/// User profile screen — name, pet info, stats, language, skin.
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@@ -14,102 +16,140 @@ class ProfileScreen extends ConsumerWidget {
final user = ref.watch(userProvider);
final petState = ref.watch(petStateProvider);
final level = ref.watch(petLevelProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Scaffold(
backgroundColor: KittieColors.cream,
backgroundColor: skinColors.cream,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Profil', style: KittieTypography.h2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(t('profile.title'), style: skinTypo.h2),
const LanguagePicker(),
],
),
const SizedBox(height: 24),
// User name
_EditableRow(
label: 'Jméno',
label: t('profile.name_label'),
value: user.name,
onEdit: () => _showEditDialog(
context,
title: 'Upravit jméno',
title: t('profile.edit_name_title'),
initialValue: user.name,
onSave: (v) => ref.read(userProvider.notifier).updateName(v),
t: t,
skinColors: skinColors,
skinTypo: skinTypo,
),
skinColors: skinColors,
skinTypo: skinTypo,
),
const SizedBox(height: 16),
// Pet name
_EditableRow(
label: 'Jméno mazlíčka',
label: t('profile.pet_name_label'),
value: user.petName,
onEdit: () => _showEditDialog(
context,
title: 'Upravit jméno mazlíčka',
title: t('profile.edit_pet_name_title'),
initialValue: user.petName,
onSave: (v) =>
ref.read(userProvider.notifier).updatePetName(v),
t: t,
skinColors: skinColors,
skinTypo: skinTypo,
),
skinColors: skinColors,
skinTypo: skinTypo,
),
const SizedBox(height: 16),
// Pet type
_InfoRow(label: 'Typ mazlíčka', value: user.petType.label),
_InfoRow(
label: t('profile.pet_type_label'),
value: user.petType.label,
skinColors: skinColors,
skinTypo: skinTypo,
),
const SizedBox(height: 32),
// Stats summary
Text('Statistiky', style: KittieTypography.h3),
Text(t('profile.stats_title'), style: skinTypo.h3),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: KittieColors.creamDark,
color: skinColors.creamDark,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KittieColors.creamBorder),
border: Border.all(color: skinColors.creamBorder),
),
child: Column(
children: [
_StatRow(
icon: Icons.local_fire_department_rounded,
label: 'Streak',
value: '${petState.streakDays} dni',
color: KittieColors.orange,
label: t('pet.stat_streak'),
value: t('pet.stat_streak_value',
params: {'days': '${petState.streakDays}'}),
color: skinColors.orange,
skinTypo: skinTypo,
),
const Divider(color: KittieColors.creamBorder, height: 24),
Divider(color: skinColors.creamBorder, height: 24),
_StatRow(
icon: Icons.check_circle_rounded,
label: 'Hotové úkoly',
label: t('pet.stat_tasks_done'),
value: '${petState.totalTasksDone}',
color: KittieColors.sageGreen,
color: skinColors.sageGreen,
skinTypo: skinTypo,
),
const Divider(color: KittieColors.creamBorder, height: 24),
Divider(color: skinColors.creamBorder, height: 24),
_StatRow(
icon: Icons.star_rounded,
label: 'Úroveň mazlíčka',
label: t('pet.stat_level'),
value: '$level',
color: KittieColors.amber,
color: skinColors.amber,
skinTypo: skinTypo,
),
],
),
),
const SizedBox(height: 32),
// Skin picker section
Text(t('skin.title'), style: skinTypo.h3),
const SizedBox(height: 12),
const SkinPicker(),
const SizedBox(height: 32),
// Export button
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Pripravujeme')),
SnackBar(
content: Text(t('profile.export_preparing')),
),
);
},
icon: const Icon(Icons.download_rounded),
label: Text('Exportovat data',
style: KittieTypography.labelLarge),
label: Text(
t('profile.export_data'),
style: skinTypo.labelLarge,
),
style: OutlinedButton.styleFrom(
foregroundColor: KittieColors.brown,
side: const BorderSide(color: KittieColors.creamBorder),
foregroundColor: skinColors.brown,
side: BorderSide(color: skinColors.creamBorder),
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -122,9 +162,9 @@ class ProfileScreen extends ConsumerWidget {
// Version
Center(
child: Text(
'KittieMobile v1.0.0',
style: KittieTypography.bodySmall.copyWith(
color: KittieColors.brownLight,
t('profile.version'),
style: skinTypo.bodySmall.copyWith(
color: skinColors.brownLight,
),
),
),
@@ -141,22 +181,25 @@ class ProfileScreen extends ConsumerWidget {
required String title,
required String initialValue,
required ValueChanged<String> onSave,
required String Function(String, {Map<String, String>? params}) t,
required dynamic skinColors,
required dynamic skinTypo,
}) {
final controller = TextEditingController(text: initialValue);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: KittieColors.cream,
title: Text(title, style: KittieTypography.h3),
backgroundColor: skinColors.cream,
title: Text(title, style: skinTypo.h3),
content: TextField(
controller: controller,
autofocus: true,
style: KittieTypography.bodyLarge,
style: skinTypo.bodyLarge,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Zrušit'),
child: Text(t('common.cancel')),
),
ElevatedButton(
onPressed: () {
@@ -164,7 +207,7 @@ class ProfileScreen extends ConsumerWidget {
if (text.isNotEmpty) onSave(text);
Navigator.pop(ctx);
},
child: const Text('Uložit'),
child: Text(t('common.save')),
),
],
),
@@ -178,11 +221,15 @@ class _EditableRow extends StatelessWidget {
final String label;
final String value;
final VoidCallback onEdit;
final dynamic skinColors;
final dynamic skinTypo;
const _EditableRow({
required this.label,
required this.value,
required this.onEdit,
required this.skinColors,
required this.skinTypo,
});
@override
@@ -193,17 +240,20 @@ class _EditableRow extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: KittieTypography.labelMedium
.copyWith(color: KittieColors.brownLight)),
Text(
label,
style: skinTypo.labelMedium.copyWith(
color: skinColors.brownLight,
),
),
const SizedBox(height: 4),
Text(value, style: KittieTypography.bodyLarge),
Text(value, style: skinTypo.bodyLarge),
],
),
),
IconButton(
icon: const Icon(Icons.edit_rounded, size: 20),
color: KittieColors.brownLight,
color: skinColors.brownLight,
onPressed: onEdit,
),
],
@@ -214,19 +264,28 @@ class _EditableRow extends StatelessWidget {
class _InfoRow extends StatelessWidget {
final String label;
final String value;
final dynamic skinColors;
final dynamic skinTypo;
const _InfoRow({required this.label, required this.value});
const _InfoRow({
required this.label,
required this.value,
required this.skinColors,
required this.skinTypo,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: KittieTypography.labelMedium
.copyWith(color: KittieColors.brownLight)),
Text(
label,
style:
skinTypo.labelMedium.copyWith(color: skinColors.brownLight),
),
const SizedBox(height: 4),
Text(value, style: KittieTypography.bodyLarge),
Text(value, style: skinTypo.bodyLarge),
],
);
}
@@ -237,12 +296,14 @@ class _StatRow extends StatelessWidget {
final String label;
final String value;
final Color color;
final dynamic skinTypo;
const _StatRow({
required this.icon,
required this.label,
required this.value,
required this.color,
required this.skinTypo,
});
@override
@@ -251,9 +312,8 @@ class _StatRow extends StatelessWidget {
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 12),
Expanded(child: Text(label, style: KittieTypography.bodyMedium)),
Text(value,
style: KittieTypography.labelLarge.copyWith(color: color)),
Expanded(child: Text(label, style: skinTypo.bodyMedium)),
Text(value, style: skinTypo.labelLarge.copyWith(color: color)),
],
);
}
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/skin_provider.dart';
import '../../core/theme/skin_type.dart';
/// Horizontal wrap of 7 skin tiles for selecting the active app theme.
///
/// Tapping a tile calls [SkinNotifier.setSkin] and persists the choice.
class SkinPicker extends ConsumerWidget {
const SkinPicker({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentSkin = ref.watch(skinProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
final t = ref.watch(tProvider);
return Wrap(
spacing: 8,
runSpacing: 8,
children: SkinType.values.map((skin) {
final isSelected = skin == currentSkin;
final colors = skin.colors;
return GestureDetector(
onTap: () => ref.read(skinProvider.notifier).setSkin(skin),
child: SizedBox(
width: 90,
child: Stack(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colors.cream,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? skinColors.brown
: Colors.grey.withValues(alpha: 0.3),
width: isSelected ? 2 : 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Color dots preview
Row(
children: [
_ColorDot(color: colors.brown),
const SizedBox(width: 4),
_ColorDot(color: colors.sageGreen),
const SizedBox(width: 4),
_ColorDot(color: colors.energyHigh),
],
),
const SizedBox(height: 6),
// Skin name
Text(
t(skin.nameKey),
style: skinTypo.labelSmall.copyWith(
fontWeight: FontWeight.bold,
color: skinColors.brownDark,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Skin description
Text(
t(skin.descriptionKey),
style: skinTypo.labelSmall.copyWith(
fontSize: 9,
color: Colors.grey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Selected checkmark overlay
if (isSelected)
Positioned(
top: 4,
right: 4,
child: Icon(
Icons.check_circle,
size: 16,
color: skinColors.brown,
),
),
],
),
),
);
}).toList(),
);
}
}
class _ColorDot extends StatelessWidget {
const _ColorDot({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
),
);
}
}
+50 -47
View File
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/skin_provider.dart';
/// Day status for a single streak dot.
enum DayStatus { done, today, future }
@@ -9,12 +10,12 @@ enum DayStatus { done, today, future }
/// A row of 7 day-dots (MonSun) showing weekly task completion streak.
///
/// Colors:
/// - done: green (#7BAE82)
/// - today: amber (#C4A882)
/// - future: grey (#E0DBD0)
/// - done: sageGreen
/// - today: amber
/// - future: creamBorder
///
/// Shows fire emoji + streak count above the dots.
class StreakDots extends StatelessWidget {
class StreakDots extends ConsumerWidget {
const StreakDots({
super.key,
this.days,
@@ -31,7 +32,15 @@ class StreakDots extends StatelessWidget {
/// Streak as an integer; used to compute [days] and count when provided.
final int? streakDays;
static const _dayLabels = ['Po', 'Ut', 'St', 'Ct', 'Pa', 'So', 'Ne'];
static const _dayKeys = [
'streak.day_mon',
'streak.day_tue',
'streak.day_wed',
'streak.day_thu',
'streak.day_fri',
'streak.day_sat',
'streak.day_sun',
];
static List<DayStatus> _generateDays(int streak) {
final todayIndex = DateTime.now().weekday - 1; // 0=Mon, 6=Sun
@@ -46,12 +55,18 @@ class StreakDots extends StatelessWidget {
}
List<DayStatus> get _effectiveDays =>
streakDays != null ? _generateDays(streakDays!) : (days ?? List.filled(7, DayStatus.future));
streakDays != null
? _generateDays(streakDays!)
: (days ?? List.filled(7, DayStatus.future));
int get _effectiveCount => streakDays ?? streakCount;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -63,8 +78,8 @@ class StreakDots extends StatelessWidget {
const SizedBox(width: 4),
Text(
'$_effectiveCount',
style: KittieTypography.labelLarge.copyWith(
color: KittieColors.brown,
style: skinTypo.labelLarge.copyWith(
color: skinColors.brown,
),
),
],
@@ -74,9 +89,32 @@ class StreakDots extends StatelessWidget {
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
final color = switch (_effectiveDays[i]) {
DayStatus.done => skinColors.sageGreen,
DayStatus.today => skinColors.amber,
DayStatus.future => skinColors.creamBorder,
};
return Padding(
padding: EdgeInsets.only(left: i > 0 ? 8 : 0),
child: _DayDot(label: _dayLabels[i], status: _effectiveDays[i]),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration:
BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(height: 4),
Text(
t(_dayKeys[i]),
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
fontSize: 10,
),
),
],
),
);
}),
),
@@ -84,38 +122,3 @@ class StreakDots extends StatelessWidget {
);
}
}
class _DayDot extends StatelessWidget {
const _DayDot({required this.label, required this.status});
final String label;
final DayStatus status;
@override
Widget build(BuildContext context) {
final color = switch (status) {
DayStatus.done => KittieColors.sageGreen,
DayStatus.today => KittieColors.amber,
DayStatus.future => KittieColors.creamBorder,
};
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(height: 4),
Text(
label,
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.brownLight,
fontSize: 10,
),
),
],
);
}
}
+60 -32
View File
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/skin_provider.dart';
import '../../domain/entities/task_entity.dart';
import 'energy_badge.dart';
@@ -13,10 +14,10 @@ import 'energy_badge.dart';
/// - Circular checkbox with haptic feedback on toggle
/// - Title with strikethrough + reduced opacity when done
/// - Subtitle row: [EnergyBadge] + optional time text
/// - Purple "Pece o sebe" tag when [isSelfCare] is true
/// - Purple "Self-care" tag when [isSelfCare] is true
/// - Red tint overlay when [isOverdue] is true
/// - Minimum height of 56px (Apple HIG touch target)
class TaskCard extends StatelessWidget {
class TaskCard extends ConsumerWidget {
const TaskCard({
super.key,
this.task,
@@ -51,32 +52,43 @@ class TaskCard extends StatelessWidget {
if (task?.deadline != null) return task!.deadline!.isBefore(DateTime.now());
return isOverdue;
}
VoidCallback? get _toggleCallback => onComplete ?? onToggle;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return GestureDetector(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: AppConstants.minTouchTarget),
constraints:
const BoxConstraints(minHeight: AppConstants.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: _isOverdue
? KittieColors.redLight
: KittieColors.creamDark,
color: _isOverdue ? skinColors.redLight : skinColors.creamDark,
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
),
child: Row(
children: [
_Checkbox(isDone: _isDone, onToggle: _toggleCallback),
const SizedBox(width: 12),
Expanded(child: _Content(
title: _title,
energyLevel: _energyLevel,
_Checkbox(
isDone: _isDone,
isSelfCare: _isSelfCare,
timeText: timeText,
)),
onToggle: _toggleCallback,
skinColors: skinColors,
),
const SizedBox(width: 12),
Expanded(
child: _Content(
title: _title,
energyLevel: _energyLevel,
isDone: _isDone,
isSelfCare: _isSelfCare,
timeText: timeText,
skinColors: skinColors,
skinTypo: skinTypo,
),
),
],
),
),
@@ -85,10 +97,15 @@ class TaskCard extends StatelessWidget {
}
class _Checkbox extends StatelessWidget {
const _Checkbox({required this.isDone, this.onToggle});
const _Checkbox({
required this.isDone,
required this.skinColors,
this.onToggle,
});
final bool isDone;
final VoidCallback? onToggle;
final dynamic skinColors;
@override
Widget build(BuildContext context) {
@@ -103,9 +120,10 @@ class _Checkbox extends StatelessWidget {
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDone ? KittieColors.sageGreen : Colors.transparent,
color: isDone ? skinColors.sageGreen : Colors.transparent,
border: Border.all(
color: isDone ? KittieColors.sageGreen : KittieColors.brownLight,
color:
isDone ? skinColors.sageGreen : skinColors.brownLight,
width: 2,
),
),
@@ -123,6 +141,8 @@ class _Content extends StatelessWidget {
required this.energyLevel,
required this.isDone,
required this.isSelfCare,
required this.skinColors,
required this.skinTypo,
this.timeText,
});
@@ -131,6 +151,8 @@ class _Content extends StatelessWidget {
final bool isDone;
final bool isSelfCare;
final String? timeText;
final dynamic skinColors;
final dynamic skinTypo;
@override
Widget build(BuildContext context) {
@@ -140,11 +162,11 @@ class _Content extends StatelessWidget {
children: [
AnimatedDefaultTextStyle(
duration: AppConstants.animationFast,
style: KittieTypography.bodyMedium.copyWith(
style: skinTypo.bodyMedium.copyWith(
decoration: isDone ? TextDecoration.lineThrough : null,
color: isDone
? KittieColors.brown.withValues(alpha: 0.4)
: KittieColors.brown,
? skinColors.brown.withValues(alpha: 0.4)
: skinColors.brown,
),
child: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis),
),
@@ -154,14 +176,14 @@ class _Content extends StatelessWidget {
EnergyBadge.fromInt(energyLevel),
if (isSelfCare) ...[
const SizedBox(width: 6),
_SelfCareTag(),
const _SelfCareTag(),
],
if (timeText != null) ...[
const SizedBox(width: 8),
Text(
timeText!,
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.brownLight,
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
),
),
],
@@ -172,21 +194,27 @@ class _Content extends StatelessWidget {
}
}
class _SelfCareTag extends StatelessWidget {
class _SelfCareTag extends ConsumerWidget {
const _SelfCareTag();
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: KittieColors.purpleLight,
color: skinColors.purpleLight,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
'Péče o sebe',
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.purple,
t('self_care.tag'),
style: skinTypo.labelSmall.copyWith(
color: skinColors.purple,
),
),
),
+1
View File
@@ -56,3 +56,4 @@ flutter:
assets:
- assets/animations/
- assets/fonts/
- assets/i18n/
+291
View File
@@ -0,0 +1,291 @@
// ignore_for_file: avoid_redundant_argument_values
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kittie_mobile/core/theme/app_skin.dart';
void main() {
// ────────────────────────────────────────────────────────────────────────
group('AppSkin enum', () {
test('has exactly 7 values', () {
expect(AppSkin.values.length, 7);
});
test('contains all expected skin keys', () {
final keys = AppSkin.values.map((s) => s.key).toSet();
expect(
keys,
containsAll([
'default',
'accessible',
'senior',
'modern',
'kids',
'epic',
'future',
]),
);
});
test('all keys are unique', () {
final keys = AppSkin.values.map((s) => s.key).toList();
expect(keys.toSet().length, keys.length);
});
});
// ────────────────────────────────────────────────────────────────────────
group('AppSkin.fromKey', () {
test('returns correct skin for each valid key', () {
for (final skin in AppSkin.values) {
expect(AppSkin.fromKey(skin.key), skin,
reason: 'fromKey("${skin.key}") should return ${skin.name}');
}
});
test('returns defaultSkin for unknown key', () {
expect(AppSkin.fromKey('unknown'), AppSkin.defaultSkin);
});
test('returns defaultSkin for empty string', () {
expect(AppSkin.fromKey(''), AppSkin.defaultSkin);
});
});
// ────────────────────────────────────────────────────────────────────────
group('AppSkin.isDark', () {
test('epic is dark', () => expect(AppSkin.epic.isDark, isTrue));
test('future is dark', () => expect(AppSkin.future.isDark, isTrue));
test('default is not dark', () => expect(AppSkin.defaultSkin.isDark, isFalse));
test('accessible is not dark', () => expect(AppSkin.accessible.isDark, isFalse));
test('senior is not dark', () => expect(AppSkin.senior.isDark, isFalse));
test('modern is not dark', () => expect(AppSkin.modern.isDark, isFalse));
test('kids is not dark', () => expect(AppSkin.kids.isDark, isFalse));
test('exactly 2 skins are dark (epic, future)', () {
final darkCount = AppSkin.values.where((s) => s.isDark).length;
expect(darkCount, 2);
});
});
// ────────────────────────────────────────────────────────────────────────
group('AppSkin.fontScale', () {
test('default fontScale is 1.0', () => expect(AppSkin.defaultSkin.fontScale, 1.0));
test('accessible fontScale is 1.0', () => expect(AppSkin.accessible.fontScale, 1.0));
test('senior fontScale is 1.25', () => expect(AppSkin.senior.fontScale, 1.25));
test('modern fontScale is 1.0', () => expect(AppSkin.modern.fontScale, 1.0));
test('kids fontScale is 1.1', () => expect(AppSkin.kids.fontScale, 1.1));
test('epic fontScale is 1.0', () => expect(AppSkin.epic.fontScale, 1.0));
test('future fontScale is 1.0', () => expect(AppSkin.future.fontScale, 1.0));
test('senior has the largest fontScale of all skins', () {
final max = AppSkin.values.map((s) => s.fontScale).reduce((a, b) => a > b ? a : b);
expect(AppSkin.senior.fontScale, max);
});
test('all scales are >= 1.0', () {
for (final skin in AppSkin.values) {
expect(skin.fontScale, greaterThanOrEqualTo(1.0),
reason: '${skin.key} fontScale must not shrink text');
}
});
});
// ────────────────────────────────────────────────────────────────────────
group('AppSkin.previewPrimary', () {
test('each skin has an opaque previewPrimary', () {
for (final skin in AppSkin.values) {
expect(skin.previewPrimary.a, greaterThan(0),
reason: '${skin.key} previewPrimary must be opaque');
}
});
test('all previewPrimary colors are distinct', () {
final colors = AppSkin.values.map((s) => s.previewPrimary).toSet();
expect(colors.length, AppSkin.values.length,
reason: 'each skin needs a unique previewPrimary for the picker');
});
});
// ────────────────────────────────────────────────────────────────────────
group('AppSkin.previewSurface', () {
test('each skin has an opaque previewSurface', () {
for (final skin in AppSkin.values) {
expect(skin.previewSurface.a, greaterThan(0),
reason: '${skin.key} previewSurface must be opaque');
}
});
test('dark skins have dark preview surfaces (luminance < 0.1)', () {
for (final skin in AppSkin.values.where((s) => s.isDark)) {
expect(skin.previewSurface.computeLuminance(), lessThan(0.1),
reason: '${skin.key} previewSurface should be dark');
}
});
test('light skins have light preview surfaces (luminance > 0.5)', () {
for (final skin in AppSkin.values.where((s) => !s.isDark)) {
expect(skin.previewSurface.computeLuminance(), greaterThan(0.5),
reason: '${skin.key} previewSurface should be light');
}
});
});
// ────────────────────────────────────────────────────────────────────────
// buildColorScheme() tests — no Google Fonts calls, fully synchronous
group('AppSkin.buildColorScheme — brightness', () {
test('dark skins have Brightness.dark', () {
for (final skin in AppSkin.values.where((s) => s.isDark)) {
expect(skin.buildColorScheme().brightness, Brightness.dark,
reason: '${skin.key} should be Brightness.dark');
}
});
test('light skins have Brightness.light', () {
for (final skin in AppSkin.values.where((s) => !s.isDark)) {
expect(skin.buildColorScheme().brightness, Brightness.light,
reason: '${skin.key} should be Brightness.light');
}
});
});
group('AppSkin.buildColorScheme — per-skin color values', () {
test('default — warm brown primary (0xFF6B5A48)', () {
expect(AppSkin.defaultSkin.buildColorScheme().primary,
const Color(0xFF6B5A48));
});
test('accessible — pure black primary (0xFF000000)', () {
expect(AppSkin.accessible.buildColorScheme().primary,
const Color(0xFF000000));
});
test('accessible — pure white surface (0xFFFFFFFF)', () {
expect(AppSkin.accessible.buildColorScheme().surface,
const Color(0xFFFFFFFF));
});
test('accessible — WCAG AAA contrast (>=7) between surface and onSurface', () {
final cs = AppSkin.accessible.buildColorScheme();
final surfL = cs.surface.computeLuminance();
final textL = cs.onSurface.computeLuminance();
final lighter = surfL > textL ? surfL : textL;
final darker = surfL < textL ? surfL : textL;
final contrast = (lighter + 0.05) / (darker + 0.05);
expect(contrast, greaterThanOrEqualTo(7.0),
reason: 'accessible skin must meet WCAG AAA (contrast >= 7)');
});
test('senior — dark navy primary (0xFF1A237E)', () {
expect(AppSkin.senior.buildColorScheme().primary,
const Color(0xFF1A237E));
});
test('modern — teal primary (0xFF00796B)', () {
expect(AppSkin.modern.buildColorScheme().primary,
const Color(0xFF00796B));
});
test('kids — bright pink primary (0xFFE91E8C)', () {
expect(AppSkin.kids.buildColorScheme().primary,
const Color(0xFFE91E8C));
});
test('kids — lavender surface (0xFFF3E5F5)', () {
expect(AppSkin.kids.buildColorScheme().surface,
const Color(0xFFF3E5F5));
});
test('epic — gold primary (0xFFFFD700)', () {
expect(AppSkin.epic.buildColorScheme().primary,
const Color(0xFFFFD700));
});
test('epic — very dark surface (luminance < 0.05)', () {
expect(
AppSkin.epic.buildColorScheme().surface.computeLuminance(),
lessThan(0.05));
});
test('future — neon cyan primary (0xFF00E5FF)', () {
expect(AppSkin.future.buildColorScheme().primary,
const Color(0xFF00E5FF));
});
test('future — very dark surface (luminance < 0.05)', () {
expect(
AppSkin.future.buildColorScheme().surface.computeLuminance(),
lessThan(0.05));
});
});
group('AppSkin.buildColorScheme — consistency with previewColors', () {
test('previewSurface matches colorScheme surface for each skin', () {
for (final skin in AppSkin.values) {
expect(
skin.previewSurface,
skin.buildColorScheme().surface,
reason: '${skin.key} previewSurface should match colorScheme.surface',
);
}
});
test('previewPrimary matches colorScheme primary for each skin', () {
for (final skin in AppSkin.values) {
expect(
skin.previewPrimary,
skin.buildColorScheme().primary,
reason: '${skin.key} previewPrimary should match colorScheme.primary',
);
}
});
});
// ────────────────────────────────────────────────────────────────────────
// buildTheme() smoke tests — uses testWidgets to properly handle async
// Google Fonts font-load events (fonts fall back to system default).
group('AppSkin.buildTheme — smoke tests', () {
testWidgets('every skin builds a ThemeData without throwing',
(tester) async {
for (final skin in AppSkin.values) {
expect(
() => skin.buildTheme(),
returnsNormally,
reason: '${skin.key} buildTheme() must not throw',
);
}
// Drain async font-load events so they do not bleed into other tests.
await tester.pumpAndSettle();
});
testWidgets('every skin theme uses Material 3', (tester) async {
for (final skin in AppSkin.values) {
expect(skin.buildTheme().useMaterial3, isTrue,
reason: '${skin.key} must use Material 3');
}
await tester.pumpAndSettle();
});
testWidgets('scaffold background matches skin surface', (tester) async {
for (final skin in AppSkin.values) {
final theme = skin.buildTheme();
expect(
theme.scaffoldBackgroundColor,
theme.colorScheme.surface,
reason: '${skin.key} scaffold bg must match colorScheme.surface',
);
}
await tester.pumpAndSettle();
});
testWidgets('ElevatedButton background matches primary for each skin',
(tester) async {
for (final skin in AppSkin.values) {
final theme = skin.buildTheme();
final bgColor = theme.elevatedButtonTheme.style!.backgroundColor
?.resolve({});
expect(bgColor, theme.colorScheme.primary,
reason: '${skin.key} button bg should match primary');
}
await tester.pumpAndSettle();
});
});
}
+134
View File
@@ -0,0 +1,134 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kittie_mobile/core/theme/skin_provider.dart';
import 'package:kittie_mobile/core/theme/skin_type.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
setInitialSkin(SkinType.defaultSkin);
});
group('SkinNotifier — initial state', () {
test('defaults to defaultSkin', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(skinProvider), SkinType.defaultSkin);
});
test('uses initialSkin set via setInitialSkin', () {
setInitialSkin(SkinType.epic);
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(skinProvider), SkinType.epic);
});
});
group('SkinNotifier.setSkin', () {
test('updates state to the chosen skin', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(skinProvider.notifier).setSkin(SkinType.epic);
expect(container.read(skinProvider), SkinType.epic);
});
test('can switch through all 7 skins', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
for (final skin in SkinType.values) {
await container.read(skinProvider.notifier).setSkin(skin);
expect(container.read(skinProvider), skin,
reason: 'should have switched to ${skin.storageKey}');
}
});
test('persists skin key to SharedPreferences', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(skinProvider.notifier).setSkin(SkinType.senior);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('app_skin'), 'senior');
});
test('persists correct key for each skin', () async {
for (final skin in SkinType.values) {
SharedPreferences.setMockInitialValues({});
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(skinProvider.notifier).setSkin(skin);
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getString('app_skin'),
skin.storageKey,
reason: '${skin.storageKey} should be persisted',
);
}
});
});
group('setInitialSkin — pre-runApp skin loading', () {
test('restores persisted skin via setInitialSkin', () {
setInitialSkin(SkinTypeExtension.fromStorageKey('future'));
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(skinProvider), SkinType.future);
});
test('stays at defaultSkin when no value is persisted', () {
setInitialSkin(SkinTypeExtension.fromStorageKey(''));
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(skinProvider), SkinType.defaultSkin);
});
test('falls back to defaultSkin for unknown persisted key', () {
setInitialSkin(SkinTypeExtension.fromStorageKey('nonexistent'));
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(skinProvider), SkinType.defaultSkin);
});
test('can restore all 7 skins', () {
for (final skin in SkinType.values) {
setInitialSkin(SkinTypeExtension.fromStorageKey(skin.storageKey));
final container = ProviderContainer();
addTearDown(container.dispose);
expect(
container.read(skinProvider),
skin,
reason: '${skin.storageKey} should be restored',
);
}
});
});
group('SkinNotifier — round-trip persistence', () {
test('setSkin then reading from prefs restores same skin', () async {
final container1 = ProviderContainer();
await container1.read(skinProvider.notifier).setSkin(SkinType.kids);
container1.dispose();
final prefs = await SharedPreferences.getInstance();
final key = prefs.getString('app_skin')!;
setInitialSkin(SkinTypeExtension.fromStorageKey(key));
final container2 = ProviderContainer();
addTearDown(container2.dispose);
expect(container2.read(skinProvider), SkinType.kids);
});
});
}
+131
View File
@@ -0,0 +1,131 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:kittie_mobile/core/i18n/i18n_validator.dart';
import 'package:kittie_mobile/core/i18n/translation_service.dart';
void main() {
group('I18n JSON files', () {
late String enJson;
late String csJson;
late Map<String, dynamic> en;
late Map<String, dynamic> cs;
setUpAll(() {
enJson = File('assets/i18n/en.json').readAsStringSync();
csJson = File('assets/i18n/cs.json').readAsStringSync();
en = json.decode(enJson) as Map<String, dynamic>;
cs = json.decode(csJson) as Map<String, dynamic>;
});
test('en.json is valid', () {
final result = I18nValidator.validateTranslationFile(enJson);
expect(result.isValid, isTrue, reason: result.errors.join('\n'));
});
test('cs.json is valid', () {
final result = I18nValidator.validateTranslationFile(csJson);
expect(result.isValid, isTrue, reason: result.errors.join('\n'));
});
test('cs.json has all keys from en.json', () {
final comparison = I18nValidator.compareTranslations(en, cs);
expect(comparison.missingKeys, isEmpty,
reason: 'Missing keys: ${comparison.missingKeys.join(', ')}');
});
test('cs.json has no extra keys', () {
final comparison = I18nValidator.compareTranslations(en, cs);
expect(comparison.extraKeys, isEmpty,
reason: 'Extra keys: ${comparison.extraKeys.join(', ')}');
});
test('cs.json has no empty values', () {
final comparison = I18nValidator.compareTranslations(en, cs);
expect(comparison.emptyValues, isEmpty,
reason: 'Empty values: ${comparison.emptyValues.join(', ')}');
});
test('cs.json params match en.json params', () {
final comparison = I18nValidator.compareTranslations(en, cs);
expect(comparison.mismatchedParams, isEmpty,
reason: 'Mismatched params: ${comparison.mismatchedParams.join(', ')}');
});
});
group('Parameter substitution', () {
test('normal substitution', () {
final result = TranslationService.substituteParams(
'Hello {name}', {'name': 'World'});
expect(result, 'Hello World');
});
test('missing param leaves placeholder', () {
final result = TranslationService.substituteParams('Hello {name}', {});
expect(result, 'Hello {name}');
});
test('unclosed bracket appends value at end', () {
final result = TranslationService.substituteParams(
'Error: {error', {'error': 'timeout'});
expect(result, 'Error: {error timeout');
});
test('multiple params with different word order', () {
final result = TranslationService.substituteParams(
'Dokončeno {count} úkolů z {total}',
{'count': '5', 'total': '10'});
expect(result, 'Dokončeno 5 úkolů z 10');
});
test('EN word order', () {
final result = TranslationService.substituteParams(
'{count} tasks completed out of {total}',
{'count': '5', 'total': '10'});
expect(result, '5 tasks completed out of 10');
});
test('unknown param name in template left as-is', () {
final result = TranslationService.substituteParams(
'Value: {unknownParam}', {'otherParam': 'x'});
expect(result, 'Value: {unknownParam}');
});
});
group('I18nValidator', () {
test('detects invalid JSON', () {
final result =
I18nValidator.validateTranslationFile('not valid json {{{');
expect(result.isValid, isFalse);
});
test('detects missing _meta', () {
final result = I18nValidator.validateTranslationFile(
'{"key": "value"}');
expect(result.errors, contains('Missing _meta object'));
});
test('detects non-string value', () {
final result = I18nValidator.validateTranslationFile(
'{"_meta": {"language":"EN","locale":"en","version":"1.0","author":"A"}, "key": 123}');
expect(result.errors.any((e) => e.contains('non-string')), isTrue);
});
test('detects unclosed placeholder', () {
final result = I18nValidator.validateTranslationFile(
'{"_meta": {"language":"EN","locale":"en","version":"1.0","author":"A"}, "key": "Error: {error"}');
expect(result.errors.any((e) => e.contains('unclosed')), isTrue);
});
test('detects invalid param name', () {
final result = I18nValidator.validateTranslationFile(
'{"_meta": {"language":"EN","locale":"en","version":"1.0","author":"A"}, "key": "Error: {my param}"}');
expect(result.errors.any((e) => e.contains('invalid param')), isTrue);
});
test('extractParams finds all params', () {
final params = I18nValidator.extractParams('{name} has {count} items');
expect(params, {'name', 'count'});
});
});
}
+19 -2
View File
@@ -2,14 +2,31 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kittie_mobile/domain/entities/user_entity.dart';
import 'package:kittie_mobile/features/tasks/providers/task_providers.dart';
import 'package:kittie_mobile/main.dart';
void main() {
testWidgets('KittieApp smoke test', (WidgetTester tester) async {
final now = DateTime.now();
await tester.pumpWidget(
const ProviderScope(child: KittieApp()),
ProviderScope(
overrides: [
currentUserProvider.overrideWith(
(ref) async => UserEntity(
id: 'test-1',
deviceId: 'test-device',
name: 'Test',
createdAt: now,
updatedAt: now,
),
),
tasksProvider.overrideWith((ref) => Stream.value([])),
],
child: const KittieApp(),
),
);
await tester.pump();
await tester.pumpAndSettle();
// App renders without crashing
expect(find.byType(MaterialApp), findsOneWidget);