Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab0acf60b4 | |||
| 17b1171e9b | |||
| 303a62b95e | |||
| 5fa10a13f0 | |||
| eb64c0f60a | |||
| 55be1e7e4e | |||
| fd30dfb07f | |||
| 326a9dbcee | |||
| 6e0a7e9760 | |||
| 214b697145 | |||
| 0d0d518678 | |||
| c8d69f7b9b | |||
| be75f129c9 | |||
| b4513f78d7 | |||
| f15d1985aa | |||
| 7b8af7a101 | |||
| 0f4d016ce3 | |||
| 9a0f44288a | |||
| ef67280298 |
+4
-1
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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+0000–U+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
|
||||
@@ -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.
|
||||
@@ -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 = "../.."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]'), '');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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')),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -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é',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.0–1.0).
|
||||
/// Alternatively, pass [energyLevel] (1–3) 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (Mon–Sun) 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -56,3 +56,4 @@ flutter:
|
||||
assets:
|
||||
- assets/animations/
|
||||
- assets/fonts/
|
||||
- assets/i18n/
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user