Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b6f26da99 | |||
| 83c3428b05 | |||
| f4c2780e34 | |||
| d26714f043 | |||
| 5aa31153e3 | |||
| fdcf82757d | |||
| 3890dd6f52 | |||
| d5041492a9 | |||
| ec5d93efab | |||
| b90533c535 | |||
| 03a9dfa0de | |||
| 1884853e4b | |||
| 882801c71d | |||
| dea8eed184 | |||
| 915a5d4742 | |||
| 4f9b910a94 | |||
| 3df5645f73 | |||
| 5f7498b755 | |||
| 733d4c8d36 |
@@ -504,45 +504,60 @@ object DataManager : IDataManager {
|
||||
* Also refreshes the summary from the updated kanban data.
|
||||
*/
|
||||
fun updateTask(task: TaskResponse) {
|
||||
// Update in allTasks
|
||||
_allTasks.value?.let { current ->
|
||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||
val newColumns = current.columns.map { column ->
|
||||
// Remove task from this column if present
|
||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||
// Add task if this is the target column
|
||||
val updatedTasks = if (column.name == targetColumn) {
|
||||
filteredTasks + task
|
||||
} else {
|
||||
filteredTasks
|
||||
}
|
||||
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||
}
|
||||
_allTasks.value = current.copy(columns = newColumns)
|
||||
}
|
||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||
|
||||
// Update in tasksByResidence if this task's residence is cached
|
||||
task.residenceId?.let { residenceId ->
|
||||
_tasksByResidence.value[residenceId]?.let { current ->
|
||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||
val newColumns = current.columns.map { column ->
|
||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||
val updatedTasks = if (column.name == targetColumn) {
|
||||
filteredTasks + task
|
||||
} else {
|
||||
filteredTasks
|
||||
}
|
||||
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||
}
|
||||
_tasksByResidence.value = _tasksByResidence.value + (residenceId to current.copy(columns = newColumns))
|
||||
}
|
||||
// Upsert into _allTasks. Crucially, when _allTasks is null (fresh
|
||||
// launch, kanban never fetched — the gitea#2 bug scenario), seed
|
||||
// an empty kanban shell so the new task isn't silently dropped.
|
||||
// The Phase 2 force-refresh after bulkCreateTasks/createTask will
|
||||
// replace this shell with authoritative server data shortly.
|
||||
val current = _allTasks.value ?: emptyKanbanShell()
|
||||
val columnsWithTarget = if (current.columns.any { it.name == targetColumn }) {
|
||||
current.columns
|
||||
} else {
|
||||
// Server returned a kanban_column the client doesn't know about
|
||||
// yet — append it so the task is still reachable.
|
||||
current.columns + emptyColumn(targetColumn)
|
||||
}
|
||||
val newColumns = columnsWithTarget.map { column ->
|
||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||
val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks
|
||||
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||
}
|
||||
_allTasks.value = current.copy(columns = newColumns)
|
||||
|
||||
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
|
||||
refreshSummaryFromKanban()
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
/// Default kanban skeleton used when `_allTasks` was never populated.
|
||||
/// Display metadata is intentionally placeholder — the Phase 2 force-refresh
|
||||
/// in `APILayer.bulkCreateTasks` / `createTask` replaces these shortly with
|
||||
/// authoritative server values. The `name` field is the contract — every
|
||||
/// observer keys off it.
|
||||
private fun emptyKanbanShell(): TaskColumnsResponse = TaskColumnsResponse(
|
||||
columns = listOf(
|
||||
emptyColumn("overdue_tasks"),
|
||||
emptyColumn("due_soon_tasks"),
|
||||
emptyColumn("in_progress_tasks"),
|
||||
emptyColumn("upcoming_tasks"),
|
||||
emptyColumn("completed_tasks")
|
||||
),
|
||||
daysThreshold = 30,
|
||||
residenceId = ""
|
||||
)
|
||||
|
||||
private fun emptyColumn(name: String): TaskColumn = TaskColumn(
|
||||
name = name,
|
||||
displayName = "",
|
||||
buttonTypes = emptyList(),
|
||||
icons = emptyMap(),
|
||||
color = "",
|
||||
tasks = emptyList(),
|
||||
count = 0
|
||||
)
|
||||
|
||||
fun removeTask(taskId: Int) {
|
||||
// Remove from allTasks
|
||||
_allTasks.value?.let { current ->
|
||||
|
||||
@@ -59,12 +59,29 @@ object HoneyDueShareCodec {
|
||||
|
||||
/**
|
||||
* Build a filesystem-safe package filename with `.honeydue` extension.
|
||||
*
|
||||
* Strips only the characters that are actually unsafe on iOS / Android
|
||||
* filesystems (`/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, control
|
||||
* chars). Spaces and apostrophes are kept intact so the recipient sees
|
||||
* the original residence / contractor name in the iOS QuickLook title
|
||||
* bar — gitea#7 called out the previous behaviour rendering
|
||||
* "The_Tartt's" instead of "The Tartt's". Internal whitespace is
|
||||
* collapsed to single spaces and trimmed; falls back to "honeyDue" if
|
||||
* the input is blank after sanitising.
|
||||
*/
|
||||
fun safeShareFileName(displayName: String): String {
|
||||
val safeName = displayName
|
||||
.replace(" ", "_")
|
||||
.replace("/", "-")
|
||||
// Keep whitespace through the filter so adjacent space+tab
|
||||
// sequences survive to the regex-collapse step below. Drop
|
||||
// only non-whitespace control chars (NUL etc.) plus the
|
||||
// explicit filesystem-unsafe set.
|
||||
.filter { it !in UNSAFE_FILENAME_CHARS && (it.isWhitespace() || !it.isISOControl()) }
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.take(50)
|
||||
.ifBlank { "honeyDue" }
|
||||
return "$safeName.honeydue"
|
||||
}
|
||||
|
||||
private val UNSAFE_FILENAME_CHARS = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
|
||||
}
|
||||
|
||||
@@ -34,15 +34,20 @@ data class PresignUploadRequest(
|
||||
/**
|
||||
* Presigned upload session — response from POST /api/uploads/presign.
|
||||
*
|
||||
* The client uses [uploadUrl] + [fields] to perform a multipart/form-data
|
||||
* POST directly to B2, then passes [id] back in the upload_ids[] field of
|
||||
* the next /api/task-completions/ or /api/documents/ create call.
|
||||
* The client makes one PUT request to [uploadUrl] with the raw object
|
||||
* bytes as the body and [headers] as the request headers. On success,
|
||||
* pass [id] back in the upload_ids[] field of the next
|
||||
* /api/task-completions/ or /api/documents/ create call.
|
||||
*
|
||||
* PUT (not POST) because B2's S3-compatible endpoint does not implement
|
||||
* the S3 POST Object form upload (returns HTTP 501).
|
||||
*/
|
||||
@Serializable
|
||||
data class PresignUploadResponse(
|
||||
val id: Int,
|
||||
@SerialName("upload_url") val uploadUrl: String,
|
||||
val fields: Map<String, String>,
|
||||
val method: String = "PUT",
|
||||
val headers: Map<String, String> = emptyMap(),
|
||||
val key: String,
|
||||
@SerialName("expires_at") val expiresAt: String
|
||||
)
|
||||
|
||||
@@ -615,36 +615,22 @@ object APILayer {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns kanban data for a single residence. Single source of truth
|
||||
* is `_allTasks`; this function ensures it's fresh, then filters.
|
||||
*
|
||||
* Replaces the previous 3-path implementation (per-residence cache →
|
||||
* filter from allTasks → API) that produced inconsistent results
|
||||
* when the per-residence cache slot was empty but `_allTasks` was
|
||||
* stale. Phase 3 deletes the per-residence cache entirely.
|
||||
*/
|
||||
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
||||
// 1. Check residence-specific cache first
|
||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) {
|
||||
val cached = DataManager.tasksByResidence.value[residenceId]
|
||||
if (cached != null) {
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
}
|
||||
val allTasksResult = getTasks(forceRefresh = forceRefresh)
|
||||
if (allTasksResult is ApiResult.Error) return allTasksResult
|
||||
|
||||
// 2. Try filtering from allTasks cache before hitting API (optimization)
|
||||
// This avoids a redundant API call when we already have all tasks loaded
|
||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) {
|
||||
val filtered = DataManager.getTasksForResidence(residenceId)
|
||||
if (filtered != null) {
|
||||
// Cache the filtered result for future use
|
||||
DataManager.setTasksForResidence(residenceId, filtered)
|
||||
return ApiResult.Success(filtered)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: Fetch from API
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
val result = taskApi.getTasksByResidence(token, residenceId)
|
||||
|
||||
// Update DataManager on success
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setTasksForResidence(residenceId, result.data)
|
||||
}
|
||||
|
||||
return result
|
||||
val filtered = DataManager.getTasksForResidence(residenceId)
|
||||
?: return ApiResult.Error("Tasks unavailable", 0)
|
||||
return ApiResult.Success(filtered)
|
||||
}
|
||||
|
||||
suspend fun createTask(request: TaskCreateRequest): ApiResult<TaskResponse> {
|
||||
@@ -667,9 +653,15 @@ object APILayer {
|
||||
|
||||
/**
|
||||
* Atomically creates 1-50 tasks via POST /api/tasks/bulk/. The whole
|
||||
* batch succeeds or fails together on the server. On success, every
|
||||
* returned task is merged into DataManager.allTasks so observing views
|
||||
* render the new batch immediately.
|
||||
* batch succeeds or fails together on the server. On success, force-
|
||||
* refreshes _allTasks from the server — the server is the
|
||||
* authoritative kanban categorizer, and a single round-trip
|
||||
* eliminates any drift between the per-task `kanbanColumn` hint and
|
||||
* the global kanban view.
|
||||
*
|
||||
* This is the bug-class fix for gitea#2: the previous per-task
|
||||
* updateTask loop was a no-op when _allTasks was null (fresh launch
|
||||
* after onboarding), silently dropping the new tasks from cache.
|
||||
*/
|
||||
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
@@ -677,7 +669,9 @@ object APILayer {
|
||||
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setTotalSummary(result.data.summary)
|
||||
result.data.tasks.forEach { DataManager.updateTask(it) }
|
||||
// Authoritative refresh — replaces any placeholder kanban
|
||||
// shell from updateTask with proper server data.
|
||||
getTasks(forceRefresh = true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ package com.tt.honeyDue.network
|
||||
*/
|
||||
object ApiConfig {
|
||||
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
||||
val CURRENT_ENV = Environment.LOCAL
|
||||
val CURRENT_ENV = Environment.PROD
|
||||
|
||||
enum class Environment {
|
||||
LOCAL,
|
||||
|
||||
@@ -5,7 +5,6 @@ import com.tt.honeyDue.models.PresignUploadResponse
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.utils.io.core.*
|
||||
@@ -14,17 +13,16 @@ import io.ktor.utils.io.core.*
|
||||
* Three-step direct-to-B2 upload helper.
|
||||
*
|
||||
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a
|
||||
* B2 POST policy plus form fields the client needs to perform the
|
||||
* direct upload.
|
||||
* Step 2: [postToStorage] — multipart/form-data POST straight to B2.
|
||||
* Bytes never traverse our API server.
|
||||
* signed PUT URL plus the headers the client must send.
|
||||
* Step 2: [putToStorage] — single PUT straight to B2. Bytes never traverse
|
||||
* our API server.
|
||||
* Step 3: caller invokes the relevant entity-creation endpoint
|
||||
* (POST /api/task-completions/, POST /api/documents/) with the
|
||||
* returned upload_id in the `upload_ids` field.
|
||||
*
|
||||
* iOS uses its own native equivalent (PresignedUploader.swift) for memory
|
||||
* reasons — Swift can stream a multipart body without buffering. Android
|
||||
* uses this Kotlin path which works fine for ≤10 MB images.
|
||||
* iOS uses its own native equivalent (PresignedUploader.swift). Both paths
|
||||
* use PUT because B2's S3-compatible endpoint does not implement the S3
|
||||
* POST Object form upload (returns HTTP 501 for any POST).
|
||||
*/
|
||||
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
private val baseUrl = ApiClient.getBaseUrl()
|
||||
@@ -61,38 +59,36 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 — POST `data` directly to B2 using the signed policy fields.
|
||||
* Step 2 — PUT `data` directly to B2 using the signed URL + headers.
|
||||
*
|
||||
* The S3 POST policy spec requires every signed field to appear before
|
||||
* the file part, and `key` + `Content-Type` must match the policy
|
||||
* exactly. Ktor's MultiPartFormDataContent preserves insertion order
|
||||
* for the appended parts.
|
||||
* The presign signature binds the headers exactly, so we send them
|
||||
* verbatim. Content-Length is filled in automatically by Ktor from
|
||||
* the body size, but we still pass through Content-Type which Ktor
|
||||
* would otherwise default to application/octet-stream.
|
||||
*/
|
||||
suspend fun postToStorage(
|
||||
suspend fun putToStorage(
|
||||
uploadUrl: String,
|
||||
fields: Map<String, String>,
|
||||
headers: Map<String, String>,
|
||||
data: ByteArray,
|
||||
contentType: String,
|
||||
fileName: String,
|
||||
): ApiResult<Unit> {
|
||||
return try {
|
||||
val parts = formData {
|
||||
// Stable order: signed fields first, then file. We rely on
|
||||
// Ktor preserving the order in which append() is called.
|
||||
fields.forEach { (k, v) -> append(k, v) }
|
||||
append(
|
||||
key = "file",
|
||||
value = data,
|
||||
headers = Headers.build {
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
||||
append(HttpHeaders.ContentType, contentType)
|
||||
},
|
||||
)
|
||||
val response = client.put(uploadUrl) {
|
||||
// Apply server-supplied headers verbatim. Skip Content-Length
|
||||
// — Ktor sets it automatically from the body and will refuse
|
||||
// a manual override on most engines.
|
||||
headers.forEach { (k, v) ->
|
||||
if (!k.equals("Content-Length", ignoreCase = true)) {
|
||||
header(k, v)
|
||||
}
|
||||
}
|
||||
// Defensive: ensure Content-Type is set even if the server
|
||||
// omits it. The signed value (if present) takes precedence.
|
||||
if (!headers.keys.any { it.equals("Content-Type", ignoreCase = true) }) {
|
||||
header(HttpHeaders.ContentType, contentType)
|
||||
}
|
||||
setBody(data)
|
||||
}
|
||||
val response = client.submitFormWithBinaryData(
|
||||
url = uploadUrl,
|
||||
formData = parts,
|
||||
)
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
@@ -124,7 +120,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
category: String,
|
||||
contentType: String,
|
||||
data: ByteArray,
|
||||
fileName: String,
|
||||
@Suppress("UNUSED_PARAMETER") fileName: String,
|
||||
): ApiResult<Int> {
|
||||
val presignResult = presign(token, category, contentType, data.size.toLong())
|
||||
val presigned = (presignResult as? ApiResult.Success)?.data
|
||||
@@ -133,16 +129,15 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
(presignResult as? ApiResult.Error)?.code,
|
||||
)
|
||||
|
||||
val postResult = postToStorage(
|
||||
val putResult = putToStorage(
|
||||
uploadUrl = presigned.uploadUrl,
|
||||
fields = presigned.fields,
|
||||
headers = presigned.headers,
|
||||
data = data,
|
||||
contentType = contentType,
|
||||
fileName = fileName,
|
||||
)
|
||||
return when (postResult) {
|
||||
return when (putResult) {
|
||||
is ApiResult.Success -> ApiResult.Success(presigned.id)
|
||||
is ApiResult.Error -> postResult
|
||||
is ApiResult.Error -> putResult
|
||||
else -> ApiResult.Error("Upload failed in unknown state")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,15 +70,26 @@ class ResidenceViewModel(
|
||||
/** Drives the residence-scoped projections. */
|
||||
private val _selectedResidenceId = MutableStateFlow<Int?>(null)
|
||||
|
||||
/// Residence-scoped kanban derived from `DataManager.allTasks` filtered
|
||||
/// by `_selectedResidenceId`. Single source of truth — eliminates the
|
||||
/// gitea#2 race window where the per-residence cache slot could be
|
||||
/// empty while `_allTasks` was populated. The per-residence cache
|
||||
/// (`tasksByResidence`) was deleted in cec521b.
|
||||
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
|
||||
combine(_selectedResidenceId, dataManager.tasksByResidence) { id, map ->
|
||||
if (id == null) ApiResult.Idle
|
||||
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
|
||||
combine(_selectedResidenceId, DataManager.allTasks) { id, all ->
|
||||
when {
|
||||
id == null -> ApiResult.Idle
|
||||
all == null -> ApiResult.Loading
|
||||
else -> {
|
||||
val filtered = DataManager.getTasksForResidence(id)
|
||||
if (filtered != null) ApiResult.Success(filtered) else ApiResult.Loading
|
||||
}
|
||||
}
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
_selectedResidenceId.value?.let { id ->
|
||||
dataManager.tasksByResidence.value[id]?.let { ApiResult.Success(it) }
|
||||
DataManager.getTasksForResidence(id)?.let { ApiResult.Success(it) }
|
||||
} ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.tt.honeyDue.data
|
||||
|
||||
import com.tt.honeyDue.models.TaskResponse
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.BeforeTest
|
||||
|
||||
/**
|
||||
* Regression tests for the gitea#2 task-cache bug:
|
||||
* `DataManager.updateTask` was a no-op when both `_allTasks` was null AND
|
||||
* `_tasksByResidence[residenceId]` was empty — exactly the cache state
|
||||
* after a fresh register-then-bulkCreateTasks flow. The just-created
|
||||
* tasks would only appear after an app restart.
|
||||
*
|
||||
* After the fix, `updateTask` must seed `_allTasks` from empty rather
|
||||
* than skipping the update.
|
||||
*/
|
||||
class DataManagerTaskCacheTest {
|
||||
|
||||
@BeforeTest
|
||||
fun resetState() {
|
||||
DataManager.clear()
|
||||
}
|
||||
|
||||
/// Onboarding-flow scenario: brand-new user, fresh launch, no kanban
|
||||
/// has ever been fetched, then a task arrives via bulkCreateTasks →
|
||||
/// DataManager.updateTask. The new task MUST land in `_allTasks` and
|
||||
/// be visible to any observer.
|
||||
@Test
|
||||
fun updateTask_seedsAllTasks_whenCacheIsEmpty() {
|
||||
// Given: fresh DataManager, kanban never loaded
|
||||
assertEquals(null, DataManager.allTasks.value, "_allTasks must start null after clear()")
|
||||
|
||||
// When: a new task arrives via the same path bulkCreateTasks uses
|
||||
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||
|
||||
// Then: _allTasks must contain that task in the right column
|
||||
val allTasks = DataManager.allTasks.value
|
||||
assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null")
|
||||
|
||||
val upcoming = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" }
|
||||
assertNotNull(upcoming, "the seeded kanban must include an upcoming_tasks column")
|
||||
assertTrue(
|
||||
upcoming.tasks.any { it.id == 1 },
|
||||
"the new task must land in upcoming_tasks; got columns=${allTasks.columns.map { it.name to it.tasks.map { t -> t.id } }}"
|
||||
)
|
||||
assertEquals(upcoming.tasks.size, upcoming.count, "column count must match tasks.size")
|
||||
}
|
||||
|
||||
/// Reasonable-defaults sanity check for the bulk-create scenario:
|
||||
/// multiple tasks land across different kanban columns and end up
|
||||
/// distributed correctly. This exercises the upsert when _allTasks
|
||||
/// was seeded by a previous call.
|
||||
@Test
|
||||
fun updateTask_distributesAcrossColumns_whenSeedingThenAdding() {
|
||||
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "overdue_tasks"))
|
||||
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "upcoming_tasks"))
|
||||
DataManager.updateTask(sampleTask(id = 3, residenceId = 100, column = "upcoming_tasks"))
|
||||
|
||||
val allTasks = DataManager.allTasks.value
|
||||
assertNotNull(allTasks)
|
||||
|
||||
val overdue = allTasks.columns.first { it.name == "overdue_tasks" }
|
||||
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
|
||||
|
||||
assertEquals(setOf(1), overdue.tasks.map { it.id }.toSet())
|
||||
assertEquals(setOf(2, 3), upcoming.tasks.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
/// Replacement contract: calling updateTask with the same id twice
|
||||
/// must not duplicate; the second call replaces the first wherever it
|
||||
/// lives. Catches the "always-append" implementation mistake.
|
||||
@Test
|
||||
fun updateTask_replacesExistingTaskById_acrossColumns() {
|
||||
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "upcoming_tasks", title = "v1"))
|
||||
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "in_progress_tasks", title = "v2"))
|
||||
|
||||
val allTasks = DataManager.allTasks.value
|
||||
assertNotNull(allTasks)
|
||||
|
||||
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
|
||||
val inProgress = allTasks.columns.first { it.name == "in_progress_tasks" }
|
||||
|
||||
assertTrue(upcoming.tasks.none { it.id == 5 }, "task 5 must move out of upcoming_tasks")
|
||||
assertEquals(1, inProgress.tasks.count { it.id == 5 }, "task 5 must appear once in in_progress_tasks")
|
||||
assertEquals("v2", inProgress.tasks.first { it.id == 5 }.title)
|
||||
}
|
||||
|
||||
/// Characterization: getTasksForResidence filters _allTasks by
|
||||
/// residence id. This is the helper that becomes the primary path
|
||||
/// for residence-detail in Phase 3 (collapse the dual cache).
|
||||
@Test
|
||||
fun getTasksForResidence_filtersAllTasksByResidenceId() {
|
||||
// Seed _allTasks with tasks across two residences via the upsert path.
|
||||
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "overdue_tasks"))
|
||||
DataManager.updateTask(sampleTask(id = 3, residenceId = 200, column = "upcoming_tasks"))
|
||||
|
||||
val r100 = DataManager.getTasksForResidence(100)
|
||||
assertNotNull(r100)
|
||||
val r100Ids = r100.columns.flatMap { it.tasks }.map { it.id }.toSet()
|
||||
assertEquals(setOf(1, 2), r100Ids)
|
||||
|
||||
val r200 = DataManager.getTasksForResidence(200)
|
||||
assertNotNull(r200)
|
||||
val r200Ids = r200.columns.flatMap { it.tasks }.map { it.id }.toSet()
|
||||
assertEquals(setOf(3), r200Ids)
|
||||
|
||||
// Counts on each column must match the filtered task lists.
|
||||
for (column in r100.columns) {
|
||||
assertEquals(column.tasks.size, column.count, "column ${column.name} count mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
/// Characterization: residenceId with no tasks returns a non-null
|
||||
/// shell so the residence-detail screen can distinguish "loading"
|
||||
/// (null) from "loaded, no tasks" (non-null with empty columns).
|
||||
@Test
|
||||
fun getTasksForResidence_returnsEmptyShellForResidenceWithNoTasks() {
|
||||
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||
|
||||
val r999 = DataManager.getTasksForResidence(999)
|
||||
assertNotNull(r999, "residence with no tasks must return an empty shell, not null")
|
||||
assertEquals(0, r999.columns.sumOf { it.tasks.size })
|
||||
}
|
||||
|
||||
/// Characterization: when _allTasks is null entirely (cache never
|
||||
/// populated), getTasksForResidence returns null — caller must call
|
||||
/// the API path. Phase 3's getTasksByResidence relies on this.
|
||||
@Test
|
||||
fun getTasksForResidence_returnsNullWhenAllTasksIsNull() {
|
||||
DataManager.clear()
|
||||
assertEquals(null, DataManager.getTasksForResidence(100))
|
||||
}
|
||||
|
||||
/// Lockdown: updateTask must NOT touch `_tasksByResidence`. That cache
|
||||
/// is being deleted in Phase 3; until then, updateTask must leave it
|
||||
/// alone. If a future commit re-introduces the conditional write
|
||||
/// branch this test catches it.
|
||||
@Test
|
||||
fun updateTask_doesNotMutate_tasksByResidence() {
|
||||
val before = DataManager.tasksByResidence.value
|
||||
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||
assertEquals(
|
||||
before,
|
||||
DataManager.tasksByResidence.value,
|
||||
"updateTask must not write to _tasksByResidence — that cache is deprecated"
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleTask(
|
||||
id: Int,
|
||||
residenceId: Int,
|
||||
column: String,
|
||||
title: String = "Task $id"
|
||||
) = TaskResponse(
|
||||
id = id,
|
||||
residenceId = residenceId,
|
||||
createdById = 1,
|
||||
title = title,
|
||||
kanbanColumn = column,
|
||||
createdAt = "2026-04-25T00:00:00Z",
|
||||
updatedAt = "2026-04-25T00:00:00Z"
|
||||
)
|
||||
}
|
||||
Binary file not shown.
+10
-2
@@ -1,9 +1,17 @@
|
||||
#Kotlin
|
||||
kotlin.code.style=official
|
||||
kotlin.daemon.jvmargs=-Xmx3072M
|
||||
# Heap sizing for KMP builds.
|
||||
# Kotlin daemon runs the K2 compiler + native linker; 4 GB headroom
|
||||
# prevents long-tail OOMs during iosArm64 framework link.
|
||||
# MaxMetaspaceSize caps slow class-loading leaks across daemon reuse;
|
||||
# G1GC keeps pauses short during incremental builds.
|
||||
kotlin.daemon.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=1g -XX:+UseG1GC
|
||||
|
||||
#Gradle
|
||||
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
||||
# Gradle daemon drives configuration cache + dependency resolution +
|
||||
# Compose/Android compilers. OOMs at 4 GB during ComposeApp.framework
|
||||
# generation; 6 GB is the usual safe size for projects this size.
|
||||
org.gradle.jvmargs=-Xmx6144M -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -Dfile.encoding=UTF-8
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.caching=true
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppLogo@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -263,13 +263,40 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
||||
}
|
||||
|
||||
private func updateUIForResidence(with residence: ResidencePreviewData) {
|
||||
// Update icon
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
||||
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
|
||||
// Brand icon. Prefer the bundled honeyDue logo so the preview
|
||||
// reads as a HoneyDue invite at a glance; fall back to a tinted
|
||||
// SF Symbol for accessibility / asset-load failures.
|
||||
if let logo = UIImage(named: "AppLogo") {
|
||||
iconImageView.image = logo.withRenderingMode(.alwaysOriginal)
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
iconImageView.layer.cornerRadius = 16
|
||||
iconImageView.layer.masksToBounds = true
|
||||
} else {
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
||||
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
|
||||
}
|
||||
|
||||
titleLabel.text = residence.residenceName
|
||||
subtitleLabel.text = "honeyDue Residence Invite"
|
||||
instructionLabel.text = "Tap the share button below, then select \"honeyDue\" to join this residence."
|
||||
|
||||
// Branch the copy on whether the share link has already lapsed.
|
||||
// Active invites get the standard "How to join" numbered steps;
|
||||
// expired invites get a clear dead-end message asking the
|
||||
// recipient to ping the sender for a new link — no point
|
||||
// showing share-sheet directions for a link the server will
|
||||
// reject.
|
||||
let expiredAgo = Self.expiredRelativePhraseOrNil(residence.expiresAt)
|
||||
if let expiredAgo {
|
||||
instructionLabel.attributedText = Self.makeExpiredInstructions(sharedBy: residence.sharedBy)
|
||||
// The down-chevron points at the Share button as a visual
|
||||
// cue to tap it; in the expired state there's nothing
|
||||
// useful to share (the server will reject the bundled
|
||||
// code) so the arrow becomes misleading. Hide it.
|
||||
arrowImageView.isHidden = true
|
||||
} else {
|
||||
instructionLabel.attributedText = Self.makeResidenceInstructions()
|
||||
arrowImageView.isHidden = false
|
||||
}
|
||||
|
||||
// Clear existing details
|
||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
@@ -280,9 +307,183 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
||||
}
|
||||
|
||||
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
||||
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)")
|
||||
if let expiredAgo {
|
||||
// "Expired 1 hour ago" — capitalised past-tense; no
|
||||
// "Expires " prefix because the share link no longer
|
||||
// expires, it has already done so (gitea#7 review).
|
||||
addDetailRow(icon: "clock", text: "Expired \(expiredAgo)")
|
||||
} else {
|
||||
let formatted = Self.formatActiveExpiry(expiresAt)
|
||||
addDetailRow(icon: "clock", text: "Expires \(formatted)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatting helpers
|
||||
|
||||
/// Render an *active* (not-yet-expired) share-link expiry as a
|
||||
/// human-readable phrase. Within a day uses
|
||||
/// `RelativeDateTimeFormatter` ("in 23 hours" / "in 12 minutes");
|
||||
/// further out switches to absolute date + time so users planning
|
||||
/// ahead see exactly when the invite lapses. Falls back to the raw
|
||||
/// ISO string if parsing fails so the row never goes blank.
|
||||
///
|
||||
/// Callers must check [expiredRelativePhraseOrNil] first — this
|
||||
/// function assumes a future expiry and produces wording that only
|
||||
/// makes sense in that case.
|
||||
static func formatActiveExpiry(_ isoString: String) -> String {
|
||||
guard let date = parseIsoDate(isoString) else { return isoString }
|
||||
let now = Date()
|
||||
let elapsed = date.timeIntervalSince(now)
|
||||
if elapsed < 24 * 60 * 60 {
|
||||
return relativeFormatter.localizedString(for: date, relativeTo: now)
|
||||
}
|
||||
return "on \(absoluteFormatter.string(from: date))"
|
||||
}
|
||||
|
||||
/// If the share link has already lapsed, return the relative
|
||||
/// "X ago" phrase. `nil` means active (or unparseable) — callers
|
||||
/// should fall back to [formatActiveExpiry] for those cases. The
|
||||
/// split lets `updateUIForResidence` branch the entire UI block
|
||||
/// (row text + instruction card) on the same signal (gitea#7
|
||||
/// review: an expired link should send the recipient back to the
|
||||
/// sender for a new invite, not show share-sheet directions for a
|
||||
/// link the server will reject).
|
||||
static func expiredRelativePhraseOrNil(_ isoString: String?) -> String? {
|
||||
guard let isoString, let date = parseIsoDate(isoString) else { return nil }
|
||||
let now = Date()
|
||||
if date.timeIntervalSince(now) > 0 { return nil }
|
||||
return relativeFormatter.localizedString(for: date, relativeTo: now)
|
||||
}
|
||||
|
||||
private static func parseIsoDate(_ raw: String) -> Date? {
|
||||
if let d = isoFormatterWithFraction.date(from: raw) { return d }
|
||||
if let d = isoFormatterNoFraction.date(from: raw) { return d }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static let isoFormatterWithFraction: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .full
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let absoluteFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .short
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Builds the "How to join" instruction copy as an attributed
|
||||
/// string with the iOS share-icon glyph (square + up-arrow) inlined
|
||||
/// next to "Tap [icon]". The glyph is the universal share symbol
|
||||
/// across iOS, so the recipient finds the right control whether
|
||||
/// it's at the top, bottom, or behind a More menu — instead of us
|
||||
/// claiming a fixed position the chrome can move (gitea#7 review
|
||||
/// feedback).
|
||||
private static func makeResidenceInstructions() -> NSAttributedString {
|
||||
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 2
|
||||
paragraph.alignment = .left
|
||||
|
||||
let result = NSMutableAttributedString()
|
||||
|
||||
func appendText(_ s: String) {
|
||||
result.append(NSAttributedString(
|
||||
string: s,
|
||||
attributes: [
|
||||
.font: bodyFont,
|
||||
.foregroundColor: tint,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
}
|
||||
|
||||
appendText("How to join:\n1. Tap ")
|
||||
|
||||
let shareImage = UIImage(
|
||||
systemName: "square.and.arrow.up",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
|
||||
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
|
||||
if let shareImage {
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = shareImage
|
||||
// Align the glyph baseline with the surrounding text by
|
||||
// nudging the bounds down a few points; the SF Symbol's
|
||||
// natural bounds sit a hair above the cap height.
|
||||
attachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: -3,
|
||||
width: shareImage.size.width,
|
||||
height: shareImage.size.height
|
||||
)
|
||||
result.append(NSAttributedString(attachment: attachment))
|
||||
}
|
||||
|
||||
appendText("\n2. Choose \"honeyDue\" from the share sheet")
|
||||
appendText("\n3. Sign in if prompted — the app finishes the rest")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Expired-state copy for the instruction card. Tells the recipient
|
||||
/// the share link is no longer valid and to ping the sender (by
|
||||
/// email if we know it) for a new one — replaces the active "How to
|
||||
/// join" steps since the server will reject the bundled code
|
||||
/// anyway.
|
||||
private static func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
|
||||
// Slightly warmer tint than the active instruction copy — the
|
||||
// app's `appError` red would feel alarmist for "just ask again",
|
||||
// and the secondary-label gray reads as muted/disabled which is
|
||||
// accurate to the link's actual state.
|
||||
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
let tint = UIColor.secondaryLabel
|
||||
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
|
||||
let titleTint = UIColor.label
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 2
|
||||
paragraph.alignment = .left
|
||||
|
||||
let result = NSMutableAttributedString()
|
||||
result.append(NSAttributedString(
|
||||
string: "This invite has expired.\n",
|
||||
attributes: [
|
||||
.font: titleFont,
|
||||
.foregroundColor: titleTint,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
|
||||
let body = if let s = sharedBy, !s.isEmpty {
|
||||
"Ask \(s) to send a new link."
|
||||
} else {
|
||||
"Ask the sender to share a new link."
|
||||
}
|
||||
result.append(NSAttributedString(
|
||||
string: body,
|
||||
attributes: [
|
||||
.font: bodyFont,
|
||||
.foregroundColor: tint,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Type Discriminator
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
//
|
||||
// Issue7PreviewScreenshotTest.swift
|
||||
// HoneyDueTests
|
||||
//
|
||||
// Records a single PNG screenshot of the post-fix QL-preview layout
|
||||
// used by `HoneyDueQLPreview/PreviewViewController.swift` so it can be
|
||||
// attached to gitea issue #7 for the reviewer to see the new look
|
||||
// without having to AirDrop a `.honeydue` file to a device.
|
||||
//
|
||||
// How it works:
|
||||
// * Faithfully recreates the UIKit layout `PreviewViewController.updateUIForResidence`
|
||||
// builds in production — same colors, same fonts, same constraints,
|
||||
// same image asset (copied into `HoneyDueTests/Resources/AppLogo.png`
|
||||
// so it is reachable from this target's bundle).
|
||||
// * Runs the same `formatExpiresAt` style (ISO parse → relative phrase
|
||||
// when within a day, absolute medium-date + short-time otherwise),
|
||||
// using a fixed reference Date so the rendering is deterministic
|
||||
// across runs / time zones.
|
||||
// * `SnapshotTesting.assertSnapshot(of: viewController, as: .image)`
|
||||
// writes the PNG to
|
||||
// `iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/`.
|
||||
//
|
||||
// The first run (no committed golden) records the PNG and the test
|
||||
// reports "failed - No reference was found on disk. Automatically
|
||||
// recorded snapshot:" — that's the file we attach to the issue.
|
||||
//
|
||||
// Note on faithfulness: this snapshot is a programmatic reproduction
|
||||
// of `PreviewViewController.updateUIForResidence`, not the QL
|
||||
// extension instance itself, because the QL extension's bundle is a
|
||||
// separate Xcode target from `HoneyDueTests` and can't be `@testable
|
||||
// import`ed without project-file surgery. The reproduction uses the
|
||||
// same UIKit primitives, colors, fonts, and asset, so the rendered
|
||||
// output matches what users see when iOS opens a `.honeydue` invite.
|
||||
//
|
||||
|
||||
@preconcurrency import SnapshotTesting
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class Issue7PreviewScreenshotTest: XCTestCase {
|
||||
|
||||
/// Force record mode for this test only — we want the PNG written
|
||||
/// regardless of whether a golden exists.
|
||||
override func invokeTest() {
|
||||
withSnapshotTesting(record: .all) {
|
||||
super.invokeTest()
|
||||
}
|
||||
}
|
||||
|
||||
func test_residence_invite_preview_after_issue7_fix() {
|
||||
let vc = MockPreviewViewController(
|
||||
residence: ResidencePreview.fixtureForIssue7,
|
||||
state: .active
|
||||
)
|
||||
vc.overrideUserInterfaceStyle = .dark
|
||||
|
||||
assertSnapshot(
|
||||
of: vc,
|
||||
as: .image(
|
||||
on: .iPhone13,
|
||||
precision: 1.0,
|
||||
perceptualPrecision: 1.0,
|
||||
traits: .init(traitsFrom: [
|
||||
UITraitCollection(userInterfaceStyle: .dark),
|
||||
UITraitCollection(displayScale: 2.0),
|
||||
])
|
||||
),
|
||||
named: "issue7_residence_invite_preview_dark"
|
||||
)
|
||||
}
|
||||
|
||||
func test_residence_invite_preview_expired_state() {
|
||||
// Same residence + sender, but expiry already 1 hour in the
|
||||
// past. Verifies the expired branch: the instruction card
|
||||
// swaps to "ask the sender for a new link" and the detail row
|
||||
// reads "Expired 1 hour ago" instead of the future-tense
|
||||
// "Expires in …" phrasing.
|
||||
let vc = MockPreviewViewController(
|
||||
residence: ResidencePreview.fixtureForIssue7,
|
||||
state: .expired(elapsedSecondsSinceExpiry: 60 * 60)
|
||||
)
|
||||
vc.overrideUserInterfaceStyle = .dark
|
||||
|
||||
assertSnapshot(
|
||||
of: vc,
|
||||
as: .image(
|
||||
on: .iPhone13,
|
||||
precision: 1.0,
|
||||
perceptualPrecision: 1.0,
|
||||
traits: .init(traitsFrom: [
|
||||
UITraitCollection(userInterfaceStyle: .dark),
|
||||
UITraitCollection(displayScale: 2.0),
|
||||
])
|
||||
),
|
||||
named: "issue7_residence_invite_preview_expired_dark"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample residence (matches the gitea#7 screenshot setup)
|
||||
|
||||
private struct ResidencePreview {
|
||||
let residenceName: String
|
||||
let sharedBy: String?
|
||||
let expiresAt: String?
|
||||
|
||||
/// Mirrors the data shown in the original gitea#7 screenshot — the
|
||||
/// post-fix version of the same payload.
|
||||
static let fixtureForIssue7 = ResidencePreview(
|
||||
residenceName: "The Tartt's",
|
||||
sharedBy: "honey@hollie37.com",
|
||||
expiresAt: "2026-05-12T17:11:02.067272789Z"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Mock view controller (UIKit copy of `updateUIForResidence`)
|
||||
|
||||
/// Renderer state for the screenshot fixture. Active = link still
|
||||
/// valid; expired = link lapsed `elapsedSecondsSinceExpiry` seconds
|
||||
/// ago. Both render with deterministic data so the recorded PNG is
|
||||
/// stable across runs.
|
||||
private enum PreviewRenderState {
|
||||
case active
|
||||
case expired(elapsedSecondsSinceExpiry: TimeInterval)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockPreviewViewController: UIViewController {
|
||||
|
||||
private let residence: ResidencePreview
|
||||
private let state: PreviewRenderState
|
||||
|
||||
private let containerView = UIView()
|
||||
private let iconImageView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let dividerView = UIView()
|
||||
private let detailsStackView = UIStackView()
|
||||
private let instructionCard = UIView()
|
||||
private let instructionLabel = UILabel()
|
||||
private let arrowImageView = UIImageView()
|
||||
|
||||
init(residence: ResidencePreview, state: PreviewRenderState) {
|
||||
self.residence = residence
|
||||
self.state = state
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("not used") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
applyResidence()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
iconImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
iconImageView.tintColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
||||
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
|
||||
titleLabel.textColor = .label
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.numberOfLines = 2
|
||||
|
||||
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
subtitleLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
subtitleLabel.textColor = .secondaryLabel
|
||||
subtitleLabel.textAlignment = .center
|
||||
|
||||
dividerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
dividerView.backgroundColor = .separator
|
||||
|
||||
detailsStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
detailsStackView.axis = .vertical
|
||||
detailsStackView.spacing = 12
|
||||
detailsStackView.alignment = .leading
|
||||
|
||||
instructionCard.translatesAutoresizingMaskIntoConstraints = false
|
||||
instructionCard.backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 0.1)
|
||||
instructionCard.layer.cornerRadius = 12
|
||||
|
||||
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
instructionLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
instructionLabel.textColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
||||
instructionLabel.textAlignment = .left
|
||||
instructionLabel.numberOfLines = 0
|
||||
|
||||
arrowImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
arrowImageView.contentMode = .scaleAspectFit
|
||||
arrowImageView.tintColor = .secondaryLabel
|
||||
let arrowConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
|
||||
arrowImageView.image = UIImage(systemName: "arrow.down", withConfiguration: arrowConfig)
|
||||
|
||||
view.addSubview(containerView)
|
||||
containerView.addSubview(iconImageView)
|
||||
containerView.addSubview(titleLabel)
|
||||
containerView.addSubview(subtitleLabel)
|
||||
containerView.addSubview(dividerView)
|
||||
containerView.addSubview(detailsStackView)
|
||||
containerView.addSubview(instructionCard)
|
||||
instructionCard.addSubview(instructionLabel)
|
||||
containerView.addSubview(arrowImageView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
|
||||
containerView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
|
||||
containerView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
|
||||
containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 340),
|
||||
|
||||
iconImageView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
iconImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
||||
iconImageView.widthAnchor.constraint(equalToConstant: 80),
|
||||
iconImageView.heightAnchor.constraint(equalToConstant: 80),
|
||||
|
||||
titleLabel.topAnchor.constraint(equalTo: iconImageView.bottomAnchor, constant: 16),
|
||||
titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
||||
subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
dividerView.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 20),
|
||||
dividerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
dividerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
dividerView.heightAnchor.constraint(equalToConstant: 1),
|
||||
|
||||
detailsStackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20),
|
||||
detailsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
detailsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
instructionCard.topAnchor.constraint(equalTo: detailsStackView.bottomAnchor, constant: 24),
|
||||
instructionCard.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
instructionCard.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
instructionLabel.topAnchor.constraint(equalTo: instructionCard.topAnchor, constant: 16),
|
||||
instructionLabel.leadingAnchor.constraint(equalTo: instructionCard.leadingAnchor, constant: 16),
|
||||
instructionLabel.trailingAnchor.constraint(equalTo: instructionCard.trailingAnchor, constant: -16),
|
||||
instructionLabel.bottomAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: -16),
|
||||
|
||||
arrowImageView.topAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: 16),
|
||||
arrowImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
||||
arrowImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func applyResidence() {
|
||||
// Mirror the post-fix branding choice: bundled honeyDue logo
|
||||
// rendered in its actual colors. The image ships with the test
|
||||
// target at `Resources/AppLogo.png`.
|
||||
if let path = Bundle(for: Self.self).path(forResource: "AppLogo", ofType: "png"),
|
||||
let logo = UIImage(contentsOfFile: path) {
|
||||
iconImageView.image = logo
|
||||
iconImageView.layer.cornerRadius = 16
|
||||
iconImageView.layer.masksToBounds = true
|
||||
} else {
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
||||
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
|
||||
}
|
||||
|
||||
titleLabel.text = residence.residenceName
|
||||
subtitleLabel.text = "honeyDue Residence Invite"
|
||||
|
||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
|
||||
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
|
||||
}
|
||||
|
||||
switch state {
|
||||
case .active:
|
||||
instructionLabel.attributedText = makeResidenceInstructions()
|
||||
arrowImageView.isHidden = false
|
||||
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
||||
addDetailRow(icon: "clock", text: "Expires \(formatActiveExpiry(expiresAt))")
|
||||
}
|
||||
case .expired(let elapsed):
|
||||
instructionLabel.attributedText = makeExpiredInstructions(sharedBy: residence.sharedBy)
|
||||
// Arrow points at the Share button — no point telling the
|
||||
// user to tap it for a dead link. Matches PreviewViewController.
|
||||
arrowImageView.isHidden = true
|
||||
addDetailRow(icon: "clock", text: "Expired \(relativePhrase(secondsAgo: elapsed))")
|
||||
}
|
||||
}
|
||||
|
||||
private func relativePhrase(secondsAgo: TimeInterval) -> String {
|
||||
// Deterministic relative phrase — we set "now" to be exactly
|
||||
// `secondsAgo` after the (fake) expiry, so the formatter says
|
||||
// "1 hour ago" instead of whatever the real clock would give.
|
||||
let fakeNow = Date()
|
||||
let pastExpiry = fakeNow.addingTimeInterval(-secondsAgo)
|
||||
let relative = RelativeDateTimeFormatter()
|
||||
relative.unitsStyle = .full
|
||||
return relative.localizedString(for: pastExpiry, relativeTo: fakeNow)
|
||||
}
|
||||
|
||||
/// Expired-state copy mirroring `PreviewViewController.makeExpiredInstructions`.
|
||||
private func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
|
||||
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 2
|
||||
paragraph.alignment = .left
|
||||
|
||||
let result = NSMutableAttributedString()
|
||||
result.append(NSAttributedString(
|
||||
string: "This invite has expired.\n",
|
||||
attributes: [
|
||||
.font: titleFont,
|
||||
.foregroundColor: UIColor.label,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
let body = if let s = sharedBy, !s.isEmpty {
|
||||
"Ask \(s) to send a new link."
|
||||
} else {
|
||||
"Ask the sender to share a new link."
|
||||
}
|
||||
result.append(NSAttributedString(
|
||||
string: body,
|
||||
attributes: [
|
||||
.font: bodyFont,
|
||||
.foregroundColor: UIColor.secondaryLabel,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
return result
|
||||
}
|
||||
|
||||
private func addDetailRow(icon: String, text: String) {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.spacing = 12
|
||||
row.alignment = .center
|
||||
|
||||
let iv = UIImageView()
|
||||
iv.translatesAutoresizingMaskIntoConstraints = false
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
|
||||
iv.image = UIImage(systemName: icon, withConfiguration: config)
|
||||
iv.tintColor = .secondaryLabel
|
||||
iv.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
iv.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
|
||||
let label = UILabel()
|
||||
label.font = .systemFont(ofSize: 15)
|
||||
label.textColor = .label
|
||||
label.text = text
|
||||
label.numberOfLines = 1
|
||||
|
||||
row.addArrangedSubview(iv)
|
||||
row.addArrangedSubview(label)
|
||||
detailsStackView.addArrangedSubview(row)
|
||||
}
|
||||
|
||||
/// Mirrors `PreviewViewController.makeResidenceInstructions()` — see
|
||||
/// the rationale comment there. Inlined here because the QL
|
||||
/// extension target can't be `@testable import`ed without
|
||||
/// project-file surgery.
|
||||
private func makeResidenceInstructions() -> NSAttributedString {
|
||||
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 2
|
||||
paragraph.alignment = .left
|
||||
|
||||
let result = NSMutableAttributedString()
|
||||
|
||||
func appendText(_ s: String) {
|
||||
result.append(NSAttributedString(
|
||||
string: s,
|
||||
attributes: [
|
||||
.font: bodyFont,
|
||||
.foregroundColor: tint,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
}
|
||||
|
||||
appendText("How to join:\n1. Tap ")
|
||||
|
||||
let shareImage = UIImage(
|
||||
systemName: "square.and.arrow.up",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
|
||||
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
|
||||
if let shareImage {
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = shareImage
|
||||
attachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: -3,
|
||||
width: shareImage.size.width,
|
||||
height: shareImage.size.height
|
||||
)
|
||||
result.append(NSAttributedString(attachment: attachment))
|
||||
}
|
||||
|
||||
appendText("\n2. Choose \"honeyDue\" from the share sheet")
|
||||
appendText("\n3. Sign in if prompted — the app finishes the rest")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Mirrors PreviewViewController.formatActiveExpiry with a fixed
|
||||
// "now" so the rendering is identical regardless of when the test
|
||||
// runs. The expired branch uses [relativePhrase(secondsAgo:)]
|
||||
// instead — see the active/expired switch in `applyResidence`.
|
||||
private func formatActiveExpiry(_ raw: String) -> String {
|
||||
let isoWithFraction: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
let isoNoFraction: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
guard let date = isoWithFraction.date(from: raw)
|
||||
?? isoNoFraction.date(from: raw) else {
|
||||
return raw
|
||||
}
|
||||
|
||||
// Deterministic "now": 23 hours before the fixture's expiry, so
|
||||
// the relative formatter always produces "in 23 hours".
|
||||
let fakeNow = date.addingTimeInterval(-23 * 60 * 60)
|
||||
let relative = RelativeDateTimeFormatter()
|
||||
relative.unitsStyle = .full
|
||||
return relative.localizedString(for: date, relativeTo: fakeNow)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
@@ -34,6 +34,23 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// When `true`, every test in the suite forces a logout → login cycle
|
||||
/// in `setUp`, guaranteeing a freshly-issued auth token on each run.
|
||||
///
|
||||
/// Default is `false`: tests reuse the existing logged-in session
|
||||
/// from the previous test in the same suite — much faster (one login
|
||||
/// per suite, not one per test) and resilient to suites where the
|
||||
/// current screen has no logout affordance (`UITestHelpers.ensureLoggedOut`
|
||||
/// times out → the test fails before its body runs).
|
||||
///
|
||||
/// Override to `true` in suites that have observed transient
|
||||
/// `Invalid token` 401s on POST/PATCH while reads continue to work.
|
||||
/// The recipe was added after a 2026-05 incident where the API
|
||||
/// container was rebuilt mid-suite and in-memory tokens went stale.
|
||||
/// In normal CI runs against a stable API + freshly-erased simulator,
|
||||
/// session reuse is the correct default.
|
||||
var forceFreshLoginPerTest: Bool { false }
|
||||
|
||||
override func setUpWithError() throws {
|
||||
guard TestAccountAPIClient.isBackendReachable() else {
|
||||
throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)")
|
||||
@@ -41,27 +58,27 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
|
||||
try super.setUpWithError()
|
||||
|
||||
// If already logged in (tab bar visible), skip the login flow
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
if tabBar.waitForExistence(timeout: defaultTimeout) {
|
||||
// Already logged in — just set up API session if needed
|
||||
if needsAPISession {
|
||||
guard let apiSession = TestAccountManager.loginSeededAccount(
|
||||
username: apiCredentials.username,
|
||||
password: apiCredentials.password
|
||||
) else {
|
||||
XCTFail("Could not login API account '\(apiCredentials.username)'")
|
||||
return
|
||||
}
|
||||
session = apiSession
|
||||
cleaner = TestDataCleaner(token: apiSession.token)
|
||||
}
|
||||
return
|
||||
}
|
||||
let alreadyLoggedIn = tabBar.waitForExistence(timeout: defaultTimeout)
|
||||
|
||||
// Not logged in — do the full login flow
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
loginToMainApp()
|
||||
// Force-fresh path: log out (if needed) and re-authenticate per
|
||||
// test so every test starts with a freshly-issued JWT. Catches
|
||||
// server-side token invalidation that would otherwise surface
|
||||
// mid-suite as opaque 401s on the first mutation call.
|
||||
if forceFreshLoginPerTest {
|
||||
if alreadyLoggedIn {
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
} else {
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
}
|
||||
loginToMainApp()
|
||||
} else if !alreadyLoggedIn {
|
||||
// Legacy session-reuse path: only log in when not already in.
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
loginToMainApp()
|
||||
}
|
||||
// (When `forceFreshLoginPerTest == false` AND we're already
|
||||
// logged in, fall through with the existing session.)
|
||||
|
||||
if needsAPISession {
|
||||
guard let apiSession = TestAccountManager.loginSeededAccount(
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import XCTest
|
||||
|
||||
/// Suite 11 — captures the gitea#2 regression at the user-visible level:
|
||||
/// after onboarding (register → name residence → bulk-create tasks → land
|
||||
/// on home), tapping the residence cell shows "no tasks" even though the
|
||||
/// server has them. Restarting the app fixes it. This test reproduces the
|
||||
/// flow without an app restart and asserts that tasks render on the
|
||||
/// residence detail screen.
|
||||
///
|
||||
/// CRITICAL: this test must FAIL at the cache-unification fix's first
|
||||
/// commit and must PASS after Phase 1-3 lands. The failing assertion is
|
||||
/// pinned to a specific message so the regression is unambiguous.
|
||||
///
|
||||
/// The test deliberately does NOT visit the Tasks tab between onboarding
|
||||
/// and tapping the residence cell. Visiting the Tasks tab would prime
|
||||
/// `_allTasks` and mask the bug — the bug is that residence detail
|
||||
/// cannot recover from the empty-cache + sink-timing window on its own.
|
||||
final class Suite11_TaskCacheRegressionTests: BaseUITestCase {
|
||||
// We need to start at the onboarding welcome screen, not the standalone
|
||||
// login screen — `completeOnboarding` would skip the entire flow.
|
||||
override var completeOnboarding: Bool { false }
|
||||
// Single test in this suite — relaunch isn't necessary, but we want a
|
||||
// clean state every time (handled by the default --reset-state).
|
||||
override var relaunchBetweenTests: Bool { true }
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// DEBUG_FIXED_CODES=true on the local Go API hardcodes this code.
|
||||
private let debugVerificationCode = "123456"
|
||||
|
||||
/// Stable name for the residence we create in onboarding. Used both for
|
||||
/// the form input and to address the cell on the home screen via
|
||||
/// `app.staticTexts[residenceName]` if the id-based identifier doesn't
|
||||
/// resolve in time.
|
||||
private let residenceName = "UI Test Property"
|
||||
|
||||
// MARK: - Test
|
||||
|
||||
/// Reproduces gitea#2: tasks created via the onboarding bulk endpoint
|
||||
/// must appear on the residence detail screen without an app restart
|
||||
/// and without first visiting the Tasks tab.
|
||||
@MainActor
|
||||
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
|
||||
// Step 1 — Register a fresh user via the onboarding Start Fresh flow.
|
||||
// The flow is: Welcome → ValueProps → NameResidence → CreateAccount
|
||||
// → VerifyEmail → HomeProfile → FirstTask → main app.
|
||||
let createAccount = TestFlows.navigateStartFreshToCreateAccount(
|
||||
app: app,
|
||||
residenceName: residenceName
|
||||
)
|
||||
createAccount.waitForLoad(timeout: navigationTimeout)
|
||||
|
||||
// Step 2 — Fill the create-account form. We address the onboarding
|
||||
// form's fields (not the standalone register sheet's fields).
|
||||
let creds = TestAccountManager.uniqueCredentials(prefix: "gitea2")
|
||||
|
||||
createAccount.expandEmailSignup()
|
||||
|
||||
// Use the same focusAndType path that OnboardingTests uses — it
|
||||
// already handles SecureTextField + iOS strong-password panel.
|
||||
// Under --ui-testing, OrganicOnboardingSecureField defaults to
|
||||
// visibility=ON (renders as TextField) to dodge the iOS 26 SecureField
|
||||
// keyboard bug. Query textFields, not secureTextFields.
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
|
||||
let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
|
||||
let passwordField = app.textFields[AccessibilityIdentifiers.Onboarding.passwordField]
|
||||
let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
|
||||
|
||||
usernameField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
usernameField.focusAndType(creds.username, app: app)
|
||||
emailField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
emailField.focusAndType(creds.email, app: app)
|
||||
passwordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
passwordField.focusAndType(creds.password, app: app)
|
||||
confirmPasswordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
confirmPasswordField.focusAndType(creds.password, app: app)
|
||||
|
||||
let createAccountButton = app.descendants(matching: .any)
|
||||
.matching(identifier: AccessibilityIdentifiers.Onboarding.createAccountButton)
|
||||
.firstMatch
|
||||
createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||
createAccountButton.forceTap()
|
||||
|
||||
// Step 3 — Verify email with the debug fixed code.
|
||||
let verification = VerificationScreen(app: app)
|
||||
verification.waitForLoad(timeout: loginTimeout)
|
||||
verification.enterCode(debugVerificationCode)
|
||||
// Many onboarding verification screens auto-submit on a 6-digit
|
||||
// code. If a verify button still exists and a code field is still
|
||||
// visible, tap it to push past edge cases.
|
||||
if verification.codeField.waitForExistence(timeout: 1) && verification.verifyButton.exists {
|
||||
verification.submitCode()
|
||||
}
|
||||
|
||||
// Step 4 — Skip the home-profile step. The home-profile screen has
|
||||
// its own Skip button (the shared onboarding skip in the nav bar)
|
||||
// which routes to the first-task step without making us pick climate
|
||||
// / appliance fields.
|
||||
let onboardingSkipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton]
|
||||
XCTAssertTrue(
|
||||
onboardingSkipButton.waitForExistence(timeout: loginTimeout),
|
||||
"Onboarding skip button should exist on the home-profile screen"
|
||||
)
|
||||
// The skip button can briefly be non-hittable during the screen-in
|
||||
// transition. Use forceTap() to bypass the strict hittable check.
|
||||
// We confirmed existence above; if the tap doesn't land on the
|
||||
// intended button the next assertion (Browse All tab) will catch it.
|
||||
onboardingSkipButton.forceTap()
|
||||
|
||||
// Step 5 — Switch to the "Browse All" tab on the First-Task screen.
|
||||
// "For You" suggestions can be empty for a fresh residence with no
|
||||
// home-profile data, so deterministic browsing is required.
|
||||
// The tab bar is a SwiftUI segmented Picker — its segments are
|
||||
// exposed as buttons with the segment label, regardless of an
|
||||
// identifier on the parent.
|
||||
let browseAllTab = app.buttons["Browse All"]
|
||||
XCTAssertTrue(
|
||||
browseAllTab.waitForExistence(timeout: loginTimeout),
|
||||
"Browse All tab should appear on the first-task screen"
|
||||
)
|
||||
browseAllTab.tap()
|
||||
|
||||
// Step 6 — Pick 3 templates by accessibility identifier prefix.
|
||||
// The catalog is loaded via GET /api/tasks/templates/grouped/, so
|
||||
// we need to wait for at least one row to render before tapping.
|
||||
let templateRowQuery = app.buttons.matching(
|
||||
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
|
||||
)
|
||||
|
||||
// Wait for the catalog to load. The grouped endpoint returns first
|
||||
// category expanded by default in the view, so rows should appear
|
||||
// shortly after Browse All becomes visible. Network call: 10s.
|
||||
let firstRow = templateRowQuery.element(boundBy: 0)
|
||||
XCTAssertTrue(
|
||||
firstRow.waitForExistence(timeout: loginTimeout),
|
||||
"At least one template row must render on the Browse All tab. " +
|
||||
"If no rows appear, the catalog endpoint failed — bug repro is invalid."
|
||||
)
|
||||
|
||||
// Tap the first 3 visible rows. Some categories may collapse rows
|
||||
// we never see; we only need at least 1, so the floor is 1 with a
|
||||
// soft cap of 3.
|
||||
let rowCount = templateRowQuery.count
|
||||
let toPick = min(3, rowCount)
|
||||
XCTAssertGreaterThanOrEqual(toPick, 1, "Expected at least one template row")
|
||||
for index in 0..<toPick {
|
||||
let row = templateRowQuery.element(boundBy: index)
|
||||
row.waitUntilHittable(timeout: navigationTimeout)
|
||||
row.tap()
|
||||
}
|
||||
|
||||
// Step 7 — Submit the bulk-create. This is the
|
||||
// POST /api/tasks/bulk/ call that produces the inconsistent client
|
||||
// cache state at the heart of gitea#2.
|
||||
let submitButton = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
|
||||
XCTAssertTrue(
|
||||
submitButton.waitForExistence(timeout: navigationTimeout),
|
||||
"Submit-tasks button must exist on the first-task screen"
|
||||
)
|
||||
submitButton.waitUntilHittable(timeout: navigationTimeout).tap()
|
||||
|
||||
// Step 8 — Land on the main app (Residences tab is selected by
|
||||
// default). CRITICAL: do NOT tap the Tasks tab. Tapping it would
|
||||
// populate `_allTasks` and mask the bug.
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|
||||
|| tabBar.waitForExistence(timeout: navigationTimeout)
|
||||
XCTAssertTrue(reachedMain, "App should reach main tabs after onboarding submit")
|
||||
|
||||
// Step 9 — Tap the residence cell directly. Prefer the
|
||||
// identifier-prefix match for any cell; fall back to the static
|
||||
// text match by name.
|
||||
let residenceCellQuery = app.buttons.matching(
|
||||
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||
AccessibilityIdentifiers.Residence.cellPrefix)
|
||||
)
|
||||
let residenceCell = residenceCellQuery.firstMatch
|
||||
if residenceCell.waitForExistence(timeout: navigationTimeout) && residenceCell.isHittable {
|
||||
residenceCell.tap()
|
||||
} else {
|
||||
// Fallback: tap the static text inside the card. The
|
||||
// NavigationLink wraps the entire card so a tap on the name
|
||||
// still routes into the detail view.
|
||||
let residenceText = app.staticTexts[residenceName]
|
||||
XCTAssertTrue(
|
||||
residenceText.waitForExistence(timeout: navigationTimeout),
|
||||
"Residence cell or name '\(residenceName)' must exist on the residences list"
|
||||
)
|
||||
residenceText.tap()
|
||||
}
|
||||
|
||||
// Step 10 — THE BUG ASSERTION. With the bug present:
|
||||
// - `_allTasks` is null on the client (never primed).
|
||||
// - `_tasksByResidence[id]` is empty (cache miss).
|
||||
// - residence detail attempts to load, hits the iOS Combine sink
|
||||
// timing window, and renders the empty state.
|
||||
// With the fix, both `_allTasks` is populated by `bulkCreateTasks`
|
||||
// and residence detail filters from it in-memory, so the empty
|
||||
// state must not appear.
|
||||
let taskRowQuery = app.descendants(matching: .any).matching(
|
||||
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||
AccessibilityIdentifiers.Task.rowPrefix)
|
||||
)
|
||||
let firstTaskRow = taskRowQuery.element(boundBy: 0)
|
||||
let anyTaskAppeared = firstTaskRow.waitForExistence(timeout: 10)
|
||||
|
||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.noTasksLabel]
|
||||
let emptyStateVisible = emptyState.exists
|
||||
|
||||
// Pin the failure message so the bug-capture is unambiguous. This
|
||||
// is the assertion that should FAIL at this commit and PASS after
|
||||
// the cache fix lands. Don't change the message — Task 12 grep's
|
||||
// for it.
|
||||
XCTAssertTrue(
|
||||
anyTaskAppeared && !emptyStateVisible,
|
||||
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,18 @@ import ComposeApp
|
||||
/// Three-step direct-to-B2 image upload.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. POST /api/uploads/presign → server returns a B2 POST policy + form
|
||||
/// fields scoped to a single object key with a content-length-range
|
||||
/// condition that B2 enforces at the protocol level.
|
||||
/// 2. Multipart POST the bytes directly to B2, no API server in the data
|
||||
/// path. B2 rejects the upload if the bytes don't match the policy.
|
||||
/// 1. POST /api/uploads/presign → server returns a signed PUT URL plus
|
||||
/// the headers (Content-Type, Content-Length) the client must send.
|
||||
/// The signature binds those headers — B2 rejects the upload if the
|
||||
/// bytes/headers don't match exactly.
|
||||
/// 2. PUT the bytes directly to B2, no API server in the data path.
|
||||
/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
|
||||
/// /api/documents/ via `upload_ids[]`. The server HEADs the object,
|
||||
/// confirms the size, and creates the linked entity rows.
|
||||
///
|
||||
/// We use PUT (not POST) because B2's S3-compatible endpoint does not
|
||||
/// implement the S3 POST Object form upload — every POST returns HTTP 501.
|
||||
///
|
||||
/// All errors map to `PresignedUploaderError` — the Swift call site can
|
||||
/// translate to user-facing copy without parsing nested HTTP details.
|
||||
enum PresignedUploaderError: Error, LocalizedError {
|
||||
@@ -33,7 +36,7 @@ enum PresignedUploaderError: Error, LocalizedError {
|
||||
default: return "Couldn't start upload (server returned \(status))."
|
||||
}
|
||||
case .uploadFailed(let status, _):
|
||||
return "Upload failed (B2 returned \(status))."
|
||||
return "Upload failed (storage returned \(status))."
|
||||
case .sessionError(let err):
|
||||
return err.localizedDescription
|
||||
}
|
||||
@@ -95,13 +98,12 @@ final class PresignedUploader {
|
||||
contentLength: Int64(data.count)
|
||||
)
|
||||
|
||||
// Step 2: direct POST to B2
|
||||
try await postToStorage(
|
||||
// Step 2: direct PUT to B2
|
||||
try await putToStorage(
|
||||
uploadURL: presigned.uploadUrl,
|
||||
fields: presigned.fields,
|
||||
headers: presigned.headers,
|
||||
data: data,
|
||||
contentType: contentType,
|
||||
fileName: fileName
|
||||
contentType: contentType
|
||||
)
|
||||
|
||||
return Int32(presigned.id)
|
||||
@@ -146,7 +148,8 @@ final class PresignedUploader {
|
||||
private struct PresignResponse: Decodable {
|
||||
let id: Int
|
||||
let upload_url: String
|
||||
let fields: [String: String]
|
||||
let method: String?
|
||||
let headers: [String: String]
|
||||
let key: String
|
||||
let expires_at: String
|
||||
|
||||
@@ -196,64 +199,39 @@ final class PresignedUploader {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 2: POST to B2
|
||||
// MARK: - Step 2: PUT to B2
|
||||
//
|
||||
// The presign response includes the exact headers (Content-Type +
|
||||
// Content-Length) that were signed. Send them verbatim — any deviation
|
||||
// invalidates the signature and B2 will reject the upload.
|
||||
//
|
||||
// Content-Length is set automatically by URLSession from httpBody.count,
|
||||
// so we don't manually echo it back; we still send Content-Type because
|
||||
// URLSession will otherwise default it to application/x-www-form-urlencoded.
|
||||
|
||||
private func postToStorage(
|
||||
private func putToStorage(
|
||||
uploadURL: String,
|
||||
fields: [String: String],
|
||||
headers: [String: String],
|
||||
data: Data,
|
||||
contentType: String,
|
||||
fileName: String
|
||||
contentType: String
|
||||
) async throws {
|
||||
guard let url = URL(string: uploadURL) else {
|
||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
|
||||
}
|
||||
|
||||
// Build a multipart/form-data body with all policy fields followed
|
||||
// by a single "file" part (S3 POST policy mandates the file part
|
||||
// come last).
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var body = Data()
|
||||
let crlf = "\r\n"
|
||||
let appendString: (String) -> Void = { s in
|
||||
body.append(s.data(using: .utf8) ?? Data())
|
||||
}
|
||||
|
||||
// Stable order: ensure "key" and "Content-Type" appear before the
|
||||
// file part so the policy signature validates. Unspecified order
|
||||
// for the rest — S3 accepts any.
|
||||
let orderedKeys = ["key", "Content-Type", "policy", "x-amz-algorithm",
|
||||
"x-amz-credential", "x-amz-date", "x-amz-signature",
|
||||
"x-amz-meta-uid"]
|
||||
var emitted = Set<String>()
|
||||
for k in orderedKeys {
|
||||
if let v = fields[k] {
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
||||
appendString(v)
|
||||
appendString(crlf)
|
||||
emitted.insert(k)
|
||||
}
|
||||
}
|
||||
for (k, v) in fields where !emitted.contains(k) {
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
||||
appendString(v)
|
||||
appendString(crlf)
|
||||
}
|
||||
|
||||
// file part — must be last
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\(crlf)")
|
||||
appendString("Content-Type: \(contentType)\(crlf)\(crlf)")
|
||||
body.append(data)
|
||||
appendString(crlf)
|
||||
appendString("--\(boundary)--\(crlf)")
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
req.httpMethod = "PUT"
|
||||
req.httpBody = data
|
||||
|
||||
// Apply server-supplied headers verbatim. Skip Content-Length —
|
||||
// URLSession sets it automatically and will refuse to override it.
|
||||
for (k, v) in headers where k.lowercased() != "content-length" {
|
||||
req.setValue(v, forHTTPHeaderField: k)
|
||||
}
|
||||
// Defensive: ensure Content-Type is set even if the server omits it.
|
||||
if req.value(forHTTPHeaderField: "Content-Type") == nil {
|
||||
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
let (respBody, response): (Data, URLResponse)
|
||||
do {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -42,6 +42,12 @@ class TaskViewModel: ObservableObject {
|
||||
private let dataManager: DataManagerObservable
|
||||
|
||||
// MARK: - Initialization
|
||||
/// Single source of truth = DataManager._allTasks. When this VM is
|
||||
/// residence-scoped (currentResidenceId set), filter in-memory by
|
||||
/// residence id. Eliminates the gitea#2 race window where the
|
||||
/// per-residence cache slot could be empty while _allTasks was
|
||||
/// populated. The per-residence cache is gone (cec521b).
|
||||
///
|
||||
/// - Parameter dataManager: Observable cache the VM subscribes to.
|
||||
/// Defaults to the shared singleton. Tests inject a fixture-backed
|
||||
/// instance so populated-state snapshots render real data.
|
||||
@@ -50,35 +56,26 @@ class TaskViewModel: ObservableObject {
|
||||
|
||||
// Seed from current cache so snapshot tests/previews render
|
||||
// populated state without waiting for Combine's async dispatch.
|
||||
// The seed path mirrors the steady-state filter below — if this
|
||||
// VM is residence-scoped at construction time the seed has to
|
||||
// pre-filter too, but currentResidenceId is set after init via
|
||||
// setResidenceFilter(...), so seeding the unfiltered list is fine.
|
||||
self.tasksResponse = dataManager.allTasks
|
||||
|
||||
// Observe injected DataManagerObservable for all tasks data
|
||||
// Observe injected DataManagerObservable for all tasks data.
|
||||
dataManager.$allTasks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] allTasks in
|
||||
// Skip DataManager updates during completion animation to prevent
|
||||
// the task from being moved out of its column before the animation finishes
|
||||
guard self?.isAnimatingCompletion != true else { return }
|
||||
// Only update if we're showing all tasks (no residence filter)
|
||||
if self?.currentResidenceId == nil {
|
||||
self?.tasksResponse = allTasks
|
||||
if allTasks != nil {
|
||||
self?.isLoadingTasks = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
guard let self else { return }
|
||||
guard !self.isAnimatingCompletion else { return }
|
||||
|
||||
// Observe tasks by residence
|
||||
dataManager.$tasksByResidence
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] tasksByResidence in
|
||||
guard self?.isAnimatingCompletion != true else { return }
|
||||
// Only update if we're filtering by residence
|
||||
if let resId = self?.currentResidenceId,
|
||||
let tasks = tasksByResidence[resId] {
|
||||
self?.tasksResponse = tasks
|
||||
self?.isLoadingTasks = false
|
||||
if let allTasks {
|
||||
if let resId = self.currentResidenceId {
|
||||
self.tasksResponse = self.filterTasks(allTasks, residenceId: resId)
|
||||
} else {
|
||||
self.tasksResponse = allTasks
|
||||
}
|
||||
self.isLoadingTasks = false
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -392,6 +389,28 @@ class TaskViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter the all-tasks kanban down to a single residence in-memory.
|
||||
/// Mirrors `DataManager.getTasksForResidence` on the Kotlin side.
|
||||
private func filterTasks(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse {
|
||||
let filteredColumns = response.columns.map { column -> TaskColumn in
|
||||
let filteredTasks = column.tasks.filter { Int32($0.residenceId) == residenceId }
|
||||
return TaskColumn(
|
||||
name: column.name,
|
||||
displayName: column.displayName,
|
||||
buttonTypes: column.buttonTypes,
|
||||
icons: column.icons,
|
||||
color: column.color,
|
||||
tasks: filteredTasks,
|
||||
count: Int32(filteredTasks.count)
|
||||
)
|
||||
}
|
||||
return TaskColumnsResponse(
|
||||
columns: filteredColumns,
|
||||
daysThreshold: response.daysThreshold,
|
||||
residenceId: String(residenceId)
|
||||
)
|
||||
}
|
||||
|
||||
/// Updates a task in the kanban board by moving it to the correct column based on kanban_column
|
||||
func updateTaskInKanban(_ updatedTask: TaskResponse) {
|
||||
guard let currentResponse = tasksResponse else { return }
|
||||
|
||||
Reference in New Issue
Block a user