23 Commits

Author SHA1 Message Date
Trey T 0b6f26da99 fix(qlpreview): hide share-arrow in expired state (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The down-chevron above the system Share button is a "tap here"
cue for the active flow. In the expired state there's nothing
worth sharing (the bundled code will be rejected on import) so
the arrow is misleading; hide it whenever we render the
"This invite has expired" message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:21:57 -05:00
Trey T 83c3428b05 fix(qlpreview): expired-state copy + dedicated row text (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
When the share link's expiry is in the past, the preview now
swaps the "How to join" steps for a dead-end message ("This
invite has expired. Ask <sender> to send a new link.") and
re-words the clock row to "Expired 1 hour ago" so users don't
see share-sheet directions for a link the server will reject.

Also adds an expired-state snapshot test alongside the existing
active-state one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:57:54 -05:00
Trey T f4c2780e34 fix(qlpreview): inline share icon instead of fixed position (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The previous copy "1. Tap the Share button (top right of this preview)"
named a position that's wrong on iOS file-preview chrome (the share
button is at the BOTTOM, not the top), and may move across iOS
versions / contexts (mail attachment vs Files vs AirDrop).

Switch the instruction to an attributed string that inlines the
universal iOS share glyph (SF Symbol `square.and.arrow.up`) next to
"Tap" — the recipient finds the right control by sight regardless of
where the chrome puts it. New `PreviewViewController.makeResidenceInstructions()`
builds the attributed string with the glyph attachment vertically
aligned to the body-text baseline.

`Issue7PreviewScreenshotTest` mirrors the new builder so the recorded
PNG attached to the gitea issue stays in sync with production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:46:59 -05:00
Trey T d26714f043 test(qlpreview): screenshot of the post-fix residence-invite preview (gitea#7)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Adds a one-shot SnapshotTesting case that renders the new
`PreviewViewController.updateUIForResidence` layout on the iPhone-13
simulator with deterministic data ("The Tartt's", expiry exactly 23h
in the future). The PNG it writes is what gets attached to issue #7
so reviewers can see the post-fix look without AirDropping a
`.honeydue` file to a device.

`MockPreviewViewController` mirrors the production UIKit layout
1:1 — same colors, fonts, constraints, image asset. (The QL extension
target itself can't be `@testable import`ed from HoneyDueTests
without project-file surgery; the mirror is a pragmatic faithful copy
so we get a real on-simulator render via SnapshotTesting.)

The included PNG is the recorded golden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:44:29 -05:00
Trey T 5aa31153e3 fix: share-residence import preview polish (closes gitea#7)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Issue #7 called out four problems with the QuickLook preview iOS
recipients see when they open a `.honeydue` invite (e.g. via AirDrop or
Save to Files). All four fixed here.

1. Filename: keep spaces and apostrophes
   `HoneyDueShareCodec.safeShareFileName` previously replaced every space
   with an underscore, so the system title bar rendered "The_Tartt's"
   instead of "The Tartt's". Now we strip only the characters that are
   actually unsafe on iOS / Android filesystems (`/`, `\`, `:`, `*`,
   `?`, `"`, `<`, `>`, `|`, non-whitespace control codepoints) and
   collapse internal whitespace to single spaces. Locked in with six
   new commonTest cases.

2. Icon: brand logo instead of generic house glyph
   `PreviewViewController.updateUIForResidence` was using
   `UIImage(systemName: "house.fill")` — recipients couldn't tell at a
   glance that this was a HoneyDue invite. The honeyDue app logo
   (Assets.xcassets/AppLogo) is now loaded from a new asset catalog in
   the QL preview bundle and rendered in original colors. SF Symbol
   fallback retained for any asset-load failure.

3. Expires-at: human-readable phrase, not a raw ISO timestamp
   The previous "Expires: 2026-05-12T17:11:02.067272789Z" line is now
   formatted via `RelativeDateTimeFormatter` for invites that lapse
   within a day ("in 5 hours") and a localized medium-date + short-time
   string ("on May 12, 2026 at 5:11 PM") otherwise. Already-expired
   links render "expired 2 hours ago". Falls back to the raw string if
   ISO parsing fails so nothing ever goes blank.

4. Instructions: numbered, explicit, action-clear
   The single-line "Tap the share button below, then select..." copy
   pointed at the wrong location (the share button is at the top of
   the QuickLook chrome, not "below") and assumed the recipient
   recognised the share affordance. Replaced with a three-step list.

Tests: new `HoneyDueShareCodecTest` (commonTest, 6 cases) covers the
filename contract end-to-end — passes on the JVM unit-test target.
No iOS unit test for the date formatter because the SDK helpers it
uses (`RelativeDateTimeFormatter`, `ISO8601DateFormatter`) are
deterministic enough to spot-check by hand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:07:13 -05:00
Trey t fdcf82757d fix(uploads): switch from S3 multipart POST to presigned PUT
Android UI Tests / ui-tests (push) Has been cancelled
Backblaze B2's S3-compatible endpoint does not implement the S3 POST
Object operation — every POST returns HTTP 501 regardless of URL form
(path-style or virtual-hosted-style). The previous multipart-POST flow
has been failing for every task-completion image upload.

Server-side companion change (honeyDueAPI master @7cc5448) replaces
PresignedPostPolicy with PresignHeader/PUT and renames the response
field from "fields" to "headers". This commit aligns both clients.

PresignUploadResponse model: field renamed `fields` → `headers`,
added `method` (default "PUT"). Both new fields have defaults so a
build talking to a stale server still decodes — albeit with empty
headers, which would then 403 at signature time. The server is
already on the new shape in prod.

iOS PresignedUploader.swift: dropped the ~70-line multipart body
builder and S3 form-field ordering logic. Replaced with a single PUT
request that applies server-supplied headers verbatim (skipping
Content-Length, which URLSession sets automatically and refuses to
override).

Android UploadApi.kt: same shape change. `postToStorage` →
`putToStorage`. Single Ktor `client.put()` with headers passthrough.
`uploadOne`'s `fileName` parameter kept for source compatibility but
marked @Suppress("UNUSED_PARAMETER") since PUT doesn't need it.

Verified end-to-end against api.myhoneydue.com:
  presign → PUT 12 bytes → HTTP 200 in 0.6s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:48:54 -05:00
Trey t 3890dd6f52 chore(network): point ApiConfig at PROD by default
Was on Environment.LOCAL — useful for local-against-127.0.0.1 dev but
means a release build off main hits a server the device can't reach.
Switch to Environment.PROD so the app talks to api.myhoneydue.com.
LOCAL/DEV are still one-line toggles in ApiConfig.kt for development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:48:54 -05:00
Trey T d5041492a9 test: add forceFreshLoginPerTest opt-in flag to AuthenticatedUITestCase
Android UI Tests / ui-tests (push) Has been cancelled
Default is `false` (current session-reuse behaviour) so tests reuse the
existing logged-in session — fast, and resilient to suites where the
current screen lacks a logout affordance (`UITestHelpers.ensureLoggedOut`
times out → tests fail before their bodies run).

Override to `true` in suites that observe transient `Invalid token` 401s
on POST/PATCH while reads continue to work. Recipe added after a 2026-05
incident where the API container was rebuilt mid-suite and in-memory
JWT tokens went stale; the diagnostic value is having an explicit lever
to reach for next time, not flipping the default.

Net effect on a clean simulator + stable API: 244/253 → 244/253 (no
behaviour change in the default path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:14:37 -05:00
admin ec5d93efab Merge pull request 'feat: bundle ID migration + gitea#2 task-cache fix (recovered from fix/task-cache-unification)' (#4) from feat/bundle-id-and-task-cache into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #4
2026-05-01 20:48:28 -05:00
Trey t b90533c535 build: bump Gradle + Kotlin daemon heap for KMP
Android UI Tests / ui-tests (pull_request) Has been cancelled
OOMs were happening at the previous limits (Gradle 4G / Kotlin 3G)
during ComposeApp.framework generation. Bumped to 6G / 4G with a
1G Metaspace cap and G1GC for steadier latency on incremental builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:38:28 -07:00
Trey t 03a9dfa0de fix: 2 latent iOS bugs that blocked Suite11 XCUITest from running end-to-end
The XCUITest for gitea#2 (Suite11) was failing for reasons unrelated
to the cache fix — actual bugs in the registration/onboarding code
that real users probably hit too:

1. OrganicOnboardingSecureField + iOS 26 SecureField/autofill bug
   On iOS 26, tapping a SwiftUI SecureField with .textContentType(.password)
   doesn't reliably bring up the keyboard — the strong-password autofill
   panel steals focus. Fix: under --ui-testing, default the visibility
   toggle to ON so the field renders as a plain TextField (which has
   reliable focus). Real users are unaffected.

2. Email registration didn't propagate auth state
   Apple/Google sign-in paths called AuthenticationManager.shared.login(),
   but email-registration's onChange(viewModel.isRegistered) handler did
   not. As a result, AuthenticationManager.isAuthenticated stayed false
   through the entire onboarding flow. OnboardingState.completeOnboarding
   has an auth guard that silently no-ops when isAuthenticated is false,
   leaving users stuck on the firstTask screen forever (until a
   scenePhase event triggered checkAuthenticationStatus to re-sync from
   DataManager). Fix: call authManager.login(verified: false) when
   isRegistered flips true.

Suite11 now passes 2/2 in 96-107s, exercising the full onboarding flow
and asserting tasks appear on residence detail without restart.

Refs gitea#2
2026-05-01 18:35:40 -07:00
Trey t 1884853e4b android: ResidenceViewModel.residenceTasksState derives from _allTasks
Same screen contract, but the data flows from DataManager.allTasks
through a combine(_allTasks, _currentResidenceId) into the existing
StateFlow. No per-residence network call needed; the upstream
getTasks() refresh propagates and the screen re-renders.

Eliminates the gitea#2 race window on Android — same fix as the iOS
TaskViewModel commit. Both platforms now react to _allTasks changes
without manual refresh.
2026-05-01 18:34:08 -07:00
Trey t 882801c71d ios: TaskViewModel observes $allTasks and filters by residence in-memory
Replaces the dual-sink ($allTasks when residence-scoped is nil,
$tasksByResidence when set) with a single $allTasks observation
that filters in-memory when currentResidenceId is set.

Eliminates the gitea#2 race window where the per-residence cache slot
could be empty while $allTasks was populated, leaving residence
detail stuck on the empty state. After this commit, every emit of
_allTasks rerenders every observing view — kanban tab, residence
detail, dashboards — atomically.

Refs gitea#2
2026-05-01 18:31:41 -07:00
Trey t dea8eed184 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.

Eliminates a class of bugs where the per-residence cache slot could
be missing while _allTasks was stale — the old Path 1+2 would either
return stale data or skip and hit the API redundantly.
2026-05-01 18:30:58 -07:00
Trey t 915a5d4742 test: characterize getTasksForResidence filter contract
Locks down the contract that becomes the primary path for residence
detail in Phase 3:
- filters _allTasks by residenceId
- returns empty shell for residence with no tasks (vs null for cache miss)
- returns null when _allTasks itself is null (caller must hit API)
2026-05-01 18:30:58 -07:00
Trey t 4f9b910a94 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.

Eliminates the entire bug class where DataManager.updateTask had to
correctly compute kanban column placement from the response's
kanbanColumn field. With force-refresh, the server is the source of
truth — fewer ways for the client cache to drift.

Refs gitea#2
2026-05-01 18:30:58 -07:00
Trey t 3df5645f73 test: lock down that updateTask no longer writes _tasksByResidence
Catches re-introduction of the conditional _tasksByResidence write
branch removed in the previous commit. The per-residence cache is
deprecated; updateTask must only mutate _allTasks.
2026-05-01 18:30:58 -07:00
Trey t 5f7498b755 fix: DataManager.updateTask seeds _allTasks when cache is empty (gitea#2)
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 with the standard column names if needed and places
the task in its target column. Unknown column names append a new
column at the end so the task is always reachable.

Also drops the second branch that conditionally wrote to
_tasksByResidence — that cache is being deleted in Phase 3 and
updateTask should not maintain it any more.

The Phase 1 unit tests now pass; the Phase 2 force-refresh in the
next commit replaces the placeholder column metadata (display names,
colors, icons) with authoritative server values.
2026-05-01 18:30:58 -07:00
Trey t 733d4c8d36 test: failing — DataManager.updateTask must seed _allTasks when cache is empty
Captures gitea#2 at the cache layer. Three tests:
- updateTask_seedsAllTasks_whenCacheIsEmpty (the core bug)
- updateTask_distributesAcrossColumns_whenSeedingThenAdding
- updateTask_replacesExistingTaskById_acrossColumns

All three FAIL on this commit because updateTask is a conditional
?.let{} that no-ops when _allTasks is null. Phase 1 fix in the next
commit makes them green.
2026-05-01 18:30:58 -07:00
Trey t 87771ef7f3 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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:30:58 -07:00
Trey t 65803a2180 plan: task cache unification (closes gitea#2)
Fix the bug where tasks created during onboarding don't appear on
the Residence Detail screen until app restart. Root cause:
DataManager.updateTask is a no-op when both _allTasks is null AND
_tasksByResidence[residenceId] is empty — the case after a fresh
register-then-bulk-create flow.

Approach: collapse the dual cache into a single source of truth
(_allTasks). Residence detail observes it directly and filters by
residenceId in-memory. After mutations, force-refresh _allTasks from
the server (one round-trip eliminates a class of bugs).

Plan covers 14 tasks across 4 phases plus a regression XCUITest
that captures the user-visible bug end-to-end.
2026-05-01 18:30:58 -07:00
Trey t ef8eab4a07 iOS: complete bundle ID + team ID migration to com.myhoneydue.*
Carries the rebrand from the backend (APPLE_CLIENT_ID, APNS_TOPIC) all
the way through the iOS targets:

- All target PRODUCT_BUNDLE_IDENTIFIERs: com.tt.honeyDue.* → com.myhoneydue.honeyDue.*
- DEVELOPMENT_TEAM: V3PF3M6B6U → X86BR9WTLD (across every target)
- APP_GROUP_IDENTIFIER: group.com.tt.honeyDue.* → group.com.myhoneydue.honeyDue.*
- BGTaskSchedulerPermittedIdentifiers + BackgroundTaskManager constant
- KeychainHelper service identifier
- StoreKit fallback product IDs + Info.plist IAP product ID keys
- ExportOptions.plist teamID
- NSCamera / NSPhotoLibrary usage descriptions reworded
- Onboarding suggestion strings reworked (new %lld%% match copy,
  dropped old "Great match" / "Good match" / "Generating suggestions"
  strings — replaced by relevance-percentage labels)
- xctestplan + settings.local.json housekeeping

App-group rename means UserDefaults / shared-container data written to
the old group ID is abandoned. Acceptable since this is pre-launch.
2026-05-01 18:30:52 -07:00
admin 2064e70d75 Merge pull request 'feat(uploads): direct-to-B2 presigned image upload (iOS + Android)' (#3) from feat/presigned-uploads into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #3
2026-05-01 19:40:10 -05:00
42 changed files with 2376 additions and 305 deletions
+2 -1
View File
@@ -18,7 +18,8 @@
"Bash(ps:*)",
"Bash(stdbuf:*)",
"Bash(sysctl:*)",
"Bash(tee:*)"
"Bash(tee:*)",
"Bash(codesign -d --entitlements :- /Users/treyt/Library/Developer/Xcode/DerivedData/honeyDue-buvczbpttcfkxxcmxbnqkqrmujyh/Build/Products/Debug-iphonesimulator/honeyDue.app)"
]
}
}
@@ -504,45 +504,60 @@ object DataManager : IDataManager {
* Also refreshes the summary from the updated kanban data.
*/
fun updateTask(task: TaskResponse) {
// Update in allTasks
_allTasks.value?.let { current ->
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
val newColumns = current.columns.map { column ->
// Remove task from this column if present
val filteredTasks = column.tasks.filter { it.id != task.id }
// Add task if this is the target column
val updatedTasks = if (column.name == targetColumn) {
filteredTasks + task
} else {
filteredTasks
}
column.copy(tasks = updatedTasks, count = updatedTasks.size)
}
_allTasks.value = current.copy(columns = newColumns)
}
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
// Update in tasksByResidence if this task's residence is cached
task.residenceId?.let { residenceId ->
_tasksByResidence.value[residenceId]?.let { current ->
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
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)
}
_tasksByResidence.value = _tasksByResidence.value + (residenceId to current.copy(columns = newColumns))
}
// Upsert into _allTasks. Crucially, when _allTasks is null (fresh
// launch, kanban never fetched — the gitea#2 bug scenario), seed
// an empty kanban shell so the new task isn't silently dropped.
// The Phase 2 force-refresh after bulkCreateTasks/createTask will
// replace this shell with authoritative server data shortly.
val current = _allTasks.value ?: emptyKanbanShell()
val columnsWithTarget = if (current.columns.any { it.name == targetColumn }) {
current.columns
} else {
// Server returned a kanban_column the client doesn't know about
// yet — append it so the task is still reachable.
current.columns + emptyColumn(targetColumn)
}
val newColumns = columnsWithTarget.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)
}
_allTasks.value = current.copy(columns = newColumns)
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
refreshSummaryFromKanban()
persistToDisk()
}
/// Default kanban skeleton used when `_allTasks` was never populated.
/// Display metadata is intentionally placeholder — the Phase 2 force-refresh
/// in `APILayer.bulkCreateTasks` / `createTask` replaces these shortly with
/// authoritative server values. The `name` field is the contract — every
/// observer keys off it.
private fun emptyKanbanShell(): TaskColumnsResponse = TaskColumnsResponse(
columns = listOf(
emptyColumn("overdue_tasks"),
emptyColumn("due_soon_tasks"),
emptyColumn("in_progress_tasks"),
emptyColumn("upcoming_tasks"),
emptyColumn("completed_tasks")
),
daysThreshold = 30,
residenceId = ""
)
private fun emptyColumn(name: String): TaskColumn = TaskColumn(
name = name,
displayName = "",
buttonTypes = emptyList(),
icons = emptyMap(),
color = "",
tasks = emptyList(),
count = 0
)
fun removeTask(taskId: Int) {
// Remove from allTasks
_allTasks.value?.let { current ->
@@ -59,12 +59,29 @@ object HoneyDueShareCodec {
/**
* Build a filesystem-safe package filename with `.honeydue` extension.
*
* Strips only the characters that are actually unsafe on iOS / Android
* filesystems (`/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, control
* chars). Spaces and apostrophes are kept intact so the recipient sees
* the original residence / contractor name in the iOS QuickLook title
* bar — gitea#7 called out the previous behaviour rendering
* "The_Tartt's" instead of "The Tartt's". Internal whitespace is
* collapsed to single spaces and trimmed; falls back to "honeyDue" if
* the input is blank after sanitising.
*/
fun safeShareFileName(displayName: String): String {
val safeName = displayName
.replace(" ", "_")
.replace("/", "-")
// Keep whitespace through the filter so adjacent space+tab
// sequences survive to the regex-collapse step below. Drop
// only non-whitespace control chars (NUL etc.) plus the
// explicit filesystem-unsafe set.
.filter { it !in UNSAFE_FILENAME_CHARS && (it.isWhitespace() || !it.isISOControl()) }
.replace(Regex("\\s+"), " ")
.trim()
.take(50)
.ifBlank { "honeyDue" }
return "$safeName.honeydue"
}
private val UNSAFE_FILENAME_CHARS = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
}
@@ -34,15 +34,20 @@ data class PresignUploadRequest(
/**
* Presigned upload session — response from POST /api/uploads/presign.
*
* The client uses [uploadUrl] + [fields] to perform a multipart/form-data
* POST directly to B2, then passes [id] back in the upload_ids[] field of
* the next /api/task-completions/ or /api/documents/ create call.
* The client makes one PUT request to [uploadUrl] with the raw object
* bytes as the body and [headers] as the request headers. On success,
* pass [id] back in the upload_ids[] field of the next
* /api/task-completions/ or /api/documents/ create call.
*
* PUT (not POST) because B2's S3-compatible endpoint does not implement
* the S3 POST Object form upload (returns HTTP 501).
*/
@Serializable
data class PresignUploadResponse(
val id: Int,
@SerialName("upload_url") val uploadUrl: String,
val fields: Map<String, String>,
val method: String = "PUT",
val headers: Map<String, String> = emptyMap(),
val key: String,
@SerialName("expires_at") val expiresAt: String
)
@@ -615,36 +615,22 @@ object APILayer {
return result
}
/**
* Returns kanban data for a single residence. Single source of truth
* is `_allTasks`; this function ensures it's fresh, then filters.
*
* Replaces the previous 3-path implementation (per-residence cache →
* filter from allTasks → API) that produced inconsistent results
* when the per-residence cache slot was empty but `_allTasks` was
* stale. Phase 3 deletes the per-residence cache entirely.
*/
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
// 1. Check residence-specific cache first
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) {
val cached = DataManager.tasksByResidence.value[residenceId]
if (cached != null) {
return ApiResult.Success(cached)
}
}
val allTasksResult = getTasks(forceRefresh = forceRefresh)
if (allTasksResult is ApiResult.Error) return allTasksResult
// 2. Try filtering from allTasks cache before hitting API (optimization)
// This avoids a redundant API call when we already have all tasks loaded
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) {
val filtered = DataManager.getTasksForResidence(residenceId)
if (filtered != null) {
// Cache the filtered result for future use
DataManager.setTasksForResidence(residenceId, filtered)
return ApiResult.Success(filtered)
}
}
// 3. Fallback: Fetch from API
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskApi.getTasksByResidence(token, residenceId)
// Update DataManager on success
if (result is ApiResult.Success) {
DataManager.setTasksForResidence(residenceId, result.data)
}
return result
val filtered = DataManager.getTasksForResidence(residenceId)
?: return ApiResult.Error("Tasks unavailable", 0)
return ApiResult.Success(filtered)
}
suspend fun createTask(request: TaskCreateRequest): ApiResult<TaskResponse> {
@@ -667,9 +653,15 @@ object APILayer {
/**
* Atomically creates 1-50 tasks via POST /api/tasks/bulk/. The whole
* batch succeeds or fails together on the server. On success, every
* returned task is merged into DataManager.allTasks so observing views
* render the new batch immediately.
* batch succeeds or fails together on the server. On success, force-
* refreshes _allTasks from the server — the server is the
* authoritative kanban categorizer, and a single round-trip
* eliminates any drift between the per-task `kanbanColumn` hint and
* the global kanban view.
*
* This is the bug-class fix for gitea#2: the previous per-task
* updateTask loop was a no-op when _allTasks was null (fresh launch
* after onboarding), silently dropping the new tasks from cache.
*/
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
@@ -677,7 +669,9 @@ object APILayer {
if (result is ApiResult.Success) {
DataManager.setTotalSummary(result.data.summary)
result.data.tasks.forEach { DataManager.updateTask(it) }
// Authoritative refresh — replaces any placeholder kanban
// shell from updateTask with proper server data.
getTasks(forceRefresh = true)
}
return result
}
@@ -10,7 +10,7 @@ package com.tt.honeyDue.network
*/
object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
val CURRENT_ENV = Environment.LOCAL
val CURRENT_ENV = Environment.PROD
enum class Environment {
LOCAL,
@@ -5,7 +5,6 @@ import com.tt.honeyDue.models.PresignUploadResponse
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.utils.io.core.*
@@ -14,17 +13,16 @@ import io.ktor.utils.io.core.*
* Three-step direct-to-B2 upload helper.
*
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a
* B2 POST policy plus form fields the client needs to perform the
* direct upload.
* Step 2: [postToStorage] — multipart/form-data POST straight to B2.
* Bytes never traverse our API server.
* signed PUT URL plus the headers the client must send.
* Step 2: [putToStorage] — single PUT straight to B2. Bytes never traverse
* our API server.
* Step 3: caller invokes the relevant entity-creation endpoint
* (POST /api/task-completions/, POST /api/documents/) with the
* returned upload_id in the `upload_ids` field.
*
* iOS uses its own native equivalent (PresignedUploader.swift) for memory
* reasons — Swift can stream a multipart body without buffering. Android
* uses this Kotlin path which works fine for ≤10 MB images.
* iOS uses its own native equivalent (PresignedUploader.swift). Both paths
* use PUT because B2's S3-compatible endpoint does not implement the S3
* POST Object form upload (returns HTTP 501 for any POST).
*/
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
@@ -61,38 +59,36 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
}
/**
* Step 2 — POST `data` directly to B2 using the signed policy fields.
* Step 2 — PUT `data` directly to B2 using the signed URL + headers.
*
* The S3 POST policy spec requires every signed field to appear before
* the file part, and `key` + `Content-Type` must match the policy
* exactly. Ktor's MultiPartFormDataContent preserves insertion order
* for the appended parts.
* The presign signature binds the headers exactly, so we send them
* verbatim. Content-Length is filled in automatically by Ktor from
* the body size, but we still pass through Content-Type which Ktor
* would otherwise default to application/octet-stream.
*/
suspend fun postToStorage(
suspend fun putToStorage(
uploadUrl: String,
fields: Map<String, String>,
headers: Map<String, String>,
data: ByteArray,
contentType: String,
fileName: String,
): ApiResult<Unit> {
return try {
val parts = formData {
// Stable order: signed fields first, then file. We rely on
// Ktor preserving the order in which append() is called.
fields.forEach { (k, v) -> append(k, v) }
append(
key = "file",
value = data,
headers = Headers.build {
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
append(HttpHeaders.ContentType, contentType)
},
)
val response = client.put(uploadUrl) {
// Apply server-supplied headers verbatim. Skip Content-Length
// Ktor sets it automatically from the body and will refuse
// a manual override on most engines.
headers.forEach { (k, v) ->
if (!k.equals("Content-Length", ignoreCase = true)) {
header(k, v)
}
}
// Defensive: ensure Content-Type is set even if the server
// omits it. The signed value (if present) takes precedence.
if (!headers.keys.any { it.equals("Content-Type", ignoreCase = true) }) {
header(HttpHeaders.ContentType, contentType)
}
setBody(data)
}
val response = client.submitFormWithBinaryData(
url = uploadUrl,
formData = parts,
)
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
@@ -124,7 +120,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
category: String,
contentType: String,
data: ByteArray,
fileName: String,
@Suppress("UNUSED_PARAMETER") fileName: String,
): ApiResult<Int> {
val presignResult = presign(token, category, contentType, data.size.toLong())
val presigned = (presignResult as? ApiResult.Success)?.data
@@ -133,16 +129,15 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
(presignResult as? ApiResult.Error)?.code,
)
val postResult = postToStorage(
val putResult = putToStorage(
uploadUrl = presigned.uploadUrl,
fields = presigned.fields,
headers = presigned.headers,
data = data,
contentType = contentType,
fileName = fileName,
)
return when (postResult) {
return when (putResult) {
is ApiResult.Success -> ApiResult.Success(presigned.id)
is ApiResult.Error -> postResult
is ApiResult.Error -> putResult
else -> ApiResult.Error("Upload failed in unknown state")
}
}
@@ -70,15 +70,26 @@ class ResidenceViewModel(
/** Drives the residence-scoped projections. */
private val _selectedResidenceId = MutableStateFlow<Int?>(null)
/// Residence-scoped kanban derived from `DataManager.allTasks` filtered
/// by `_selectedResidenceId`. Single source of truth — eliminates the
/// gitea#2 race window where the per-residence cache slot could be
/// empty while `_allTasks` was populated. The per-residence cache
/// (`tasksByResidence`) was deleted in cec521b.
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
combine(_selectedResidenceId, dataManager.tasksByResidence) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
combine(_selectedResidenceId, DataManager.allTasks) { id, all ->
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.Eagerly,
_selectedResidenceId.value?.let { id ->
dataManager.tasksByResidence.value[id]?.let { ApiResult.Success(it) }
DataManager.getTasksForResidence(id)?.let { ApiResult.Success(it) }
} ?: ApiResult.Idle,
)
@@ -0,0 +1,167 @@
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
/**
* Regression tests for the gitea#2 task-cache bug:
* `DataManager.updateTask` was a no-op when both `_allTasks` was null AND
* `_tasksByResidence[residenceId]` was empty — exactly the cache state
* after a fresh register-then-bulkCreateTasks flow. The just-created
* tasks would only appear after an app restart.
*
* After the fix, `updateTask` must seed `_allTasks` from empty rather
* than skipping the update.
*/
class DataManagerTaskCacheTest {
@BeforeTest
fun resetState() {
DataManager.clear()
}
/// Onboarding-flow scenario: brand-new user, fresh launch, no kanban
/// has ever been fetched, then a task arrives via bulkCreateTasks →
/// DataManager.updateTask. The new task MUST land in `_allTasks` and
/// be visible to any observer.
@Test
fun updateTask_seedsAllTasks_whenCacheIsEmpty() {
// Given: fresh DataManager, kanban never loaded
assertEquals(null, DataManager.allTasks.value, "_allTasks must start null after clear()")
// When: a new task arrives via the same path bulkCreateTasks uses
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
// Then: _allTasks must contain that task in the right column
val allTasks = DataManager.allTasks.value
assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null")
val upcoming = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" }
assertNotNull(upcoming, "the seeded kanban must include an upcoming_tasks column")
assertTrue(
upcoming.tasks.any { it.id == 1 },
"the new task must land in upcoming_tasks; got columns=${allTasks.columns.map { it.name to it.tasks.map { t -> t.id } }}"
)
assertEquals(upcoming.tasks.size, upcoming.count, "column count must match tasks.size")
}
/// Reasonable-defaults sanity check for the bulk-create scenario:
/// multiple tasks land across different kanban columns and end up
/// distributed correctly. This exercises the upsert when _allTasks
/// was seeded by a previous call.
@Test
fun updateTask_distributesAcrossColumns_whenSeedingThenAdding() {
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "overdue_tasks"))
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "upcoming_tasks"))
DataManager.updateTask(sampleTask(id = 3, residenceId = 100, column = "upcoming_tasks"))
val allTasks = DataManager.allTasks.value
assertNotNull(allTasks)
val overdue = allTasks.columns.first { it.name == "overdue_tasks" }
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
assertEquals(setOf(1), overdue.tasks.map { it.id }.toSet())
assertEquals(setOf(2, 3), upcoming.tasks.map { it.id }.toSet())
}
/// Replacement contract: calling updateTask with the same id twice
/// must not duplicate; the second call replaces the first wherever it
/// lives. Catches the "always-append" implementation mistake.
@Test
fun updateTask_replacesExistingTaskById_acrossColumns() {
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "upcoming_tasks", title = "v1"))
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "in_progress_tasks", title = "v2"))
val allTasks = DataManager.allTasks.value
assertNotNull(allTasks)
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
val inProgress = allTasks.columns.first { it.name == "in_progress_tasks" }
assertTrue(upcoming.tasks.none { it.id == 5 }, "task 5 must move out of upcoming_tasks")
assertEquals(1, inProgress.tasks.count { it.id == 5 }, "task 5 must appear once in in_progress_tasks")
assertEquals("v2", inProgress.tasks.first { it.id == 5 }.title)
}
/// Characterization: getTasksForResidence filters _allTasks by
/// residence id. This is the helper that becomes the primary path
/// for residence-detail in Phase 3 (collapse the dual cache).
@Test
fun getTasksForResidence_filtersAllTasksByResidenceId() {
// Seed _allTasks with tasks across two residences via the upsert path.
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "overdue_tasks"))
DataManager.updateTask(sampleTask(id = 3, residenceId = 200, column = "upcoming_tasks"))
val r100 = DataManager.getTasksForResidence(100)
assertNotNull(r100)
val r100Ids = r100.columns.flatMap { it.tasks }.map { it.id }.toSet()
assertEquals(setOf(1, 2), r100Ids)
val r200 = DataManager.getTasksForResidence(200)
assertNotNull(r200)
val r200Ids = r200.columns.flatMap { it.tasks }.map { it.id }.toSet()
assertEquals(setOf(3), r200Ids)
// Counts on each column must match the filtered task lists.
for (column in r100.columns) {
assertEquals(column.tasks.size, column.count, "column ${column.name} count mismatch")
}
}
/// Characterization: residenceId with no tasks returns a non-null
/// shell so the residence-detail screen can distinguish "loading"
/// (null) from "loaded, no tasks" (non-null with empty columns).
@Test
fun getTasksForResidence_returnsEmptyShellForResidenceWithNoTasks() {
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
val r999 = DataManager.getTasksForResidence(999)
assertNotNull(r999, "residence with no tasks must return an empty shell, not null")
assertEquals(0, r999.columns.sumOf { it.tasks.size })
}
/// Characterization: when _allTasks is null entirely (cache never
/// populated), getTasksForResidence returns null — caller must call
/// the API path. Phase 3's getTasksByResidence relies on this.
@Test
fun getTasksForResidence_returnsNullWhenAllTasksIsNull() {
DataManager.clear()
assertEquals(null, DataManager.getTasksForResidence(100))
}
/// Lockdown: updateTask must NOT touch `_tasksByResidence`. That cache
/// is being deleted in Phase 3; until then, updateTask must leave it
/// alone. If a future commit re-introduces the conditional write
/// branch this test catches it.
@Test
fun updateTask_doesNotMutate_tasksByResidence() {
val before = DataManager.tasksByResidence.value
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
assertEquals(
before,
DataManager.tasksByResidence.value,
"updateTask must not write to _tasksByResidence — that cache is deprecated"
)
}
private fun sampleTask(
id: Int,
residenceId: Int,
column: String,
title: String = "Task $id"
) = TaskResponse(
id = id,
residenceId = residenceId,
createdById = 1,
title = title,
kanbanColumn = column,
createdAt = "2026-04-25T00:00:00Z",
updatedAt = "2026-04-25T00:00:00Z"
)
}
@@ -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.
+10 -2
View File
@@ -1,9 +1,17 @@
#Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M
# Heap sizing for KMP builds.
# Kotlin daemon runs the K2 compiler + native linker; 4 GB headroom
# prevents long-tail OOMs during iosArm64 framework link.
# MaxMetaspaceSize caps slow class-loading leaks across daemon reuse;
# G1GC keeps pauses short during incremental builds.
kotlin.daemon.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=1g -XX:+UseG1GC
#Gradle
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
# Gradle daemon drives configuration cache + dependency resolution +
# Compose/Android compilers. OOMs at 4 GB during ComposeApp.framework
# generation; 6 GB is the usual safe size for projects this size.
org.gradle.jvmargs=-Xmx6144M -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true
+1 -1
View File
@@ -15,6 +15,6 @@
<key>manageAppVersionAndBuildNumber</key>
<true/>
<key>teamID</key>
<string>V3PF3M6B6U</string>
<string>X86BR9WTLD</string>
</dict>
</plist>
+1 -1
View File
@@ -148,7 +148,7 @@ final class WidgetActionManager {
static let shared = WidgetActionManager()
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 tokenKey = "widget_auth_token"
+1 -1
View File
@@ -111,7 +111,7 @@ class CacheManager {
}
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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppLogo@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -263,13 +263,40 @@ class PreviewViewController: UIViewController, QLPreviewingController {
}
private func updateUIForResidence(with residence: ResidencePreviewData) {
// Update icon
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
// Brand icon. Prefer the bundled honeyDue logo so the preview
// reads as a HoneyDue invite at a glance; fall back to a tinted
// SF Symbol for accessibility / asset-load failures.
if let logo = UIImage(named: "AppLogo") {
iconImageView.image = logo.withRenderingMode(.alwaysOriginal)
iconImageView.contentMode = .scaleAspectFit
iconImageView.layer.cornerRadius = 16
iconImageView.layer.masksToBounds = true
} else {
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
}
titleLabel.text = residence.residenceName
subtitleLabel.text = "honeyDue Residence Invite"
instructionLabel.text = "Tap the share button below, then select \"honeyDue\" to join this residence."
// Branch the copy on whether the share link has already lapsed.
// Active invites get the standard "How to join" numbered steps;
// expired invites get a clear dead-end message asking the
// recipient to ping the sender for a new link no point
// showing share-sheet directions for a link the server will
// reject.
let expiredAgo = Self.expiredRelativePhraseOrNil(residence.expiresAt)
if let expiredAgo {
instructionLabel.attributedText = Self.makeExpiredInstructions(sharedBy: residence.sharedBy)
// The down-chevron points at the Share button as a visual
// cue to tap it; in the expired state there's nothing
// useful to share (the server will reject the bundled
// code) so the arrow becomes misleading. Hide it.
arrowImageView.isHidden = true
} else {
instructionLabel.attributedText = Self.makeResidenceInstructions()
arrowImageView.isHidden = false
}
// Clear existing details
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
@@ -280,9 +307,183 @@ class PreviewViewController: UIViewController, QLPreviewingController {
}
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)")
if let expiredAgo {
// "Expired 1 hour ago" capitalised past-tense; no
// "Expires " prefix because the share link no longer
// expires, it has already done so (gitea#7 review).
addDetailRow(icon: "clock", text: "Expired \(expiredAgo)")
} else {
let formatted = Self.formatActiveExpiry(expiresAt)
addDetailRow(icon: "clock", text: "Expires \(formatted)")
}
}
}
// MARK: - Formatting helpers
/// Render an *active* (not-yet-expired) share-link expiry as a
/// human-readable phrase. Within a day uses
/// `RelativeDateTimeFormatter` ("in 23 hours" / "in 12 minutes");
/// further out switches to absolute date + time so users planning
/// ahead see exactly when the invite lapses. Falls back to the raw
/// ISO string if parsing fails so the row never goes blank.
///
/// Callers must check [expiredRelativePhraseOrNil] first this
/// function assumes a future expiry and produces wording that only
/// makes sense in that case.
static func formatActiveExpiry(_ isoString: String) -> String {
guard let date = parseIsoDate(isoString) else { return isoString }
let now = Date()
let elapsed = date.timeIntervalSince(now)
if elapsed < 24 * 60 * 60 {
return relativeFormatter.localizedString(for: date, relativeTo: now)
}
return "on \(absoluteFormatter.string(from: date))"
}
/// If the share link has already lapsed, return the relative
/// "X ago" phrase. `nil` means active (or unparseable) callers
/// should fall back to [formatActiveExpiry] for those cases. The
/// split lets `updateUIForResidence` branch the entire UI block
/// (row text + instruction card) on the same signal (gitea#7
/// review: an expired link should send the recipient back to the
/// sender for a new invite, not show share-sheet directions for a
/// link the server will reject).
static func expiredRelativePhraseOrNil(_ isoString: String?) -> String? {
guard let isoString, let date = parseIsoDate(isoString) else { return nil }
let now = Date()
if date.timeIntervalSince(now) > 0 { return nil }
return relativeFormatter.localizedString(for: date, relativeTo: now)
}
private static func parseIsoDate(_ raw: String) -> Date? {
if let d = isoFormatterWithFraction.date(from: raw) { return d }
if let d = isoFormatterNoFraction.date(from: raw) { return d }
return nil
}
private static let isoFormatterWithFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .full
return f
}()
private static let absoluteFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
/// Builds the "How to join" instruction copy as an attributed
/// string with the iOS share-icon glyph (square + up-arrow) inlined
/// next to "Tap [icon]". The glyph is the universal share symbol
/// across iOS, so the recipient finds the right control whether
/// it's at the top, bottom, or behind a More menu instead of us
/// claiming a fixed position the chrome can move (gitea#7 review
/// feedback).
private static func makeResidenceInstructions() -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
func appendText(_ s: String) {
result.append(NSAttributedString(
string: s,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
}
appendText("How to join:\n1. Tap ")
let shareImage = UIImage(
systemName: "square.and.arrow.up",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
if let shareImage {
let attachment = NSTextAttachment()
attachment.image = shareImage
// Align the glyph baseline with the surrounding text by
// nudging the bounds down a few points; the SF Symbol's
// natural bounds sit a hair above the cap height.
attachment.bounds = CGRect(
x: 0,
y: -3,
width: shareImage.size.width,
height: shareImage.size.height
)
result.append(NSAttributedString(attachment: attachment))
}
appendText("\n2. Choose \"honeyDue\" from the share sheet")
appendText("\n3. Sign in if prompted — the app finishes the rest")
return result
}
/// Expired-state copy for the instruction card. Tells the recipient
/// the share link is no longer valid and to ping the sender (by
/// email if we know it) for a new one replaces the active "How to
/// join" steps since the server will reject the bundled code
/// anyway.
private static func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
// Slightly warmer tint than the active instruction copy the
// app's `appError` red would feel alarmist for "just ask again",
// and the secondary-label gray reads as muted/disabled which is
// accurate to the link's actual state.
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor.secondaryLabel
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
let titleTint = UIColor.label
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
result.append(NSAttributedString(
string: "This invite has expired.\n",
attributes: [
.font: titleFont,
.foregroundColor: titleTint,
.paragraphStyle: paragraph,
]
))
let body = if let s = sharedBy, !s.isEmpty {
"Ask \(s) to send a new link."
} else {
"Ask the sender to share a new link."
}
result.append(NSAttributedString(
string: body,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
return result
}
}
// MARK: - Type Discriminator
@@ -0,0 +1,437 @@
//
// Issue7PreviewScreenshotTest.swift
// HoneyDueTests
//
// Records a single PNG screenshot of the post-fix QL-preview layout
// used by `HoneyDueQLPreview/PreviewViewController.swift` so it can be
// attached to gitea issue #7 for the reviewer to see the new look
// without having to AirDrop a `.honeydue` file to a device.
//
// How it works:
// * Faithfully recreates the UIKit layout `PreviewViewController.updateUIForResidence`
// builds in production same colors, same fonts, same constraints,
// same image asset (copied into `HoneyDueTests/Resources/AppLogo.png`
// so it is reachable from this target's bundle).
// * Runs the same `formatExpiresAt` style (ISO parse relative phrase
// when within a day, absolute medium-date + short-time otherwise),
// using a fixed reference Date so the rendering is deterministic
// across runs / time zones.
// * `SnapshotTesting.assertSnapshot(of: viewController, as: .image)`
// writes the PNG to
// `iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/`.
//
// The first run (no committed golden) records the PNG and the test
// reports "failed - No reference was found on disk. Automatically
// recorded snapshot:" that's the file we attach to the issue.
//
// Note on faithfulness: this snapshot is a programmatic reproduction
// of `PreviewViewController.updateUIForResidence`, not the QL
// extension instance itself, because the QL extension's bundle is a
// separate Xcode target from `HoneyDueTests` and can't be `@testable
// import`ed without project-file surgery. The reproduction uses the
// same UIKit primitives, colors, fonts, and asset, so the rendered
// output matches what users see when iOS opens a `.honeydue` invite.
//
@preconcurrency import SnapshotTesting
import UIKit
import XCTest
@MainActor
final class Issue7PreviewScreenshotTest: XCTestCase {
/// Force record mode for this test only we want the PNG written
/// regardless of whether a golden exists.
override func invokeTest() {
withSnapshotTesting(record: .all) {
super.invokeTest()
}
}
func test_residence_invite_preview_after_issue7_fix() {
let vc = MockPreviewViewController(
residence: ResidencePreview.fixtureForIssue7,
state: .active
)
vc.overrideUserInterfaceStyle = .dark
assertSnapshot(
of: vc,
as: .image(
on: .iPhone13,
precision: 1.0,
perceptualPrecision: 1.0,
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: 2.0),
])
),
named: "issue7_residence_invite_preview_dark"
)
}
func test_residence_invite_preview_expired_state() {
// Same residence + sender, but expiry already 1 hour in the
// past. Verifies the expired branch: the instruction card
// swaps to "ask the sender for a new link" and the detail row
// reads "Expired 1 hour ago" instead of the future-tense
// "Expires in " phrasing.
let vc = MockPreviewViewController(
residence: ResidencePreview.fixtureForIssue7,
state: .expired(elapsedSecondsSinceExpiry: 60 * 60)
)
vc.overrideUserInterfaceStyle = .dark
assertSnapshot(
of: vc,
as: .image(
on: .iPhone13,
precision: 1.0,
perceptualPrecision: 1.0,
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: 2.0),
])
),
named: "issue7_residence_invite_preview_expired_dark"
)
}
}
// MARK: - Sample residence (matches the gitea#7 screenshot setup)
private struct ResidencePreview {
let residenceName: String
let sharedBy: String?
let expiresAt: String?
/// Mirrors the data shown in the original gitea#7 screenshot the
/// post-fix version of the same payload.
static let fixtureForIssue7 = ResidencePreview(
residenceName: "The Tartt's",
sharedBy: "honey@hollie37.com",
expiresAt: "2026-05-12T17:11:02.067272789Z"
)
}
// MARK: - Mock view controller (UIKit copy of `updateUIForResidence`)
/// Renderer state for the screenshot fixture. Active = link still
/// valid; expired = link lapsed `elapsedSecondsSinceExpiry` seconds
/// ago. Both render with deterministic data so the recorded PNG is
/// stable across runs.
private enum PreviewRenderState {
case active
case expired(elapsedSecondsSinceExpiry: TimeInterval)
}
@MainActor
private final class MockPreviewViewController: UIViewController {
private let residence: ResidencePreview
private let state: PreviewRenderState
private let containerView = UIView()
private let iconImageView = UIImageView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let dividerView = UIView()
private let detailsStackView = UIStackView()
private let instructionCard = UIView()
private let instructionLabel = UILabel()
private let arrowImageView = UIImageView()
init(residence: ResidencePreview, state: PreviewRenderState) {
self.residence = residence
self.state = state
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("not used") }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
applyResidence()
}
private func setupUI() {
view.backgroundColor = .systemBackground
containerView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.contentMode = .scaleAspectFit
iconImageView.tintColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
titleLabel.textColor = .label
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 2
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
subtitleLabel.font = .systemFont(ofSize: 15, weight: .medium)
subtitleLabel.textColor = .secondaryLabel
subtitleLabel.textAlignment = .center
dividerView.translatesAutoresizingMaskIntoConstraints = false
dividerView.backgroundColor = .separator
detailsStackView.translatesAutoresizingMaskIntoConstraints = false
detailsStackView.axis = .vertical
detailsStackView.spacing = 12
detailsStackView.alignment = .leading
instructionCard.translatesAutoresizingMaskIntoConstraints = false
instructionCard.backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 0.1)
instructionCard.layer.cornerRadius = 12
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
instructionLabel.font = .systemFont(ofSize: 15, weight: .medium)
instructionLabel.textColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
instructionLabel.textAlignment = .left
instructionLabel.numberOfLines = 0
arrowImageView.translatesAutoresizingMaskIntoConstraints = false
arrowImageView.contentMode = .scaleAspectFit
arrowImageView.tintColor = .secondaryLabel
let arrowConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
arrowImageView.image = UIImage(systemName: "arrow.down", withConfiguration: arrowConfig)
view.addSubview(containerView)
containerView.addSubview(iconImageView)
containerView.addSubview(titleLabel)
containerView.addSubview(subtitleLabel)
containerView.addSubview(dividerView)
containerView.addSubview(detailsStackView)
containerView.addSubview(instructionCard)
instructionCard.addSubview(instructionLabel)
containerView.addSubview(arrowImageView)
NSLayoutConstraint.activate([
containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
containerView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
containerView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 340),
iconImageView.topAnchor.constraint(equalTo: containerView.topAnchor),
iconImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
iconImageView.widthAnchor.constraint(equalToConstant: 80),
iconImageView.heightAnchor.constraint(equalToConstant: 80),
titleLabel.topAnchor.constraint(equalTo: iconImageView.bottomAnchor, constant: 16),
titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
dividerView.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 20),
dividerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
dividerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
dividerView.heightAnchor.constraint(equalToConstant: 1),
detailsStackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20),
detailsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
detailsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
instructionCard.topAnchor.constraint(equalTo: detailsStackView.bottomAnchor, constant: 24),
instructionCard.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
instructionCard.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
instructionLabel.topAnchor.constraint(equalTo: instructionCard.topAnchor, constant: 16),
instructionLabel.leadingAnchor.constraint(equalTo: instructionCard.leadingAnchor, constant: 16),
instructionLabel.trailingAnchor.constraint(equalTo: instructionCard.trailingAnchor, constant: -16),
instructionLabel.bottomAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: -16),
arrowImageView.topAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: 16),
arrowImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
arrowImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
}
private func applyResidence() {
// Mirror the post-fix branding choice: bundled honeyDue logo
// rendered in its actual colors. The image ships with the test
// target at `Resources/AppLogo.png`.
if let path = Bundle(for: Self.self).path(forResource: "AppLogo", ofType: "png"),
let logo = UIImage(contentsOfFile: path) {
iconImageView.image = logo
iconImageView.layer.cornerRadius = 16
iconImageView.layer.masksToBounds = true
} else {
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
}
titleLabel.text = residence.residenceName
subtitleLabel.text = "honeyDue Residence Invite"
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
}
switch state {
case .active:
instructionLabel.attributedText = makeResidenceInstructions()
arrowImageView.isHidden = false
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires \(formatActiveExpiry(expiresAt))")
}
case .expired(let elapsed):
instructionLabel.attributedText = makeExpiredInstructions(sharedBy: residence.sharedBy)
// Arrow points at the Share button no point telling the
// user to tap it for a dead link. Matches PreviewViewController.
arrowImageView.isHidden = true
addDetailRow(icon: "clock", text: "Expired \(relativePhrase(secondsAgo: elapsed))")
}
}
private func relativePhrase(secondsAgo: TimeInterval) -> String {
// Deterministic relative phrase we set "now" to be exactly
// `secondsAgo` after the (fake) expiry, so the formatter says
// "1 hour ago" instead of whatever the real clock would give.
let fakeNow = Date()
let pastExpiry = fakeNow.addingTimeInterval(-secondsAgo)
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .full
return relative.localizedString(for: pastExpiry, relativeTo: fakeNow)
}
/// Expired-state copy mirroring `PreviewViewController.makeExpiredInstructions`.
private func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
result.append(NSAttributedString(
string: "This invite has expired.\n",
attributes: [
.font: titleFont,
.foregroundColor: UIColor.label,
.paragraphStyle: paragraph,
]
))
let body = if let s = sharedBy, !s.isEmpty {
"Ask \(s) to send a new link."
} else {
"Ask the sender to share a new link."
}
result.append(NSAttributedString(
string: body,
attributes: [
.font: bodyFont,
.foregroundColor: UIColor.secondaryLabel,
.paragraphStyle: paragraph,
]
))
return result
}
private func addDetailRow(icon: String, text: String) {
let row = UIStackView()
row.axis = .horizontal
row.spacing = 12
row.alignment = .center
let iv = UIImageView()
iv.translatesAutoresizingMaskIntoConstraints = false
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
iv.image = UIImage(systemName: icon, withConfiguration: config)
iv.tintColor = .secondaryLabel
iv.widthAnchor.constraint(equalToConstant: 24).isActive = true
iv.heightAnchor.constraint(equalToConstant: 24).isActive = true
let label = UILabel()
label.font = .systemFont(ofSize: 15)
label.textColor = .label
label.text = text
label.numberOfLines = 1
row.addArrangedSubview(iv)
row.addArrangedSubview(label)
detailsStackView.addArrangedSubview(row)
}
/// Mirrors `PreviewViewController.makeResidenceInstructions()` see
/// the rationale comment there. Inlined here because the QL
/// extension target can't be `@testable import`ed without
/// project-file surgery.
private func makeResidenceInstructions() -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
func appendText(_ s: String) {
result.append(NSAttributedString(
string: s,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
}
appendText("How to join:\n1. Tap ")
let shareImage = UIImage(
systemName: "square.and.arrow.up",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
if let shareImage {
let attachment = NSTextAttachment()
attachment.image = shareImage
attachment.bounds = CGRect(
x: 0,
y: -3,
width: shareImage.size.width,
height: shareImage.size.height
)
result.append(NSAttributedString(attachment: attachment))
}
appendText("\n2. Choose \"honeyDue\" from the share sheet")
appendText("\n3. Sign in if prompted — the app finishes the rest")
return result
}
// Mirrors PreviewViewController.formatActiveExpiry with a fixed
// "now" so the rendering is identical regardless of when the test
// runs. The expired branch uses [relativePhrase(secondsAgo:)]
// instead see the active/expired switch in `applyResidence`.
private func formatActiveExpiry(_ raw: String) -> String {
let isoWithFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
let isoNoFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
guard let date = isoWithFraction.date(from: raw)
?? isoNoFraction.date(from: raw) else {
return raw
}
// Deterministic "now": 23 hours before the fixture's expiry, so
// the relative formatter always produces "in 23 hours".
let fakeNow = date.addingTimeInterval(-23 * 60 * 60)
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .full
return relative.localizedString(for: date, relativeTo: fakeNow)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+2 -2
View File
@@ -9,13 +9,13 @@
}
],
"defaultOptions" : {
"testTimeoutsEnabled" : true,
"defaultTestExecutionTimeAllowance" : 300,
"targetForVariableExpansion" : {
"containerPath" : "container:honeyDue.xcodeproj",
"identifier" : "D4ADB376A7A4CFB73469E173",
"name" : "HoneyDue"
}
},
"testTimeoutsEnabled" : true
},
"testTargets" : [
{
@@ -34,6 +34,23 @@ class AuthenticatedUITestCase: BaseUITestCase {
}
}
/// When `true`, every test in the suite forces a logout login cycle
/// in `setUp`, guaranteeing a freshly-issued auth token on each run.
///
/// Default is `false`: tests reuse the existing logged-in session
/// from the previous test in the same suite much faster (one login
/// per suite, not one per test) and resilient to suites where the
/// current screen has no logout affordance (`UITestHelpers.ensureLoggedOut`
/// times out the test fails before its body runs).
///
/// Override to `true` in suites that have observed transient
/// `Invalid token` 401s on POST/PATCH while reads continue to work.
/// The recipe was added after a 2026-05 incident where the API
/// container was rebuilt mid-suite and in-memory tokens went stale.
/// In normal CI runs against a stable API + freshly-erased simulator,
/// session reuse is the correct default.
var forceFreshLoginPerTest: Bool { false }
override func setUpWithError() throws {
guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)")
@@ -41,27 +58,27 @@ class AuthenticatedUITestCase: BaseUITestCase {
try super.setUpWithError()
// If already logged in (tab bar visible), skip the login flow
let tabBar = app.tabBars.firstMatch
if tabBar.waitForExistence(timeout: defaultTimeout) {
// Already logged in just set up API session if needed
if needsAPISession {
guard let apiSession = TestAccountManager.loginSeededAccount(
username: apiCredentials.username,
password: apiCredentials.password
) else {
XCTFail("Could not login API account '\(apiCredentials.username)'")
return
}
session = apiSession
cleaner = TestDataCleaner(token: apiSession.token)
}
return
}
let alreadyLoggedIn = tabBar.waitForExistence(timeout: defaultTimeout)
// Not logged in do the full login flow
UITestHelpers.ensureLoggedOut(app: app)
loginToMainApp()
// Force-fresh path: log out (if needed) and re-authenticate per
// test so every test starts with a freshly-issued JWT. Catches
// server-side token invalidation that would otherwise surface
// mid-suite as opaque 401s on the first mutation call.
if forceFreshLoginPerTest {
if alreadyLoggedIn {
UITestHelpers.ensureLoggedOut(app: app)
} else {
UITestHelpers.ensureLoggedOut(app: app)
}
loginToMainApp()
} else if !alreadyLoggedIn {
// Legacy session-reuse path: only log in when not already in.
UITestHelpers.ensureLoggedOut(app: app)
loginToMainApp()
}
// (When `forceFreshLoginPerTest == false` AND we're already
// logged in, fall through with the existing session.)
if needsAPISession {
guard let apiSession = TestAccountManager.loginSeededAccount(
@@ -0,0 +1,221 @@
import XCTest
/// Suite 11 captures the gitea#2 regression at the user-visible level:
/// after onboarding (register name residence bulk-create tasks land
/// on home), tapping the residence cell shows "no tasks" even though the
/// server has them. Restarting the app fixes it. This test reproduces the
/// flow without an app restart and asserts that tasks render on the
/// residence detail screen.
///
/// CRITICAL: this test must FAIL at the cache-unification fix's first
/// commit and must PASS after Phase 1-3 lands. The failing assertion is
/// pinned to a specific message so the regression is unambiguous.
///
/// The test deliberately does NOT visit the Tasks tab between onboarding
/// and tapping the residence cell. Visiting the Tasks tab would prime
/// `_allTasks` and mask the bug the bug is that residence detail
/// cannot recover from the empty-cache + sink-timing window on its own.
final class Suite11_TaskCacheRegressionTests: BaseUITestCase {
// We need to start at the onboarding welcome screen, not the standalone
// login screen `completeOnboarding` would skip the entire flow.
override var completeOnboarding: Bool { false }
// Single test in this suite relaunch isn't necessary, but we want a
// clean state every time (handled by the default --reset-state).
override var relaunchBetweenTests: Bool { true }
// MARK: - Constants
/// DEBUG_FIXED_CODES=true on the local Go API hardcodes this code.
private let debugVerificationCode = "123456"
/// Stable name for the residence we create in onboarding. Used both for
/// the form input and to address the cell on the home screen via
/// `app.staticTexts[residenceName]` if the id-based identifier doesn't
/// resolve in time.
private let residenceName = "UI Test Property"
// MARK: - Test
/// Reproduces gitea#2: tasks created via the onboarding bulk endpoint
/// must appear on the residence detail screen without an app restart
/// and without first visiting the Tasks tab.
@MainActor
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
// Step 1 Register a fresh user via the onboarding Start Fresh flow.
// The flow is: Welcome ValueProps NameResidence CreateAccount
// VerifyEmail HomeProfile FirstTask main app.
let createAccount = TestFlows.navigateStartFreshToCreateAccount(
app: app,
residenceName: residenceName
)
createAccount.waitForLoad(timeout: navigationTimeout)
// Step 2 Fill the create-account form. We address the onboarding
// form's fields (not the standalone register sheet's fields).
let creds = TestAccountManager.uniqueCredentials(prefix: "gitea2")
createAccount.expandEmailSignup()
// Use the same focusAndType path that OnboardingTests uses it
// already handles SecureTextField + iOS strong-password panel.
// Under --ui-testing, OrganicOnboardingSecureField defaults to
// visibility=ON (renders as TextField) to dodge the iOS 26 SecureField
// keyboard bug. Query textFields, not secureTextFields.
let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
let passwordField = app.textFields[AccessibilityIdentifiers.Onboarding.passwordField]
let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
usernameField.waitForExistenceOrFail(timeout: navigationTimeout)
usernameField.focusAndType(creds.username, app: app)
emailField.waitForExistenceOrFail(timeout: navigationTimeout)
emailField.focusAndType(creds.email, app: app)
passwordField.waitForExistenceOrFail(timeout: navigationTimeout)
passwordField.focusAndType(creds.password, app: app)
confirmPasswordField.waitForExistenceOrFail(timeout: navigationTimeout)
confirmPasswordField.focusAndType(creds.password, app: app)
let createAccountButton = app.descendants(matching: .any)
.matching(identifier: AccessibilityIdentifiers.Onboarding.createAccountButton)
.firstMatch
createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout)
createAccountButton.forceTap()
// Step 3 Verify email with the debug fixed code.
let verification = VerificationScreen(app: app)
verification.waitForLoad(timeout: loginTimeout)
verification.enterCode(debugVerificationCode)
// Many onboarding verification screens auto-submit on a 6-digit
// code. If a verify button still exists and a code field is still
// visible, tap it to push past edge cases.
if verification.codeField.waitForExistence(timeout: 1) && verification.verifyButton.exists {
verification.submitCode()
}
// Step 4 Skip the home-profile step. The home-profile screen has
// its own Skip button (the shared onboarding skip in the nav bar)
// which routes to the first-task step without making us pick climate
// / appliance fields.
let onboardingSkipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton]
XCTAssertTrue(
onboardingSkipButton.waitForExistence(timeout: loginTimeout),
"Onboarding skip button should exist on the home-profile screen"
)
// The skip button can briefly be non-hittable during the screen-in
// transition. Use forceTap() to bypass the strict hittable check.
// We confirmed existence above; if the tap doesn't land on the
// intended button the next assertion (Browse All tab) will catch it.
onboardingSkipButton.forceTap()
// Step 5 Switch to the "Browse All" tab on the First-Task screen.
// "For You" suggestions can be empty for a fresh residence with no
// home-profile data, so deterministic browsing is required.
// The tab bar is a SwiftUI segmented Picker its segments are
// exposed as buttons with the segment label, regardless of an
// identifier on the parent.
let browseAllTab = app.buttons["Browse All"]
XCTAssertTrue(
browseAllTab.waitForExistence(timeout: loginTimeout),
"Browse All tab should appear on the first-task screen"
)
browseAllTab.tap()
// Step 6 Pick 3 templates by accessibility identifier prefix.
// The catalog is loaded via GET /api/tasks/templates/grouped/, so
// we need to wait for at least one row to render before tapping.
let templateRowQuery = app.buttons.matching(
NSPredicate(format: "identifier BEGINSWITH %@",
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
)
// Wait for the catalog to load. The grouped endpoint returns first
// category expanded by default in the view, so rows should appear
// shortly after Browse All becomes visible. Network call: 10s.
let firstRow = templateRowQuery.element(boundBy: 0)
XCTAssertTrue(
firstRow.waitForExistence(timeout: loginTimeout),
"At least one template row must render on the Browse All tab. " +
"If no rows appear, the catalog endpoint failed — bug repro is invalid."
)
// Tap the first 3 visible rows. Some categories may collapse rows
// we never see; we only need at least 1, so the floor is 1 with a
// soft cap of 3.
let rowCount = templateRowQuery.count
let toPick = min(3, rowCount)
XCTAssertGreaterThanOrEqual(toPick, 1, "Expected at least one template row")
for index in 0..<toPick {
let row = templateRowQuery.element(boundBy: index)
row.waitUntilHittable(timeout: navigationTimeout)
row.tap()
}
// Step 7 Submit the bulk-create. This is the
// POST /api/tasks/bulk/ call that produces the inconsistent client
// cache state at the heart of gitea#2.
let submitButton = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
XCTAssertTrue(
submitButton.waitForExistence(timeout: navigationTimeout),
"Submit-tasks button must exist on the first-task screen"
)
submitButton.waitUntilHittable(timeout: navigationTimeout).tap()
// Step 8 Land on the main app (Residences tab is selected by
// default). CRITICAL: do NOT tap the Tasks tab. Tapping it would
// populate `_allTasks` and mask the bug.
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
let tabBar = app.tabBars.firstMatch
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|| tabBar.waitForExistence(timeout: navigationTimeout)
XCTAssertTrue(reachedMain, "App should reach main tabs after onboarding submit")
// Step 9 Tap the residence cell directly. Prefer the
// identifier-prefix match for any cell; fall back to the static
// text match by name.
let residenceCellQuery = app.buttons.matching(
NSPredicate(format: "identifier BEGINSWITH %@",
AccessibilityIdentifiers.Residence.cellPrefix)
)
let residenceCell = residenceCellQuery.firstMatch
if residenceCell.waitForExistence(timeout: navigationTimeout) && residenceCell.isHittable {
residenceCell.tap()
} else {
// Fallback: tap the static text inside the card. The
// NavigationLink wraps the entire card so a tap on the name
// still routes into the detail view.
let residenceText = app.staticTexts[residenceName]
XCTAssertTrue(
residenceText.waitForExistence(timeout: navigationTimeout),
"Residence cell or name '\(residenceName)' must exist on the residences list"
)
residenceText.tap()
}
// Step 10 THE BUG ASSERTION. With the bug present:
// - `_allTasks` is null on the client (never primed).
// - `_tasksByResidence[id]` is empty (cache miss).
// - residence detail attempts to load, hits the iOS Combine sink
// timing window, and renders the empty state.
// With the fix, both `_allTasks` is populated by `bulkCreateTasks`
// and residence detail filters from it in-memory, so the empty
// state must not appear.
let taskRowQuery = app.descendants(matching: .any).matching(
NSPredicate(format: "identifier BEGINSWITH %@",
AccessibilityIdentifiers.Task.rowPrefix)
)
let firstTaskRow = taskRowQuery.element(boundBy: 0)
let anyTaskAppeared = firstTaskRow.waitForExistence(timeout: 10)
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.noTasksLabel]
let emptyStateVisible = emptyState.exists
// Pin the failure message so the bug-capture is unambiguous. This
// is the assertion that should FAIL at this commit and PASS after
// the cache fix lands. Don't change the message Task 12 grep's
// for it.
XCTAssertTrue(
anyTaskAppeared && !emptyStateVisible,
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
)
}
}
+34 -32
View File
@@ -661,7 +661,7 @@
0248CABA5A5197845F2E5C26 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue;
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue;
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -669,14 +669,15 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs access to your camera to take photos of completed tasks";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs access to your photo library to select photos of completed tasks";
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
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_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -686,7 +687,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -696,14 +697,14 @@
1C0789552EBC218D00392B46 /* Debug */ = {
isa = XCBuildConfiguration;
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_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDue/Info.plist;
@@ -717,7 +718,7 @@
);
MARKETING_VERSION = 1.0;
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)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -732,14 +733,14 @@
1C0789562EBC218D00392B46 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue;
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDue/Info.plist;
@@ -753,7 +754,7 @@
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueExtension;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -772,12 +773,12 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueTests;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -798,12 +799,12 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueTests;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -823,7 +824,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
@@ -836,7 +837,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueQLPreview;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueQLPreview;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -854,7 +855,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
@@ -867,7 +868,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueQLPreview;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueQLPreview;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -885,7 +886,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
@@ -898,7 +899,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueQLThumbnail;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueQLThumbnail;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -916,7 +917,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
@@ -929,7 +930,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueQLThumbnail;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueQLThumbnail;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -946,13 +947,13 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
MACOSX_DEPLOYMENT_TARGET = 26.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueUITests;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -972,13 +973,13 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
MACOSX_DEPLOYMENT_TARGET = 26.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueUITests;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -1121,7 +1122,7 @@
E767E942685C7832D51FF978 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue.dev;
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue.dev;
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -1129,14 +1130,15 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = V3PF3M6B6U;
DEVELOPMENT_TEAM = X86BR9WTLD;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs access to your camera to take photos of completed tasks";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs access to your photo library to select photos of completed tasks";
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
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_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -1146,7 +1148,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev;
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -16,7 +16,7 @@ final class BackgroundTaskManager {
static let shared = BackgroundTaskManager()
/// 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)
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)
/// 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 {
return """
Background Task Debug Info:
@@ -48,6 +48,9 @@ struct AccessibilityIdentifiers {
static let addButton = "Residence.AddButton"
static let residencesList = "Residence.List"
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 emptyStateButton = "Residence.EmptyState.AddButton"
@@ -87,7 +90,15 @@ struct AccessibilityIdentifiers {
static let refreshButton = "Task.RefreshButton"
static let tasksList = "Task.List"
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"
/// 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 overdueColumn = "Task.Column.Overdue"
static let upcomingColumn = "Task.Column.Upcoming"
@@ -229,8 +240,24 @@ struct AccessibilityIdentifiers {
static let taskSelectionCounter = "Onboarding.TaskSelectionCounter"
static let addPopularTasksButton = "Onboarding.AddPopularTasksButton"
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 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
static let subscriptionTitle = "Onboarding.SubscriptionTitle"
+39 -61
View File
@@ -4,15 +4,18 @@ import ComposeApp
/// Three-step direct-to-B2 image upload.
///
/// Flow:
/// 1. POST /api/uploads/presign server returns a B2 POST policy + form
/// fields scoped to a single object key with a content-length-range
/// condition that B2 enforces at the protocol level.
/// 2. Multipart POST the bytes directly to B2, no API server in the data
/// path. B2 rejects the upload if the bytes don't match the policy.
/// 1. POST /api/uploads/presign server returns a signed PUT URL plus
/// the headers (Content-Type, Content-Length) the client must send.
/// The signature binds those headers B2 rejects the upload if the
/// bytes/headers don't match exactly.
/// 2. PUT the bytes directly to B2, no API server in the data path.
/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
/// /api/documents/ via `upload_ids[]`. The server HEADs the object,
/// confirms the size, and creates the linked entity rows.
///
/// We use PUT (not POST) because B2's S3-compatible endpoint does not
/// implement the S3 POST Object form upload every POST returns HTTP 501.
///
/// All errors map to `PresignedUploaderError` the Swift call site can
/// translate to user-facing copy without parsing nested HTTP details.
enum PresignedUploaderError: Error, LocalizedError {
@@ -33,7 +36,7 @@ enum PresignedUploaderError: Error, LocalizedError {
default: return "Couldn't start upload (server returned \(status))."
}
case .uploadFailed(let status, _):
return "Upload failed (B2 returned \(status))."
return "Upload failed (storage returned \(status))."
case .sessionError(let err):
return err.localizedDescription
}
@@ -95,13 +98,12 @@ final class PresignedUploader {
contentLength: Int64(data.count)
)
// Step 2: direct POST to B2
try await postToStorage(
// Step 2: direct PUT to B2
try await putToStorage(
uploadURL: presigned.uploadUrl,
fields: presigned.fields,
headers: presigned.headers,
data: data,
contentType: contentType,
fileName: fileName
contentType: contentType
)
return Int32(presigned.id)
@@ -146,7 +148,8 @@ final class PresignedUploader {
private struct PresignResponse: Decodable {
let id: Int
let upload_url: String
let fields: [String: String]
let method: String?
let headers: [String: String]
let key: String
let expires_at: String
@@ -196,64 +199,39 @@ final class PresignedUploader {
}
}
// MARK: - Step 2: POST to B2
// MARK: - Step 2: PUT to B2
//
// The presign response includes the exact headers (Content-Type +
// Content-Length) that were signed. Send them verbatim any deviation
// invalidates the signature and B2 will reject the upload.
//
// Content-Length is set automatically by URLSession from httpBody.count,
// so we don't manually echo it back; we still send Content-Type because
// URLSession will otherwise default it to application/x-www-form-urlencoded.
private func postToStorage(
private func putToStorage(
uploadURL: String,
fields: [String: String],
headers: [String: String],
data: Data,
contentType: String,
fileName: String
contentType: String
) async throws {
guard let url = URL(string: uploadURL) else {
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
}
// Build a multipart/form-data body with all policy fields followed
// by a single "file" part (S3 POST policy mandates the file part
// come last).
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
let crlf = "\r\n"
let appendString: (String) -> Void = { s in
body.append(s.data(using: .utf8) ?? Data())
}
// Stable order: ensure "key" and "Content-Type" appear before the
// file part so the policy signature validates. Unspecified order
// for the rest S3 accepts any.
let orderedKeys = ["key", "Content-Type", "policy", "x-amz-algorithm",
"x-amz-credential", "x-amz-date", "x-amz-signature",
"x-amz-meta-uid"]
var emitted = Set<String>()
for k in orderedKeys {
if let v = fields[k] {
appendString("--\(boundary)\(crlf)")
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
appendString(v)
appendString(crlf)
emitted.insert(k)
}
}
for (k, v) in fields where !emitted.contains(k) {
appendString("--\(boundary)\(crlf)")
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
appendString(v)
appendString(crlf)
}
// file part must be last
appendString("--\(boundary)\(crlf)")
appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\(crlf)")
appendString("Content-Type: \(contentType)\(crlf)\(crlf)")
body.append(data)
appendString(crlf)
appendString("--\(boundary)--\(crlf)")
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
req.httpBody = body
req.httpMethod = "PUT"
req.httpBody = data
// Apply server-supplied headers verbatim. Skip Content-Length
// URLSession sets it automatically and will refuse to override it.
for (k, v) in headers where k.lowercased() != "content-length" {
req.setValue(v, forHTTPHeaderField: k)
}
// Defensive: ensure Content-Type is set even if the server omits it.
if req.value(forHTTPHeaderField: "Content-Type") == nil {
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
let (respBody, response): (Data, URLResponse)
do {
+1 -1
View File
@@ -61,7 +61,7 @@ enum ThemeID: String, CaseIterable, Codable {
// MARK: - Shared App Group UserDefaults
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 = {
guard let defaults = UserDefaults(suiteName: appGroupID) else {
@@ -21,7 +21,7 @@ final class WidgetDataManager {
static let cancelledColumn = "cancelled_tasks"
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 actionsFileName = "widget_pending_actions.json"
+7 -13
View File
@@ -6,14 +6,8 @@
<string>$(APP_GROUP_IDENTIFIER)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.tt.honeyDue.refresh</string>
<string>com.myhoneydue.honeyDue.refresh</string>
</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>
<array>
<dict>
@@ -40,17 +34,17 @@
</array>
</dict>
</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>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</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>
<array>
<string>remote-notification</string>
+20 -23
View File
@@ -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.",
"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.",
"isCommentAutoGenerated" : true
@@ -221,9 +237,6 @@
},
"Add document" : {
},
"Add Most Popular" : {
},
"Add new property" : {
"comment" : "A label displayed as a button in the toolbar.",
@@ -17684,10 +17697,6 @@
"comment" : "A button that generates a new share code.",
"isCommentAutoGenerated" : true
},
"Generating suggestions..." : {
"comment" : "Text displayed while the app is generating personalized task suggestions.",
"isCommentAutoGenerated" : true
},
"Get notified when someone joins your property" : {
},
@@ -17710,16 +17719,8 @@
"comment" : "A label for the back button.",
"isCommentAutoGenerated" : true
},
"Good match" : {
"comment" : "A label describing a task's relevance.",
"isCommentAutoGenerated" : true
},
"Google Sign-In Error" : {
},
"Great match" : {
"comment" : "A label describing a high-relevance task.",
"isCommentAutoGenerated" : true
},
"Help improve honeyDue by sharing anonymous usage data" : {
@@ -17862,10 +17863,6 @@
},
"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" : {
@@ -25444,6 +25441,10 @@
"comment" : "A button label that allows users to skip the current onboarding step.",
"isCommentAutoGenerated" : true
},
"Skip for now" : {
"comment" : "A button label that skips onboarding.",
"isCommentAutoGenerated" : true
},
"Skip for Now" : {
},
@@ -30617,10 +30618,6 @@
"comment" : "A button label that says \"Try Again\".",
"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" : {
"comment" : "A button that unarchives a task.",
"isCommentAutoGenerated" : true
@@ -366,7 +366,12 @@ struct OnboardingCreateAccountContent: View {
}
.onChange(of: viewModel.isRegistered) { _, isRegistered in
if isRegistered {
// Registration successful - user is authenticated but not verified
// Registration successful server gave us a token, so we ARE
// authenticated (just not verified yet). Mark the iOS-side auth
// state to match, otherwise OnboardingState.completeOnboarding's
// auth guard silently no-ops at the end of the flow and the
// user gets stuck on the firstTask screen.
AuthenticationManager.shared.login(verified: false)
onAccountCreated(false)
}
}
@@ -451,7 +456,13 @@ private struct OrganicOnboardingSecureField: View {
@Binding var text: String
var isFocused: Bool = false
var accessibilityIdentifier: String? = nil
@State private var showPassword = false
// iOS 26 has a known bug where tapping a SwiftUI SecureField with
// `.textContentType(.password)` doesn't reliably bring up the keyboard
// the strong-password autofill panel steals focus. Under UI tests
// we force the visibility toggle ON, rendering as a plain TextField,
// which has reliable focus behavior. The plaintext isn't a security
// concern in test mode (test creds are throwaway).
@State private var showPassword = UITestRuntime.isEnabled
var body: some View {
HStack(spacing: 14) {
@@ -158,6 +158,7 @@ struct OnboardingFirstTaskContent: View {
OnboardingTaskTabBar(selectedTab: $selectedTab)
.padding(.horizontal, OrganicSpacing.comfortable)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.firstTaskTabBar)
switch selectedTab {
case .forYou:
@@ -384,6 +385,7 @@ struct OnboardingFirstTaskContent: View {
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(selectedCount > 0 ? .medium : .subtle)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.submitTasksButton)
.disabled(vm.isSubmitting)
.animation(.easeInOut(duration: 0.2), value: selectedCount)
}
@@ -653,6 +655,7 @@ private struct OnboardingSuggestionRow: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(suggestion.template.id)")
.accessibilityLabel("\(suggestion.template.title), \(suggestion.template.frequencyDisplay), \(relevancePercent)% match")
.accessibilityValue(isSelected ? "selected" : "not selected")
}
@@ -798,6 +801,7 @@ private struct OnboardingTemplateRow: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(template.id)")
.accessibilityLabel("\(template.title), \(template.frequencyLabel)")
.accessibilityValue(isSelected ? "selected" : "not selected")
}
@@ -231,6 +231,7 @@ private struct ResidencesContent: View {
.padding(.horizontal, 16)
}
.buttonStyle(OrganicCardButtonStyle())
.accessibilityIdentifier("\(AccessibilityIdentifiers.Residence.cellPrefix).\(residence.id)")
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .opacity
@@ -7,7 +7,7 @@ import ComposeApp
final class KeychainHelper: NSObject, KeychainDelegate {
static let shared = KeychainHelper()
private let service = "com.tt.honeyDue"
private let service = "com.myhoneydue.honeyDue"
func save(key: String, value: String) -> Bool {
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).
// Keep these in sync with SubscriptionProducts.MONTHLY / SubscriptionProducts.ANNUAL.
private let fallbackProductIDs = [
"com.tt.honeyDue.pro.monthly",
"com.tt.honeyDue.pro.annual"
"com.myhoneydue.honeyDue.pro.monthly",
"com.myhoneydue.honeyDue.pro.annual"
]
private var configuredProductIDs: [String] {
@@ -122,6 +122,7 @@ struct DynamicTaskCard: View {
.cornerRadius(12)
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
.simultaneousGesture(TapGesture(), including: .subviews)
.accessibilityIdentifier("\(AccessibilityIdentifiers.Task.rowPrefix).\(task.id)")
.sheet(isPresented: $showCompletionHistory) {
CompletionHistorySheet(
taskTitle: task.title,
@@ -22,6 +22,8 @@ struct EmptyTasksView: View {
.background(Color.appBackgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(.subtle)
.accessibilityElement(children: .combine)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.noTasksLabel)
}
}
+42 -23
View File
@@ -42,6 +42,12 @@ class TaskViewModel: ObservableObject {
private let dataManager: DataManagerObservable
// MARK: - Initialization
/// Single source of truth = DataManager._allTasks. When this VM is
/// residence-scoped (currentResidenceId set), filter in-memory by
/// residence id. Eliminates the gitea#2 race window where the
/// per-residence cache slot could be empty while _allTasks was
/// populated. The per-residence cache is gone (cec521b).
///
/// - Parameter dataManager: Observable cache the VM subscribes to.
/// Defaults to the shared singleton. Tests inject a fixture-backed
/// instance so populated-state snapshots render real data.
@@ -50,35 +56,26 @@ class TaskViewModel: ObservableObject {
// Seed from current cache so snapshot tests/previews render
// populated state without waiting for Combine's async dispatch.
// The seed path mirrors the steady-state filter below if this
// VM is residence-scoped at construction time the seed has to
// pre-filter too, but currentResidenceId is set after init via
// setResidenceFilter(...), so seeding the unfiltered list is fine.
self.tasksResponse = dataManager.allTasks
// Observe injected DataManagerObservable for all tasks data
// Observe injected DataManagerObservable for all tasks data.
dataManager.$allTasks
.receive(on: DispatchQueue.main)
.sink { [weak self] allTasks in
// Skip DataManager updates during completion animation to prevent
// the task from being moved out of its column before the animation finishes
guard self?.isAnimatingCompletion != true else { return }
// Only update if we're showing all tasks (no residence filter)
if self?.currentResidenceId == nil {
self?.tasksResponse = allTasks
if allTasks != nil {
self?.isLoadingTasks = false
}
}
}
.store(in: &cancellables)
guard let self else { return }
guard !self.isAnimatingCompletion else { return }
// Observe tasks by residence
dataManager.$tasksByResidence
.receive(on: DispatchQueue.main)
.sink { [weak self] tasksByResidence in
guard self?.isAnimatingCompletion != true else { return }
// Only update if we're filtering by residence
if let resId = self?.currentResidenceId,
let tasks = tasksByResidence[resId] {
self?.tasksResponse = tasks
self?.isLoadingTasks = false
if let allTasks {
if let resId = self.currentResidenceId {
self.tasksResponse = self.filterTasks(allTasks, residenceId: resId)
} else {
self.tasksResponse = allTasks
}
self.isLoadingTasks = false
}
}
.store(in: &cancellables)
@@ -392,6 +389,28 @@ class TaskViewModel: ObservableObject {
}
}
/// Filter the all-tasks kanban down to a single residence in-memory.
/// Mirrors `DataManager.getTasksForResidence` on the Kotlin side.
private func filterTasks(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse {
let filteredColumns = response.columns.map { column -> TaskColumn in
let filteredTasks = column.tasks.filter { Int32($0.residenceId) == residenceId }
return TaskColumn(
name: column.name,
displayName: column.displayName,
buttonTypes: column.buttonTypes,
icons: column.icons,
color: column.color,
tasks: filteredTasks,
count: Int32(filteredTasks.count)
)
}
return TaskColumnsResponse(
columns: filteredColumns,
daysThreshold: response.daysThreshold,
residenceId: String(residenceId)
)
}
/// Updates a task in the kanban board by moving it to the correct column based on kanban_column
func updateTaskInKanban(_ updatedTask: TaskResponse) {
guard let currentResponse = tasksResponse else { return }