Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87771ef7f3 | |||
| 65803a2180 | |||
| ef8eab4a07 | |||
| 2064e70d75 |
@@ -18,7 +18,8 @@
|
|||||||
"Bash(ps:*)",
|
"Bash(ps:*)",
|
||||||
"Bash(stdbuf:*)",
|
"Bash(stdbuf:*)",
|
||||||
"Bash(sysctl:*)",
|
"Bash(sysctl:*)",
|
||||||
"Bash(tee:*)"
|
"Bash(tee:*)",
|
||||||
|
"Bash(codesign -d --entitlements :- /Users/treyt/Library/Developer/Xcode/DerivedData/honeyDue-buvczbpttcfkxxcmxbnqkqrmujyh/Build/Products/Debug-iphonesimulator/honeyDue.app)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,919 @@
|
|||||||
|
# Task Cache Unification Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Make `_allTasks` the single source of truth for tasks; collapse `_tasksByResidence` into a derived view. Fix the bug where tasks created during onboarding don't appear on the Residence Detail screen until app restart (Gitea issue #2).
|
||||||
|
|
||||||
|
**Architecture:** The current code has two parallel task caches that must be kept in sync (`_allTasks` for the kanban tab, `_tasksByResidence` per-residence for the residence detail screen). `DataManager.updateTask` is a no-op when either cache is empty, so post-`bulkCreateTasks` the new tasks live only on the server until something forces a fetch. After this change there is exactly one cache (`_allTasks`); residence detail screens observe it and apply an in-memory filter by `residenceId`. Mutations (`createTask`, `bulkCreateTasks`) force a refresh of `_allTasks` from the server to guarantee freshness with one round-trip instead of relying on conditional branches that silently skip.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin Multiplatform (commonMain), Ktor client, kotlinx.serialization, Combine bridge to SwiftUI iOS, Compose StateFlow on Android. Test framework: `kotlin.test` in commonTest.
|
||||||
|
|
||||||
|
**Affected files:**
|
||||||
|
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt` — remove `_tasksByResidence`, simplify `updateTask`/`removeTask`, add upsert behavior
|
||||||
|
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt` — change `bulkCreateTasks`, `createTask`, `getTasksByResidence`
|
||||||
|
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt` — feed `_residenceTasksState` from a `combine(allTasks, residenceId)` flow
|
||||||
|
- `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` (new) — cache behavior tests
|
||||||
|
- `iosApp/iosApp/Task/TaskViewModel.swift` — drop `$tasksByResidence` sink, filter `$allTasks` when residence-scoped
|
||||||
|
- `iosApp/iosApp/Data/DataManagerObservable.swift` — drop `tasksByResidence` `@Published` and its `for await` task
|
||||||
|
|
||||||
|
**Out of scope (do not touch):**
|
||||||
|
- Backend Go API — `/api/tasks/by-residence/:id/` endpoint stays in place untouched (might still be used by web admin)
|
||||||
|
- Android `ResidenceDetailScreen.kt` — the screen contract (`residenceViewModel.residenceTasksState`) is preserved; only the VM internals change
|
||||||
|
- Disk persistence schema — kotlinx.serialization is configured with `ignoreUnknownKeys` for forward/backward compat (verified in Task 11)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-flight
|
||||||
|
|
||||||
|
### Task 0: Verify clean state and baseline
|
||||||
|
|
||||||
|
**Files:** none (read only)
|
||||||
|
|
||||||
|
**Step 1: Confirm working tree is clean (or only the expected exception)**
|
||||||
|
|
||||||
|
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP status --short`
|
||||||
|
Expected: empty, **or** the only line is `M composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt`. That single file is intentionally on `Environment.LOCAL` for the duration of this work — it stays uncommitted and gets flipped back to `Environment.PROD` in Task 11 Step 5. If anything else shows up, stop and ask the user.
|
||||||
|
|
||||||
|
**Step 2: Confirm we're on a feature branch (not master)**
|
||||||
|
|
||||||
|
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP rev-parse --abbrev-ref HEAD`
|
||||||
|
Expected: NOT `master`. If `master`, run `git checkout -b fix/task-cache-unification` before continuing.
|
||||||
|
|
||||||
|
**Step 3: Run the existing commonTest baseline so we know what currently passes**
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||||
|
Expected: BUILD SUCCESSFUL. Note the count — every later run must keep ≥ this count of green tests.
|
||||||
|
|
||||||
|
**Step 4: Build iOS to confirm starting point compiles**
|
||||||
|
|
||||||
|
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
|
||||||
|
Expected: `** BUILD SUCCEEDED **`
|
||||||
|
|
||||||
|
No commit — this is verification only.
|
||||||
|
|
||||||
|
### Task 0.5: Failing regression XCUITest — reproduce the bug end-to-end
|
||||||
|
|
||||||
|
**Goal:** Write a UI test that drives the exact onboarding-to-residence-detail flow from gitea#2 and asserts that tasks appear on the residence detail screen without an app restart. Run it now — it MUST fail. The Phase 1-3 fixes will make it pass; Task 12 re-runs it as the final gate.
|
||||||
|
|
||||||
|
**Why before the unit tests:** The unit tests in Phase 1 catch the bug at the cache layer, but the *user-facing* bug is "I tap my residence and see 'no tasks'". A passing UI test is the only thing that proves the user experience is actually fixed. Writing it once up front + running it once at the end is cheaper than running a full UI cycle every iteration.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift`
|
||||||
|
- Maybe modify: `iosApp/HoneyDueUITests/AccessibilityIdentifiers.swift` (only if missing IDs are needed — see Step 2)
|
||||||
|
- Maybe modify: SwiftUI views in `iosApp/iosApp/` (only if the view layer is missing accessibility identifiers — see Step 2)
|
||||||
|
|
||||||
|
**Pre-requisites already satisfied by Task 0 setup:**
|
||||||
|
- iOS app is on `Environment.LOCAL`
|
||||||
|
- Docker stack is up and healthy at `http://127.0.0.1:8000`
|
||||||
|
- `DEBUG=true` on the local API → email confirmation code is fixed at `123456` (saves a manual step in the test)
|
||||||
|
|
||||||
|
**Step 1: Pick a clean run by wiping prior simulator state**
|
||||||
|
|
||||||
|
Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev`
|
||||||
|
Run: `docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down && docker volume rm honeydueapi-go_postgres_data && docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d`
|
||||||
|
Wait until: `curl -fsS http://127.0.0.1:8000/api/health/` returns 200.
|
||||||
|
|
||||||
|
**Step 2: Audit accessibility identifiers along the test path**
|
||||||
|
|
||||||
|
The test taps and asserts on these SwiftUI surfaces. Open `iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift` and verify (or add) identifiers for each. Use stable, non-localized strings.
|
||||||
|
|
||||||
|
Surfaces to identify:
|
||||||
|
| Where | Why the test needs it | Suggested ID constant |
|
||||||
|
|---|---|---|
|
||||||
|
| Login/Register screen — username, email, password, first/last name fields, "Register" button, "Verify" code field | Drive registration | already in `AccessibilityIdentifiers.Authentication.*` per existing UI tests — verify by grep |
|
||||||
|
| Onboarding — residence-creation form (name field + Continue) | Drive residence creation | `AccessibilityIdentifiers.Onboarding.residenceNameField`, `.continueButton` (add if missing) |
|
||||||
|
| Onboarding First-Task screen — "Browse All" tab button | Switch from suggestions to browse | `AccessibilityIdentifiers.Onboarding.browseAllTab` (add if missing) |
|
||||||
|
| Onboarding First-Task screen — each template row (selectable) | Pick 3 tasks | `AccessibilityIdentifiers.Onboarding.templateRowPrefix` (e.g., `"onboarding.template.<id>"`) — see how `OnboardingFirstTaskView.swift` renders rows; add an `.accessibilityIdentifier(...)` keyed on `template.id` |
|
||||||
|
| Onboarding First-Task screen — Submit button | Trigger bulk-create | `AccessibilityIdentifiers.Onboarding.submitTasksButton` (add if missing) |
|
||||||
|
| Residence list / home — the residence cell | Tap into detail | `AccessibilityIdentifiers.Residence.cellPrefix` (e.g., `"residence.cell.<name>"` or `<id>`) — verify in `ResidenceListView` or wherever the post-onboarding landing screen renders cells |
|
||||||
|
| Residence detail — task row | Assert presence | `AccessibilityIdentifiers.Task.rowPrefix` (e.g., `"task.row"`) — verify the task list inside `TasksSectionContainer` in `ResidenceDetailView.swift:538` |
|
||||||
|
| Residence detail — empty state ("No tasks" copy) | Assert ABSENCE | `AccessibilityIdentifiers.Task.noTasksLabel` (add if missing) — find the empty-state copy in the residence-detail tasks section and pin an identifier on it |
|
||||||
|
|
||||||
|
For each missing ID, add it in two places:
|
||||||
|
1. The constant in `AccessibilityIdentifiers.swift`
|
||||||
|
2. `.accessibilityIdentifier(AccessibilityIdentifiers.X.Y)` on the SwiftUI view
|
||||||
|
|
||||||
|
Keep these app-side additions in **a single dedicated commit** so reviewers can see "test scaffolding only, no behavior change":
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: add accessibility identifiers along the onboarding-to-residence-detail path
|
||||||
|
|
||||||
|
Scaffolding for the gitea#2 regression XCUITest. No user-visible
|
||||||
|
change — pure metadata for UI automation."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Write the failing UI test**
|
||||||
|
|
||||||
|
Create `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Regression test for gitea#2.
|
||||||
|
///
|
||||||
|
/// Onboarding flow: register → create residence → pick 3 tasks → submit.
|
||||||
|
/// After submit, the user lands on the home/residences screen. They tap
|
||||||
|
/// the new residence WITHOUT visiting the Tasks tab first (the Tasks tab
|
||||||
|
/// triggers a `getTasks()` that masks the bug by populating `_allTasks`).
|
||||||
|
///
|
||||||
|
/// Expected: residence detail shows ≥1 task row within 10s.
|
||||||
|
/// Pre-fix: residence detail shows empty state ("no tasks") forever
|
||||||
|
/// until the app is restarted.
|
||||||
|
final class Suite11_TaskCacheRegressionTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments += ["UI-Testing"]
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// 1. Register a fresh user. Email confirmation code is fixed at "123456"
|
||||||
|
// in DEBUG mode (DEBUG_FIXED_CODES=true on the local docker stack).
|
||||||
|
let stamp = String(Int(Date().timeIntervalSince1970))
|
||||||
|
UITestHelpers.register(
|
||||||
|
in: app,
|
||||||
|
username: "uitest\(stamp)",
|
||||||
|
email: "uitest+\(stamp)@treymail.com",
|
||||||
|
password: "UItest\(stamp)!aZ",
|
||||||
|
confirmationCode: "123456"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Onboarding: create the residence.
|
||||||
|
UITestHelpers.completeResidenceCreation(in: app, name: "UI Test Property")
|
||||||
|
|
||||||
|
// 3. Switch to "Browse All" tab and pick 3 templates. The "For You"
|
||||||
|
// suggestions tab depends on a server-side recommendation that
|
||||||
|
// might be empty for a freshly created residence; Browse is
|
||||||
|
// deterministic.
|
||||||
|
let browseTab = app.buttons[AccessibilityIdentifiers.Onboarding.browseAllTab]
|
||||||
|
XCTAssertTrue(browseTab.waitForExistence(timeout: 5),
|
||||||
|
"Browse All tab must appear on First-Task screen")
|
||||||
|
browseTab.tap()
|
||||||
|
|
||||||
|
let templates = app.buttons.matching(
|
||||||
|
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||||
|
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for the catalog to load — fresh API call against local backend.
|
||||||
|
XCTAssertTrue(templates.element(boundBy: 0).waitForExistence(timeout: 10),
|
||||||
|
"Template catalog must load")
|
||||||
|
|
||||||
|
for i in 0..<3 {
|
||||||
|
templates.element(boundBy: i).tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Submit. This calls APILayer.bulkCreateTasks → POST /api/tasks/bulk/
|
||||||
|
// The bug lives in the cache update path between this call returning
|
||||||
|
// and the residence detail screen rendering.
|
||||||
|
let submit = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
|
||||||
|
XCTAssertTrue(submit.waitForExistence(timeout: 3))
|
||||||
|
submit.tap()
|
||||||
|
|
||||||
|
// 5. Land on home/residences. Tap the residence we just created.
|
||||||
|
// Critical: do NOT visit the Tasks tab — that would call getTasks()
|
||||||
|
// and populate _allTasks via setAllTasks, masking the bug.
|
||||||
|
let residenceCell = app.buttons[
|
||||||
|
AccessibilityIdentifiers.Residence.cellPrefix + "UI Test Property"
|
||||||
|
]
|
||||||
|
XCTAssertTrue(residenceCell.waitForExistence(timeout: 10),
|
||||||
|
"Residence cell must appear on home after onboarding submit")
|
||||||
|
residenceCell.tap()
|
||||||
|
|
||||||
|
// 6. Residence detail must show ≥1 task row, NOT the empty state.
|
||||||
|
// Generous timeout (10s) covers the network round-trip on slow
|
||||||
|
// local Docker startups.
|
||||||
|
let firstTaskRow = app.cells
|
||||||
|
.matching(NSPredicate(format: "identifier BEGINSWITH %@",
|
||||||
|
AccessibilityIdentifiers.Task.rowPrefix))
|
||||||
|
.firstMatch
|
||||||
|
XCTAssertTrue(
|
||||||
|
firstTaskRow.waitForExistence(timeout: 10),
|
||||||
|
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
|
||||||
|
)
|
||||||
|
|
||||||
|
let emptyState = app.staticTexts[AccessibilityIdentifiers.Task.noTasksLabel]
|
||||||
|
XCTAssertFalse(
|
||||||
|
emptyState.exists,
|
||||||
|
"Empty 'no tasks' state must NOT show when tasks exist (gitea#2)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 7. Cleanup — delete the test user via UI (or skip; clearing the
|
||||||
|
// docker volume between runs is the cheaper reset).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes for the engineer writing this test:**
|
||||||
|
|
||||||
|
- `UITestHelpers.register(...)` and `UITestHelpers.completeResidenceCreation(...)` may not exist verbatim — read `iosApp/HoneyDueUITests/UITestHelpers.swift` for the existing helpers. If `register(...)` exists but doesn't take a `confirmationCode:` arg, either add one or inline the verification step.
|
||||||
|
- DO NOT use `sleep()` anywhere. Use `waitForExistence(timeout:)` everywhere. The skill `axiom-ui-testing` is loaded if you need patterns.
|
||||||
|
- `continueAfterFailure = false` so we stop at the exact assertion that fails — easier to triage video.
|
||||||
|
- If you can't get a residence cell identifier reliably (e.g., the home screen shows a custom layout, not standard cells), substitute `app.staticTexts["UI Test Property"]` and tap that. The point is to land on the residence detail without going through the Tasks tab.
|
||||||
|
|
||||||
|
**Step 4: Run the test — must FAIL**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```
|
||||||
|
xcodebuild -project iosApp/honeyDue.xcodeproj \
|
||||||
|
-scheme HoneyDueUITests \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
|
||||||
|
test 2>&1 | tail -40
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Test Suite '...' failed.` with the assertion **"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"**.
|
||||||
|
|
||||||
|
If it FAILS for a different reason (residence cell not found, timeout on browse tab, etc.) → that's an accessibility-identifier mismatch, not a bug repro. Fix the test/IDs and re-run. The test must fail SPECIFICALLY on the "no tasks on residence detail" assertion to be a valid bug capture.
|
||||||
|
|
||||||
|
If it PASSES → the bug isn't reproducing in this environment. Possibilities:
|
||||||
|
- App was already cached with `_allTasks` from a prior run (re-run Step 1 to fully wipe simulator + DB)
|
||||||
|
- The user navigated through the Tasks tab implicitly (check the home screen layout)
|
||||||
|
- The bug only happens on a code path you didn't replicate (re-read the iOS-side onboarding flow)
|
||||||
|
|
||||||
|
**Step 5: Commit the failing test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — onboarding tasks must appear on residence detail without restart
|
||||||
|
|
||||||
|
Captures gitea#2 at the user-visible level. The kanban tab works but
|
||||||
|
the residence detail screen does not, until the app is restarted. This
|
||||||
|
test must FAIL at this commit and PASS after the cache unification work.
|
||||||
|
Re-run gates the merge in Task 12."
|
||||||
|
```
|
||||||
|
|
||||||
|
The test stays failing through Phase 1-3 commits. Don't run it on every commit — it's slow. Run it once at the end (Task 12).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — TDD: catch the bug, then fix it
|
||||||
|
|
||||||
|
### Task 1: Failing test — `bulkCreateTasks` must populate `_allTasks`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
This test reproduces the onboarding bug at the cache layer. We can't easily mock Ktor here without infrastructure, so we test the cache mutation contract directly: after a successful bulk-create, `_allTasks` MUST contain every returned task, regardless of prior cache state.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package com.tt.honeyDue.data
|
||||||
|
|
||||||
|
import com.tt.honeyDue.models.TaskResponse
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
|
||||||
|
class DataManagerTaskCacheTest {
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun resetState() {
|
||||||
|
DataManager.clearAllData()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateTask seeds _allTasks when cache is empty`() {
|
||||||
|
// Given: fresh DataManager with no tasks loaded (the onboarding scenario)
|
||||||
|
assertEquals(null, DataManager.allTasks.value)
|
||||||
|
|
||||||
|
// When: a new task arrives via the same path bulkCreateTasks uses
|
||||||
|
val task = TaskResponse(
|
||||||
|
id = 1,
|
||||||
|
residenceId = 100,
|
||||||
|
title = "Replace HVAC filter",
|
||||||
|
kanbanColumn = "upcoming_tasks",
|
||||||
|
// ... fill remaining required TaskResponse fields with sensible defaults
|
||||||
|
)
|
||||||
|
DataManager.updateTask(task)
|
||||||
|
|
||||||
|
// Then: _allTasks is populated with this task in the right column
|
||||||
|
val allTasks = DataManager.allTasks.value
|
||||||
|
assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null")
|
||||||
|
|
||||||
|
val upcomingColumn = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" }
|
||||||
|
assertNotNull(upcomingColumn)
|
||||||
|
assertTrue(
|
||||||
|
upcomingColumn.tasks.any { it.id == 1 },
|
||||||
|
"task must land in the upcoming_tasks column"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll need to look at `TaskResponse` in `composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/CustomTask.kt` to fill the required fields. Use defaults that match an onboarding-created task (no completion, no priority, due-soon date).
|
||||||
|
|
||||||
|
**Step 2: Run the test — must FAIL**
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"`
|
||||||
|
Expected: FAIL with `expected:<not null> but was:<null>` (or similar). This proves the test catches the bug.
|
||||||
|
|
||||||
|
**Step 3: Commit the failing test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — DataManager.updateTask must seed _allTasks"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Make `DataManager.updateTask` a real upsert
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt:482-520`
|
||||||
|
|
||||||
|
**Step 1: Replace the conditional branch on `_allTasks` with an upsert**
|
||||||
|
|
||||||
|
Current (lines 484-498):
|
||||||
|
```kotlin
|
||||||
|
_allTasks.value?.let { current ->
|
||||||
|
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||||
|
val newColumns = current.columns.map { column -> ... }
|
||||||
|
_allTasks.value = current.copy(columns = newColumns)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```kotlin
|
||||||
|
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||||
|
val current = _allTasks.value ?: TaskColumnsResponse(
|
||||||
|
columns = standardKanbanColumns(), // see Step 2
|
||||||
|
daysThreshold = 30,
|
||||||
|
residenceId = null
|
||||||
|
)
|
||||||
|
val newColumns = current.columns.map { column ->
|
||||||
|
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||||
|
val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks
|
||||||
|
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||||
|
}
|
||||||
|
// If targetColumn doesn't exist in current.columns (e.g. fresh seed), append it
|
||||||
|
val finalColumns = if (newColumns.none { it.name == targetColumn }) {
|
||||||
|
newColumns + Column(name = targetColumn, tasks = listOf(task), count = 1, /* fill rest */)
|
||||||
|
} else newColumns
|
||||||
|
_allTasks.value = current.copy(columns = finalColumns)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add `standardKanbanColumns()` helper**
|
||||||
|
|
||||||
|
Look at the backend response — `internal/repositories/task_repo.go` `GetKanbanDataForMultipleResidences` defines the column order. Mirror it in Kotlin:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun standardKanbanColumns(): List<Column> = listOf(
|
||||||
|
Column(name = "overdue_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
||||||
|
Column(name = "due_soon_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
||||||
|
Column(name = "in_progress_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
||||||
|
Column(name = "upcoming_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
||||||
|
Column(name = "completed_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
||||||
|
// archived/cancelled are hidden from kanban — see honeyDueAPI-go/CLAUDE.md
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Look at `Column` in `CustomTask.kt` for its required fields (display label, color, etc.). Fill in matching defaults.
|
||||||
|
|
||||||
|
**Step 3: Drop the second branch (`_tasksByResidence`) — it's going away in Phase 3**
|
||||||
|
|
||||||
|
Remove lines 500-515 entirely. The `_tasksByResidence` slot is still there for now (Phase 3 deletes it), but `updateTask` should not write to it any more.
|
||||||
|
|
||||||
|
**Step 4: Run the test — must PASS**
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Run the full test suite to confirm no regressions**
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||||
|
Expected: same green count as Task 0 baseline + 1 new test.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "fix: DataManager.updateTask seeds _allTasks when cache is empty
|
||||||
|
|
||||||
|
Closes the silent no-op when _allTasks is null on first launch (the
|
||||||
|
onboarding bulkCreateTasks path). The function now upserts: builds an
|
||||||
|
empty kanban shell if needed and places the task in its target column.
|
||||||
|
Adds an unknown column at the end for forward compatibility with future
|
||||||
|
column names from the server.
|
||||||
|
|
||||||
|
Refs gitea#2"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Add upsert test for `_tasksByResidence` deletion guard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
|
||||||
|
|
||||||
|
**Step 1: Add a test asserting `updateTask` does NOT touch `_tasksByResidence` any more**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `updateTask no longer mutates _tasksByResidence`() {
|
||||||
|
val before = DataManager.tasksByResidence.value
|
||||||
|
DataManager.updateTask(/* sample task */)
|
||||||
|
assertEquals(before, DataManager.tasksByResidence.value,
|
||||||
|
"updateTask must not touch _tasksByResidence — it's deprecated")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run — must PASS** (we already removed the branch in Task 2)
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask no longer mutates _tasksByResidence"`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: lock down that updateTask no longer writes _tasksByResidence"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Belt-and-suspenders: force-refresh after mutations
|
||||||
|
|
||||||
|
The Phase 1 upsert handles the fresh-cache case correctly, but it makes assumptions about kanban column placement based on the response's `kanbanColumn` field. The server is the authoritative kanban categorizer. To eliminate any drift, also force a `_allTasks` refresh after multi-task mutations.
|
||||||
|
|
||||||
|
### Task 4: Force `_allTasks` refresh after `bulkCreateTasks`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:647-655`
|
||||||
|
|
||||||
|
**Step 1: Add post-success refresh**
|
||||||
|
|
||||||
|
Current:
|
||||||
|
```kotlin
|
||||||
|
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
||||||
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
|
val result = taskApi.bulkCreateTasks(token, request)
|
||||||
|
|
||||||
|
if (result is ApiResult.Success) {
|
||||||
|
DataManager.setTotalSummary(result.data.summary)
|
||||||
|
result.data.tasks.forEach { DataManager.updateTask(it) }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```kotlin
|
||||||
|
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
||||||
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
|
val result = taskApi.bulkCreateTasks(token, request)
|
||||||
|
|
||||||
|
if (result is ApiResult.Success) {
|
||||||
|
DataManager.setTotalSummary(result.data.summary)
|
||||||
|
// Authoritative refresh — server knows the right kanban placement.
|
||||||
|
// Cheap (one round-trip) and eliminates any client-side drift between
|
||||||
|
// the per-task kanbanColumn hint and the global kanban view.
|
||||||
|
getTasks(forceRefresh = true)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Drop the `forEach { updateTask }` — it becomes redundant with the force-refresh.
|
||||||
|
|
||||||
|
**Step 2: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||||
|
Expected: all green (the Phase 1 upsert tests still pass because they exercise `updateTask` directly, not `bulkCreateTasks`).
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "fix: bulkCreateTasks force-refreshes _allTasks instead of merging task-by-task
|
||||||
|
|
||||||
|
Server is the authoritative kanban categorizer. After a bulk insert,
|
||||||
|
re-fetch /api/tasks/ so the kanban view reflects exactly what the
|
||||||
|
server sees, including any column re-categorizations the client's
|
||||||
|
in-memory upsert wouldn't compute. One extra round-trip per onboarding
|
||||||
|
submission, called once per session typically.
|
||||||
|
|
||||||
|
Refs gitea#2"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Collapse the dual cache
|
||||||
|
|
||||||
|
### Task 5: Characterization test for `getTasksForResidence`
|
||||||
|
|
||||||
|
`getTasksForResidence` already implements the filter we want to use everywhere. Lock it down with a test before we make it the primary path.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
|
||||||
|
|
||||||
|
**Step 1: Add the test**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `getTasksForResidence filters _allTasks by residence id`() {
|
||||||
|
DataManager.setAllTasks(/* response with tasks across residences 100 and 200 */)
|
||||||
|
|
||||||
|
val r100 = DataManager.getTasksForResidence(100)
|
||||||
|
assertNotNull(r100)
|
||||||
|
assertTrue(r100.columns.flatMap { it.tasks }.all { it.residenceId == 100 })
|
||||||
|
|
||||||
|
val r999 = DataManager.getTasksForResidence(999)
|
||||||
|
assertNotNull(r999)
|
||||||
|
assertEquals(0, r999.columns.sumOf { it.tasks.size }) // valid id, just no tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTasksForResidence returns null when _allTasks is null`() {
|
||||||
|
DataManager.clearAllData()
|
||||||
|
assertEquals(null, DataManager.getTasksForResidence(100))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run — must PASS** (no implementation change yet)
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.getTasksForResidence*"`
|
||||||
|
Expected: PASS for both.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: characterize getTasksForResidence filter contract"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Simplify `APILayer.getTasksByResidence`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:591-621`
|
||||||
|
|
||||||
|
**Step 1: Replace the 3-path implementation with "ensure _allTasks fresh, then filter"**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
||||||
|
// Ensure _allTasks is loaded and reasonably fresh.
|
||||||
|
// getTasks itself respects forceRefresh and the global tasksCacheTime.
|
||||||
|
val allTasksResult = getTasks(forceRefresh = forceRefresh)
|
||||||
|
if (allTasksResult is ApiResult.Error) return allTasksResult
|
||||||
|
|
||||||
|
val filtered = DataManager.getTasksForResidence(residenceId)
|
||||||
|
?: return ApiResult.Error("Tasks unavailable", 0)
|
||||||
|
return ApiResult.Success(filtered)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This deletes the per-residence cache reliance entirely. `_tasksByResidence` is no longer written by this path.
|
||||||
|
|
||||||
|
**Step 2: Run the test suite**
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||||
|
Expected: all green.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "refactor: getTasksByResidence is now a thin filter over _allTasks
|
||||||
|
|
||||||
|
Was 3 fallback paths (per-residence cache → filter from allTasks →
|
||||||
|
network). Now: ensure _allTasks fresh, return filter. The per-residence
|
||||||
|
cache becomes write-only by this path, scheduled for deletion in the
|
||||||
|
next commit."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: iOS — `TaskViewModel` observes `$allTasks` with filter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `iosApp/iosApp/Task/TaskViewModel.swift:46-74`
|
||||||
|
|
||||||
|
**Step 1: Replace dual-sink with single-sink + filter**
|
||||||
|
|
||||||
|
Current logic uses two Combine sinks: `$allTasks` (only when `currentResidenceId == nil`) and `$tasksByResidence` (only when set).
|
||||||
|
|
||||||
|
Replace with one sink on `$allTasks` that conditionally filters:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
DataManagerObservable.shared.$allTasks
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] allTasks in
|
||||||
|
guard let self else { return }
|
||||||
|
guard !self.isAnimatingCompletion else { return }
|
||||||
|
|
||||||
|
if let allTasks {
|
||||||
|
if let resId = self.currentResidenceId {
|
||||||
|
self.tasksResponse = self.filterByResidence(allTasks, residenceId: resId)
|
||||||
|
} else {
|
||||||
|
self.tasksResponse = allTasks
|
||||||
|
}
|
||||||
|
self.isLoadingTasks = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add the `filterByResidence` helper**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private func filterByResidence(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse {
|
||||||
|
let filteredColumns = response.columns.map { column -> Column in
|
||||||
|
let filteredTasks = column.tasks.filter { Int32($0.residenceId ?? 0) == residenceId }
|
||||||
|
return column.copy(tasks: filteredTasks, count: Int32(filteredTasks.count))
|
||||||
|
}
|
||||||
|
return response.copy(columns: filteredColumns, residenceId: String(residenceId))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Use `.doCopy(...)` SKIE syntax if `.copy` isn't directly callable from Swift — check what other Swift code does with TaskColumnsResponse copies.)
|
||||||
|
|
||||||
|
**Step 3: Drop the `$tasksByResidence` subscription block entirely**
|
||||||
|
|
||||||
|
Remove the second `.sink` on `$tasksByResidence` (currently lines 62-74).
|
||||||
|
|
||||||
|
**Step 4: Build iOS**
|
||||||
|
|
||||||
|
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
|
||||||
|
Expected: `** BUILD SUCCEEDED **`
|
||||||
|
|
||||||
|
**Step 5: Run iOS unit tests**
|
||||||
|
|
||||||
|
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20`
|
||||||
|
Expected: TEST SUCCEEDED.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/iosApp/Task/TaskViewModel.swift
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "ios: TaskViewModel observes \$allTasks and filters by residence in-memory
|
||||||
|
|
||||||
|
Single source of truth eliminates the race window where the residence
|
||||||
|
detail screen could mount before the per-residence cache slot existed.
|
||||||
|
After this, every emit of _allTasks rerenders every observing view —
|
||||||
|
kanban tab, residence detail, dashboards — atomically.
|
||||||
|
|
||||||
|
Refs gitea#2"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8: Android — `ResidenceViewModel` feeds `residenceTasksState` from a combined flow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt:31-94`
|
||||||
|
|
||||||
|
**Step 1: Replace the imperative `loadResidenceTasks` with a derived flow**
|
||||||
|
|
||||||
|
Look at how `_residenceTasksState` is currently populated (line 88-94). Instead of imperatively calling `APILayer.getTasksByResidence` and storing the result, derive it:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private val _currentResidenceId = MutableStateFlow<Int?>(null)
|
||||||
|
|
||||||
|
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
|
||||||
|
combine(DataManager.allTasks, _currentResidenceId) { all, id ->
|
||||||
|
when {
|
||||||
|
id == null -> ApiResult.Idle
|
||||||
|
all == null -> ApiResult.Loading
|
||||||
|
else -> {
|
||||||
|
val filtered = DataManager.getTasksForResidence(id)
|
||||||
|
if (filtered != null) ApiResult.Success(filtered) else ApiResult.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ApiResult.Idle)
|
||||||
|
|
||||||
|
fun loadResidenceTasks(residenceId: Int, forceRefresh: Boolean = false) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_currentResidenceId.value = residenceId
|
||||||
|
// Trigger the underlying _allTasks refresh; the combine above
|
||||||
|
// re-emits Success when allTasks arrives.
|
||||||
|
APILayer.getTasks(forceRefresh = forceRefresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The screen contract (`residenceViewModel.residenceTasksState`) is preserved — `ResidenceDetailScreen.kt:59` doesn't need any change.
|
||||||
|
|
||||||
|
**Step 2: Build Android debug**
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:assembleDebug`
|
||||||
|
Expected: BUILD SUCCESSFUL.
|
||||||
|
|
||||||
|
**Step 3: Run commonTest again**
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||||
|
Expected: all green. (`ResidenceViewModelTest` may need adjusting — check it.)
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt
|
||||||
|
# Also stage any test fixes
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "android: ResidenceViewModel.residenceTasksState derives from _allTasks
|
||||||
|
|
||||||
|
Same screen contract, but the data flows from DataManager.allTasks
|
||||||
|
through a combine(...) into the existing StateFlow. No per-residence
|
||||||
|
network call needed; the upstream getTasks() refresh propagates."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 9: Delete dead code — `_tasksByResidence` and friends
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt`
|
||||||
|
- Modify: `iosApp/iosApp/Data/DataManagerObservable.swift`
|
||||||
|
|
||||||
|
**Step 1: In DataManager.kt, delete:**
|
||||||
|
|
||||||
|
- `_tasksByResidence` (line 141) and `tasksByResidence` (line 142)
|
||||||
|
- `tasksByResidenceCacheTime` (line 65)
|
||||||
|
- `setTasksForResidence` (lines 448-451)
|
||||||
|
- `invalidateTasksFor` (line 417 — verify it has no other callers first via grep)
|
||||||
|
- `_tasksByResidence` mutations in `removeTask` (lines 533-onwards) — keep only the `_allTasks` removal
|
||||||
|
- `_tasksByResidence.value = emptyMap()` in clearAllData and similar wipes (lines 783, 836)
|
||||||
|
- `tasksByResidenceCacheTime.clear()` in same wipes (lines 814, 849)
|
||||||
|
|
||||||
|
Keep `getTasksForResidence` — it's the public filter API, still used by the new `getTasksByResidence` and Android VM.
|
||||||
|
|
||||||
|
**Step 2: In DataManagerObservable.swift, delete:**
|
||||||
|
|
||||||
|
- `@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]` (line 44)
|
||||||
|
- The `for await tasks in DataManager.shared.tasksByResidence` task (lines 195-201)
|
||||||
|
- The `tasksByResidence[residenceId]` reader at line 524 (replace with `DataManager.shared.getTasksForResidence(residenceId)` or its iOS-friendly equivalent if anything still calls this — grep first)
|
||||||
|
|
||||||
|
**Step 3: Compile both targets**
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:assembleDebug && ./gradlew :composeApp:testDebugUnitTest`
|
||||||
|
Then: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
|
||||||
|
Expected: both BUILD SUCCEEDED, all tests green.
|
||||||
|
|
||||||
|
If anything fails to compile, follow the compiler — there's likely a missed reader. Common suspects: `TaskViewModel.kt` (Kotlin VM, not Swift) line ~38-42 references `_tasksByResidenceState`; verify it's still wired correctly or also delete it.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A
|
||||||
|
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "refactor: delete _tasksByResidence and per-residence task cache plumbing
|
||||||
|
|
||||||
|
All readers and writers gone after the previous commits. Single source
|
||||||
|
of truth = DataManager._allTasks, residence views derive via
|
||||||
|
getTasksForResidence(id). Net deletion ~100 LOC across DataManager,
|
||||||
|
APILayer, DataManagerObservable, and iOS TaskViewModel.
|
||||||
|
|
||||||
|
Closes gitea#2"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Verification
|
||||||
|
|
||||||
|
### Task 10: Verify disk persistence is forward-compatible
|
||||||
|
|
||||||
|
**Files:** none (verification only)
|
||||||
|
|
||||||
|
**Step 1: Find the persistence model**
|
||||||
|
|
||||||
|
Run: `grep -rn "ignoreUnknownKeys\|Json {\|tasksByResidence" composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/PersistenceManager.kt`
|
||||||
|
Expected: kotlinx.serialization Json config with `ignoreUnknownKeys = true`. If NOT, an existing user upgrading the app will crash on first launch when the persisted blob has the now-removed `tasksByResidence` field.
|
||||||
|
|
||||||
|
**Step 2: If `ignoreUnknownKeys` is missing, ADD IT BEFORE SHIPPING**
|
||||||
|
|
||||||
|
Edit the Json config:
|
||||||
|
```kotlin
|
||||||
|
val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
// ... existing config
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Commit separately as `chore: persistence Json must ignoreUnknownKeys`.
|
||||||
|
|
||||||
|
**Step 3: Manual test — wipe simulator app data, install old app, then this version**
|
||||||
|
|
||||||
|
If you have a TestFlight build of the previous version:
|
||||||
|
1. Install old version → register → create residence → quit
|
||||||
|
2. Update to this build → launch → confirm no crash, data loads
|
||||||
|
3. Quit → relaunch → confirm persistence works correctly
|
||||||
|
|
||||||
|
If no old TestFlight build available, skip this empirical check but the `ignoreUnknownKeys` setting is sufficient.
|
||||||
|
|
||||||
|
### Task 11: Manual smoke — the actual bug repro
|
||||||
|
|
||||||
|
**Files:** none (manual test)
|
||||||
|
|
||||||
|
**Step 1: Wipe simulator state for the dev build**
|
||||||
|
|
||||||
|
Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev`
|
||||||
|
|
||||||
|
**Step 2: Confirm iOS is on LOCAL (set during pre-flight, stays uncommitted)**
|
||||||
|
|
||||||
|
Run: `grep "CURRENT_ENV" /Users/treyt/Desktop/code/honeyDue/honeyDueKMP/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt`
|
||||||
|
Expected: `val CURRENT_ENV = Environment.LOCAL`. If it's not, edit the file. DO NOT COMMIT — revert in Step 5.
|
||||||
|
|
||||||
|
**Step 3: Build and install**
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:assembleDebug` and Xcode build to simulator.
|
||||||
|
|
||||||
|
**Step 4: Reproduce the original bug path**
|
||||||
|
|
||||||
|
1. Launch app → land on register screen
|
||||||
|
2. Register a fresh user with a unique email
|
||||||
|
3. Onboarding: create residence → choose 3+ tasks from the catalog → submit
|
||||||
|
4. Land on home/dashboard
|
||||||
|
5. Navigate to the new residence's detail screen WITHOUT visiting the Tasks tab first
|
||||||
|
6. **Expected: tasks visible immediately. No "no tasks" state. No restart needed.**
|
||||||
|
|
||||||
|
If the bug still reproduces, return to Phase 1 — the upsert or refresh isn't working. Capture iOS console with `xclog launch com.myhoneydue.honeyDue.dev` and inspect.
|
||||||
|
|
||||||
|
**Step 5: Revert the ApiConfig change**
|
||||||
|
|
||||||
|
Edit `ApiConfig.kt` back to `Environment.PROD` (or whatever it was). Confirm with `git diff`. Do not commit.
|
||||||
|
|
||||||
|
### Task 12: Final regression sweep
|
||||||
|
|
||||||
|
**Files:** none (verification only)
|
||||||
|
|
||||||
|
**Step 1: Full Kotlin test suite green**
|
||||||
|
|
||||||
|
Run: `./gradlew :composeApp:testDebugUnitTest`
|
||||||
|
|
||||||
|
**Step 2: iOS unit tests green**
|
||||||
|
|
||||||
|
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDue -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20`
|
||||||
|
|
||||||
|
**Step 3: The Task 0.5 regression XCUITest now passes**
|
||||||
|
|
||||||
|
Wipe state for a clean run (mirrors Task 0.5 Step 1):
|
||||||
|
```
|
||||||
|
xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev
|
||||||
|
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down
|
||||||
|
docker volume rm honeydueapi-go_postgres_data
|
||||||
|
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait for `curl -fsS http://127.0.0.1:8000/api/health/` → 200. Then re-run the regression test:
|
||||||
|
|
||||||
|
```
|
||||||
|
xcodebuild -project iosApp/honeyDue.xcodeproj \
|
||||||
|
-scheme HoneyDueUITests \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
|
||||||
|
test 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: **TEST SUCCEEDED**. The "Tasks created during onboarding must appear on residence detail without restart" assertion now holds.
|
||||||
|
|
||||||
|
**If it FAILS:** the cache fix is incomplete. Inspect the test report video (`xcrun xcresulttool get --path build/reports/...xcresult ...`) and follow the failure point. Common causes: missed updateTask call site in the dual-cache deletion, a residual reader of `_tasksByResidence` in iOS not pruned, or a race between `getTasks(forceRefresh=true)` and the residence detail's first observation. **DO NOT** weaken the test to make it pass — fix the underlying issue.
|
||||||
|
|
||||||
|
**Step 4: Stress run (catch flakiness before merge)**
|
||||||
|
|
||||||
|
Run the test 5× to confirm it's stable, not just lucky:
|
||||||
|
```
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev
|
||||||
|
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down >/dev/null
|
||||||
|
docker volume rm honeydueapi-go_postgres_data >/dev/null
|
||||||
|
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d >/dev/null
|
||||||
|
until curl -fsS http://127.0.0.1:8000/api/health/ >/dev/null 2>&1; do sleep 2; done
|
||||||
|
xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDueUITests -sdk iphonesimulator \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
|
||||||
|
test 2>&1 | tail -3
|
||||||
|
echo "=== run $i done ==="
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 5/5 TEST SUCCEEDED. If even one fails, treat as flaky — don't merge.
|
||||||
|
|
||||||
|
**Step 5: Diff summary**
|
||||||
|
|
||||||
|
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP diff --stat master...HEAD`
|
||||||
|
Expected: net deletion ~80-150 lines across the listed files. If the diff is much larger, scope creep — review commits.
|
||||||
|
|
||||||
|
**Step 6: Push and open a PR (only if user confirms)**
|
||||||
|
|
||||||
|
Don't push without asking the user. Wait for explicit go-ahead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback plan
|
||||||
|
|
||||||
|
If anything goes sideways in production:
|
||||||
|
1. `git revert <merge-commit-sha>` — every commit in this plan is independently revertable in reverse order, but the cleanest rollback is reverting the merge commit.
|
||||||
|
2. Old persistence blob format is preserved by `ignoreUnknownKeys` — no migration required.
|
||||||
|
3. Backend `/api/tasks/by-residence/:id/` was never touched, so a rolled-back client immediately starts using it again with no server change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the executing engineer
|
||||||
|
|
||||||
|
- **Frequent commits.** Every task ends with a commit. If you deviate from the plan, commit before deviating.
|
||||||
|
- **Don't auto-commit any other changes.** Per `honeyDueKMP/CLAUDE.md`: "DO NOT auto-commit code changes." Commit only what's specified.
|
||||||
|
- **Don't push to remote.** Let the user trigger the push after they review.
|
||||||
|
- **TaskColumnsResponse fields.** The `Column` data class in `CustomTask.kt` may have more fields than shown (display label, color, sort order). Read it before writing the standard column shell in Task 2 — the test will fail on missing required constructor args.
|
||||||
|
- **TaskResponse fields.** Same — has many fields. For test fixtures, build a small helper:
|
||||||
|
```kotlin
|
||||||
|
private fun sampleTask(id: Int, residenceId: Int, column: String) = TaskResponse(...)
|
||||||
|
```
|
||||||
|
in the test file rather than repeating the giant constructor.
|
||||||
|
- **SKIE/Swift copy.** TaskColumnsResponse `.copy()` from Swift may need `.doCopy(...)` if SKIE renamed it. Check `iosApp/iosApp/Task/TaskViewModel.swift` line 387 onward for an existing example of how Swift copies a Kotlin data class.
|
||||||
|
- **Don't refactor "while you're here."** This plan is laser-focused on the cache unification. Other smells you spot — log them, don't fix them in this PR.
|
||||||
@@ -15,6 +15,6 @@
|
|||||||
<key>manageAppVersionAndBuildNumber</key>
|
<key>manageAppVersionAndBuildNumber</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>teamID</key>
|
<key>teamID</key>
|
||||||
<string>V3PF3M6B6U</string>
|
<string>X86BR9WTLD</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ final class WidgetActionManager {
|
|||||||
static let shared = WidgetActionManager()
|
static let shared = WidgetActionManager()
|
||||||
|
|
||||||
private let appGroupIdentifier: String = {
|
private let appGroupIdentifier: String = {
|
||||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
||||||
}()
|
}()
|
||||||
private let pendingTasksFileName = "widget_pending_tasks.json"
|
private let pendingTasksFileName = "widget_pending_tasks.json"
|
||||||
private let tokenKey = "widget_auth_token"
|
private let tokenKey = "widget_auth_token"
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class CacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let appGroupIdentifier: String = {
|
private static let appGroupIdentifier: String = {
|
||||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
||||||
}()
|
}()
|
||||||
private static let tasksFileName = "widget_tasks.json"
|
private static let tasksFileName = "widget_tasks.json"
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"defaultOptions" : {
|
"defaultOptions" : {
|
||||||
"testTimeoutsEnabled" : true,
|
|
||||||
"defaultTestExecutionTimeAllowance" : 300,
|
"defaultTestExecutionTimeAllowance" : 300,
|
||||||
"targetForVariableExpansion" : {
|
"targetForVariableExpansion" : {
|
||||||
"containerPath" : "container:honeyDue.xcodeproj",
|
"containerPath" : "container:honeyDue.xcodeproj",
|
||||||
"identifier" : "D4ADB376A7A4CFB73469E173",
|
"identifier" : "D4ADB376A7A4CFB73469E173",
|
||||||
"name" : "HoneyDue"
|
"name" : "HoneyDue"
|
||||||
}
|
},
|
||||||
|
"testTimeoutsEnabled" : true
|
||||||
},
|
},
|
||||||
"testTargets" : [
|
"testTargets" : [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -661,7 +661,7 @@
|
|||||||
0248CABA5A5197845F2E5C26 /* Release */ = {
|
0248CABA5A5197845F2E5C26 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue;
|
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue;
|
||||||
ARCHS = arm64;
|
ARCHS = arm64;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
@@ -669,14 +669,15 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs access to your camera to take photos of completed tasks";
|
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs access to your photo library to select photos of completed tasks";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -686,7 +687,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -696,14 +697,14 @@
|
|||||||
1C0789552EBC218D00392B46 /* Debug */ = {
|
1C0789552EBC218D00392B46 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue.dev;
|
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue.dev;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDue/Info.plist;
|
INFOPLIST_FILE = HoneyDue/Info.plist;
|
||||||
@@ -717,7 +718,7 @@
|
|||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -732,14 +733,14 @@
|
|||||||
1C0789562EBC218D00392B46 /* Release */ = {
|
1C0789562EBC218D00392B46 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue;
|
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDue/Info.plist;
|
INFOPLIST_FILE = HoneyDue/Info.plist;
|
||||||
@@ -753,7 +754,7 @@
|
|||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -772,12 +773,12 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
@@ -798,12 +799,12 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
@@ -823,7 +824,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
|
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
|
||||||
@@ -836,7 +837,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueQLPreview;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueQLPreview;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -854,7 +855,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
|
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
|
||||||
@@ -867,7 +868,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueQLPreview;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueQLPreview;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -885,7 +886,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
|
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
|
||||||
@@ -898,7 +899,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueQLThumbnail;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueQLThumbnail;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -916,7 +917,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
|
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
|
||||||
@@ -929,7 +930,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueQLThumbnail;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueQLThumbnail;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -946,13 +947,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.1;
|
MACOSX_DEPLOYMENT_TARGET = 26.1;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -972,13 +973,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.1;
|
MACOSX_DEPLOYMENT_TARGET = 26.1;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -1121,7 +1122,7 @@
|
|||||||
E767E942685C7832D51FF978 /* Debug */ = {
|
E767E942685C7832D51FF978 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue.dev;
|
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue.dev;
|
||||||
ARCHS = arm64;
|
ARCHS = arm64;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
@@ -1129,14 +1130,15 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = X86BR9WTLD;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs access to your camera to take photos of completed tasks";
|
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs access to your photo library to select photos of completed tasks";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -1146,7 +1148,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ final class BackgroundTaskManager {
|
|||||||
static let shared = BackgroundTaskManager()
|
static let shared = BackgroundTaskManager()
|
||||||
|
|
||||||
/// Background task identifier - must match Info.plist BGTaskSchedulerPermittedIdentifiers
|
/// Background task identifier - must match Info.plist BGTaskSchedulerPermittedIdentifiers
|
||||||
static let taskIdentifier = "com.tt.honeyDue.refresh"
|
static let taskIdentifier = "com.myhoneydue.honeyDue.refresh"
|
||||||
|
|
||||||
/// Time window for overnight refresh (12:00 AM - 4:00 AM)
|
/// Time window for overnight refresh (12:00 AM - 4:00 AM)
|
||||||
private let refreshWindowStartHour = 0 // 12:00 AM
|
private let refreshWindowStartHour = 0 // 12:00 AM
|
||||||
@@ -187,7 +187,7 @@ final class BackgroundTaskManager {
|
|||||||
|
|
||||||
/// Force a background refresh for testing (only works in debug builds with Xcode)
|
/// Force a background refresh for testing (only works in debug builds with Xcode)
|
||||||
/// Usage: In Xcode debugger console:
|
/// Usage: In Xcode debugger console:
|
||||||
/// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.tt.honeyDue.refresh"]
|
/// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.myhoneydue.honeyDue.refresh"]
|
||||||
func debugInfo() -> String {
|
func debugInfo() -> String {
|
||||||
return """
|
return """
|
||||||
Background Task Debug Info:
|
Background Task Debug Info:
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ struct AccessibilityIdentifiers {
|
|||||||
static let addButton = "Residence.AddButton"
|
static let addButton = "Residence.AddButton"
|
||||||
static let residencesList = "Residence.List"
|
static let residencesList = "Residence.List"
|
||||||
static let residenceCard = "Residence.Card"
|
static let residenceCard = "Residence.Card"
|
||||||
|
/// Prefix for individual residence cells in the list. Suffix with the
|
||||||
|
/// residence id to address a specific cell (e.g. "Residence.Cell.42").
|
||||||
|
static let cellPrefix = "Residence.Cell"
|
||||||
static let emptyStateView = "Residence.EmptyState"
|
static let emptyStateView = "Residence.EmptyState"
|
||||||
static let emptyStateButton = "Residence.EmptyState.AddButton"
|
static let emptyStateButton = "Residence.EmptyState.AddButton"
|
||||||
|
|
||||||
@@ -87,7 +90,15 @@ struct AccessibilityIdentifiers {
|
|||||||
static let refreshButton = "Task.RefreshButton"
|
static let refreshButton = "Task.RefreshButton"
|
||||||
static let tasksList = "Task.List"
|
static let tasksList = "Task.List"
|
||||||
static let taskCard = "Task.Card"
|
static let taskCard = "Task.Card"
|
||||||
|
/// Prefix for individual task rows. Suffix with the task id to
|
||||||
|
/// address a specific row (e.g. "Task.Row.42"). Use `BEGINSWITH`
|
||||||
|
/// in tests to detect "any task row exists".
|
||||||
|
static let rowPrefix = "Task.Row"
|
||||||
static let emptyStateView = "Task.EmptyState"
|
static let emptyStateView = "Task.EmptyState"
|
||||||
|
/// Label rendered when a residence-detail tasks section has no tasks
|
||||||
|
/// in any kanban column. Asserted ABSENT after onboarding bulk-create
|
||||||
|
/// in the gitea#2 regression test.
|
||||||
|
static let noTasksLabel = "Task.NoTasksLabel"
|
||||||
static let kanbanView = "Task.KanbanView"
|
static let kanbanView = "Task.KanbanView"
|
||||||
static let overdueColumn = "Task.Column.Overdue"
|
static let overdueColumn = "Task.Column.Overdue"
|
||||||
static let upcomingColumn = "Task.Column.Upcoming"
|
static let upcomingColumn = "Task.Column.Upcoming"
|
||||||
@@ -229,8 +240,24 @@ struct AccessibilityIdentifiers {
|
|||||||
static let taskSelectionCounter = "Onboarding.TaskSelectionCounter"
|
static let taskSelectionCounter = "Onboarding.TaskSelectionCounter"
|
||||||
static let addPopularTasksButton = "Onboarding.AddPopularTasksButton"
|
static let addPopularTasksButton = "Onboarding.AddPopularTasksButton"
|
||||||
static let addTasksContinueButton = "Onboarding.AddTasksContinueButton"
|
static let addTasksContinueButton = "Onboarding.AddTasksContinueButton"
|
||||||
|
/// Submit/continue button at the bottom of the First-Task screen.
|
||||||
|
/// Triggers `POST /api/tasks/bulk/` for the selected templates.
|
||||||
|
static let submitTasksButton = "Onboarding.SubmitTasksButton"
|
||||||
|
/// Tab bar control above the task list. The "Browse All" segment is
|
||||||
|
/// addressed via `app.buttons["Browse All"]` from the segmented
|
||||||
|
/// picker once this identifier is set.
|
||||||
|
static let firstTaskTabBar = "Onboarding.FirstTaskTabBar"
|
||||||
|
/// Tab segment that shows the full template catalog.
|
||||||
|
/// Tap from a test by addressing the Picker's segment label
|
||||||
|
/// "Browse All" within the element identified above.
|
||||||
|
static let browseAllTab = "Onboarding.BrowseAllTab"
|
||||||
static let taskCategorySection = "Onboarding.TaskCategorySection"
|
static let taskCategorySection = "Onboarding.TaskCategorySection"
|
||||||
static let taskTemplateRow = "Onboarding.TaskTemplateRow"
|
static let taskTemplateRow = "Onboarding.TaskTemplateRow"
|
||||||
|
/// Prefix for individual template rows on the First-Task screen
|
||||||
|
/// (Browse All tab). Suffix with the backend template id —
|
||||||
|
/// e.g. `"Onboarding.TemplateRow.123"`. Tests use `BEGINSWITH` to
|
||||||
|
/// pick the first N rows deterministically without knowing ids.
|
||||||
|
static let templateRowPrefix = "Onboarding.TemplateRow"
|
||||||
|
|
||||||
// Subscription Screen
|
// Subscription Screen
|
||||||
static let subscriptionTitle = "Onboarding.SubscriptionTitle"
|
static let subscriptionTitle = "Onboarding.SubscriptionTitle"
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ enum ThemeID: String, CaseIterable, Codable {
|
|||||||
|
|
||||||
// MARK: - Shared App Group UserDefaults
|
// MARK: - Shared App Group UserDefaults
|
||||||
private let appGroupID: String = {
|
private let appGroupID: String = {
|
||||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
||||||
}()
|
}()
|
||||||
private let sharedDefaults: UserDefaults = {
|
private let sharedDefaults: UserDefaults = {
|
||||||
guard let defaults = UserDefaults(suiteName: appGroupID) else {
|
guard let defaults = UserDefaults(suiteName: appGroupID) else {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ final class WidgetDataManager {
|
|||||||
static let cancelledColumn = "cancelled_tasks"
|
static let cancelledColumn = "cancelled_tasks"
|
||||||
|
|
||||||
private let appGroupIdentifier: String = {
|
private let appGroupIdentifier: String = {
|
||||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
||||||
}()
|
}()
|
||||||
private let tasksFileName = "widget_tasks.json"
|
private let tasksFileName = "widget_tasks.json"
|
||||||
private let actionsFileName = "widget_pending_actions.json"
|
private let actionsFileName = "widget_pending_actions.json"
|
||||||
|
|||||||
@@ -6,14 +6,8 @@
|
|||||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>com.tt.honeyDue.refresh</string>
|
<string>com.myhoneydue.honeyDue.refresh</string>
|
||||||
</array>
|
</array>
|
||||||
<key>HONEYDUE_IAP_ANNUAL_PRODUCT_ID</key>
|
|
||||||
<string>com.tt.honeyDue.pro.annual</string>
|
|
||||||
<key>HONEYDUE_IAP_MONTHLY_PRODUCT_ID</key>
|
|
||||||
<string>com.tt.honeyDue.pro.monthly</string>
|
|
||||||
<key>HONEYDUE_GOOGLE_WEB_CLIENT_ID</key>
|
|
||||||
<string></string>
|
|
||||||
<key>CFBundleDocumentTypes</key>
|
<key>CFBundleDocumentTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -40,17 +34,17 @@
|
|||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<key>HONEYDUE_GOOGLE_WEB_CLIENT_ID</key>
|
||||||
|
<string></string>
|
||||||
|
<key>HONEYDUE_IAP_ANNUAL_PRODUCT_ID</key>
|
||||||
|
<string>com.myhoneydue.honeyDue.pro.annual</string>
|
||||||
|
<key>HONEYDUE_IAP_MONTHLY_PRODUCT_ID</key>
|
||||||
|
<string>com.myhoneydue.honeyDue.pro.monthly</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
|
||||||
<string>honeyDue needs camera access to take photos of tasks, documents, and receipts.</string>
|
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>honeyDue needs photo library access to attach photos to tasks and documents.</string>
|
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
|
||||||
<string>honeyDue needs permission to save photos to your library.</string>
|
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
|||||||
@@ -74,6 +74,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%@, %@, %lld%% match" : {
|
||||||
|
"comment" : "A row that displays a suggestion with a title, frequency, and relevance percentage.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$@, %2$@, %3$lld%% match"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"%@, %@%@" : {
|
"%@, %@%@" : {
|
||||||
"comment" : "A button that displays the name of a product and its price.",
|
"comment" : "A button that displays the name of a product and its price.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@@ -154,6 +166,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%lld%%" : {
|
||||||
|
"comment" : "A badge that shows the relevance of a suggestion. The argument is the relevance percentage.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"•" : {
|
"•" : {
|
||||||
"comment" : "A separator between different pieces of information in a text.",
|
"comment" : "A separator between different pieces of information in a text.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -221,9 +237,6 @@
|
|||||||
},
|
},
|
||||||
"Add document" : {
|
"Add document" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Add Most Popular" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Add new property" : {
|
"Add new property" : {
|
||||||
"comment" : "A label displayed as a button in the toolbar.",
|
"comment" : "A label displayed as a button in the toolbar.",
|
||||||
@@ -17684,10 +17697,6 @@
|
|||||||
"comment" : "A button that generates a new share code.",
|
"comment" : "A button that generates a new share code.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Generating suggestions..." : {
|
|
||||||
"comment" : "Text displayed while the app is generating personalized task suggestions.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Get notified when someone joins your property" : {
|
"Get notified when someone joins your property" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -17710,16 +17719,8 @@
|
|||||||
"comment" : "A label for the back button.",
|
"comment" : "A label for the back button.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Good match" : {
|
|
||||||
"comment" : "A label describing a task's relevance.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Google Sign-In Error" : {
|
"Google Sign-In Error" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Great match" : {
|
|
||||||
"comment" : "A label describing a high-relevance task.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"Help improve honeyDue by sharing anonymous usage data" : {
|
"Help improve honeyDue by sharing anonymous usage data" : {
|
||||||
|
|
||||||
@@ -17862,10 +17863,6 @@
|
|||||||
},
|
},
|
||||||
"No personal data is collected. Analytics are fully anonymous." : {
|
"No personal data is collected. Analytics are fully anonymous." : {
|
||||||
|
|
||||||
},
|
|
||||||
"No personalized suggestions yet" : {
|
|
||||||
"comment" : "A message displayed when the user has not yet been personalized.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"No properties yet" : {
|
"No properties yet" : {
|
||||||
|
|
||||||
@@ -25444,6 +25441,10 @@
|
|||||||
"comment" : "A button label that allows users to skip the current onboarding step.",
|
"comment" : "A button label that allows users to skip the current onboarding step.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Skip for now" : {
|
||||||
|
"comment" : "A button label that skips onboarding.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Skip for Now" : {
|
"Skip for Now" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -30617,10 +30618,6 @@
|
|||||||
"comment" : "A button label that says \"Try Again\".",
|
"comment" : "A button label that says \"Try Again\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Try the Browse tab to explore tasks by category,\nor add home details for better suggestions." : {
|
|
||||||
"comment" : "A description of the benefits of using the",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Unarchive" : {
|
"Unarchive" : {
|
||||||
"comment" : "A button that unarchives a task.",
|
"comment" : "A button that unarchives a task.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
|
|
||||||
OnboardingTaskTabBar(selectedTab: $selectedTab)
|
OnboardingTaskTabBar(selectedTab: $selectedTab)
|
||||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.firstTaskTabBar)
|
||||||
|
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
case .forYou:
|
case .forYou:
|
||||||
@@ -384,6 +385,7 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.naturalShadow(selectedCount > 0 ? .medium : .subtle)
|
.naturalShadow(selectedCount > 0 ? .medium : .subtle)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.submitTasksButton)
|
||||||
.disabled(vm.isSubmitting)
|
.disabled(vm.isSubmitting)
|
||||||
.animation(.easeInOut(duration: 0.2), value: selectedCount)
|
.animation(.easeInOut(duration: 0.2), value: selectedCount)
|
||||||
}
|
}
|
||||||
@@ -653,6 +655,7 @@ private struct OnboardingSuggestionRow: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(suggestion.template.id)")
|
||||||
.accessibilityLabel("\(suggestion.template.title), \(suggestion.template.frequencyDisplay), \(relevancePercent)% match")
|
.accessibilityLabel("\(suggestion.template.title), \(suggestion.template.frequencyDisplay), \(relevancePercent)% match")
|
||||||
.accessibilityValue(isSelected ? "selected" : "not selected")
|
.accessibilityValue(isSelected ? "selected" : "not selected")
|
||||||
}
|
}
|
||||||
@@ -798,6 +801,7 @@ private struct OnboardingTemplateRow: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(template.id)")
|
||||||
.accessibilityLabel("\(template.title), \(template.frequencyLabel)")
|
.accessibilityLabel("\(template.title), \(template.frequencyLabel)")
|
||||||
.accessibilityValue(isSelected ? "selected" : "not selected")
|
.accessibilityValue(isSelected ? "selected" : "not selected")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ private struct ResidencesContent: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
.buttonStyle(OrganicCardButtonStyle())
|
.buttonStyle(OrganicCardButtonStyle())
|
||||||
|
.accessibilityIdentifier("\(AccessibilityIdentifiers.Residence.cellPrefix).\(residence.id)")
|
||||||
.transition(.asymmetric(
|
.transition(.asymmetric(
|
||||||
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||||
removal: .opacity
|
removal: .opacity
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ComposeApp
|
|||||||
final class KeychainHelper: NSObject, KeychainDelegate {
|
final class KeychainHelper: NSObject, KeychainDelegate {
|
||||||
static let shared = KeychainHelper()
|
static let shared = KeychainHelper()
|
||||||
|
|
||||||
private let service = "com.tt.honeyDue"
|
private let service = "com.myhoneydue.honeyDue"
|
||||||
|
|
||||||
func save(key: String, value: String) -> Bool {
|
func save(key: String, value: String) -> Bool {
|
||||||
guard let data = value.data(using: .utf8) else { return false }
|
guard let data = value.data(using: .utf8) else { return false }
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ class StoreKitManager: ObservableObject {
|
|||||||
// Canonical source: SubscriptionProducts in commonMain (Kotlin shared code).
|
// Canonical source: SubscriptionProducts in commonMain (Kotlin shared code).
|
||||||
// Keep these in sync with SubscriptionProducts.MONTHLY / SubscriptionProducts.ANNUAL.
|
// Keep these in sync with SubscriptionProducts.MONTHLY / SubscriptionProducts.ANNUAL.
|
||||||
private let fallbackProductIDs = [
|
private let fallbackProductIDs = [
|
||||||
"com.tt.honeyDue.pro.monthly",
|
"com.myhoneydue.honeyDue.pro.monthly",
|
||||||
"com.tt.honeyDue.pro.annual"
|
"com.myhoneydue.honeyDue.pro.annual"
|
||||||
]
|
]
|
||||||
|
|
||||||
private var configuredProductIDs: [String] {
|
private var configuredProductIDs: [String] {
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ struct DynamicTaskCard: View {
|
|||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
|
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
|
||||||
.simultaneousGesture(TapGesture(), including: .subviews)
|
.simultaneousGesture(TapGesture(), including: .subviews)
|
||||||
|
.accessibilityIdentifier("\(AccessibilityIdentifiers.Task.rowPrefix).\(task.id)")
|
||||||
.sheet(isPresented: $showCompletionHistory) {
|
.sheet(isPresented: $showCompletionHistory) {
|
||||||
CompletionHistorySheet(
|
CompletionHistorySheet(
|
||||||
taskTitle: task.title,
|
taskTitle: task.title,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ struct EmptyTasksView: View {
|
|||||||
.background(Color.appBackgroundSecondary)
|
.background(Color.appBackgroundSecondary)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.naturalShadow(.subtle)
|
.naturalShadow(.subtle)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.noTasksLabel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user