Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions .claude/rules/architecture.md
Original file line number Diff line number Diff line change
@@ -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<String>
)

// 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<UiState>` — 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<ProfilesUiState> = _uiState.asStateFlow()
}
```

## Repository Pattern

Domain layer defines the interface:

```kotlin
// domain/blocking/ProfileRepository.kt
interface ProfileRepository {
fun getProfiles(): Flow<List<BlockingProfile>>
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<T>` 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)
```
42 changes: 42 additions & 0 deletions .claude/rules/naming.md
Original file line number Diff line number Diff line change
@@ -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
<!-- ✅ Correct -->
<string name="btn_save">Guardar</string>
<string name="profile_name">Nombre del perfil</string>
<string name="error_nfc_not_supported">Tu dispositivo no soporta NFC</string>

<!-- ❌ Wrong -->
<!-- Text("Guardar") — hardcoded -->
<!-- Text("Save") — wrong language + hardcoded -->
```

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`
127 changes: 127 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
@@ -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<ProfileRepository>()
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<ProfileRepository>()
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"
```
Loading