From 9f32c5e07dffae5159a0cffd0b968c3bd9f6ac3b Mon Sep 17 00:00:00 2001 From: Omar Tosca Date: Sun, 8 Mar 2026 01:26:26 -0600 Subject: [PATCH 1/5] [Docs] Update documentation to reflect completed development state - Rewrite README.md: update project status from pre-development to Beta, add gamification/notifications/Design System v2 features, add Build & Run instructions, complete tech stack table, update roadmap and contributions sections, define MIT license - Update CLAUDE.md: remove outdated pre-development next steps, add Room v6 schema table (12 tables), update feature list with all implemented extras, update project structure tree, add current status and upcoming release steps - Create AGENTS.md: portable AI agent guide with naming conventions, architecture overview, DB schema, module map, testing patterns, and what to avoid - Create .claude/rules/: three focused rule files for agents (naming.md, architecture.md, testing.md) with code examples --- .claude/rules/architecture.md | 120 +++++++++++++++++++ .claude/rules/naming.md | 42 +++++++ .claude/rules/testing.md | 127 ++++++++++++++++++++ AGENTS.md | 188 +++++++++++++++++++++++++++++ CLAUDE.md | 184 +++++++++++++++++------------ README.md | 216 ++++++++++++++++++++-------------- 6 files changed, 714 insertions(+), 163 deletions(-) create mode 100644 .claude/rules/architecture.md create mode 100644 .claude/rules/naming.md create mode 100644 .claude/rules/testing.md create mode 100644 AGENTS.md diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md new file mode 100644 index 0000000..a92ea98 --- /dev/null +++ b/.claude/rules/architecture.md @@ -0,0 +1,120 @@ +# Architecture Rules + +## Layer Boundaries + +The project follows Clean Architecture with three layers. Dependencies flow **inward only**: + +``` +presentation → domain ← data +``` + +- `presentation` imports `domain` — never imports `data` directly +- `data` implements `domain` interfaces +- `domain` has zero Android framework dependencies (pure Kotlin) + +### What goes where + +| Layer | Contains | Must NOT contain | +|-------|----------|-----------------| +| `presentation` | ViewModels, Compose screens, components, Navigation | Room entities, DAOs, direct DB access | +| `domain` | Models (`BlockingProfile`), repository interfaces, business managers | Room annotations, Android Context, implementation details | +| `data` | Room entities, DAOs, repository implementations, services, NFC/QR managers | UI logic, ViewModel, Compose | + +## Models vs Entities + +Domain models and Room entities are **separate classes**. + +```kotlin +// domain/blocking/BlockingProfile.kt — domain model +data class BlockingProfile( + val id: String, + val name: String, + val blockedApps: List +) + +// data/local/entity/BlockingProfileEntity.kt — Room entity +@Entity(tableName = "blocking_profiles") +data class BlockingProfileEntity( + @PrimaryKey val id: String, + val name: String, + val blockedAppsJson: String // serialized +) +``` + +Map between them in the repository implementation — never expose entities outside `data`. + +## ViewModels + +- Annotate with `@HiltViewModel` and inject via constructor +- Expose state as `StateFlow` — never `LiveData` or raw mutable state +- Collect from repositories with `viewModelScope` +- One `UiState` data class per ViewModel + +```kotlin +@HiltViewModel +class ProfilesViewModel @Inject constructor( + private val profileRepository: ProfileRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(ProfilesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() +} +``` + +## Repository Pattern + +Domain layer defines the interface: + +```kotlin +// domain/blocking/ProfileRepository.kt +interface ProfileRepository { + fun getProfiles(): Flow> + suspend fun saveProfile(profile: BlockingProfile) + suspend fun deleteProfile(id: String) +} +``` + +Data layer implements it: + +```kotlin +// data/blocking/ProfileRepositoryImpl.kt +class ProfileRepositoryImpl @Inject constructor( + private val dao: BlockingProfileDao +) : ProfileRepository { ... } +``` + +Hilt binds them: + +```kotlin +@Binds +abstract fun bindProfileRepository(impl: ProfileRepositoryImpl): ProfileRepository +``` + +## Dependency Injection + +- **Always** use Hilt — never instantiate repositories, managers, or DAOs manually +- Inject via constructor (`@Inject constructor`) +- Use `@Singleton` for repositories and managers +- Use `@ActivityRetainedScoped` or `@ViewModelScoped` where appropriate + +## Room + +- DAOs return `Flow` for observable queries +- Use `suspend fun` for one-shot write operations +- Complex queries use `@Query` with explicit SQL — avoid ORM magic +- Never access the database on the main thread +- Every schema change requires a migration — never use `fallbackToDestructiveMigration` in production + +## Services + +`BlockingService` runs as a foreground service. Keep it focused on monitoring — delegate business logic to domain managers. + +## Screens + +Each screen corresponds to one Composable function and one ViewModel: + +``` +presentation/ui/screens/profiles/ + ProfilesScreen.kt ← @Composable, collects from ViewModel + ProfilesViewModel.kt + ProfilesUiState.kt ← data class (can be nested in ViewModel) +``` diff --git a/.claude/rules/naming.md b/.claude/rules/naming.md new file mode 100644 index 0000000..9ca41cd --- /dev/null +++ b/.claude/rules/naming.md @@ -0,0 +1,42 @@ +# Naming Conventions + +## Kotlin Code — English only + +- **Classes:** `PascalCase` → `BlockingProfile`, `NfcTagManager`, `ProfileRepository` +- **Functions & variables:** `camelCase` → `startBlocking`, `profileId`, `isStrictMode` +- **Constants:** `SCREAMING_SNAKE_CASE` → `MAX_BLOCKED_APPS`, `NFC_TAG_UID` +- **Packages:** `lowercase` → `com.umbral.nfc`, `com.umbral.blocking`, `com.umbral.data` + +## Database — English only + +- **Table names:** `snake_case` → `blocking_profiles`, `nfc_tags`, `blocking_events` +- **Column names:** `snake_case` → `created_at`, `is_whitelisted`, `profile_id` +- **NEVER** use Spanish in database identifiers + +## UI Strings — Spanish only + +All user-visible text must be in Spanish via `strings.xml`. Never hardcode text. + +```xml + +Guardar +Nombre del perfil +Tu dispositivo no soporta NFC + + + + +``` + +In Compose: always use `stringResource(R.string.key)`. + +## Files + +- **Kotlin files:** match the primary class name → `BlockingProfile.kt`, `NfcTagDao.kt` +- **Layout/resource files:** `snake_case` → `activity_main.xml`, `ic_nfc_tag.xml` +- **Test files:** mirror the tested class → `BlockingProfileRepositoryTest.kt` + +## Hilt Modules + +- Suffix with `Module` → `DatabaseModule`, `RepositoryModule`, `ManagerModule` +- Bind interfaces with `@Binds` in abstract modules, provide instances with `@Provides` diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..a3e1a38 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,127 @@ +# Testing Standards + +## Stack + +| Tool | Purpose | +|------|---------| +| JUnit 4 | Test runner and assertions | +| MockK | Mocking Kotlin classes and coroutines | +| Turbine | Testing `Flow` emissions | +| Robolectric | Android framework in unit tests (no device needed) | +| JaCoCo | Code coverage (configured in `app/jacoco.gradle`) | + +## File Locations + +``` +app/src/test/java/com/umbral/ ← unit tests (JVM + Robolectric) +app/src/androidTest/java/com/umbral/ ← instrumented tests (device/emulator) +``` + +Mirror the main source structure: +- `data/local/dao/` tests go in `test/data/local/dao/` +- `presentation/viewmodel/` tests go in `test/presentation/viewmodel/` + +## Coverage Expectations + +- **Every DAO** must have integration tests (use `@RunWith(RobolectricTestRunner::class)` with an in-memory Room DB) +- **Every ViewModel** must have state-transition tests +- **Repositories** must test happy path + error cases +- **Managers** (NfcManager, BlockingManager, etc.) must test core logic + +## Writing Unit Tests + +### ViewModel tests + +```kotlin +@Test +fun `activating a profile updates active state`() = runTest { + val repo = mockk() + every { repo.getProfiles() } returns flowOf(listOf(fakeProfile)) + coEvery { repo.activateProfile(any()) } just Runs + + val viewModel = ProfilesViewModel(repo) + viewModel.activateProfile("profile-1") + + viewModel.uiState.test { + val state = awaitItem() + assertEquals("profile-1", state.activeProfileId) + } +} +``` + +### Flow tests (Turbine) + +```kotlin +viewModel.uiState.test { + assertEquals(HomeUiState(), awaitItem()) // initial state + viewModel.loadData() + val loaded = awaitItem() + assertFalse(loaded.isLoading) + cancelAndIgnoreRemainingEvents() +} +``` + +### DAO integration tests + +```kotlin +@RunWith(RobolectricTestRunner::class) +class BlockingProfileDaoTest { + private lateinit var db: UmbralDatabase + private lateinit var dao: BlockingProfileDao + + @Before + fun setup() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + UmbralDatabase::class.java + ).allowMainThreadQueries().build() + dao = db.blockingProfileDao() + } + + @After + fun teardown() = db.close() + + @Test + fun `insert and retrieve profile`() = runTest { + dao.insert(fakeProfileEntity) + val result = dao.getAll().first() + assertEquals(1, result.size) + } +} +``` + +### Mocking with MockK + +```kotlin +val repo = mockk() +every { repo.getProfiles() } returns flowOf(emptyList()) +coEvery { repo.saveProfile(any()) } just Runs +verify { repo.getProfiles() } +coVerify { repo.saveProfile(any()) } +``` + +## What to Test + +- State transitions in ViewModels (loading → success → error) +- Repository methods: correct DAO calls, correct mapping from entity to domain model +- Manager logic: blocking conditions, NFC tag validation, timer behavior +- Edge cases: empty lists, null values, permission denied scenarios + +## What Not to Test + +- Compose UI layout (covered by screenshot tests if needed) +- Third-party library internals (Room, Hilt, etc.) +- Android system behavior (NFC hardware, UsageStats APIs) — mock these + +## Running Tests + +```bash +# All unit tests +./gradlew test + +# With coverage report +./gradlew jacocoTestReport + +# Specific test class +./gradlew test --tests "com.umbral.presentation.viewmodel.HomeViewModelTest" +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..28b2f99 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,188 @@ +# Umbral — Agent Guidelines + +This file provides context and conventions for AI agents (Claude, GitHub Copilot, Gemini CLI, Cursor, etc.) working on the Umbral codebase. + +For Claude-specific project instructions, see [CLAUDE.md](CLAUDE.md). + +--- + +## Project Overview + +**Umbral** is an Android app that blocks social media apps using NFC tags as a physical trigger. When a user taps their phone on a NFC tag placed at their door, the app blocks a configurable list of apps. Tapping again unlocks them. + +**Status:** Development complete — Beta phase +**Architecture:** Clean Architecture + MVVM +**Language:** Kotlin 2.1 +**Min SDK:** 26 (Android 8.0) + +--- + +## Critical Naming Rules + +### Kotlin Code — English only +- Classes: `PascalCase` (e.g., `BlockingProfile`, `NfcTagManager`) +- Functions & variables: `camelCase` (e.g., `startBlocking`, `profileId`) +- Packages: `lowercase` (e.g., `com.umbral.nfc`, `com.umbral.blocking`) + +### Database — English only +- Table names: `snake_case` (e.g., `blocking_profiles`, `nfc_tags`) +- Column names: `snake_case` (e.g., `created_at`, `is_whitelisted`) +- **Never use Spanish in database identifiers** + +### UI Strings — Spanish only +- All user-visible text must be in Spanish +- Always use `strings.xml` — never hardcode text in Compose + - ✅ `stringResource(R.string.btn_save)` → "Guardar" + - ❌ `Text("Save")` or `Text("Guardar")` + +--- + +## Architecture + +The project follows Clean Architecture with three main layers: + +``` +presentation/ ← ViewModels, Compose screens, components + ↓ (consumes) +domain/ ← Models, repository interfaces, business logic + ↓ (implements) +data/ ← Room entities, DAOs, repository implementations, services +``` + +**Key rules:** +- `presentation` never imports `data` directly — only `domain` +- `data` entities (e.g., `BlockingProfileEntity`) must not leak into `domain` or `presentation` +- `domain` uses its own models (e.g., `BlockingProfile`) separate from Room entities +- All dependency injection is via Hilt — no manual instantiation of repositories or managers + +--- + +## Key Patterns + +### ViewModel state +```kotlin +data class HomeUiState( + val profiles: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null +) + +class HomeViewModel @HiltViewModel constructor( + private val profileRepository: ProfileRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() +} +``` + +### Repository pattern +- Domain layer defines the interface (e.g., `ProfileRepository`) +- Data layer implements it (e.g., `ProfileRepositoryImpl`) +- Hilt binds the interface to the implementation in a `@Module` + +### Room DAO +- DAOs return `Flow` for observable queries +- Use `suspend fun` for one-shot operations (insert, update, delete) +- Complex queries use `@Query` with explicit SQL — no raw SQLite + +--- + +## Database (Room v6) + +**File:** `app/src/main/java/com/umbral/data/local/database/UmbralDatabase.kt` + +### Existing tables +| Table | Entity Class | Purpose | +|-------|-------------|---------| +| `blocking_profiles` | `BlockingProfileEntity` | User blocking profiles | +| `blocked_apps` | `BlockedAppEntity` | Apps blocked per profile | +| `nfc_tags` | `NfcTagEntity` | Registered NFC tags | +| `blocking_sessions` | `BlockingSessionEntity` | Active blocking sessions | +| `blocked_attempts` | `BlockedAttemptEntity` | Access attempt log | +| `blocking_events` | `BlockingEventEntity` | Unified event log | +| `companion` | `CompanionEntity` | Gamification companion state | +| `locations` | `LocationEntity` | Gamification map locations | +| `progress` | `ProgressEntity` | Player progression | +| `achievements` | `AchievementEntity` | Unlocked achievements | +| `decorations` | `DecorationEntity` | Unlocked decorations | +| `blocked_notifications` | `BlockedNotificationEntity` | Blocked notification log | + +### Adding a migration +1. Increment the version constant in `UmbralDatabase.kt` +2. Add the migration script in `DatabaseMigrations.kt` +3. Add the migration to the `Room.databaseBuilder` call +4. Export schema: schemas are saved to `app/schemas/` + +--- + +## Module Map + +| Package | Purpose | +|---------|---------| +| `com.umbral.data` | DAOs, entities, repositories, services, NFC, QR | +| `com.umbral.domain` | Models, repository interfaces, business managers | +| `com.umbral.presentation` | ViewModels, 8 screens, 20+ Compose components | +| `com.umbral.expedition` | Gamification system (achievements, companions, map) | +| `com.umbral.glance` | 4 Jetpack Glance widgets | +| `com.umbral.notifications` | Blocked notification tracking | +| `com.umbral.di` | Hilt modules | + +--- + +## Testing + +- **Framework:** JUnit + MockK + Turbine + Robolectric +- **Unit tests:** `app/src/test/java/com/umbral/` +- **Instrumented tests:** `app/src/androidTest/java/com/umbral/` +- **Coverage:** JaCoCo configured (`app/jacoco.gradle`) + +### Conventions +- Mock dependencies with `MockK` — avoid real implementations in unit tests +- Use `Turbine` to test `Flow` emissions +- Use `Robolectric` when Android framework is needed without a device +- Every DAO should have integration tests +- Every ViewModel should have state-transition tests + +```kotlin +@Test +fun `activate profile emits active state`() = runTest { + val viewModel = HomeViewModel(fakeRepository) + viewModel.activateProfile(profileId = "abc") + viewModel.uiState.test { + val state = awaitItem() + assertTrue(state.activeProfileId == "abc") + } +} +``` + +--- + +## What to Avoid + +- **Hardcoded strings** in Compose or XML — always use `strings.xml` +- **Raw SQLite** — use Room DAOs only +- **Business logic in ViewModels** — keep it in domain layer managers/use cases +- **Accessing `data` layer from `presentation`** — respect layer boundaries +- **Skipping migrations** — every schema change needs a migration script +- **Spanish identifiers** in code or database + +--- + +## CI/CD + +- **GitHub Actions:** `.github/workflows/android.yml` and `test.yml` +- Runs on push/PR to `main` and `develop` +- Pipeline: unit tests → lint → build debug APK +- **Firebase App Distribution** configured for release builds + +## Commit Format + +``` +[Feat] Add NFC tag reading module +[Fix] Resolve crash on permission denial +[Docs] Update architecture decisions +[Test] Add ViewModel state transition tests +[Refactor] Extract blocking logic to domain layer +``` + +No `Co-Authored-By` or `Generated with` footers. diff --git a/CLAUDE.md b/CLAUDE.md index 535a5e8..09b9312 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,12 +7,13 @@ **Stack:** Kotlin + Jetpack Compose + Room + Hilt **Arquitectura:** Clean Architecture + MVVM **Metodología:** Oden (Documentation-First Development) +**Estado:** Desarrollo completo — fase Beta / distribución --- ## Naming Conventions CRÍTICAS -### Database & TypeScript (si se agrega backend futuro) +### Database - **Nombres de tablas:** INGLÉS (ej: `blocking_profiles`, `nfc_tags`) - **Nombres de columnas:** INGLÉS snake_case (ej: `created_at`, `is_whitelisted`) - **NUNCA:** Nombres en español en DB @@ -44,27 +45,6 @@ --- -## Comandos Oden Disponibles - -### Pre-Desarrollo (ESTADO ACTUAL) -- `/oden:architect` - **SIGUIENTE PASO** - Completar arquitectura y schema detallado -- `/oden:analyze` - Análisis competitivo profundo -- `/oden:spec [modulo]` - Especificaciones detalladas por módulo -- `/oden:plan` - Plan de implementación semana por semana -- `/oden:checklist` - Verificar que todo esté listo antes de codificar - -### Durante Desarrollo -- `/oden:daily` - Registrar progreso diario -- `/oden:test` - Testing strategy -- `/oden:review` - Code review -- `/oden:debug` - Debugging - -### Gestión -- `/oden:status` - Ver estado del proyecto -- `/oden:help` - Ver todos los comandos - ---- - ## Decisiones Técnicas Clave ### 1. Android Only (por ahora) @@ -74,7 +54,7 @@ ### 2. 100% Local-First - Sin backend en V1 -- Room Database para persistencia +- Room Database para persistencia (versión 6) - DataStore para preferences - Funciona completamente offline @@ -88,14 +68,15 @@ - Requiere Permission Declaration Form - Mayor escrutinio de Google -### 4. Scope - Modo Completo -- 12-16 semanas de desarrollo -- Todas las features desde V1 -- Producto enterprise-ready +### 4. Build Config +- `compileSdk`: 35 (Android 15) +- `minSdk`: 26 (Android 8.0+) +- `targetSdk`: 35 +- Kotlin: 2.1.0, AGP: 8.7.3, Java: 17 --- -## Features V1 (Completo) +## Features V1 (Implementadas) ### Core ✅ - [x] NFC tag reading/writing (NTAG213/215/216) @@ -106,11 +87,47 @@ ### Advanced ✅ - [x] Timer-based auto-unblock - [x] QR code alternative to NFC -- [x] Widgets (estado, quick toggle) -- [x] Usage statistics y gráficas +- [x] Widgets (4 tipos: Status, Quick Toggle, Stats, Streak) +- [x] Usage statistics con gráficas (Vico) - [x] Physical unlock requirement (optional) - [x] Focus Mode integration -- [x] Shortcuts & Quick Settings +- [x] Shortcuts & Quick Settings tile +- [x] 6-step onboarding flow con permisos +- [x] Foreground service para monitoreo en background +- [x] Blocking overlay screen + +### Extras ✅ +- [x] Gamification system (Expedition) — logros, compañeros, progresión, mapa +- [x] Notifications module — seguimiento de notificaciones bloqueadas +- [x] Design System v2 — 20+ componentes Material 3 con tema Umbral +- [x] Blocking event log unificado + +--- + +## Base de Datos (Room v6) + +**Versión actual:** 6 +**Schemas en:** `app/schemas/` +**Migraciones activas:** v2→3, v3→4, v4→5, v5→6 + +### Tablas + +| Tabla | Propósito | +|-------|-----------| +| `blocking_profiles` | Perfiles de bloqueo del usuario | +| `blocked_apps` | Apps bloqueadas por perfil | +| `nfc_tags` | Tags NFC registrados | +| `blocking_sessions` | Sesiones de bloqueo activas | +| `blocked_attempts` | Log de intentos de acceso | +| `blocking_events` | Evento unificado (BLOCK_STARTED/ENDED, APP_ATTEMPT) | +| `companion` | Estado y evolución del compañero (gamificación) | +| `locations` | Mapa de ubicaciones (gamificación) | +| `progress` | Progresión del jugador (gamificación) | +| `achievements` | Logros desbloqueados (gamificación) | +| `decorations` | Decoraciones desbloqueadas (gamificación) | +| `blocked_notifications` | Notificaciones bloqueadas | + +**Al agregar una migración:** incrementar la versión en `UmbralDatabase.kt` y añadir el script en `DatabaseMigrations.kt`. --- @@ -119,24 +136,37 @@ ``` umbral/ ├── docs/ -│ ├── guides/ # Guías de usuario/desarrollo +│ ├── guides/ │ ├── reference/ -│ │ ├── technical-decisions.md # ✅ Creado -│ │ ├── competitive-analysis.md # 🔄 Template -│ │ ├── implementation-plan.md # 🔄 Template -│ │ └── modules/ # Specs por módulo +│ │ ├── technical-decisions.md +│ │ ├── competitive-analysis.md +│ │ ├── implementation-plan.md +│ │ ├── user-personas.md +│ │ ├── user-stories.md +│ │ └── modules/ # Specs por módulo │ ├── development/ -│ │ ├── current/ # Features en progreso -│ │ └── completed/ # Features completadas -│ ├── archived/ # Docs obsoletos -│ └── temp/ # Temporal (max 5 archivos) +│ │ ├── current/ # Features en progreso +│ │ └── completed/ # Features completadas +│ └── archived/ ├── .claude/ -│ ├── commands/ # Custom commands -│ ├── scripts/ # Automation scripts -│ ├── rules/ # Project-specific rules -│ └── context/ # Context for agents -├── app/ # Android app (por crear) -└── CLAUDE.md # Este archivo +│ ├── epics/ # Tracking de épicas e issues +│ ├── prds/ # Product Requirements Documents +│ └── rules/ # Reglas del proyecto para agentes +├── app/ +│ ├── schemas/ # Room migration schemas (v1-v6) +│ └── src/main/java/com/umbral/ +│ ├── data/ # DAOs, repositorios, entidades +│ ├── domain/ # Modelos e interfaces +│ ├── presentation/ # ViewModels, pantallas, componentes +│ ├── expedition/ # Módulo de gamificación +│ ├── glance/ # Widgets (Jetpack Glance) +│ ├── notifications/ # Módulo de notificaciones +│ └── di/ # Módulos Hilt +├── CLAUDE.md +├── AGENTS.md +├── CONTRIBUTING.md +├── README.md +└── TESTS_IMPLEMENTED.md ``` --- @@ -168,44 +198,54 @@ umbral/ ### Commits - **NO** incluir "Generated with Claude Code" ni "Co-Authored-By: Claude" - **Formato:** `[Type] Brief description` -- **Ejemplos:** - - `[Feat] Add NFC tag reading module` - - `[Fix] Resolve crash on permission denial` - - `[Docs] Update architecture decisions` +- **Tipos:** Feat / Fix / Docs / Refactor / Test / Chore ### Branches -- `main` - Producción estable -- `develop` - Desarrollo activo -- `feature/nombre` - Features individuales +- `main` — Producción estable (protegida, requiere PR + aprobación) +- `develop` — Desarrollo activo +- `feature/nombre` — Features individuales +- `claude/nombre` — Ramas generadas por agentes + +--- + +## Comandos Oden Disponibles + +### Durante Desarrollo +- `/oden:daily` - Registrar progreso diario +- `/oden:test` - Testing strategy +- `/oden:review` - Code review +- `/oden:debug` - Debugging + +### Gestión +- `/oden:status` - Ver estado del proyecto +- `/oden:help` - Ver todos los comandos --- -## Próximos Pasos Inmediatos +## Estado Actual y Próximos Pasos -1. **AHORA:** Ejecutar `/oden:architect` - - Completar arquitectura detallada - - Schema de Room DB completo - - Estructura de carpetas del código - - Patrones de diseño +**Fase:** Beta — preparando release a Google Play y F-Droid -2. **Después:** Ejecutar `/oden:analyze` - - Analizar Foqos, Brick, Unpluq en detalle - - User personas - - Priorización de features +### Tests +- 150+ unit tests (MockK + Turbine + Robolectric) +- Tests de DAOs, repositorios, ViewModels, managers +- JaCoCo configurado para coverage -3. **Luego:** Specs por módulo con `/oden:spec` - - `nfc-module` (800-1200 líneas) - - `app-blocking-module` (800-1200 líneas) - - `profiles-module` (800-1200 líneas) - - `ui-module` (800-1200 líneas) +### CI/CD +- GitHub Actions: build + tests en push/PR a `main` y `develop` +- Firebase App Distribution configurado para builds de release -4. **Finalmente:** `/oden:plan` - Plan semana por semana +### Próximos pasos +1. Beta testing con usuarios reales +2. Correcciones basadas en feedback +3. Preparar listing de Google Play Store +4. Release en F-Droid --- ## Recursos de Referencia -- [Foqos GitHub](https://github.com/awaseem/foqos) - iOS reference +- [Foqos GitHub](https://github.com/awaseem/foqos) — iOS reference - [Android NFC Guide](https://developer.android.com/develop/connectivity/nfc/nfc) - [Jetpack Compose](https://developer.android.com/jetpack/compose) - [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) @@ -213,5 +253,5 @@ umbral/ --- -**Creado:** 2026-01-03T01:53:14Z -**Última actualización:** 2026-01-03T01:53:14Z +**Creado:** 2026-01-03 +**Última actualización:** 2026-03-08 diff --git a/README.md b/README.md index b643df6..2744e92 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ --- -## 🎯 Concepto +## Concepto El nombre "Umbral" proviene del concepto filosófico griego del **metaxy (μεταξύ)** - el espacio liminal entre dos estados. Representa el momento consciente de transición al cruzar el umbral de tu casa, donde eliges tu estado digital. @@ -18,67 +18,107 @@ El nombre "Umbral" proviene del concepto filosófico griego del **metaxy (μετ --- -## ✨ Features (V1 - Completo) +## Features (V1) ### Core -- 📱 **NFC tag reading/writing** - Compatible con NTAG213/215/216 -- 🚫 **App blocking** - UsageStatsManager integration -- ✅ **Whitelist** - Apps esenciales (banco, sistema, etc.) -- 📋 **Multiple profiles** - Diferentes perfiles para diferentes situaciones +- **NFC tag reading/writing** — Compatible con NTAG213/215/216 +- **App blocking** — Integración con UsageStatsManager +- **Whitelist** — Apps esenciales siempre accesibles (banco, sistema, etc.) +- **Multiple profiles** — Perfiles distintos para diferentes situaciones ### Advanced -- ⏱️ **Timer auto-unlock** - Desbloqueo automático después de X tiempo -- 📷 **QR alternative** - Fallback si NFC no disponible -- 🎨 **Widgets** - Estado, quick toggle, countdown -- 📊 **Statistics** - Tiempo bloqueado, apps más bloqueadas, rachas -- 🔒 **Physical unlock** - Solo tag específico puede desbloquear (opcional) -- 🎯 **Focus Mode** - Integración con Digital Wellbeing -- ⚡ **Quick Settings** - Toggle desde panel rápido +- **Timer auto-unlock** — Desbloqueo automático después de X tiempo +- **QR alternative** — Fallback si NFC no disponible +- **Widgets** — 4 tipos: Status, Quick Toggle, Stats, Streak +- **Statistics** — Tiempo bloqueado, apps más bloqueadas, rachas con gráficas (Vico) +- **Physical unlock** — Solo el tag específico puede desbloquear (opcional) +- **Focus Mode** — Integración con Digital Wellbeing de Android +- **Quick Settings** — Toggle desde el panel rápido + +### Extras +- **Gamification (Expedition)** — Sistema de logros, compañeros y progresión +- **Notifications module** — Seguimiento de notificaciones bloqueadas +- **Design System v2** — Componentes Material 3 personalizados con tema Umbral --- -## 🛠️ Tech Stack - -- **Lenguaje:** Kotlin -- **UI:** Jetpack Compose (Material Design 3) -- **Arquitectura:** Clean Architecture + MVVM -- **Database:** Room (SQLite) -- **DI:** Hilt -- **Build:** Gradle (Kotlin DSL) +## Tech Stack + +| Capa | Tecnología | +|------|------------| +| Lenguaje | Kotlin 2.1 | +| UI | Jetpack Compose + Material Design 3 | +| Arquitectura | Clean Architecture + MVVM | +| Base de datos | Room 2.6 (SQLite) + DataStore | +| DI | Hilt 2.54 | +| Animaciones | Lottie 6.3 | +| Gráficas | Vico 2.0 | +| QR / Cámara | CameraX 1.4 + ML Kit Barcode | +| Widgets | Jetpack Glance 1.1 | +| Build | Gradle (Kotlin DSL) | +| Tests | JUnit + MockK + Turbine + Robolectric | --- -## 🚀 Estado del Proyecto - -**Fase actual:** 🟡 Pre-Desarrollo (Documentación) +## Estado del Proyecto -### Metodología: Documentation-First Development +**Fase actual:** Desarrollo completo — Beta -Seguimos la **Metodología Oden** donde documentamos y diseñamos COMPLETAMENTE antes de escribir código. - -**Progreso:** +### Progreso - [x] Inicialización del proyecto - [x] Technical decisions documentadas -- [ ] Arquitectura detallada (próximo paso) -- [ ] Análisis competitivo -- [ ] Especificaciones por módulo -- [ ] Plan de implementación -- [ ] Desarrollo (12-16 semanas) +- [x] Arquitectura detallada +- [x] Análisis competitivo +- [x] Especificaciones por módulo +- [x] Plan de implementación +- [x] Desarrollo (todos los features V1 implementados) +- [x] Testing (150+ unit tests) +- [x] Design System v2 +- [x] Gamification system (Expedition) +- [x] Firebase App Distribution configurado +- [ ] Release en Google Play Store +- [ ] Release en F-Droid + +### Base de Datos +- **Room versión:** 6 +- **Tablas:** 12 (perfiles, apps, NFC, sesiones, intentos, eventos, gamificación, notificaciones) +- **Migraciones:** v1 → v6 con scripts completos --- -## 📚 Documentación +## Build & Run + +**Requisitos:** +- Android Studio Hedgehog o superior +- JDK 17 +- Android SDK 35 -Ver [docs/README.md](docs/README.md) para documentación completa. +```bash +git clone https://github.com/omartosca/umbral.git +cd umbral +./gradlew assembleDebug +``` -**Documentos clave:** -- [Technical Decisions](docs/reference/technical-decisions.md) - Stack, arquitectura y decisiones -- [Competitive Analysis](docs/reference/competitive-analysis.md) - Análisis de mercado (pendiente) -- [Implementation Plan](docs/reference/implementation-plan.md) - Plan detallado (pendiente) +Para tests: + +```bash +./gradlew test +``` --- -## 🤝 Inspiración y Colaboración +## Documentación + +- [CONTRIBUTING.md](CONTRIBUTING.md) — Guía de contribución y workflow de branches +- [TESTS_IMPLEMENTED.md](TESTS_IMPLEMENTED.md) — Resumen de tests implementados +- [docs/README.md](docs/README.md) — Índice de documentación técnica completa +- [docs/reference/technical-decisions.md](docs/reference/technical-decisions.md) — Stack, arquitectura y decisiones +- [docs/reference/competitive-analysis.md](docs/reference/competitive-analysis.md) — Análisis de mercado +- [docs/reference/modules/](docs/reference/modules/) — Specs técnicas por módulo + +--- + +## Inspiración y Colaboración Umbral está inspirado en [**Foqos**](https://github.com/awaseem/foqos), una excelente app iOS open-source con funcionalidad similar. @@ -90,96 +130,90 @@ Umbral está inspirado en [**Foqos**](https://github.com/awaseem/foqos), una exc --- -## 🎨 Diferenciadores +## Diferenciadores vs **Foqos** (iOS open source): -- ✅ Plataforma Android -- ✅ UX más pulida y onboarding mejorado -- ✅ Mercado hispanohablante (UI en español) +- Plataforma Android nativa +- UI en español para el mercado hispanohablante +- Sistema de gamificación integrado vs **Brick** (iOS/Android comercial): -- ✅ 100% gratis y open source -- ✅ No requiere hardware propietario -- ✅ Tags NFC baratos de Amazon +- 100% gratis y open source +- No requiere hardware propietario +- Tags NFC baratos (desde $1 USD) vs **Unpluq** (iOS/Android comercial): -- ✅ Sin suscripción mensual -- ✅ Código abierto -- ✅ Privacidad total (100% local, sin cloud) +- Sin suscripción mensual +- Código abierto y auditable +- Privacidad total (100% local, sin cloud) --- -## 🔒 Privacidad +## Privacidad -- 🔐 **100% local** - Sin backend en V1 -- 🔐 **Sin tracking** - Cero analytics por defecto -- 🔐 **Open source** - Auditable por cualquiera -- 🔐 **Sin permisos innecesarios** - Solo lo estrictamente necesario +- **100% local** — Sin backend en V1 +- **Sin tracking** — Cero analytics por defecto +- **Open source** — Auditable por cualquiera +- **Sin permisos innecesarios** — Solo los estrictamente necesarios --- -## 📦 Distribución +## Distribución -**Planeada:** -- Google Play Store (primario) -- F-Droid (secundario, para usuarios privacy-focused) +- Google Play Store (primario) — *próximamente* +- F-Droid (secundario, usuarios privacy-focused) — *próximamente* --- -## 🗺️ Roadmap +## Roadmap -### V1.0 - Core (12-16 semanas) -Todas las features listadas arriba +### V1.0 — Core (completado) +Todos los features listados arriba implementados y testeados. -### V1.1 - Refinement (2 semanas) -Bug fixes y polish basado en feedback +### V1.1 — Polish (en progreso) +- Bug fixes basados en feedback beta +- Mejoras de UX en onboarding +- Optimizaciones de rendimiento -### V2.0 - Cloud Features (4-6 semanas) -- Supabase backend (opcional) -- Cloud sync de perfiles +### V2.0 — Cloud Features +- Backend Supabase (opcional, opt-in) +- Sync de perfiles entre dispositivos - Multi-device support -- Premium tier +- Tier premium -### V3.0 - Advanced (6-8 semanas) -- Website blocking -- Location-based triggers -- Scheduled blocking +### V3.0 — Advanced +- Bloqueo de sitios web +- Triggers por geolocalización +- Bloqueo programado por horario - Social features (accountability partner) --- -## 👥 Contribuciones +## Contribuciones -**¡Contributions welcome!** +¡Contribuciones bienvenidas! -Este proyecto está en fase de documentación. Una vez que empecemos desarrollo, publicaremos guías de contribución. +Lee [CONTRIBUTING.md](CONTRIBUTING.md) para el workflow de ramas, formato de commits y convenciones de código. En resumen: -Por ahora, si quieres ayudar: -- ⭐ Dale star al repo -- 💡 Sugiere features (Issues) -- 📖 Revisa la documentación y da feedback +- Forkea el repo y crea una rama `feature/nombre-feature` +- Escribe tests para tu código +- Abre un PR contra `develop` --- -## 📄 Licencia +## Licencia -[Pendiente definir - probablemente MIT] +MIT — ver [LICENSE](LICENSE) --- -## 🙏 Agradecimientos +## Agradecimientos -- [Foqos](https://github.com/awaseem/foqos) - Inspiración y referencia +- [Foqos](https://github.com/awaseem/foqos) — Inspiración y referencia iOS - Comunidad open source de Android -- Filósofos griegos por el concepto de metaxy 😄 - ---- - -## 📬 Contacto - -[Pendiente: agregar info de contacto] +- Filósofos griegos por el concepto de metaxy --- **Proyecto iniciado:** 2026-01-03 -**Filosofía:** Documentation-First Development (Metodología Oden) +**Última actualización:** 2026-03-08 From 92c1993448cc97b82961263b36da58928e7ff57f Mon Sep 17 00:00:00 2001 From: Omar Tosca Date: Sun, 8 Mar 2026 12:51:38 -0600 Subject: [PATCH 2/5] [Feat] Apply MD3 Dynamic Color to theme and core components - Theme.kt: enable dynamicDarkColorScheme/dynamicLightColorScheme on API 31+, keep sage teal palette as fallback for Android < 12 - UmbralTopBar: remove isSystemInDarkTheme + hardcoded color constants, use MaterialTheme.colorScheme.background - UmbralButton: replace hardcoded Color(0xFF151515) contentColor with MaterialTheme.colorScheme.onPrimary for Primary variant - UmbralCard: replace all hardcoded Dark/LightBackground* and Border* constants with MD3 colorScheme roles (surface, surfaceContainerHigh, outline, outlineVariant, primary) - UmbralBottomBar: replace deprecated Divider with HorizontalDivider --- .../ui/components/UmbralBottomBar.kt | 4 +-- .../ui/components/UmbralButton.kt | 6 ++-- .../presentation/ui/components/UmbralCard.kt | 35 ++++++------------- .../ui/components/UmbralTopBar.kt | 13 ++----- .../com/umbral/presentation/ui/theme/Theme.kt | 35 +++++++++++-------- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 7 files changed, 41 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/umbral/presentation/ui/components/UmbralBottomBar.kt b/app/src/main/java/com/umbral/presentation/ui/components/UmbralBottomBar.kt index b79d15d..31fe0ff 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/UmbralBottomBar.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/UmbralBottomBar.kt @@ -18,7 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -91,7 +91,7 @@ fun UmbralBottomBar( ) { Column(modifier = Modifier.fillMaxWidth()) { // Top border (1px) - Divider( + HorizontalDivider( modifier = Modifier.fillMaxWidth(), thickness = 1.dp, color = MaterialTheme.colorScheme.outline diff --git a/app/src/main/java/com/umbral/presentation/ui/components/UmbralButton.kt b/app/src/main/java/com/umbral/presentation/ui/components/UmbralButton.kt index 4473e72..70bbe47 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/UmbralButton.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/UmbralButton.kt @@ -126,8 +126,8 @@ fun UmbralButton( enabled = enabled, shape = MaterialTheme.shapes.small, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, // Sage teal - contentColor = Color(0xFF151515) // Dark text for contrast + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary ), interactionSource = interactionSource, contentPadding = PaddingValues( @@ -139,7 +139,7 @@ fun UmbralButton( text = text, loading = loading, leadingIcon = leadingIcon, - contentColor = Color(0xFF151515), + contentColor = MaterialTheme.colorScheme.onPrimary, textStyle = textStyle ) } diff --git a/app/src/main/java/com/umbral/presentation/ui/components/UmbralCard.kt b/app/src/main/java/com/umbral/presentation/ui/components/UmbralCard.kt index cfd8123..da8d417 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/UmbralCard.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/UmbralCard.kt @@ -25,22 +25,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.umbral.presentation.ui.theme.DarkAccentPrimary -import com.umbral.presentation.ui.theme.DarkBackgroundBase -import com.umbral.presentation.ui.theme.DarkBackgroundElevated -import com.umbral.presentation.ui.theme.DarkBackgroundSurface -import com.umbral.presentation.ui.theme.DarkBorderDefault -import com.umbral.presentation.ui.theme.DarkBorderFocus -import com.umbral.presentation.ui.theme.LightBackgroundBase -import com.umbral.presentation.ui.theme.LightBackgroundElevated -import com.umbral.presentation.ui.theme.LightBackgroundSurface -import com.umbral.presentation.ui.theme.LightBorderDefault -import com.umbral.presentation.ui.theme.LightBorderFocus import com.umbral.presentation.ui.theme.UmbralSpacing import com.umbral.presentation.ui.theme.UmbralTheme import com.umbral.presentation.ui.theme.surfaceColorAtElevation @@ -140,21 +128,20 @@ fun UmbralCard( shape: Shape = MaterialTheme.shapes.large, content: @Composable ColumnScope.() -> Unit ) { - val isDarkTheme = isSystemInDarkTheme() val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val isFocused by interactionSource.collectIsFocusedAsState() - // Background color based on variant and theme + // Background color based on variant val backgroundColor = when (variant) { - CardVariant.Default -> if (isDarkTheme) DarkBackgroundSurface else LightBackgroundSurface - CardVariant.Elevated -> if (isDarkTheme) DarkBackgroundElevated else LightBackgroundElevated - CardVariant.Outlined -> if (isDarkTheme) DarkBackgroundSurface else LightBackgroundSurface - CardVariant.Interactive -> if (isDarkTheme) DarkBackgroundSurface else LightBackgroundSurface + CardVariant.Default -> MaterialTheme.colorScheme.surface + CardVariant.Elevated -> MaterialTheme.colorScheme.surfaceContainerHigh + CardVariant.Outlined -> MaterialTheme.colorScheme.surface + CardVariant.Interactive -> MaterialTheme.colorScheme.surface } // Apply +4% overlay when pressed (for interactive cards only) - val pressedOverlay = if (isDarkTheme) Color.White.copy(alpha = 0.04f) else Color.Black.copy(alpha = 0.04f) + val pressedOverlay = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.04f) // Border configuration val borderWidth = when (variant) { @@ -163,9 +150,9 @@ fun UmbralCard( } val borderColor = when { - isFocused && onClick != null -> if (isDarkTheme) DarkBorderFocus else LightBorderFocus - variant == CardVariant.Outlined -> if (isDarkTheme) DarkBorderDefault.copy(alpha = 1.5f) else LightBorderDefault.copy(alpha = 1.5f) - else -> if (isDarkTheme) DarkBorderDefault else LightBorderDefault + isFocused && onClick != null -> MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + variant == CardVariant.Outlined -> MaterialTheme.colorScheme.outline + else -> MaterialTheme.colorScheme.outlineVariant } // Scale animation for pressed state @@ -513,7 +500,7 @@ private fun UmbralCardDarkPreview() { Box( modifier = Modifier .fillMaxWidth() - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(16.dp) ) { Column { @@ -559,7 +546,7 @@ private fun UmbralCardDarkPreview() { Text( text = "Dark Theme - Interactive", style = MaterialTheme.typography.titleMedium, - color = DarkAccentPrimary + color = MaterialTheme.colorScheme.primary ) Text( text = "Tap to see press states", diff --git a/app/src/main/java/com/umbral/presentation/ui/components/UmbralTopBar.kt b/app/src/main/java/com/umbral/presentation/ui/components/UmbralTopBar.kt index f5212ac..b671e5b 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/UmbralTopBar.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/UmbralTopBar.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -39,8 +38,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.umbral.presentation.ui.theme.DarkBackgroundBase -import com.umbral.presentation.ui.theme.LightBackgroundBase import com.umbral.presentation.ui.theme.UmbralTheme // ============================================================================= @@ -72,14 +69,11 @@ fun UmbralTopBar( navigationIcon: @Composable (() -> Unit)? = null, actions: @Composable RowScope.() -> Unit = { } ) { - val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) DarkBackgroundBase else LightBackgroundBase - Box( modifier = modifier .fillMaxWidth() .height(64.dp) - .background(backgroundColor), + .background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.CenterStart ) { Row( @@ -149,7 +143,6 @@ fun UmbralTabRow( onTabSelected: (Int) -> Unit, modifier: Modifier = Modifier ) { - val isDarkTheme = isSystemInDarkTheme() val selectedColor = MaterialTheme.colorScheme.onBackground val unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant val indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) @@ -295,7 +288,7 @@ private fun UmbralTopBarDarkPreview() { Box( modifier = Modifier .fillMaxWidth() - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.background) ) { UmbralTopBar( title = "Configuración", @@ -363,7 +356,7 @@ private fun UmbralTabRowDarkPreview() { Box( modifier = Modifier .fillMaxWidth() - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(16.dp) ) { UmbralTabRow( diff --git a/app/src/main/java/com/umbral/presentation/ui/theme/Theme.kt b/app/src/main/java/com/umbral/presentation/ui/theme/Theme.kt index 65d29f3..0266f02 100644 --- a/app/src/main/java/com/umbral/presentation/ui/theme/Theme.kt +++ b/app/src/main/java/com/umbral/presentation/ui/theme/Theme.kt @@ -5,12 +5,15 @@ import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @@ -21,7 +24,8 @@ import androidx.core.view.WindowCompat * - Light theme: Soft gray backgrounds with pure white surfaces * - Dark theme: Deep OLED-optimized blacks with subtle elevation * - * No dynamic color - we want consistent brand identity + * Supports Dynamic Color (Material You) on Android 12+. + * Falls back to Umbral sage teal palette on older devices. */ // ============================================================================= @@ -146,14 +150,25 @@ private val DarkColorScheme = darkColorScheme( * Umbral app theme. * * @param darkTheme Whether to use dark theme. Defaults to system setting. + * @param dynamicColor Whether to use Dynamic Color (Material You) on Android 12+. + * Falls back to the Umbral sage teal palette on older devices. * @param content The composable content to theme. */ @Composable fun UmbralTheme( darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, content: @Composable () -> Unit ) { - val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + val context = LocalContext.current + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkTheme) dynamicDarkColorScheme(context) + else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } // Update system bars color val view = LocalView.current @@ -161,19 +176,9 @@ fun UmbralTheme( SideEffect { val window = (view.context as Activity).window - // Status bar color - window.statusBarColor = if (darkTheme) { - DarkBackgroundBase.toArgb() - } else { - LightBackgroundBase.toArgb() - } - - // Navigation bar color - window.navigationBarColor = if (darkTheme) { - DarkBackgroundBase.toArgb() - } else { - LightBackgroundBase.toArgb() - } + // Use transparent system bars — let the app content handle color + window.statusBarColor = Color.Transparent.toArgb() + window.navigationBarColor = colorScheme.background.toArgb() // Icon colors (light icons on dark bg, dark icons on light bg) WindowCompat.getInsetsController(window, view).apply { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f09fbd9..f8a5cc6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] # Android & Kotlin -agp = "8.7.3" +agp = "8.13.2" kotlin = "2.1.0" ksp = "2.1.0-1.0.29" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 1f74575f0db9d9fa5dc2ba87fa7c55441b53a4d6 Mon Sep 17 00:00:00 2001 From: Omar Tosca Date: Sun, 8 Mar 2026 13:12:44 -0600 Subject: [PATCH 3/5] [Refactor] Migrate remaining components to MD3 color roles (Phase 3-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded color constants and fragile isDark checks across 13 components: - UmbralSwitch: trackColor/thumbColor → colorScheme.primary/onPrimary/outline/onSurfaceVariant - UmbralCheckbox: Color.White (in Canvas) → colorScheme.onPrimary captured in composable scope - UmbralIconButton: Color(0xFF151515) → colorScheme.onPrimary; preview backgrounds → surfaceContainer - UmbralSurface: Level0-3 elevation → colorScheme.background/surface/surfaceContainerHigh/surfaceContainerHighest - UmbralDivider: DarkBorderDefault/LightBorderDefault → colorScheme.outlineVariant - UmbralSnackbar: hacky isDark (background color comparison) → isSystemInDarkTheme(); DarkBackgroundElevated → surfaceContainerHigh; DarkTextPrimary → onSurface; DarkError → colorScheme.error - UmbralToast: hacky isDark (surface color comparison) → surfaceContainerHigh + onSurface - UmbralSkeleton: Color(0xFF252525/0xFFE8E8E8) → surfaceContainerHigh; Color(0xFF303030/0xFFF5F5F5) → surfaceContainerHighest - UmbralBadge: isDark (background comparison) → isSystemInDarkTheme(); Default badge → colorScheme.primary/onPrimary; Error → colorScheme.error/onError; Neutral → surfaceVariant/onSurfaceVariant - UmbralAvatar: DarkAccentPrimary/DarkSuccess/DarkTextTertiary → colorScheme.primary/tertiary/onSurfaceVariant - UmbralTag: remove unused DarkAccentPrimary/LightAccentPrimary imports - EmptyStateIllustrations: DarkAccentPrimary/DarkTextTertiary → colorScheme.primary/onSurfaceVariant - ProfileCard (ActiveBadge): Color(0xFF4CAF50) → colorScheme.tertiary --- .../presentation/ui/components/ProfileCard.kt | 2 +- .../ui/components/UmbralCheckbox.kt | 8 +++- .../ui/components/UmbralDivider.kt | 12 ++---- .../ui/components/UmbralIconButton.kt | 13 +++---- .../ui/components/UmbralSurface.kt | 31 ++++----------- .../ui/components/UmbralSwitch.kt | 21 ++-------- .../ui/components/display/UmbralAvatar.kt | 9 ++--- .../ui/components/display/UmbralBadge.kt | 19 ++++----- .../ui/components/display/UmbralTag.kt | 2 - .../empty/EmptyStateIllustrations.kt | 10 +---- .../ui/components/feedback/UmbralSnackbar.kt | 39 +++++-------------- .../ui/components/feedback/UmbralToast.kt | 33 ++++++---------- .../ui/components/skeleton/UmbralSkeleton.kt | 25 +++--------- 13 files changed, 69 insertions(+), 155 deletions(-) diff --git a/app/src/main/java/com/umbral/presentation/ui/components/ProfileCard.kt b/app/src/main/java/com/umbral/presentation/ui/components/ProfileCard.kt index 11b51d7..fd9a4ee 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/ProfileCard.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/ProfileCard.kt @@ -244,7 +244,7 @@ private fun getProfileIcon(iconName: String): ImageVector { @Composable fun ActiveBadge(modifier: Modifier = Modifier) { - val successColor = Color(0xFF4CAF50) + val successColor = MaterialTheme.colorScheme.tertiary Surface( modifier = modifier, diff --git a/app/src/main/java/com/umbral/presentation/ui/components/UmbralCheckbox.kt b/app/src/main/java/com/umbral/presentation/ui/components/UmbralCheckbox.kt index d794d76..136da79 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/UmbralCheckbox.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/UmbralCheckbox.kt @@ -115,6 +115,13 @@ fun UmbralCheckbox( label = "checkmarkProgress" ) + // Capture checkmark color in composable scope (not available inside DrawScope) + val checkmarkColor = if (enabled) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.6f) + } + Row( modifier = modifier .clickable( @@ -154,7 +161,6 @@ fun UmbralCheckbox( // Draw checkmark if checked if (checkProgress > 0f) { - val checkmarkColor = if (enabled) Color.White else Color.White.copy(alpha = 0.6f) val checkmarkStrokeWidth = 2.dp.toPx() // Checkmark path (approximate coordinates for 24x24 box) diff --git a/app/src/main/java/com/umbral/presentation/ui/components/UmbralDivider.kt b/app/src/main/java/com/umbral/presentation/ui/components/UmbralDivider.kt index 579711f..2db3987 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/UmbralDivider.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/UmbralDivider.kt @@ -1,7 +1,6 @@ package com.umbral.presentation.ui.components import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -21,9 +20,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.umbral.presentation.ui.theme.DarkBackgroundBase -import com.umbral.presentation.ui.theme.DarkBorderDefault -import com.umbral.presentation.ui.theme.LightBorderDefault import com.umbral.presentation.ui.theme.UmbralTheme /** @@ -64,10 +60,8 @@ fun UmbralDivider( thickness: androidx.compose.ui.unit.Dp = 1.dp, vertical: Boolean = false ) { - val isDarkTheme = isSystemInDarkTheme() - - // Use custom color or theme's border color - val dividerColor = color ?: if (isDarkTheme) DarkBorderDefault else LightBorderDefault + // Use custom color or MD3 outline variant + val dividerColor = color ?: MaterialTheme.colorScheme.outlineVariant // Calculate padding based on variant val horizontalPadding = when (variant) { @@ -142,7 +136,7 @@ private fun UmbralDividerVariantsDarkPreview() { Column( modifier = Modifier .fillMaxWidth() - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(vertical = 16.dp) ) { Text( diff --git a/app/src/main/java/com/umbral/presentation/ui/components/UmbralIconButton.kt b/app/src/main/java/com/umbral/presentation/ui/components/UmbralIconButton.kt index bd9819c..4c4b1a9 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/UmbralIconButton.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/UmbralIconButton.kt @@ -38,7 +38,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.umbral.presentation.ui.theme.DarkBackgroundBase import com.umbral.presentation.ui.theme.UmbralSpacing import com.umbral.presentation.ui.theme.UmbralTheme @@ -123,7 +122,7 @@ fun UmbralIconButton( } else { MaterialTheme.colorScheme.onSurface } - IconButtonVariant.Filled -> Color(0xFF151515) // Dark color for filled variant + IconButtonVariant.Filled -> MaterialTheme.colorScheme.onPrimary IconButtonVariant.Tonal -> MaterialTheme.colorScheme.primary } } @@ -179,7 +178,7 @@ private fun UmbralIconButtonGhostPreview() { UmbralTheme { Box( modifier = Modifier - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.surfaceContainer) .padding(16.dp) ) { Row { @@ -217,7 +216,7 @@ private fun UmbralIconButtonFilledPreview() { UmbralTheme { Box( modifier = Modifier - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.surfaceContainer) .padding(16.dp) ) { Row { @@ -255,7 +254,7 @@ private fun UmbralIconButtonTonalPreview() { UmbralTheme { Box( modifier = Modifier - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.surfaceContainer) .padding(16.dp) ) { Row { @@ -293,7 +292,7 @@ private fun UmbralIconButtonDisabledPreview() { UmbralTheme { Box( modifier = Modifier - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.surfaceContainer) .padding(16.dp) ) { Row { @@ -331,7 +330,7 @@ private fun UmbralIconButtonAllVariantsPreview() { UmbralTheme { Box( modifier = Modifier - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.surfaceContainer) .padding(16.dp) ) { Row { diff --git a/app/src/main/java/com/umbral/presentation/ui/components/UmbralSurface.kt b/app/src/main/java/com/umbral/presentation/ui/components/UmbralSurface.kt index 1196fa6..fd58a79 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/UmbralSurface.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/UmbralSurface.kt @@ -1,7 +1,6 @@ package com.umbral.presentation.ui.components import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -11,16 +10,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.umbral.presentation.ui.theme.DarkBackgroundBase -import com.umbral.presentation.ui.theme.DarkBackgroundElevated -import com.umbral.presentation.ui.theme.DarkBackgroundSurface -import com.umbral.presentation.ui.theme.LightBackgroundBase -import com.umbral.presentation.ui.theme.LightBackgroundElevated -import com.umbral.presentation.ui.theme.LightBackgroundSurface import com.umbral.presentation.ui.theme.UmbralTheme /** @@ -63,21 +55,12 @@ fun UmbralSurface( shape: Shape = MaterialTheme.shapes.medium, content: @Composable () -> Unit ) { - val isDarkTheme = isSystemInDarkTheme() - - // Get background color based on elevation and theme + // Map elevation levels to MD3 surface color roles val backgroundColor = when (elevation) { - SurfaceElevation.Level0 -> if (isDarkTheme) DarkBackgroundBase else LightBackgroundBase - SurfaceElevation.Level1 -> if (isDarkTheme) DarkBackgroundSurface else LightBackgroundSurface - SurfaceElevation.Level2 -> if (isDarkTheme) DarkBackgroundElevated else LightBackgroundElevated - SurfaceElevation.Level3 -> { - // Level 3 = Elevated + 2% white overlay (dark) or 2% black overlay (light) - val baseColor = if (isDarkTheme) DarkBackgroundElevated else LightBackgroundElevated - val overlay = if (isDarkTheme) Color.White.copy(alpha = 0.02f) else Color.Black.copy(alpha = 0.02f) - // Note: In production, you'd blend these properly. For simplicity, we'll just use the base color - // with a slight adjustment. Proper blending would require color manipulation utilities. - baseColor - } + SurfaceElevation.Level0 -> MaterialTheme.colorScheme.background + SurfaceElevation.Level1 -> MaterialTheme.colorScheme.surface + SurfaceElevation.Level2 -> MaterialTheme.colorScheme.surfaceContainerHigh + SurfaceElevation.Level3 -> MaterialTheme.colorScheme.surfaceContainerHighest } Box( @@ -100,7 +83,7 @@ private fun UmbralSurfaceAllLevelsLightPreview() { Box( modifier = Modifier .fillMaxWidth() - .background(LightBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(16.dp) ) { Column { @@ -171,7 +154,7 @@ private fun UmbralSurfaceAllLevelsDarkPreview() { Box( modifier = Modifier .fillMaxWidth() - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(16.dp) ) { Column { diff --git a/app/src/main/java/com/umbral/presentation/ui/components/UmbralSwitch.kt b/app/src/main/java/com/umbral/presentation/ui/components/UmbralSwitch.kt index deadf88..bbaa605 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/UmbralSwitch.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/UmbralSwitch.kt @@ -23,18 +23,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.umbral.presentation.ui.theme.DarkAccentPrimary -import com.umbral.presentation.ui.theme.DarkBorderDefault -import com.umbral.presentation.ui.theme.DarkTextSecondary -import com.umbral.presentation.ui.theme.LightAccentPrimary -import com.umbral.presentation.ui.theme.LightBorderDefault -import com.umbral.presentation.ui.theme.LightTextSecondary import com.umbral.presentation.ui.theme.UmbralTheme -import com.umbral.presentation.ui.theme.isUmbralDarkTheme /** * Umbral Design System 2.0 - Switch Component @@ -118,7 +110,6 @@ private fun UmbralSwitchCore( modifier: Modifier = Modifier, enabled: Boolean = true ) { - val isDark = isUmbralDarkTheme() val interactionSource = remember { MutableInteractionSource() } // Design System 2.0 specs @@ -142,12 +133,8 @@ private fun UmbralSwitchCore( val trackColor by animateColorAsState( targetValue = when { !enabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f) - checked -> if (isDark) DarkAccentPrimary else LightAccentPrimary - else -> { - // Off state: borderDefault with 12% opacity - val borderColor = if (isDark) DarkBorderDefault else LightBorderDefault - borderColor.copy(alpha = 0.12f) - } + checked -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) }, animationSpec = tween(durationMillis = 200), label = "trackColor" @@ -157,8 +144,8 @@ private fun UmbralSwitchCore( val thumbColor by animateColorAsState( targetValue = when { !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - checked -> Color(0xFF151515) // Dark color for thumb when on - else -> if (isDark) DarkTextSecondary else LightTextSecondary + checked -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurfaceVariant }, animationSpec = tween(durationMillis = 200), label = "thumbColor" diff --git a/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralAvatar.kt b/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralAvatar.kt index 2281a1e..8494c59 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralAvatar.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralAvatar.kt @@ -28,9 +28,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.umbral.presentation.ui.theme.DarkAccentPrimary -import com.umbral.presentation.ui.theme.DarkSuccess -import com.umbral.presentation.ui.theme.DarkTextTertiary import com.umbral.presentation.ui.theme.UmbralMotion import com.umbral.presentation.ui.theme.UmbralTheme @@ -158,9 +155,9 @@ private fun AvatarBadgeIndicator( contentAlignment = Alignment.BottomEnd ) { val badgeColor = when (badge) { - AvatarBadge.Online -> DarkSuccess - AvatarBadge.Offline -> DarkTextTertiary - AvatarBadge.Active -> DarkAccentPrimary + AvatarBadge.Online -> MaterialTheme.colorScheme.tertiary + AvatarBadge.Offline -> MaterialTheme.colorScheme.onSurfaceVariant + AvatarBadge.Active -> MaterialTheme.colorScheme.primary AvatarBadge.None -> return } diff --git a/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralBadge.kt b/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralBadge.kt index 33a27aa..14e6f16 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralBadge.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralBadge.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -188,32 +189,32 @@ private data class BadgeColors( */ @Composable private fun getBadgeColors(variant: BadgeVariant): BadgeColors { - val isDark = MaterialTheme.colorScheme.background == DarkBackgroundBase + val isDark = isSystemInDarkTheme() return when (variant) { BadgeVariant.Default -> BadgeColors( - background = if (isDark) DarkAccentPrimary else LightAccentPrimary, - text = Color(0xFF151515) // Dark text on accent background + background = MaterialTheme.colorScheme.primary, + text = MaterialTheme.colorScheme.onPrimary ) BadgeVariant.Success -> BadgeColors( background = if (isDark) DarkSuccess else LightSuccess, - text = Color(0xFF151515) // Dark text on success background + text = MaterialTheme.colorScheme.onSurface ) BadgeVariant.Warning -> BadgeColors( background = if (isDark) DarkWarning else LightWarning, - text = Color(0xFF151515) // Dark text on warning background + text = MaterialTheme.colorScheme.onSurface ) BadgeVariant.Error -> BadgeColors( - background = if (isDark) DarkError else LightError, - text = Color(0xFFFFFFFF) // White text on error background + background = MaterialTheme.colorScheme.error, + text = MaterialTheme.colorScheme.onError ) BadgeVariant.Neutral -> BadgeColors( - background = if (isDark) DarkBackgroundSurface else LightBackgroundSurface, - text = if (isDark) DarkTextSecondary else LightTextSecondary + background = MaterialTheme.colorScheme.surfaceVariant, + text = MaterialTheme.colorScheme.onSurfaceVariant ) } } diff --git a/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralTag.kt b/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralTag.kt index deedc29..85148d1 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralTag.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/display/UmbralTag.kt @@ -39,8 +39,6 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.umbral.presentation.ui.theme.DarkAccentPrimary -import com.umbral.presentation.ui.theme.LightAccentPrimary import com.umbral.presentation.ui.theme.UmbralMotion import com.umbral.presentation.ui.theme.UmbralTheme diff --git a/app/src/main/java/com/umbral/presentation/ui/components/empty/EmptyStateIllustrations.kt b/app/src/main/java/com/umbral/presentation/ui/components/empty/EmptyStateIllustrations.kt index bb77657..d096530 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/empty/EmptyStateIllustrations.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/empty/EmptyStateIllustrations.kt @@ -1,7 +1,6 @@ package com.umbral.presentation.ui.components.empty import androidx.compose.foundation.Canvas -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -15,10 +14,6 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp -import com.umbral.presentation.ui.theme.DarkAccentPrimary -import com.umbral.presentation.ui.theme.DarkTextTertiary -import com.umbral.presentation.ui.theme.LightAccentPrimary -import com.umbral.presentation.ui.theme.LightTextTertiary /** * Illustration types for empty states @@ -42,9 +37,8 @@ fun EmptyStateIllustrationView( type: EmptyStateIllustration, modifier: Modifier = Modifier ) { - val isDark = isSystemInDarkTheme() - val baseColor = if (isDark) DarkTextTertiary else LightTextTertiary - val accentColor = if (isDark) DarkAccentPrimary else LightAccentPrimary + val baseColor = MaterialTheme.colorScheme.onSurfaceVariant + val accentColor = MaterialTheme.colorScheme.primary Canvas(modifier = modifier.size(120.dp)) { when (type) { diff --git a/app/src/main/java/com/umbral/presentation/ui/components/feedback/UmbralSnackbar.kt b/app/src/main/java/com/umbral/presentation/ui/components/feedback/UmbralSnackbar.kt index c9e7eff..57dec93 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/feedback/UmbralSnackbar.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/feedback/UmbralSnackbar.kt @@ -38,21 +38,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.umbral.presentation.ui.theme.DarkBackgroundElevated -import com.umbral.presentation.ui.theme.DarkBorderDefault -import com.umbral.presentation.ui.theme.DarkError import com.umbral.presentation.ui.theme.DarkSuccess -import com.umbral.presentation.ui.theme.DarkTextPrimary import com.umbral.presentation.ui.theme.DarkWarning -import com.umbral.presentation.ui.theme.LightBackgroundElevated -import com.umbral.presentation.ui.theme.LightBorderDefault -import com.umbral.presentation.ui.theme.LightError import com.umbral.presentation.ui.theme.LightSuccess -import com.umbral.presentation.ui.theme.LightTextPrimary import com.umbral.presentation.ui.theme.LightWarning import com.umbral.presentation.ui.theme.UmbralMotion import com.umbral.presentation.ui.theme.UmbralTheme @@ -96,7 +88,7 @@ data class SnackbarAction( * with support for different variants, actions, and auto-dismiss durations. * * Visual Specs: - * - Background: DarkBackgroundElevated / LightBackgroundElevated + * - Background: surfaceContainerHigh (MD3 role, auto-adapts to theme + dynamic color) * - Border: 1px semantic color based on variant * - Corner Radius: 12.dp * - Padding: 16.dp @@ -118,31 +110,18 @@ fun UmbralSnackbar( action: SnackbarAction? = null, duration: SnackbarDuration = SnackbarDuration.Medium ) { - val isDark = MaterialTheme.colorScheme.background == Color(0xFF151515) || - MaterialTheme.colorScheme.background == DarkBackgroundElevated + val isDark = isSystemInDarkTheme() // Colors based on variant and theme val (borderColor, icon) = when (variant) { - SnackbarVariant.Default -> { - val border = if (isDark) DarkBorderDefault else LightBorderDefault - Pair(border, null) - } - SnackbarVariant.Success -> { - val border = if (isDark) DarkSuccess else LightSuccess - Pair(border, Icons.Default.Check) - } - SnackbarVariant.Error -> { - val border = if (isDark) DarkError else LightError - Pair(border, Icons.Default.Close) - } - SnackbarVariant.Warning -> { - val border = if (isDark) DarkWarning else LightWarning - Pair(border, Icons.Default.Warning) - } + SnackbarVariant.Default -> Pair(MaterialTheme.colorScheme.outlineVariant, null) + SnackbarVariant.Success -> Pair(if (isDark) DarkSuccess else LightSuccess, Icons.Default.Check) + SnackbarVariant.Error -> Pair(MaterialTheme.colorScheme.error, Icons.Default.Close) + SnackbarVariant.Warning -> Pair(if (isDark) DarkWarning else LightWarning, Icons.Default.Warning) } - val backgroundColor = if (isDark) DarkBackgroundElevated else LightBackgroundElevated - val textColor = if (isDark) DarkTextPrimary else LightTextPrimary + val backgroundColor = MaterialTheme.colorScheme.surfaceContainerHigh + val textColor = MaterialTheme.colorScheme.onSurface Card( modifier = modifier diff --git a/app/src/main/java/com/umbral/presentation/ui/components/feedback/UmbralToast.kt b/app/src/main/java/com/umbral/presentation/ui/components/feedback/UmbralToast.kt index 54c0de5..b416bb0 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/feedback/UmbralToast.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/feedback/UmbralToast.kt @@ -46,7 +46,7 @@ import kotlinx.coroutines.launch * - Smooth fade + scale animations * * ## Design Specs - * - Background: DarkBackgroundElevated / LightBackgroundElevated (90% opacity) + * - Background: surfaceContainerHigh at 90% opacity (MD3 role, auto-adapts to theme + dynamic color) * - Corner Radius: Full (pill shape) * - Padding: 12.dp horizontal, 8.dp vertical * - Height: auto (~36-40.dp) @@ -163,19 +163,8 @@ fun UmbralToast( icon: ImageVector? = null, modifier: Modifier = Modifier ) { - val isDark = !MaterialTheme.colorScheme.surface.equals(LightBackgroundSurface) - - val backgroundColor = if (isDark) { - DarkBackgroundElevated.copy(alpha = 0.9f) - } else { - LightBackgroundElevated.copy(alpha = 0.9f) - } - - val textColor = if (isDark) { - DarkTextPrimary - } else { - LightTextPrimary - } + val backgroundColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.9f) + val textColor = MaterialTheme.colorScheme.onSurface Row( modifier = modifier @@ -218,7 +207,7 @@ private fun PreviewToastSimpleLight() { Box( modifier = Modifier .fillMaxWidth() - .background(LightBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(16.dp), contentAlignment = Alignment.Center ) { @@ -234,7 +223,7 @@ private fun PreviewToastSimpleDark() { Box( modifier = Modifier .fillMaxWidth() - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(16.dp), contentAlignment = Alignment.Center ) { @@ -250,7 +239,7 @@ private fun PreviewToastSuccessLight() { Box( modifier = Modifier .fillMaxWidth() - .background(LightBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(16.dp), contentAlignment = Alignment.Center ) { @@ -269,7 +258,7 @@ private fun PreviewToastSuccessDark() { Box( modifier = Modifier .fillMaxWidth() - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(16.dp), contentAlignment = Alignment.Center ) { @@ -288,7 +277,7 @@ private fun PreviewToastInfoLight() { Box( modifier = Modifier .fillMaxWidth() - .background(LightBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(16.dp), contentAlignment = Alignment.Center ) { @@ -307,7 +296,7 @@ private fun PreviewToastWarningDark() { Box( modifier = Modifier .fillMaxWidth() - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(16.dp), contentAlignment = Alignment.Center ) { @@ -326,7 +315,7 @@ private fun PreviewToastLongLight() { Box( modifier = Modifier .fillMaxWidth() - .background(LightBackgroundBase) + .background(MaterialTheme.colorScheme.background) .padding(16.dp), contentAlignment = Alignment.Center ) { @@ -348,7 +337,7 @@ private fun PreviewToastHost() { Box( modifier = Modifier .fillMaxSize() - .background(DarkBackgroundBase) + .background(MaterialTheme.colorScheme.background) ) { // Toast host UmbralToastHost(toastState = toastState) diff --git a/app/src/main/java/com/umbral/presentation/ui/components/skeleton/UmbralSkeleton.kt b/app/src/main/java/com/umbral/presentation/ui/components/skeleton/UmbralSkeleton.kt index 61e5c14..5586a55 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/skeleton/UmbralSkeleton.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/skeleton/UmbralSkeleton.kt @@ -2,16 +2,15 @@ package com.umbral.presentation.ui.components.skeleton import androidx.compose.animation.core.* import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalAccessibilityManager import androidx.compose.ui.unit.dp @@ -22,10 +21,8 @@ import androidx.compose.ui.unit.dp * Base skeleton component with shimmer animation for loading states. * * ## Visual Specs - * - Background Dark: #252525 - * - Background Light: #E8E8E8 - * - Shimmer Highlight Dark: #303030 - * - Shimmer Highlight Light: #F5F5F5 + * - Background: surfaceContainerHigh (MD3 role, adapts to light/dark + dynamic color) + * - Shimmer Highlight: surfaceContainerHighest (MD3 role, slightly lighter/darker than background) * - Animation: shimmer left-to-right, 1200ms, infinite * * ## Accessibility @@ -53,7 +50,6 @@ fun UmbralSkeleton( modifier: Modifier = Modifier, shape: Shape = RoundedCornerShape(8.dp) ) { - val isDarkTheme = isSystemInDarkTheme() val accessibilityManager = LocalAccessibilityManager.current // Check if "Reduce Motion" is enabled @@ -64,18 +60,9 @@ fun UmbralSkeleton( containsControls = false ) == Long.MAX_VALUE - // Skeleton colors based on theme - val backgroundColor = if (isDarkTheme) { - Color(0xFF252525) // Dark background - } else { - Color(0xFFE8E8E8) // Light background - } - - val highlightColor = if (isDarkTheme) { - Color(0xFF303030) // Dark highlight - } else { - Color(0xFFF5F5F5) // Light highlight - } + // Skeleton colors from MD3 surface container roles (auto-adapts to light/dark + dynamic color) + val backgroundColor = MaterialTheme.colorScheme.surfaceContainerHigh + val highlightColor = MaterialTheme.colorScheme.surfaceContainerHighest // Shimmer animation colors val shimmerColors = listOf( From dba98eab14bbc270b5c6c04113c8fbd985a3d468 Mon Sep 17 00:00:00 2001 From: Omar Tosca Date: Sun, 8 Mar 2026 13:26:16 -0600 Subject: [PATCH 4/5] [Refactor] Migrate screen hardcoded colors to MD3 roles (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatsScreen: PercentageChangeChip uses colorScheme.tertiary (positive) and colorScheme.error (negative) instead of Color(0xFF4CAF50/F44336) - SettingsScreen: PermissionRow successColor uses colorScheme.tertiary instead of Color(0xFF4CAF50); remove unused Color import in both files HomeScreen, ProfilesScreen, ProfileDetailScreen and all onboarding screens were already clean — no changes needed. --- .../presentation/ui/screens/settings/SettingsScreen.kt | 3 +-- .../com/umbral/presentation/ui/screens/stats/StatsScreen.kt | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/umbral/presentation/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/umbral/presentation/ui/screens/settings/SettingsScreen.kt index 808b44c..2495a30 100644 --- a/app/src/main/java/com/umbral/presentation/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/umbral/presentation/ui/screens/settings/SettingsScreen.kt @@ -60,7 +60,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -550,7 +549,7 @@ private fun PermissionRow( onRequestClick: (() -> Unit)?, modifier: Modifier = Modifier ) { - val successColor = Color(0xFF4CAF50) + val successColor = MaterialTheme.colorScheme.tertiary Row( modifier = modifier diff --git a/app/src/main/java/com/umbral/presentation/ui/screens/stats/StatsScreen.kt b/app/src/main/java/com/umbral/presentation/ui/screens/stats/StatsScreen.kt index 1cc2716..b15a6bd 100644 --- a/app/src/main/java/com/umbral/presentation/ui/screens/stats/StatsScreen.kt +++ b/app/src/main/java/com/umbral/presentation/ui/screens/stats/StatsScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -275,9 +274,9 @@ private fun PercentageChangeChip( ) { val isPositive = percentageChange > 0 val chipColor = if (isPositive) { - Color(0xFF4CAF50) // Green - more blocking is good + MaterialTheme.colorScheme.tertiary // more blocking is good } else { - Color(0xFFF44336) // Red - less blocking might be concerning + MaterialTheme.colorScheme.error // less blocking might be concerning } Row( From d907d93ead43dd65d588d81e48e0494cd00fb0a6 Mon Sep 17 00:00:00 2001 From: Omar Tosca Date: Sun, 8 Mar 2026 20:52:04 -0600 Subject: [PATCH 5/5] [Refactor] Replace isSystemInDarkTheme() with MD3 colorScheme roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThemeUtils: gradient/brush functions now read from MaterialTheme.colorScheme (background, surface, surfaceContainerHigh, primary, primaryContainer, outlineVariant) instead of hand-picking dark/light named constants - ThemeUtils: accentDimmed(), dividerColor(), shimmerBrush(), surfaceColorAtElevation() all migrated to colorScheme roles - UmbralCard legacy overloads: containerColor uses surfaceColorAtElevation() without isSystemInDarkTheme(); removed unused import - WidgetTheme: Color(0xFFFFFFFF) → Color.White - BlockingActivity: timer/streak icon tints and glow color use colorScheme.primary / colorScheme.tertiary --- .../com/umbral/glance/theme/WidgetTheme.kt | 2 +- .../ui/blocking/BlockingActivity.kt | 7 +- .../presentation/ui/components/UmbralCard.kt | 22 +----- .../presentation/ui/theme/ThemeUtils.kt | 78 ++++++++----------- 4 files changed, 40 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/umbral/glance/theme/WidgetTheme.kt b/app/src/main/java/com/umbral/glance/theme/WidgetTheme.kt index c5ec840..341b44f 100644 --- a/app/src/main/java/com/umbral/glance/theme/WidgetTheme.kt +++ b/app/src/main/java/com/umbral/glance/theme/WidgetTheme.kt @@ -82,7 +82,7 @@ object WidgetColors { * Text on primary color backgrounds * White for maximum contrast on sage teal */ - val onPrimary = ColorProvider(Color(0xFFFFFFFF)) // White + val onPrimary = ColorProvider(Color.White) // ============================================================================= // SEMANTIC COLORS diff --git a/app/src/main/java/com/umbral/presentation/ui/blocking/BlockingActivity.kt b/app/src/main/java/com/umbral/presentation/ui/blocking/BlockingActivity.kt index eae412b..9d0437b 100644 --- a/app/src/main/java/com/umbral/presentation/ui/blocking/BlockingActivity.kt +++ b/app/src/main/java/com/umbral/presentation/ui/blocking/BlockingActivity.kt @@ -493,7 +493,7 @@ private fun ElapsedTimeCard( Icon( imageVector = Icons.Outlined.Timer, contentDescription = null, - tint = Color(0xFF64B5F6), // Azul claro + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(28.dp) ) @@ -547,7 +547,7 @@ private fun StreakCard( Icon( imageVector = Icons.Outlined.LocalFireDepartment, contentDescription = null, - tint = Color(0xFFFF9800), + tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(28.dp) ) @@ -717,8 +717,7 @@ private fun BreathingShieldIcon(modifier: Modifier = Modifier) { label = "breathingAlpha" ) - // Colores para dark mode - val glowColor = Color(0xFF4A90D9) // Azul brillante para contraste + val glowColor = MaterialTheme.colorScheme.primary Box( modifier = modifier, diff --git a/app/src/main/java/com/umbral/presentation/ui/components/UmbralCard.kt b/app/src/main/java/com/umbral/presentation/ui/components/UmbralCard.kt index da8d417..b097958 100644 --- a/app/src/main/java/com/umbral/presentation/ui/components/UmbralCard.kt +++ b/app/src/main/java/com/umbral/presentation/ui/components/UmbralCard.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -230,11 +229,9 @@ fun UmbralCard( ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() - val isDarkTheme = isSystemInDarkTheme() - // Use different elevation values for light and dark themes - val defaultElevation = if (isDarkTheme) elevation.darkDefault else elevation.lightDefault - val pressedElevation = if (isDarkTheme) elevation.darkPressed else elevation.lightPressed + val defaultElevation = elevation.lightDefault + val pressedElevation = elevation.lightPressed val animatedElevation by animateDpAsState( targetValue = if (isPressed && onClick != null) pressedElevation else defaultElevation, @@ -245,12 +242,7 @@ fun UmbralCard( label = "cardElevation" ) - // In dark mode, use tonal surface color for elevation instead of shadows - val containerColor = if (isDarkTheme) { - surfaceColorAtElevation(elevation.surfaceLevel) - } else { - MaterialTheme.colorScheme.surface - } + val containerColor = surfaceColorAtElevation(elevation.surfaceLevel) if (onClick != null) { Card( @@ -312,14 +304,8 @@ fun UmbralOutlinedCard( content: @Composable ColumnScope.() -> Unit ) { val interactionSource = remember { MutableInteractionSource() } - val isDarkTheme = isSystemInDarkTheme() - // In dark mode, use a slightly elevated surface for better contrast - val containerColor = if (isDarkTheme) { - surfaceColorAtElevation(SurfaceElevation.Level1) - } else { - MaterialTheme.colorScheme.surface - } + val containerColor = MaterialTheme.colorScheme.surface if (onClick != null) { androidx.compose.material3.OutlinedCard( diff --git a/app/src/main/java/com/umbral/presentation/ui/theme/ThemeUtils.kt b/app/src/main/java/com/umbral/presentation/ui/theme/ThemeUtils.kt index fd577e0..5d7ebee 100644 --- a/app/src/main/java/com/umbral/presentation/ui/theme/ThemeUtils.kt +++ b/app/src/main/java/com/umbral/presentation/ui/theme/ThemeUtils.kt @@ -56,11 +56,9 @@ object UmbralThemeUtils { */ @Composable fun backgroundGradient(): Brush { - return if (isSystemInDarkTheme()) { - DarkBackgroundGradient - } else { - LightBackgroundGradient - } + val background = MaterialTheme.colorScheme.background + val surface = MaterialTheme.colorScheme.surface + return Brush.verticalGradient(colors = listOf(background, surface)) } // ------------------------------------------------------------------------- @@ -97,11 +95,9 @@ object UmbralThemeUtils { */ @Composable fun accentGradient(): Brush { - return if (isSystemInDarkTheme()) { - DarkAccentGradient - } else { - LightAccentGradient - } + val primary = MaterialTheme.colorScheme.primary + val primaryContainer = MaterialTheme.colorScheme.primaryContainer + return Brush.horizontalGradient(colors = listOf(primary, primaryContainer)) } // ------------------------------------------------------------------------- @@ -138,11 +134,9 @@ object UmbralThemeUtils { */ @Composable fun cardGradient(): Brush { - return if (isSystemInDarkTheme()) { - DarkCardGradient - } else { - LightCardGradient - } + val surface = MaterialTheme.colorScheme.surface + val surfaceHigh = MaterialTheme.colorScheme.surfaceContainerHigh + return Brush.verticalGradient(colors = listOf(surface, surfaceHigh)) } // ------------------------------------------------------------------------- @@ -226,13 +220,9 @@ object UmbralThemeUtils { center: Offset = Offset.Unspecified, radius: Float = Float.POSITIVE_INFINITY ): Brush { - val colors = if (isSystemInDarkTheme()) { - listOf(DarkAccentPrimary, DarkAccentHover) - } else { - listOf(LightAccentPrimary, LightAccentHover) - } + val primary = MaterialTheme.colorScheme.primary return Brush.radialGradient( - colors = listOf(colors[0].copy(alpha = 0.3f), Color.Transparent), + colors = listOf(primary.copy(alpha = 0.3f), Color.Transparent), center = center, radius = radius ) @@ -287,10 +277,13 @@ object UmbralThemeUtils { */ @Composable fun animatedBackground(): Color { - return animatedThemeColor( - lightColor = LightBackgroundBase, - darkColor = DarkBackgroundBase + val color = MaterialTheme.colorScheme.background + val animatedColor by animateColorAsState( + targetValue = color, + animationSpec = tween(300), + label = "backgroundAnimation" ) + return animatedColor } /** @@ -299,10 +292,13 @@ object UmbralThemeUtils { */ @Composable fun animatedSurface(): Color { - return animatedThemeColor( - lightColor = LightBackgroundSurface, - darkColor = DarkBackgroundSurface + val color = MaterialTheme.colorScheme.surface + val animatedColor by animateColorAsState( + targetValue = color, + animationSpec = tween(300), + label = "surfaceAnimation" ) + return animatedColor } } @@ -317,13 +313,12 @@ object UmbralThemeUtils { */ @Composable fun surfaceColorAtElevation(elevation: SurfaceElevation): Color { - val isDark = isSystemInDarkTheme() return when (elevation) { - SurfaceElevation.Level0 -> if (isDark) DarkBackgroundBase else LightBackgroundBase - SurfaceElevation.Level1 -> if (isDark) DarkBackgroundSurface else LightBackgroundSurface - SurfaceElevation.Level2 -> if (isDark) DarkBackgroundElevated else LightBackgroundElevated - SurfaceElevation.Level3 -> if (isDark) DarkBackgroundElevated else LightBackgroundElevated - SurfaceElevation.Level4 -> if (isDark) DarkBackgroundElevated else LightBackgroundElevated + SurfaceElevation.Level0 -> MaterialTheme.colorScheme.background + SurfaceElevation.Level1 -> MaterialTheme.colorScheme.surface + SurfaceElevation.Level2 -> MaterialTheme.colorScheme.surfaceContainerLow + SurfaceElevation.Level3 -> MaterialTheme.colorScheme.surfaceContainer + SurfaceElevation.Level4 -> MaterialTheme.colorScheme.surfaceContainerHigh } } @@ -353,11 +348,7 @@ fun contentColorFor(surfaceElevation: SurfaceElevation): Color { */ @Composable fun accentDimmed(): Color { - return if (isSystemInDarkTheme()) { - DarkAccentPrimary.copy(alpha = 0.12f) - } else { - LightAccentPrimary.copy(alpha = 0.08f) - } + return MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) } /** @@ -376,11 +367,7 @@ fun primaryDimmed(): Color { */ @Composable fun dividerColor(): Color { - return if (isSystemInDarkTheme()) { - DarkBorderDefault.copy(alpha = 0.8f) - } else { - LightBorderDefault.copy(alpha = 0.8f) - } + return MaterialTheme.colorScheme.outlineVariant } /** @@ -420,9 +407,8 @@ fun backgroundGradientBrush(): Brush { */ @Composable fun shimmerBrush(translateX: Float): Brush { - val isDark = isSystemInDarkTheme() - val baseColor = if (isDark) DarkBackgroundSurface else LightBackgroundSurface - val highlightColor = if (isDark) DarkBackgroundElevated else LightBackgroundElevated + val baseColor = MaterialTheme.colorScheme.surface + val highlightColor = MaterialTheme.colorScheme.surfaceContainerHigh return Brush.linearGradient( colors = listOf(baseColor, highlightColor, baseColor),