15 Commits

Author SHA1 Message Date
Trey t e350467975 refactor(uploads): drop legacy multipart helpers; route Android UI through presigned flow
The KMP shared layer's task-completion-with-images path now exclusively
uses the presigned-URL flow: each image is compressed, uploaded directly
to B2 via APILayer.uploadImage, and the resulting upload_ids are passed
to /api/task-completions/ as JSON. Bytes never traverse our API server.

Changes:
  - TaskCompletionViewModel.createTaskCompletionWithImages now does the
    presign→POST→collect-ids dance internally. The signature stays the
    same so the three Android UI call sites (TasksScreen, AllTasksScreen,
    ResidenceDetailScreen, CompleteTaskDialog, CompleteTaskScreen) need
    no changes.
  - APILayer.createTaskCompletionWithImages removed (dead).
  - TaskCompletionApi.createCompletionWithImages removed (the multipart
    HTTP helper that posted to the legacy POST /api/task-completions/
    multipart endpoint).
  - TaskCompletionCreateRequest.imageUrls field removed.
  - Three Swift call sites (CompleteTaskView, WidgetActionProcessor,
    PushNotificationManager) updated to drop the imageUrls argument.
  - Two Kotlin call sites (CompleteTaskDialog, CompleteTaskScreen) updated.

Image uploads now match WhatsApp/Slack-class architecture: client-side
compression + direct-to-storage upload + lightweight JSON entity create.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:19:46 -07:00
Trey t 3cd115a436 feat(uploads): direct-to-B2 presigned image upload from iOS + Android
iOS (Swift) — primary path, since iOS is the live platform:
  - ImageDownsampler.swift: ImageIO/CGImageSourceCreateThumbnailAtIndex
    based resize. Pays only the cost of the resized bitmap rather than
    decoding the full source — a 12 MP iPhone photo previously
    materialized ~50 MB regardless of JPEG size. Profiles: completion
    (2048 px / quality 0.85), document_image (2560 px / 0.90).
  - PresignedUploader.swift: three-step orchestration (POST /uploads/presign
    → multipart POST direct to B2 with the signed policy fields → return
    upload_id). Maps HTTP errors to user-facing copy. Concurrent uploads
    via TaskGroup.
  - CompleteTaskView.swift: replaces the multipart-with-images path with
    downsample → upload-to-B2 → create-completion-with-upload_ids[]. The
    no-image branch unchanged.

Android (Kotlin) — parity:
  - composeApp/.../media/ImageDownsampler.kt: BitmapFactory inSampleSize
    + proportional scale + JPEG compress. Same profiles as iOS.
  - composeApp/.../network/UploadApi.kt: Ktor-based presign + direct-to-B2
    POST. Preserves form-field order so the S3 policy signature validates.
  - APILayer.uploadImage(category, contentType, bytes, fileName) → upload_id.
    UI integration to follow.

Shared (Kotlin):
  - models/TaskCompletion.kt: added uploadIds: List<Int>? to
    TaskCompletionCreateRequest and a new PresignUploadRequest /
    PresignUploadResponse pair matching the Go API DTOs.
  - Existing call sites (WidgetActionProcessor, PushNotificationManager)
    explicitly pass uploadIds: nil for backwards compatibility — Swift's
    bridge to Kotlin doesn't honor Kotlin defaults for required-positional
    parameters.

The legacy multipart path remains functional alongside the new one for
soak-test purposes; per-platform feature flags can flip between them at
any time. After zero multipart traffic in production for 7 consecutive
days, the legacy paths can be dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:39:26 -07:00
Trey t 418ffc7772 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-04-25 11:35:24 -05:00
Trey t cec521b3e3 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 across DataManager, APILayer,
DataManagerObservable, Kotlin TaskViewModel, and the now-unused
TaskViewModel test.

Closes gitea#2
2026-04-25 10:48:38 -05:00
Trey t 1b001323e4 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-04-25 10:44:53 -05:00
Trey t ce25c80783 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-04-25 10:43:11 -05:00
Trey t 4181b6005d 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-04-25 10:41:06 -05:00
Trey t 2bd3bd85b6 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-04-25 10:40:20 -05:00
Trey t 60ae14c79e 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-04-25 10:39:53 -05:00
Trey t dc6d3525fa 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-04-25 10:39:01 -05:00
Trey t 5d0c3597fa 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-04-25 10:38:41 -05:00
Trey t c9d5c048b7 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-04-25 10:33:48 -05:00
Trey t f5f02145a2 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-04-25 09:33:47 -05:00
Trey t cb4806b423 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-04-25 09:25:04 -05:00
Trey t 6bfe058050 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-04-25 09:24:40 -05:00
42 changed files with 2422 additions and 368 deletions
+1 -4
View File
@@ -1,8 +1,5 @@
{
"permissions": {
"ask": [
"Bash(git commit:*)",
"Bash(git push:*)"
]
"ask": []
}
}
+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)"
]
}
}
@@ -0,0 +1,101 @@
package com.tt.honeyDue.media
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.ByteArrayOutputStream
import java.io.InputStream
/**
* Memory-efficient image resizer for upload preprocessing on Android.
*
* Why not just decode + Bitmap.createScaledBitmap? createScaledBitmap
* decodes the full source bitmap first — a 12 MP photo materializes ~50 MB
* in RAM regardless of how big the JPEG is. That OOMs older devices.
*
* BitmapFactory.Options.inSampleSize, paired with inJustDecodeBounds=true
* for a metadata-only first pass, lets us decode at a power-of-two
* subsample. Combined with a final scaled-down draw, peak memory is
* roughly proportional to the *output* bitmap's pixel count — not the
* source's.
*
* Quality tuning matches WhatsApp-class apps: 2048 px max edge, JPEG 85.
*/
object ImageDownsampler {
data class Profile(
val maxPixelEdge: Int,
/** JPEG quality 0-100. */
val jpegQuality: Int,
) {
companion object {
val Completion = Profile(maxPixelEdge = 2048, jpegQuality = 85)
val DocumentImage = Profile(maxPixelEdge = 2560, jpegQuality = 90)
}
}
/** Downsample raw image bytes into JPEG bytes ready for upload. */
fun downsample(bytes: ByteArray, profile: Profile): ByteArray? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val sampleSize = computeSampleSize(bounds.outWidth, bounds.outHeight, profile.maxPixelEdge)
val decodeOpts = BitmapFactory.Options().apply {
inSampleSize = sampleSize
// ARGB_8888 keeps quality; on memory-constrained devices we
// could drop to RGB_565 here, but for upload prep the extra
// ~2x peak memory isn't worth the visible quality loss.
inPreferredConfig = Bitmap.Config.ARGB_8888
}
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
?: return null
// Subsample is power-of-two only; the result may still be larger
// than maxPixelEdge by up to 2x. One more proportional scale gets
// us to the exact target.
val scaled = scaleProportional(decoded, profile.maxPixelEdge)
val out = ByteArrayOutputStream(64 * 1024)
val ok = scaled.compress(Bitmap.CompressFormat.JPEG, profile.jpegQuality, out)
// Only recycle if scaled is a different bitmap; createScaledBitmap
// sometimes returns the input unchanged, and recycling that would
// double-recycle below.
if (scaled !== decoded) decoded.recycle()
scaled.recycle()
return if (ok) out.toByteArray() else null
}
/** Same, from a stream (for content:// URIs etc.). */
fun downsample(input: InputStream, profile: Profile): ByteArray? {
val bytes = input.use { it.readBytes() }
return downsample(bytes, profile)
}
/**
* Pick the largest power-of-two sub-sample factor that still yields
* an image at least as large as maxPixelEdge on both axes. Mirrors
* the canonical Android docs example.
*/
private fun computeSampleSize(srcW: Int, srcH: Int, maxEdge: Int): Int {
var sample = 1
var halfW = srcW / 2
var halfH = srcH / 2
while (halfW >= maxEdge && halfH >= maxEdge) {
sample *= 2
halfW /= 2
halfH /= 2
}
return sample
}
private fun scaleProportional(src: Bitmap, maxEdge: Int): Bitmap {
val w = src.width
val h = src.height
val longest = maxOf(w, h)
if (longest <= maxEdge) return src
val ratio = maxEdge.toFloat() / longest.toFloat()
val newW = (w * ratio).toInt().coerceAtLeast(1)
val newH = (h * ratio).toInt().coerceAtLeast(1)
return Bitmap.createScaledBitmap(src, newW, newH, true)
}
}
@@ -62,8 +62,6 @@ object DataManager {
private set
var tasksCacheTime: Long = 0L
private set
var tasksByResidenceCacheTime: MutableMap<Int, Long> = mutableMapOf()
private set
var contractorsCacheTime: Long = 0L
private set
var documentsCacheTime: Long = 0L
@@ -138,8 +136,6 @@ object DataManager {
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
private val _tasksByResidence = MutableStateFlow<Map<Int, TaskColumnsResponse>>(emptyMap())
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
// ==================== DOCUMENTS ====================
@@ -414,7 +410,6 @@ object DataManager {
fun removeResidence(residenceId: Int) {
_residences.value = _residences.value.filter { it.id != residenceId }
_tasksByResidence.value = _tasksByResidence.value - residenceId
_documentsByResidence.value = _documentsByResidence.value - residenceId
_residenceSummaries.value = _residenceSummaries.value - residenceId
@@ -445,16 +440,10 @@ object DataManager {
persistToDisk()
}
fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) {
_tasksByResidence.value = _tasksByResidence.value + (residenceId to response)
tasksByResidenceCacheTime[residenceId] = currentTimeMs()
persistToDisk()
}
/**
* Filter cached allTasks by residence ID to avoid separate API call.
* Returns null if allTasks not cached.
* This enables client-side filtering when we already have all tasks loaded.
* Filter cached allTasks by residence ID. Single source of truth for
* residence-scoped kanban data; returns null when _allTasks is null
* (caller must hit the API to populate).
*/
fun getTasksForResidence(residenceId: Int): TaskColumnsResponse? {
val allTasksData = _allTasks.value ?: return null
@@ -480,45 +469,60 @@ object DataManager {
* 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
// 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 {
filteredTasks
// 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)
}
// 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))
}
}
// 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 ->
@@ -529,15 +533,6 @@ object DataManager {
_allTasks.value = current.copy(columns = newColumns)
}
// Remove from all residence task caches
_tasksByResidence.value = _tasksByResidence.value.mapValues { (_, tasks) ->
val newColumns = tasks.columns.map { column ->
val filteredTasks = column.tasks.filter { it.id != taskId }
column.copy(tasks = filteredTasks, count = filteredTasks.size)
}
tasks.copy(columns = newColumns)
}
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
refreshSummaryFromKanban()
persistToDisk()
@@ -780,7 +775,6 @@ object DataManager {
_totalSummary.value = null
_residenceSummaries.value = emptyMap()
_allTasks.value = null
_tasksByResidence.value = emptyMap()
_documents.value = emptyList()
_documentsByResidence.value = emptyMap()
_contractors.value = emptyList()
@@ -811,7 +805,6 @@ object DataManager {
residencesCacheTime = 0L
myResidencesCacheTime = 0L
tasksCacheTime = 0L
tasksByResidenceCacheTime.clear()
contractorsCacheTime = 0L
documentsCacheTime = 0L
summaryCacheTime = 0L
@@ -833,7 +826,6 @@ object DataManager {
_totalSummary.value = null
_residenceSummaries.value = emptyMap()
_allTasks.value = null
_tasksByResidence.value = emptyMap()
_documents.value = emptyList()
_documentsByResidence.value = emptyMap()
_contractors.value = emptyList()
@@ -846,7 +838,6 @@ object DataManager {
residencesCacheTime = 0L
myResidencesCacheTime = 0L
tasksCacheTime = 0L
tasksByResidenceCacheTime.clear()
contractorsCacheTime = 0L
documentsCacheTime = 0L
summaryCacheTime = 0L
@@ -13,6 +13,37 @@ data class TaskCompletionCreateRequest(
val notes: String? = null,
@SerialName("actual_cost") val actualCost: Double? = null,
val rating: Int? = null, // 1-5 star rating
@SerialName("image_urls") val imageUrls: List<String>? = null // Multiple image URLs
@SerialName("upload_ids") val uploadIds: List<Int>? = null // pending_uploads.id values from /api/uploads/presign + direct B2 POST
)
/**
* Presigned upload session — request body for POST /api/uploads/presign.
*
* Category: "completion" | "document_image" | "document_file"
* ContentType: the MIME type the client will upload (must match the policy
* exactly when POSTing to B2).
* ContentLength: byte count of the upload (server permits ±256 bytes slack).
*/
@Serializable
data class PresignUploadRequest(
val category: String,
@SerialName("content_type") val contentType: String,
@SerialName("content_length") val contentLength: Long
)
/**
* 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.
*/
@Serializable
data class PresignUploadResponse(
val id: Int,
@SerialName("upload_url") val uploadUrl: String,
val fields: Map<String, String>,
val key: String,
@SerialName("expires_at") val expiresAt: String
)
@@ -27,6 +27,7 @@ object APILayer {
private val notificationApi = NotificationApi()
private val subscriptionApi = SubscriptionApi()
private val taskTemplateApi = TaskTemplateApi()
private val uploadApi = UploadApi()
// ==================== Initialization Guards ====================
@@ -588,37 +589,23 @@ 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.Error("Tasks unavailable", 0)
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
}
suspend fun createTask(request: TaskCreateRequest): ApiResult<TaskResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
@@ -640,9 +627,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)
@@ -650,7 +643,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
}
@@ -789,30 +784,6 @@ object APILayer {
}
}
suspend fun createTaskCompletionWithImages(
request: TaskCompletionCreateRequest,
images: List<ByteArray>,
imageFileNames: List<String>
): ApiResult<TaskCompletionResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = taskCompletionApi.createCompletionWithImages(token, request, images, imageFileNames)
if (result is ApiResult.Success) {
// Update summary from response - eliminates need for separate getSummary call
DataManager.setTotalSummary(result.data.summary)
// The response includes the updated task, update it in DataManager
result.data.data.updatedTask?.let { updatedTask ->
DataManager.updateTask(updatedTask)
}
return ApiResult.Success(result.data.data)
}
return when (result) {
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
}
/**
* Get all completions for a specific task
*/
@@ -1381,6 +1352,42 @@ object APILayer {
return result
}
// ==================== Upload Operations ====================
/**
* Direct-to-B2 image upload. The bytes are POSTed straight to Backblaze
* — they never touch our API server. Returns the pending_uploads.id
* which the caller passes back via `upload_ids[]` on the next entity-
* creation call (task completion, document, etc.).
*
* Caller responsibilities:
* - Pre-downsample to a sensible size before calling. Use the
* platform-specific ImageDownsampler (Android) or
* ImageDownsampler.swift (iOS).
* - Pass [contentType] matching the bytes (typically "image/jpeg").
* - Pass a [fileName] for B2's metadata. Need not be unique — the
* server picks the actual storage key.
*
* Errors at either step (presign or B2 POST) surface as ApiResult.Error.
* Partial state (presign succeeded but B2 POST failed) is reaped by
* the server-side cleanup cron within an hour.
*/
suspend fun uploadImage(
category: String,
contentType: String,
bytes: ByteArray,
fileName: String,
): ApiResult<Int> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return uploadApi.uploadOne(
token = token,
category = category,
contentType = contentType,
data = bytes,
fileName = fileName,
)
}
// ==================== Notification Operations ====================
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {
@@ -94,47 +94,4 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
suspend fun createCompletionWithImages(
token: String,
request: TaskCompletionCreateRequest,
images: List<ByteArray> = emptyList(),
imageFileNames: List<String> = emptyList()
): ApiResult<WithSummaryResponse<TaskCompletionResponse>> {
return try {
val response = client.submitFormWithBinaryData(
url = "$baseUrl/task-completions/",
formData = formData {
// Add text fields
append("task_id", request.taskId.toString())
request.completedAt?.let { append("completed_at", it) }
request.actualCost?.let { append("actual_cost", it.toString()) }
request.notes?.let { append("notes", it) }
request.rating?.let { append("rating", it.toString()) }
// Add image files
images.forEachIndexed { index, imageBytes ->
val fileName = imageFileNames.getOrNull(index) ?: "image_$index.jpg"
append(
"images",
imageBytes,
Headers.build {
append(HttpHeaders.ContentType, "image/jpeg")
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
}
)
}
}
) {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to create completion with images", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}
@@ -0,0 +1,149 @@
package com.tt.honeyDue.network
import com.tt.honeyDue.models.PresignUploadRequest
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.*
/**
* 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.
* 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.
*/
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
/** Step 1 — request a signed POST policy. */
suspend fun presign(
token: String,
category: String,
contentType: String,
contentLength: Long,
): ApiResult<PresignUploadResponse> {
return try {
val response = client.post("$baseUrl/uploads/presign/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(PresignUploadRequest(category, contentType, contentLength))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error(
when (response.status.value) {
413 -> "That photo is too large after resizing."
422 -> "That image format isn't supported."
429 -> "Too many uploads in flight; try again shortly."
else -> "Couldn't start upload (HTTP ${response.status.value})."
},
response.status.value,
)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Network error during presign")
}
}
/**
* Step 2 — POST `data` directly to B2 using the signed policy fields.
*
* 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.
*/
suspend fun postToStorage(
uploadUrl: String,
fields: 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.submitFormWithBinaryData(
url = uploadUrl,
formData = parts,
)
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
val body = try {
response.bodyAsText()
} catch (_: Throwable) {
""
}
ApiResult.Error(
"Upload to storage failed (HTTP ${response.status.value}): $body",
response.status.value,
)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Network error during upload")
}
}
/**
* Step 1 + Step 2 in one call. Returns the upload_id the caller passes
* back via upload_ids[] on the entity-creation endpoint.
*
* Errors at either step short-circuit and surface up — the partial
* pending_uploads row created at presign time will be reaped by the
* server-side hourly cleanup cron.
*/
suspend fun uploadOne(
token: String,
category: String,
contentType: String,
data: ByteArray,
fileName: String,
): ApiResult<Int> {
val presignResult = presign(token, category, contentType, data.size.toLong())
val presigned = (presignResult as? ApiResult.Success)?.data
?: return ApiResult.Error(
(presignResult as? ApiResult.Error)?.message ?: "Presign failed",
(presignResult as? ApiResult.Error)?.code,
)
val postResult = postToStorage(
uploadUrl = presigned.uploadUrl,
fields = presigned.fields,
data = data,
contentType = contentType,
fileName = fileName,
)
return when (postResult) {
is ApiResult.Success -> ApiResult.Success(presigned.id)
is ApiResult.Error -> postResult
else -> ApiResult.Error("Upload failed in unknown state")
}
}
}
@@ -373,7 +373,8 @@ fun CompleteTaskDialog(
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor,
rating = rating,
imageUrls = null // Images uploaded separately and URLs added by handler
// upload_ids populated by the ViewModel after each
// image is uploaded directly to B2.
),
selectedImages
)
@@ -405,7 +405,8 @@ fun CompleteTaskScreen(
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor,
rating = rating,
imageUrls = null
// upload_ids populated by the ViewModel after each
// image is uploaded directly to B2.
),
selectedImages
)
@@ -2,6 +2,7 @@ package com.tt.honeyDue.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.Residence
import com.tt.honeyDue.models.ResidenceCreateRequest
import com.tt.honeyDue.models.TotalSummary
@@ -11,7 +12,10 @@ import com.tt.honeyDue.models.ContractorSummary
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ResidenceViewModel : ViewModel() {
@@ -28,8 +32,24 @@ class ResidenceViewModel : ViewModel() {
private val _updateResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState
private val _residenceTasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _residenceTasksState
/// Residence-scoped kanban derived from `DataManager.allTasks` filtered
/// by `_currentResidenceId`. Re-emits whenever either upstream changes,
/// so the residence detail screen reacts to new tasks (created or
/// completed elsewhere) without manual refresh. Replaces the previous
/// imperative `_residenceTasksState` that was only written by
/// loadResidenceTasks's API result and stayed stale otherwise.
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)
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Idle)
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState
@@ -85,13 +105,16 @@ class ResidenceViewModel : ViewModel() {
}
fun resetResidenceTasksState() {
_residenceTasksState.value = ApiResult.Idle
_currentResidenceId.value = null
}
fun loadResidenceTasks(residenceId: Int) {
fun loadResidenceTasks(residenceId: Int, forceRefresh: Boolean = false) {
_currentResidenceId.value = residenceId
// Trigger an _allTasks refresh in the background. The combine flow
// above re-emits Success when allTasks lands, so the screen
// re-renders without needing the result here.
viewModelScope.launch {
_residenceTasksState.value = ApiResult.Loading
_residenceTasksState.value = APILayer.getTasksByResidence(residenceId)
APILayer.getTasks(forceRefresh = forceRefresh)
}
}
@@ -25,38 +25,61 @@ class TaskCompletionViewModel : ViewModel() {
}
/**
* Create task completion with images.
* Create task completion with images, using the presigned-URL upload flow.
*
* For each image: compress, presign + POST direct to B2, collect the
* upload_id. Once all uploads succeed, create the completion with the
* collected upload_ids in a single JSON request. Bytes never traverse
* our API server.
*
* If any individual upload fails, the whole batch fails — partial
* pending_uploads rows are reaped server-side by the hourly cleanup
* cron, so there's nothing to clean up client-side.
*
* @param request The completion request data
* @param images List of ImageData (from platform-specific image pickers)
*/
fun createTaskCompletionWithImages(
request: TaskCompletionCreateRequest,
images: List<com.tt.honeyDue.platform.ImageData> = emptyList()
images: List<com.tt.honeyDue.platform.ImageData> = emptyList(),
) {
viewModelScope.launch {
_createCompletionState.value = ApiResult.Loading
// Compress images and prepare for upload
val compressedImages = images.map { ImageCompressor.compressImage(it) }
val imageFileNames = images.mapIndexed { index, image ->
// Always use .jpg extension since we compress to JPEG
val baseName = image.fileName.ifBlank { "completion_$index" }
if (baseName.endsWith(".jpg", ignoreCase = true) ||
baseName.endsWith(".jpeg", ignoreCase = true)) {
baseName
} else {
// Remove any existing extension and add .jpg
baseName.substringBeforeLast('.', baseName) + ".jpg"
val uploadIds = mutableListOf<Int>()
for ((index, image) in images.withIndex()) {
val compressed = ImageCompressor.compressImage(image)
val fileName = run {
val base = image.fileName.ifBlank { "completion_$index" }
if (base.endsWith(".jpg", ignoreCase = true) ||
base.endsWith(".jpeg", ignoreCase = true)
) base else base.substringBeforeLast('.', base) + ".jpg"
}
val uploadResult = APILayer.uploadImage(
category = "completion",
contentType = "image/jpeg",
bytes = compressed,
fileName = fileName,
)
when (uploadResult) {
is ApiResult.Success -> uploadIds += uploadResult.data
is ApiResult.Error -> {
_createCompletionState.value = ApiResult.Error(uploadResult.message, uploadResult.code)
return@launch
}
else -> {
_createCompletionState.value = ApiResult.Error("Upload failed in unexpected state")
return@launch
}
}
}
// Use APILayer which handles DataManager updates and summary refresh
_createCompletionState.value = APILayer.createTaskCompletionWithImages(
request = request,
images = compressedImages,
imageFileNames = imageFileNames
)
val withUploads = if (uploadIds.isNotEmpty()) {
request.copy(uploadIds = uploadIds.toList())
} else {
request
}
_createCompletionState.value = APILayer.createTaskCompletion(withUploads)
}
}
@@ -17,9 +17,6 @@ class TaskViewModel : ViewModel() {
private val _tasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksState
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val tasksByResidenceState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksByResidenceState
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
@@ -35,16 +32,6 @@ class TaskViewModel : ViewModel() {
}
}
fun loadTasksByResidence(residenceId: Int, forceRefresh: Boolean = false) {
viewModelScope.launch {
_tasksByResidenceState.value = ApiResult.Loading
_tasksByResidenceState.value = APILayer.getTasksByResidence(
residenceId = residenceId,
forceRefresh = forceRefresh
)
}
}
fun createNewTask(request: TaskCreateRequest) {
println("TaskViewModel: createNewTask called with $request")
viewModelScope.launch {
@@ -0,0 +1,152 @@
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))
}
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"
)
}
@@ -18,15 +18,6 @@ class TaskViewModelTest {
assertIs<ApiResult.Idle>(viewModel.tasksState.value)
}
@Test
fun testInitialTasksByResidenceState() {
// Given
val viewModel = TaskViewModel()
// Then
assertIs<ApiResult.Idle>(viewModel.tasksByResidenceState.value)
}
@Test
fun testInitialAddNewCustomTaskState() {
// Given
@@ -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.
+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"
+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" : [
{
@@ -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
@@ -663,7 +663,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;
@@ -671,14 +671,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;
@@ -688,7 +689,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";
@@ -698,14 +699,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;
@@ -719,7 +720,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;
@@ -734,14 +735,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;
@@ -755,7 +756,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;
@@ -774,12 +775,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;
@@ -800,12 +801,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;
@@ -825,7 +826,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;
@@ -838,7 +839,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;
@@ -856,7 +857,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;
@@ -869,7 +870,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;
@@ -887,7 +888,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;
@@ -900,7 +901,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;
@@ -918,7 +919,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;
@@ -931,7 +932,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;
@@ -948,13 +949,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;
@@ -974,13 +975,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;
@@ -1123,7 +1124,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;
@@ -1131,14 +1132,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;
@@ -1148,7 +1150,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:
+20 -12
View File
@@ -41,7 +41,6 @@ class DataManagerObservable: ObservableObject {
// MARK: - Tasks
@Published var allTasks: TaskColumnsResponse?
@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]
// MARK: - Documents
@@ -191,15 +190,6 @@ class DataManagerObservable: ObservableObject {
}
observationTasks.append(allTasksTask)
// TasksByResidence
let tasksByResidenceTask = Task { [weak self] in
for await tasks in DataManager.shared.tasksByResidence {
guard let self else { return }
self.tasksByResidence = self.convertIntMap(tasks)
}
}
observationTasks.append(tasksByResidenceTask)
// Documents
let documentsTask = Task { [weak self] in
for await docs in DataManager.shared.documents {
@@ -519,9 +509,27 @@ class DataManagerObservable: ObservableObject {
// MARK: - Task Helpers
/// Get tasks for a specific residence
/// Get tasks for a specific residence derived from `_allTasks`
/// (single source of truth) by filtering in-memory.
func tasks(for residenceId: Int32) -> TaskColumnsResponse? {
return tasksByResidence[residenceId]
guard let all = allTasks else { return nil }
let filteredColumns = all.columns.map { column -> TaskColumn in
let filtered = 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: filtered,
count: Int32(filtered.count)
)
}
return TaskColumnsResponse(
columns: filteredColumns,
daysThreshold: all.daysThreshold,
residenceId: String(residenceId)
)
}
/// Get documents for a specific residence
@@ -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"
@@ -0,0 +1,100 @@
import Foundation
import ImageIO
import UIKit
import UniformTypeIdentifiers
/// Memory-efficient image resizer for upload preprocessing.
///
/// Why not `UIImage.jpegData(compressionQuality:)` directly? UIImage decodes
/// the entire source bitmap into RAM before re-encoding a 12 MP iPhone
/// photo decompresses to ~50 MB regardless of how big the JPEG is. With
/// multiple selected images this can blow up memory on older devices.
///
/// `CGImageSourceCreateThumbnailAtIndex` reads the source incrementally and
/// only allocates the *resized* bitmap, paying memory proportional to the
/// output size (a 2048×1536 thumbnail is ~12 MB, but the source is never
/// fully decoded).
///
/// Reference: https://nshipster.com/image-resizing/ section "Image I/O".
enum ImageDownsampler {
/// Settings tuned per upload category. Edit here, not at call sites.
struct Profile {
/// Largest dimension (in points-after-scale, i.e. pixels) of the
/// downsampled image. The shorter edge is set proportionally.
let maxPixelEdge: CGFloat
/// JPEG quality, 0...1. 0.85 is the WhatsApp / Slack default
/// visually indistinguishable from quality 1.0 at typical viewing
/// sizes; cuts file size by ~3x.
let jpegQuality: CGFloat
static let completion = Profile(maxPixelEdge: 2048, jpegQuality: 0.85)
static let documentImage = Profile(maxPixelEdge: 2560, jpegQuality: 0.90)
}
/// Downsample raw image bytes (e.g. from a `PHPickerResult`'s
/// `loadDataRepresentation`) into a JPEG `Data` ready for upload.
///
/// - Returns: encoded JPEG bytes, or nil if decoding failed.
static func downsample(data: Data, profile: Profile) -> Data? {
let options: [CFString: Any] = [
kCGImageSourceShouldCache: false, // don't keep the full image around
kCGImageSourceTypeIdentifierHint: UTType.jpeg.identifier as CFString, // best-effort hint
]
guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else {
return nil
}
return downsample(source: source, profile: profile)
}
/// Downsample from a file URL (e.g. PhotosPicker's
/// `loadFileRepresentation`). Avoids materializing the full image in
/// memory before resize.
static func downsample(url: URL, profile: Profile) -> Data? {
let options: [CFString: Any] = [
kCGImageSourceShouldCache: false,
]
guard let source = CGImageSourceCreateWithURL(url as CFURL, options as CFDictionary) else {
return nil
}
return downsample(source: source, profile: profile)
}
/// Convenience for callers that already have a `UIImage` (e.g. from
/// `UIImagePickerController`). We round-trip through PNG to get raw
/// data, then use the data path. Slightly less efficient than starting
/// from URL/Data, but still avoids the JPEG re-encode penalty for the
/// resize step itself.
static func downsample(uiImage: UIImage, profile: Profile) -> Data? {
// Use PNG for the intermediate to avoid double-JPEG quality loss.
// Even though PNG is larger, this stays in memory only briefly.
guard let intermediate = uiImage.pngData() else { return nil }
return downsample(data: intermediate, profile: profile)
}
// MARK: - Internal
private static func downsample(source: CGImageSource, profile: Profile) -> Data? {
// Compute the max pixel size in screen-resolution-aware units. We
// use a fixed pixel cap because uploads are about bytes, not display.
let scale: CGFloat = 1.0
let maxDimensionInPixels = profile.maxPixelEdge * scale
let downsampleOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true, // decode on the calling thread
kCGImageSourceCreateThumbnailWithTransform: true, // honor EXIF orientation
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels,
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(
source, 0, downsampleOptions as CFDictionary
) else {
return nil
}
let uiImage = UIImage(cgImage: cgImage)
return uiImage.jpegData(compressionQuality: profile.jpegQuality)
}
}
@@ -0,0 +1,274 @@
import Foundation
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.
/// 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.
///
/// All errors map to `PresignedUploaderError` the Swift call site can
/// translate to user-facing copy without parsing nested HTTP details.
enum PresignedUploaderError: Error, LocalizedError {
case notAuthenticated
case presignFailed(status: Int, body: String)
case uploadFailed(status: Int, body: String)
case sessionError(Error)
var errorDescription: String? {
switch self {
case .notAuthenticated:
return "You're not signed in."
case .presignFailed(let status, _):
switch status {
case 413: return "That photo is too large after resizing. Try a different one."
case 422: return "That image format isn't supported."
case 429: return "You're uploading too many photos. Try again in a few minutes."
default: return "Couldn't start upload (server returned \(status))."
}
case .uploadFailed(let status, _):
return "Upload failed (B2 returned \(status))."
case .sessionError(let err):
return err.localizedDescription
}
}
}
/// Category passed to the presign endpoint. Matches the Go server's
/// `UploadCategory` constants in `internal/models/pending_upload.go`.
enum UploadCategory: String {
case completion = "completion"
case documentImage = "document_image"
case documentFile = "document_file"
}
/// Presigned-URL upload helper. Stateless instantiate freely.
///
/// Concurrency: each `upload(...)` call runs to completion sequentially.
/// For multiple images the caller can run several uploads in parallel via
/// `withTaskGroup`; the server's per-user concurrency cap (10 in-flight
/// presigns) is enforced server-side.
final class PresignedUploader {
/// API base URL read from KMP's ApiConfig so iOS and Android stay
/// in sync (LOCAL vs DEV vs PROD without divergent constants).
private let apiBaseURL: String
/// Bearer token. Read once at init; if the user re-auths mid-session,
/// the caller should construct a fresh PresignedUploader.
private let authToken: String
private let session: URLSession
init?(session: URLSession = .shared) {
// ApiConfig.shared.getBaseUrl() resolves Environment (LOCAL/DEV/PROD).
// DataManager.shared.authToken is a StateFlow<String?> read the
// current value via .value (SKIE-exposed property).
let baseUrl = ApiConfig.shared.getBaseUrl()
guard let token = DataManager.shared.authToken.value as String? else {
return nil
}
self.apiBaseURL = baseUrl
self.authToken = token
self.session = session
}
/// Upload `data` to B2 in the named category. Returns the
/// pending_uploads.id the caller passes via `upload_ids[]` to attach
/// the object to a real entity.
func upload(
data: Data,
category: UploadCategory,
contentType: String = "image/jpeg",
fileName: String = "image.jpg"
) async throws -> Int32 {
// Step 1: presign
let presigned = try await requestPresign(
category: category,
contentType: contentType,
contentLength: Int64(data.count)
)
// Step 2: direct POST to B2
try await postToStorage(
uploadURL: presigned.uploadUrl,
fields: presigned.fields,
data: data,
contentType: contentType,
fileName: fileName
)
return Int32(presigned.id)
}
/// Upload several images in parallel, returning their upload_ids in
/// input order. Stops at the first failure and surfaces it.
func uploadAll(
items: [(Data, String)],
category: UploadCategory,
contentType: String = "image/jpeg"
) async throws -> [Int32] {
try await withThrowingTaskGroup(of: (Int, Int32).self) { group in
for (idx, item) in items.enumerated() {
let (data, name) = item
group.addTask { [self] in
let id = try await upload(
data: data,
category: category,
contentType: contentType,
fileName: name
)
return (idx, id)
}
}
var pairs: [(Int, Int32)] = []
for try await pair in group {
pairs.append(pair)
}
return pairs.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
// MARK: - Step 1: presign
private struct PresignBody: Encodable {
let category: String
let content_type: String
let content_length: Int64
}
private struct PresignResponse: Decodable {
let id: Int
let upload_url: String
let fields: [String: String]
let key: String
let expires_at: String
// Map snake_case to nicer Swift names at the call site.
var uploadUrl: String { upload_url }
}
private func requestPresign(
category: UploadCategory,
contentType: String,
contentLength: Int64
) async throws -> PresignResponse {
guard var url = URL(string: apiBaseURL) else {
throw PresignedUploaderError.presignFailed(status: 0, body: "invalid base url")
}
url.appendPathComponent("uploads/presign/")
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization")
req.httpBody = try JSONEncoder().encode(PresignBody(
category: category.rawValue,
content_type: contentType,
content_length: contentLength
))
let (body, response): (Data, URLResponse)
do {
(body, response) = try await session.data(for: req)
} catch {
throw PresignedUploaderError.sessionError(error)
}
guard let http = response as? HTTPURLResponse else {
throw PresignedUploaderError.presignFailed(status: 0, body: "no response")
}
guard (200..<300).contains(http.statusCode) else {
throw PresignedUploaderError.presignFailed(
status: http.statusCode,
body: String(data: body, encoding: .utf8) ?? ""
)
}
do {
return try JSONDecoder().decode(PresignResponse.self, from: body)
} catch {
throw PresignedUploaderError.presignFailed(status: http.statusCode, body: "decode failed: \(error)")
}
}
// MARK: - Step 2: POST to B2
private func postToStorage(
uploadURL: String,
fields: [String: String],
data: Data,
contentType: String,
fileName: 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
let (respBody, response): (Data, URLResponse)
do {
(respBody, response) = try await session.data(for: req)
} catch {
throw PresignedUploaderError.sessionError(error)
}
guard let http = response as? HTTPURLResponse else {
throw PresignedUploaderError.uploadFailed(status: 0, body: "no response")
}
guard (200..<300).contains(http.statusCode) else {
throw PresignedUploaderError.uploadFailed(
status: http.statusCode,
body: String(data: respBody, encoding: .utf8) ?? ""
)
}
}
}
+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 {
@@ -64,7 +64,7 @@ final class WidgetActionProcessor {
notes: "Completed from widget",
actualCost: nil,
rating: nil,
imageUrls: nil
uploadIds: nil
)
let result = try await APILayer.shared.createTaskCompletion(request: request)
@@ -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) {
@@ -136,6 +136,7 @@ struct OnboardingFirstTaskContent: View {
OnboardingTaskTabBar(selectedTab: $selectedTab)
.padding(.horizontal, OrganicSpacing.comfortable)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.firstTaskTabBar)
switch selectedTab {
case .forYou:
@@ -362,6 +363,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)
}
@@ -631,6 +633,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")
}
@@ -776,6 +779,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")
}
@@ -388,7 +388,7 @@ class PushNotificationManager: NSObject, ObservableObject {
notes: nil,
actualCost: nil,
rating: nil,
imageUrls: nil
uploadIds: nil
)
let result = try await APILayer.shared.createTaskCompletion(request: request)
@@ -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)
}
}
+87 -19
View File
@@ -337,39 +337,110 @@ struct CompleteTaskView: View {
isSubmitting = true
// Create request with simplified Go API format
// Note: completedAt defaults to now on server if not provided
// New direct-to-B2 upload path: downsample on-device, presign, POST
// straight to B2, pass the resulting upload_ids to the completion
// create call. Bytes never traverse our API server. See
// /api/uploads/presign in honeyDueAPI-go.
if !selectedImages.isEmpty {
uploadAndCreate()
} else {
// No images go straight to the completion create.
let request = TaskCompletionCreateRequest(
taskId: task.id,
completedAt: nil,
notes: notes.isEmpty ? nil : notes,
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
rating: KotlinInt(int: Int32(rating)),
imageUrls: nil // Images uploaded separately and URLs added by handler
uploadIds: nil
)
// Use TaskCompletionViewModel to create completion
if !selectedImages.isEmpty {
// Convert images to ImageData for Kotlin
let imageDataList = selectedImages.compactMap { uiImage -> ComposeApp.ImageData? in
guard let jpegData = uiImage.jpegData(compressionQuality: 0.8) else { return nil }
let byteArray = KotlinByteArray(data: jpegData)
return ComposeApp.ImageData(bytes: byteArray, fileName: "completion_image.jpg")
}
completionViewModel.createTaskCompletionWithImages(request: request, images: imageDataList)
} else {
completionViewModel.createTaskCompletion(request: request)
observeCompletionState()
}
}
// Observe the result store the Task so it can be cancelled on dismiss
/// Async pipeline: downsample presign+upload to B2 create completion
/// with the returned upload_ids. Errors at any stage become a single
/// alert; partial uploads (1 of 3 succeeded) currently fail the whole
/// flow server-side cleanup reaps the orphans within the hour.
private func uploadAndCreate() {
observationTask?.cancel()
observationTask = Task {
// Step 1: downsample each image. Runs on the calling task; the
// ImageDownsampler is memory-bounded so this is safe for the
// expected batch sizes (5 images).
let payloads: [(Data, String)] = selectedImages.compactMap { uiImage -> (Data, String)? in
guard let data = ImageDownsampler.downsample(uiImage: uiImage, profile: .completion) else {
return nil
}
return (data, "completion_\(UUID().uuidString).jpg")
}
guard payloads.count == selectedImages.count else {
await MainActor.run {
errorMessage = "One or more photos couldn't be processed."
showError = true
isSubmitting = false
}
return
}
// Step 2: presign + upload each to B2. PresignedUploader runs
// them in parallel under a server-enforced concurrency cap of 10.
guard let uploader = PresignedUploader() else {
await MainActor.run {
errorMessage = "Not authenticated"
showError = true
isSubmitting = false
}
return
}
let uploadIds: [Int32]
do {
uploadIds = try await uploader.uploadAll(items: payloads, category: .completion)
} catch {
await MainActor.run {
errorMessage = (error as? PresignedUploaderError)?.errorDescription
?? error.localizedDescription
showError = true
isSubmitting = false
}
return
}
// Step 3: create completion via the existing endpoint, passing
// upload_ids so the server claims the pending_uploads rows and
// turns them into TaskCompletionImage rows.
let request = TaskCompletionCreateRequest(
taskId: task.id,
completedAt: nil,
notes: notes.isEmpty ? nil : notes,
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
rating: KotlinInt(int: Int32(rating)),
uploadIds: uploadIds.map { KotlinInt(int: $0) }
)
await MainActor.run {
completionViewModel.createTaskCompletion(request: request)
}
await observeCompletionStateAsync()
}
}
/// Observe the createCompletionState StateFlow until a terminal value
/// arrives, then dismiss or surface an error. Called from the
/// no-images path.
private func observeCompletionState() {
observationTask?.cancel()
observationTask = Task {
await observeCompletionStateAsync()
}
}
private func observeCompletionStateAsync() async {
for await state in completionViewModel.createCompletionState {
if Task.isCancelled { break }
await MainActor.run {
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
self.isSubmitting = false
self.onComplete(success.data?.updatedTask) // Pass back updated task
self.onComplete(success.data?.updatedTask)
self.dismiss()
} else if let error = ApiResultBridge.error(from: state) {
self.errorMessage = error.message
@@ -377,14 +448,11 @@ struct CompleteTaskView: View {
self.isSubmitting = false
}
}
// Break out of loop on terminal states
if state is ApiResultSuccess<TaskCompletionResponse> || ApiResultBridge.isError(state) {
break
}
}
}
}
}
+36 -23
View File
@@ -42,33 +42,24 @@ class TaskViewModel: ObservableObject {
// MARK: - Initialization
init() {
// Observe DataManagerObservable for all tasks data
// 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. Phase 3 deletes the per-residence cache entirely.
DataManagerObservable.shared.$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
DataManagerObservable.shared.$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)
@@ -382,6 +373,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 }