Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4d66c6ed1 |
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"ask": []
|
"ask": [
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git push:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,8 +18,7 @@
|
|||||||
"Bash(ps:*)",
|
"Bash(ps:*)",
|
||||||
"Bash(stdbuf:*)",
|
"Bash(stdbuf:*)",
|
||||||
"Bash(sysctl:*)",
|
"Bash(sysctl:*)",
|
||||||
"Bash(tee:*)",
|
"Bash(tee:*)"
|
||||||
"Bash(codesign -d --entitlements :- /Users/treyt/Library/Developer/Xcode/DerivedData/honeyDue-buvczbpttcfkxxcmxbnqkqrmujyh/Build/Products/Debug-iphonesimulator/honeyDue.app)"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
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,6 +62,8 @@ object DataManager {
|
|||||||
private set
|
private set
|
||||||
var tasksCacheTime: Long = 0L
|
var tasksCacheTime: Long = 0L
|
||||||
private set
|
private set
|
||||||
|
var tasksByResidenceCacheTime: MutableMap<Int, Long> = mutableMapOf()
|
||||||
|
private set
|
||||||
var contractorsCacheTime: Long = 0L
|
var contractorsCacheTime: Long = 0L
|
||||||
private set
|
private set
|
||||||
var documentsCacheTime: Long = 0L
|
var documentsCacheTime: Long = 0L
|
||||||
@@ -136,6 +138,8 @@ object DataManager {
|
|||||||
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
|
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
|
||||||
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
|
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
|
||||||
|
|
||||||
|
private val _tasksByResidence = MutableStateFlow<Map<Int, TaskColumnsResponse>>(emptyMap())
|
||||||
|
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
|
||||||
|
|
||||||
// ==================== DOCUMENTS ====================
|
// ==================== DOCUMENTS ====================
|
||||||
|
|
||||||
@@ -410,6 +414,7 @@ object DataManager {
|
|||||||
|
|
||||||
fun removeResidence(residenceId: Int) {
|
fun removeResidence(residenceId: Int) {
|
||||||
_residences.value = _residences.value.filter { it.id != residenceId }
|
_residences.value = _residences.value.filter { it.id != residenceId }
|
||||||
|
_tasksByResidence.value = _tasksByResidence.value - residenceId
|
||||||
_documentsByResidence.value = _documentsByResidence.value - residenceId
|
_documentsByResidence.value = _documentsByResidence.value - residenceId
|
||||||
_residenceSummaries.value = _residenceSummaries.value - residenceId
|
_residenceSummaries.value = _residenceSummaries.value - residenceId
|
||||||
|
|
||||||
@@ -440,10 +445,16 @@ object DataManager {
|
|||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) {
|
||||||
|
_tasksByResidence.value = _tasksByResidence.value + (residenceId to response)
|
||||||
|
tasksByResidenceCacheTime[residenceId] = currentTimeMs()
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter cached allTasks by residence ID. Single source of truth for
|
* Filter cached allTasks by residence ID to avoid separate API call.
|
||||||
* residence-scoped kanban data; returns null when _allTasks is null
|
* Returns null if allTasks not cached.
|
||||||
* (caller must hit the API to populate).
|
* This enables client-side filtering when we already have all tasks loaded.
|
||||||
*/
|
*/
|
||||||
fun getTasksForResidence(residenceId: Int): TaskColumnsResponse? {
|
fun getTasksForResidence(residenceId: Int): TaskColumnsResponse? {
|
||||||
val allTasksData = _allTasks.value ?: return null
|
val allTasksData = _allTasks.value ?: return null
|
||||||
@@ -469,60 +480,45 @@ object DataManager {
|
|||||||
* Also refreshes the summary from the updated kanban data.
|
* Also refreshes the summary from the updated kanban data.
|
||||||
*/
|
*/
|
||||||
fun updateTask(task: TaskResponse) {
|
fun updateTask(task: TaskResponse) {
|
||||||
|
// Update in allTasks
|
||||||
|
_allTasks.value?.let { current ->
|
||||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||||
|
val newColumns = current.columns.map { column ->
|
||||||
// Upsert into _allTasks. Crucially, when _allTasks is null (fresh
|
// Remove task from this column if present
|
||||||
// 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 filteredTasks = column.tasks.filter { it.id != task.id }
|
||||||
val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks
|
// 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)
|
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||||
}
|
}
|
||||||
_allTasks.value = current.copy(columns = newColumns)
|
_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)
|
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
|
||||||
refreshSummaryFromKanban()
|
refreshSummaryFromKanban()
|
||||||
persistToDisk()
|
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) {
|
fun removeTask(taskId: Int) {
|
||||||
// Remove from allTasks
|
// Remove from allTasks
|
||||||
_allTasks.value?.let { current ->
|
_allTasks.value?.let { current ->
|
||||||
@@ -533,6 +529,15 @@ object DataManager {
|
|||||||
_allTasks.value = current.copy(columns = newColumns)
|
_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)
|
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
|
||||||
refreshSummaryFromKanban()
|
refreshSummaryFromKanban()
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
@@ -775,6 +780,7 @@ object DataManager {
|
|||||||
_totalSummary.value = null
|
_totalSummary.value = null
|
||||||
_residenceSummaries.value = emptyMap()
|
_residenceSummaries.value = emptyMap()
|
||||||
_allTasks.value = null
|
_allTasks.value = null
|
||||||
|
_tasksByResidence.value = emptyMap()
|
||||||
_documents.value = emptyList()
|
_documents.value = emptyList()
|
||||||
_documentsByResidence.value = emptyMap()
|
_documentsByResidence.value = emptyMap()
|
||||||
_contractors.value = emptyList()
|
_contractors.value = emptyList()
|
||||||
@@ -805,6 +811,7 @@ object DataManager {
|
|||||||
residencesCacheTime = 0L
|
residencesCacheTime = 0L
|
||||||
myResidencesCacheTime = 0L
|
myResidencesCacheTime = 0L
|
||||||
tasksCacheTime = 0L
|
tasksCacheTime = 0L
|
||||||
|
tasksByResidenceCacheTime.clear()
|
||||||
contractorsCacheTime = 0L
|
contractorsCacheTime = 0L
|
||||||
documentsCacheTime = 0L
|
documentsCacheTime = 0L
|
||||||
summaryCacheTime = 0L
|
summaryCacheTime = 0L
|
||||||
@@ -826,6 +833,7 @@ object DataManager {
|
|||||||
_totalSummary.value = null
|
_totalSummary.value = null
|
||||||
_residenceSummaries.value = emptyMap()
|
_residenceSummaries.value = emptyMap()
|
||||||
_allTasks.value = null
|
_allTasks.value = null
|
||||||
|
_tasksByResidence.value = emptyMap()
|
||||||
_documents.value = emptyList()
|
_documents.value = emptyList()
|
||||||
_documentsByResidence.value = emptyMap()
|
_documentsByResidence.value = emptyMap()
|
||||||
_contractors.value = emptyList()
|
_contractors.value = emptyList()
|
||||||
@@ -838,6 +846,7 @@ object DataManager {
|
|||||||
residencesCacheTime = 0L
|
residencesCacheTime = 0L
|
||||||
myResidencesCacheTime = 0L
|
myResidencesCacheTime = 0L
|
||||||
tasksCacheTime = 0L
|
tasksCacheTime = 0L
|
||||||
|
tasksByResidenceCacheTime.clear()
|
||||||
contractorsCacheTime = 0L
|
contractorsCacheTime = 0L
|
||||||
documentsCacheTime = 0L
|
documentsCacheTime = 0L
|
||||||
summaryCacheTime = 0L
|
summaryCacheTime = 0L
|
||||||
|
|||||||
@@ -13,37 +13,6 @@ data class TaskCompletionCreateRequest(
|
|||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
@SerialName("actual_cost") val actualCost: Double? = null,
|
@SerialName("actual_cost") val actualCost: Double? = null,
|
||||||
val rating: Int? = null, // 1-5 star rating
|
val rating: Int? = null, // 1-5 star rating
|
||||||
@SerialName("upload_ids") val uploadIds: List<Int>? = null // pending_uploads.id values from /api/uploads/presign + direct B2 POST
|
@SerialName("image_urls") val imageUrls: List<String>? = null // Multiple image URLs
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,7 +27,6 @@ object APILayer {
|
|||||||
private val notificationApi = NotificationApi()
|
private val notificationApi = NotificationApi()
|
||||||
private val subscriptionApi = SubscriptionApi()
|
private val subscriptionApi = SubscriptionApi()
|
||||||
private val taskTemplateApi = TaskTemplateApi()
|
private val taskTemplateApi = TaskTemplateApi()
|
||||||
private val uploadApi = UploadApi()
|
|
||||||
|
|
||||||
// ==================== Initialization Guards ====================
|
// ==================== Initialization Guards ====================
|
||||||
|
|
||||||
@@ -589,23 +588,37 @@ object APILayer {
|
|||||||
return result
|
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> {
|
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
||||||
val allTasksResult = getTasks(forceRefresh = forceRefresh)
|
// 1. Check residence-specific cache first
|
||||||
if (allTasksResult is ApiResult.Error) return allTasksResult
|
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) {
|
||||||
|
val cached = DataManager.tasksByResidence.value[residenceId]
|
||||||
|
if (cached != null) {
|
||||||
|
return ApiResult.Success(cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
val filtered = DataManager.getTasksForResidence(residenceId)
|
||||||
?: return ApiResult.Error("Tasks unavailable", 0)
|
if (filtered != null) {
|
||||||
|
// Cache the filtered result for future use
|
||||||
|
DataManager.setTasksForResidence(residenceId, filtered)
|
||||||
return ApiResult.Success(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
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun createTask(request: TaskCreateRequest): ApiResult<TaskResponse> {
|
suspend fun createTask(request: TaskCreateRequest): ApiResult<TaskResponse> {
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
@@ -627,15 +640,9 @@ object APILayer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically creates 1-50 tasks via POST /api/tasks/bulk/. The whole
|
* Atomically creates 1-50 tasks via POST /api/tasks/bulk/. The whole
|
||||||
* batch succeeds or fails together on the server. On success, force-
|
* batch succeeds or fails together on the server. On success, every
|
||||||
* refreshes _allTasks from the server — the server is the
|
* returned task is merged into DataManager.allTasks so observing views
|
||||||
* authoritative kanban categorizer, and a single round-trip
|
* render the new batch immediately.
|
||||||
* 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> {
|
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
@@ -643,9 +650,7 @@ object APILayer {
|
|||||||
|
|
||||||
if (result is ApiResult.Success) {
|
if (result is ApiResult.Success) {
|
||||||
DataManager.setTotalSummary(result.data.summary)
|
DataManager.setTotalSummary(result.data.summary)
|
||||||
// Authoritative refresh — replaces any placeholder kanban
|
result.data.tasks.forEach { DataManager.updateTask(it) }
|
||||||
// shell from updateTask with proper server data.
|
|
||||||
getTasks(forceRefresh = true)
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -784,6 +789,30 @@ 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
|
* Get all completions for a specific task
|
||||||
*/
|
*/
|
||||||
@@ -1352,42 +1381,6 @@ object APILayer {
|
|||||||
return result
|
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 ====================
|
// ==================== Notification Operations ====================
|
||||||
|
|
||||||
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {
|
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {
|
||||||
|
|||||||
@@ -94,4 +94,47 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-2
@@ -373,8 +373,7 @@ fun CompleteTaskDialog(
|
|||||||
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
||||||
notes = notesWithContractor,
|
notes = notesWithContractor,
|
||||||
rating = rating,
|
rating = rating,
|
||||||
// upload_ids populated by the ViewModel after each
|
imageUrls = null // Images uploaded separately and URLs added by handler
|
||||||
// image is uploaded directly to B2.
|
|
||||||
),
|
),
|
||||||
selectedImages
|
selectedImages
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -405,8 +405,7 @@ fun CompleteTaskScreen(
|
|||||||
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
||||||
notes = notesWithContractor,
|
notes = notesWithContractor,
|
||||||
rating = rating,
|
rating = rating,
|
||||||
// upload_ids populated by the ViewModel after each
|
imageUrls = null
|
||||||
// image is uploaded directly to B2.
|
|
||||||
),
|
),
|
||||||
selectedImages
|
selectedImages
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.tt.honeyDue.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.tt.honeyDue.data.DataManager
|
|
||||||
import com.tt.honeyDue.models.Residence
|
import com.tt.honeyDue.models.Residence
|
||||||
import com.tt.honeyDue.models.ResidenceCreateRequest
|
import com.tt.honeyDue.models.ResidenceCreateRequest
|
||||||
import com.tt.honeyDue.models.TotalSummary
|
import com.tt.honeyDue.models.TotalSummary
|
||||||
@@ -12,10 +11,7 @@ import com.tt.honeyDue.models.ContractorSummary
|
|||||||
import com.tt.honeyDue.network.ApiResult
|
import com.tt.honeyDue.network.ApiResult
|
||||||
import com.tt.honeyDue.network.APILayer
|
import com.tt.honeyDue.network.APILayer
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ResidenceViewModel : ViewModel() {
|
class ResidenceViewModel : ViewModel() {
|
||||||
@@ -32,24 +28,8 @@ class ResidenceViewModel : ViewModel() {
|
|||||||
private val _updateResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
|
private val _updateResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
|
||||||
val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState
|
val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState
|
||||||
|
|
||||||
/// Residence-scoped kanban derived from `DataManager.allTasks` filtered
|
private val _residenceTasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
|
||||||
/// by `_currentResidenceId`. Re-emits whenever either upstream changes,
|
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _residenceTasksState
|
||||||
/// 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)
|
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Idle)
|
||||||
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState
|
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState
|
||||||
@@ -105,16 +85,13 @@ class ResidenceViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun resetResidenceTasksState() {
|
fun resetResidenceTasksState() {
|
||||||
_currentResidenceId.value = null
|
_residenceTasksState.value = ApiResult.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadResidenceTasks(residenceId: Int, forceRefresh: Boolean = false) {
|
fun loadResidenceTasks(residenceId: Int) {
|
||||||
_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 {
|
viewModelScope.launch {
|
||||||
APILayer.getTasks(forceRefresh = forceRefresh)
|
_residenceTasksState.value = ApiResult.Loading
|
||||||
|
_residenceTasksState.value = APILayer.getTasksByResidence(residenceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-42
@@ -25,61 +25,38 @@ class TaskCompletionViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create task completion with images, using the presigned-URL upload flow.
|
* Create task completion with images.
|
||||||
*
|
|
||||||
* 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 request The completion request data
|
||||||
* @param images List of ImageData (from platform-specific image pickers)
|
* @param images List of ImageData (from platform-specific image pickers)
|
||||||
*/
|
*/
|
||||||
fun createTaskCompletionWithImages(
|
fun createTaskCompletionWithImages(
|
||||||
request: TaskCompletionCreateRequest,
|
request: TaskCompletionCreateRequest,
|
||||||
images: List<com.tt.honeyDue.platform.ImageData> = emptyList(),
|
images: List<com.tt.honeyDue.platform.ImageData> = emptyList()
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_createCompletionState.value = ApiResult.Loading
|
_createCompletionState.value = ApiResult.Loading
|
||||||
|
|
||||||
val uploadIds = mutableListOf<Int>()
|
// Compress images and prepare for upload
|
||||||
for ((index, image) in images.withIndex()) {
|
val compressedImages = images.map { ImageCompressor.compressImage(it) }
|
||||||
val compressed = ImageCompressor.compressImage(image)
|
val imageFileNames = images.mapIndexed { index, image ->
|
||||||
val fileName = run {
|
// Always use .jpg extension since we compress to JPEG
|
||||||
val base = image.fileName.ifBlank { "completion_$index" }
|
val baseName = image.fileName.ifBlank { "completion_$index" }
|
||||||
if (base.endsWith(".jpg", ignoreCase = true) ||
|
if (baseName.endsWith(".jpg", ignoreCase = true) ||
|
||||||
base.endsWith(".jpeg", ignoreCase = true)
|
baseName.endsWith(".jpeg", ignoreCase = true)) {
|
||||||
) base else base.substringBeforeLast('.', base) + ".jpg"
|
baseName
|
||||||
}
|
} else {
|
||||||
val uploadResult = APILayer.uploadImage(
|
// Remove any existing extension and add .jpg
|
||||||
category = "completion",
|
baseName.substringBeforeLast('.', baseName) + ".jpg"
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val withUploads = if (uploadIds.isNotEmpty()) {
|
// Use APILayer which handles DataManager updates and summary refresh
|
||||||
request.copy(uploadIds = uploadIds.toList())
|
_createCompletionState.value = APILayer.createTaskCompletionWithImages(
|
||||||
} else {
|
request = request,
|
||||||
request
|
images = compressedImages,
|
||||||
}
|
imageFileNames = imageFileNames
|
||||||
_createCompletionState.value = APILayer.createTaskCompletion(withUploads)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class TaskViewModel : ViewModel() {
|
|||||||
private val _tasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
|
private val _tasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
|
||||||
val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksState
|
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)
|
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
|
||||||
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
|
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
|
||||||
|
|
||||||
@@ -32,6 +35,16 @@ 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) {
|
fun createNewTask(request: TaskCreateRequest) {
|
||||||
println("TaskViewModel: createNewTask called with $request")
|
println("TaskViewModel: createNewTask called with $request")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
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,6 +18,15 @@ class TaskViewModelTest {
|
|||||||
assertIs<ApiResult.Idle>(viewModel.tasksState.value)
|
assertIs<ApiResult.Idle>(viewModel.tasksState.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testInitialTasksByResidenceState() {
|
||||||
|
// Given
|
||||||
|
val viewModel = TaskViewModel()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertIs<ApiResult.Idle>(viewModel.tasksByResidenceState.value)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInitialAddNewCustomTaskState() {
|
fun testInitialAddNewCustomTaskState() {
|
||||||
// Given
|
// Given
|
||||||
|
|||||||
@@ -1,919 +0,0 @@
|
|||||||
# Task Cache Unification Implementation Plan
|
|
||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** Make `_allTasks` the single source of truth for tasks; collapse `_tasksByResidence` into a derived view. Fix the bug where tasks created during onboarding don't appear on the Residence Detail screen until app restart (Gitea issue #2).
|
|
||||||
|
|
||||||
**Architecture:** The current code has two parallel task caches that must be kept in sync (`_allTasks` for the kanban tab, `_tasksByResidence` per-residence for the residence detail screen). `DataManager.updateTask` is a no-op when either cache is empty, so post-`bulkCreateTasks` the new tasks live only on the server until something forces a fetch. After this change there is exactly one cache (`_allTasks`); residence detail screens observe it and apply an in-memory filter by `residenceId`. Mutations (`createTask`, `bulkCreateTasks`) force a refresh of `_allTasks` from the server to guarantee freshness with one round-trip instead of relying on conditional branches that silently skip.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin Multiplatform (commonMain), Ktor client, kotlinx.serialization, Combine bridge to SwiftUI iOS, Compose StateFlow on Android. Test framework: `kotlin.test` in commonTest.
|
|
||||||
|
|
||||||
**Affected files:**
|
|
||||||
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt` — remove `_tasksByResidence`, simplify `updateTask`/`removeTask`, add upsert behavior
|
|
||||||
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt` — change `bulkCreateTasks`, `createTask`, `getTasksByResidence`
|
|
||||||
- `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt` — feed `_residenceTasksState` from a `combine(allTasks, residenceId)` flow
|
|
||||||
- `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt` (new) — cache behavior tests
|
|
||||||
- `iosApp/iosApp/Task/TaskViewModel.swift` — drop `$tasksByResidence` sink, filter `$allTasks` when residence-scoped
|
|
||||||
- `iosApp/iosApp/Data/DataManagerObservable.swift` — drop `tasksByResidence` `@Published` and its `for await` task
|
|
||||||
|
|
||||||
**Out of scope (do not touch):**
|
|
||||||
- Backend Go API — `/api/tasks/by-residence/:id/` endpoint stays in place untouched (might still be used by web admin)
|
|
||||||
- Android `ResidenceDetailScreen.kt` — the screen contract (`residenceViewModel.residenceTasksState`) is preserved; only the VM internals change
|
|
||||||
- Disk persistence schema — kotlinx.serialization is configured with `ignoreUnknownKeys` for forward/backward compat (verified in Task 11)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pre-flight
|
|
||||||
|
|
||||||
### Task 0: Verify clean state and baseline
|
|
||||||
|
|
||||||
**Files:** none (read only)
|
|
||||||
|
|
||||||
**Step 1: Confirm working tree is clean (or only the expected exception)**
|
|
||||||
|
|
||||||
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP status --short`
|
|
||||||
Expected: empty, **or** the only line is `M composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt`. That single file is intentionally on `Environment.LOCAL` for the duration of this work — it stays uncommitted and gets flipped back to `Environment.PROD` in Task 11 Step 5. If anything else shows up, stop and ask the user.
|
|
||||||
|
|
||||||
**Step 2: Confirm we're on a feature branch (not master)**
|
|
||||||
|
|
||||||
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP rev-parse --abbrev-ref HEAD`
|
|
||||||
Expected: NOT `master`. If `master`, run `git checkout -b fix/task-cache-unification` before continuing.
|
|
||||||
|
|
||||||
**Step 3: Run the existing commonTest baseline so we know what currently passes**
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
|
||||||
Expected: BUILD SUCCESSFUL. Note the count — every later run must keep ≥ this count of green tests.
|
|
||||||
|
|
||||||
**Step 4: Build iOS to confirm starting point compiles**
|
|
||||||
|
|
||||||
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
|
|
||||||
Expected: `** BUILD SUCCEEDED **`
|
|
||||||
|
|
||||||
No commit — this is verification only.
|
|
||||||
|
|
||||||
### Task 0.5: Failing regression XCUITest — reproduce the bug end-to-end
|
|
||||||
|
|
||||||
**Goal:** Write a UI test that drives the exact onboarding-to-residence-detail flow from gitea#2 and asserts that tasks appear on the residence detail screen without an app restart. Run it now — it MUST fail. The Phase 1-3 fixes will make it pass; Task 12 re-runs it as the final gate.
|
|
||||||
|
|
||||||
**Why before the unit tests:** The unit tests in Phase 1 catch the bug at the cache layer, but the *user-facing* bug is "I tap my residence and see 'no tasks'". A passing UI test is the only thing that proves the user experience is actually fixed. Writing it once up front + running it once at the end is cheaper than running a full UI cycle every iteration.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift`
|
|
||||||
- Maybe modify: `iosApp/HoneyDueUITests/AccessibilityIdentifiers.swift` (only if missing IDs are needed — see Step 2)
|
|
||||||
- Maybe modify: SwiftUI views in `iosApp/iosApp/` (only if the view layer is missing accessibility identifiers — see Step 2)
|
|
||||||
|
|
||||||
**Pre-requisites already satisfied by Task 0 setup:**
|
|
||||||
- iOS app is on `Environment.LOCAL`
|
|
||||||
- Docker stack is up and healthy at `http://127.0.0.1:8000`
|
|
||||||
- `DEBUG=true` on the local API → email confirmation code is fixed at `123456` (saves a manual step in the test)
|
|
||||||
|
|
||||||
**Step 1: Pick a clean run by wiping prior simulator state**
|
|
||||||
|
|
||||||
Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev`
|
|
||||||
Run: `docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down && docker volume rm honeydueapi-go_postgres_data && docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d`
|
|
||||||
Wait until: `curl -fsS http://127.0.0.1:8000/api/health/` returns 200.
|
|
||||||
|
|
||||||
**Step 2: Audit accessibility identifiers along the test path**
|
|
||||||
|
|
||||||
The test taps and asserts on these SwiftUI surfaces. Open `iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift` and verify (or add) identifiers for each. Use stable, non-localized strings.
|
|
||||||
|
|
||||||
Surfaces to identify:
|
|
||||||
| Where | Why the test needs it | Suggested ID constant |
|
|
||||||
|---|---|---|
|
|
||||||
| Login/Register screen — username, email, password, first/last name fields, "Register" button, "Verify" code field | Drive registration | already in `AccessibilityIdentifiers.Authentication.*` per existing UI tests — verify by grep |
|
|
||||||
| Onboarding — residence-creation form (name field + Continue) | Drive residence creation | `AccessibilityIdentifiers.Onboarding.residenceNameField`, `.continueButton` (add if missing) |
|
|
||||||
| Onboarding First-Task screen — "Browse All" tab button | Switch from suggestions to browse | `AccessibilityIdentifiers.Onboarding.browseAllTab` (add if missing) |
|
|
||||||
| Onboarding First-Task screen — each template row (selectable) | Pick 3 tasks | `AccessibilityIdentifiers.Onboarding.templateRowPrefix` (e.g., `"onboarding.template.<id>"`) — see how `OnboardingFirstTaskView.swift` renders rows; add an `.accessibilityIdentifier(...)` keyed on `template.id` |
|
|
||||||
| Onboarding First-Task screen — Submit button | Trigger bulk-create | `AccessibilityIdentifiers.Onboarding.submitTasksButton` (add if missing) |
|
|
||||||
| Residence list / home — the residence cell | Tap into detail | `AccessibilityIdentifiers.Residence.cellPrefix` (e.g., `"residence.cell.<name>"` or `<id>`) — verify in `ResidenceListView` or wherever the post-onboarding landing screen renders cells |
|
|
||||||
| Residence detail — task row | Assert presence | `AccessibilityIdentifiers.Task.rowPrefix` (e.g., `"task.row"`) — verify the task list inside `TasksSectionContainer` in `ResidenceDetailView.swift:538` |
|
|
||||||
| Residence detail — empty state ("No tasks" copy) | Assert ABSENCE | `AccessibilityIdentifiers.Task.noTasksLabel` (add if missing) — find the empty-state copy in the residence-detail tasks section and pin an identifier on it |
|
|
||||||
|
|
||||||
For each missing ID, add it in two places:
|
|
||||||
1. The constant in `AccessibilityIdentifiers.swift`
|
|
||||||
2. `.accessibilityIdentifier(AccessibilityIdentifiers.X.Y)` on the SwiftUI view
|
|
||||||
|
|
||||||
Keep these app-side additions in **a single dedicated commit** so reviewers can see "test scaffolding only, no behavior change":
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: add accessibility identifiers along the onboarding-to-residence-detail path
|
|
||||||
|
|
||||||
Scaffolding for the gitea#2 regression XCUITest. No user-visible
|
|
||||||
change — pure metadata for UI automation."
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: Write the failing UI test**
|
|
||||||
|
|
||||||
Create `iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import XCTest
|
|
||||||
|
|
||||||
/// Regression test for gitea#2.
|
|
||||||
///
|
|
||||||
/// Onboarding flow: register → create residence → pick 3 tasks → submit.
|
|
||||||
/// After submit, the user lands on the home/residences screen. They tap
|
|
||||||
/// the new residence WITHOUT visiting the Tasks tab first (the Tasks tab
|
|
||||||
/// triggers a `getTasks()` that masks the bug by populating `_allTasks`).
|
|
||||||
///
|
|
||||||
/// Expected: residence detail shows ≥1 task row within 10s.
|
|
||||||
/// Pre-fix: residence detail shows empty state ("no tasks") forever
|
|
||||||
/// until the app is restarted.
|
|
||||||
final class Suite11_TaskCacheRegressionTests: XCTestCase {
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
continueAfterFailure = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
|
|
||||||
let app = XCUIApplication()
|
|
||||||
app.launchArguments += ["UI-Testing"]
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// 1. Register a fresh user. Email confirmation code is fixed at "123456"
|
|
||||||
// in DEBUG mode (DEBUG_FIXED_CODES=true on the local docker stack).
|
|
||||||
let stamp = String(Int(Date().timeIntervalSince1970))
|
|
||||||
UITestHelpers.register(
|
|
||||||
in: app,
|
|
||||||
username: "uitest\(stamp)",
|
|
||||||
email: "uitest+\(stamp)@treymail.com",
|
|
||||||
password: "UItest\(stamp)!aZ",
|
|
||||||
confirmationCode: "123456"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 2. Onboarding: create the residence.
|
|
||||||
UITestHelpers.completeResidenceCreation(in: app, name: "UI Test Property")
|
|
||||||
|
|
||||||
// 3. Switch to "Browse All" tab and pick 3 templates. The "For You"
|
|
||||||
// suggestions tab depends on a server-side recommendation that
|
|
||||||
// might be empty for a freshly created residence; Browse is
|
|
||||||
// deterministic.
|
|
||||||
let browseTab = app.buttons[AccessibilityIdentifiers.Onboarding.browseAllTab]
|
|
||||||
XCTAssertTrue(browseTab.waitForExistence(timeout: 5),
|
|
||||||
"Browse All tab must appear on First-Task screen")
|
|
||||||
browseTab.tap()
|
|
||||||
|
|
||||||
let templates = app.buttons.matching(
|
|
||||||
NSPredicate(format: "identifier BEGINSWITH %@",
|
|
||||||
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Wait for the catalog to load — fresh API call against local backend.
|
|
||||||
XCTAssertTrue(templates.element(boundBy: 0).waitForExistence(timeout: 10),
|
|
||||||
"Template catalog must load")
|
|
||||||
|
|
||||||
for i in 0..<3 {
|
|
||||||
templates.element(boundBy: i).tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Submit. This calls APILayer.bulkCreateTasks → POST /api/tasks/bulk/
|
|
||||||
// The bug lives in the cache update path between this call returning
|
|
||||||
// and the residence detail screen rendering.
|
|
||||||
let submit = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
|
|
||||||
XCTAssertTrue(submit.waitForExistence(timeout: 3))
|
|
||||||
submit.tap()
|
|
||||||
|
|
||||||
// 5. Land on home/residences. Tap the residence we just created.
|
|
||||||
// Critical: do NOT visit the Tasks tab — that would call getTasks()
|
|
||||||
// and populate _allTasks via setAllTasks, masking the bug.
|
|
||||||
let residenceCell = app.buttons[
|
|
||||||
AccessibilityIdentifiers.Residence.cellPrefix + "UI Test Property"
|
|
||||||
]
|
|
||||||
XCTAssertTrue(residenceCell.waitForExistence(timeout: 10),
|
|
||||||
"Residence cell must appear on home after onboarding submit")
|
|
||||||
residenceCell.tap()
|
|
||||||
|
|
||||||
// 6. Residence detail must show ≥1 task row, NOT the empty state.
|
|
||||||
// Generous timeout (10s) covers the network round-trip on slow
|
|
||||||
// local Docker startups.
|
|
||||||
let firstTaskRow = app.cells
|
|
||||||
.matching(NSPredicate(format: "identifier BEGINSWITH %@",
|
|
||||||
AccessibilityIdentifiers.Task.rowPrefix))
|
|
||||||
.firstMatch
|
|
||||||
XCTAssertTrue(
|
|
||||||
firstTaskRow.waitForExistence(timeout: 10),
|
|
||||||
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
|
|
||||||
)
|
|
||||||
|
|
||||||
let emptyState = app.staticTexts[AccessibilityIdentifiers.Task.noTasksLabel]
|
|
||||||
XCTAssertFalse(
|
|
||||||
emptyState.exists,
|
|
||||||
"Empty 'no tasks' state must NOT show when tasks exist (gitea#2)"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 7. Cleanup — delete the test user via UI (or skip; clearing the
|
|
||||||
// docker volume between runs is the cheaper reset).
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes for the engineer writing this test:**
|
|
||||||
|
|
||||||
- `UITestHelpers.register(...)` and `UITestHelpers.completeResidenceCreation(...)` may not exist verbatim — read `iosApp/HoneyDueUITests/UITestHelpers.swift` for the existing helpers. If `register(...)` exists but doesn't take a `confirmationCode:` arg, either add one or inline the verification step.
|
|
||||||
- DO NOT use `sleep()` anywhere. Use `waitForExistence(timeout:)` everywhere. The skill `axiom-ui-testing` is loaded if you need patterns.
|
|
||||||
- `continueAfterFailure = false` so we stop at the exact assertion that fails — easier to triage video.
|
|
||||||
- If you can't get a residence cell identifier reliably (e.g., the home screen shows a custom layout, not standard cells), substitute `app.staticTexts["UI Test Property"]` and tap that. The point is to land on the residence detail without going through the Tasks tab.
|
|
||||||
|
|
||||||
**Step 4: Run the test — must FAIL**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```
|
|
||||||
xcodebuild -project iosApp/honeyDue.xcodeproj \
|
|
||||||
-scheme HoneyDueUITests \
|
|
||||||
-sdk iphonesimulator \
|
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
|
||||||
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
|
|
||||||
test 2>&1 | tail -40
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `Test Suite '...' failed.` with the assertion **"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"**.
|
|
||||||
|
|
||||||
If it FAILS for a different reason (residence cell not found, timeout on browse tab, etc.) → that's an accessibility-identifier mismatch, not a bug repro. Fix the test/IDs and re-run. The test must fail SPECIFICALLY on the "no tasks on residence detail" assertion to be a valid bug capture.
|
|
||||||
|
|
||||||
If it PASSES → the bug isn't reproducing in this environment. Possibilities:
|
|
||||||
- App was already cached with `_allTasks` from a prior run (re-run Step 1 to fully wipe simulator + DB)
|
|
||||||
- The user navigated through the Tasks tab implicitly (check the home screen layout)
|
|
||||||
- The bug only happens on a code path you didn't replicate (re-read the iOS-side onboarding flow)
|
|
||||||
|
|
||||||
**Step 5: Commit the failing test**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — onboarding tasks must appear on residence detail without restart
|
|
||||||
|
|
||||||
Captures gitea#2 at the user-visible level. The kanban tab works but
|
|
||||||
the residence detail screen does not, until the app is restarted. This
|
|
||||||
test must FAIL at this commit and PASS after the cache unification work.
|
|
||||||
Re-run gates the merge in Task 12."
|
|
||||||
```
|
|
||||||
|
|
||||||
The test stays failing through Phase 1-3 commits. Don't run it on every commit — it's slow. Run it once at the end (Task 12).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — TDD: catch the bug, then fix it
|
|
||||||
|
|
||||||
### Task 1: Failing test — `bulkCreateTasks` must populate `_allTasks`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
|
|
||||||
|
|
||||||
**Step 1: Write the failing test**
|
|
||||||
|
|
||||||
This test reproduces the onboarding bug at the cache layer. We can't easily mock Ktor here without infrastructure, so we test the cache mutation contract directly: after a successful bulk-create, `_allTasks` MUST contain every returned task, regardless of prior cache state.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package com.tt.honeyDue.data
|
|
||||||
|
|
||||||
import com.tt.honeyDue.models.TaskResponse
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import kotlin.test.BeforeTest
|
|
||||||
|
|
||||||
class DataManagerTaskCacheTest {
|
|
||||||
|
|
||||||
@BeforeTest
|
|
||||||
fun resetState() {
|
|
||||||
DataManager.clearAllData()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `updateTask seeds _allTasks when cache is empty`() {
|
|
||||||
// Given: fresh DataManager with no tasks loaded (the onboarding scenario)
|
|
||||||
assertEquals(null, DataManager.allTasks.value)
|
|
||||||
|
|
||||||
// When: a new task arrives via the same path bulkCreateTasks uses
|
|
||||||
val task = TaskResponse(
|
|
||||||
id = 1,
|
|
||||||
residenceId = 100,
|
|
||||||
title = "Replace HVAC filter",
|
|
||||||
kanbanColumn = "upcoming_tasks",
|
|
||||||
// ... fill remaining required TaskResponse fields with sensible defaults
|
|
||||||
)
|
|
||||||
DataManager.updateTask(task)
|
|
||||||
|
|
||||||
// Then: _allTasks is populated with this task in the right column
|
|
||||||
val allTasks = DataManager.allTasks.value
|
|
||||||
assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null")
|
|
||||||
|
|
||||||
val upcomingColumn = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" }
|
|
||||||
assertNotNull(upcomingColumn)
|
|
||||||
assertTrue(
|
|
||||||
upcomingColumn.tasks.any { it.id == 1 },
|
|
||||||
"task must land in the upcoming_tasks column"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
You'll need to look at `TaskResponse` in `composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/CustomTask.kt` to fill the required fields. Use defaults that match an onboarding-created task (no completion, no priority, due-soon date).
|
|
||||||
|
|
||||||
**Step 2: Run the test — must FAIL**
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"`
|
|
||||||
Expected: FAIL with `expected:<not null> but was:<null>` (or similar). This proves the test catches the bug.
|
|
||||||
|
|
||||||
**Step 3: Commit the failing test**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: failing — DataManager.updateTask must seed _allTasks"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 2: Make `DataManager.updateTask` a real upsert
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt:482-520`
|
|
||||||
|
|
||||||
**Step 1: Replace the conditional branch on `_allTasks` with an upsert**
|
|
||||||
|
|
||||||
Current (lines 484-498):
|
|
||||||
```kotlin
|
|
||||||
_allTasks.value?.let { current ->
|
|
||||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
|
||||||
val newColumns = current.columns.map { column -> ... }
|
|
||||||
_allTasks.value = current.copy(columns = newColumns)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```kotlin
|
|
||||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
|
||||||
val current = _allTasks.value ?: TaskColumnsResponse(
|
|
||||||
columns = standardKanbanColumns(), // see Step 2
|
|
||||||
daysThreshold = 30,
|
|
||||||
residenceId = null
|
|
||||||
)
|
|
||||||
val newColumns = current.columns.map { column ->
|
|
||||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
|
||||||
val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks
|
|
||||||
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
|
||||||
}
|
|
||||||
// If targetColumn doesn't exist in current.columns (e.g. fresh seed), append it
|
|
||||||
val finalColumns = if (newColumns.none { it.name == targetColumn }) {
|
|
||||||
newColumns + Column(name = targetColumn, tasks = listOf(task), count = 1, /* fill rest */)
|
|
||||||
} else newColumns
|
|
||||||
_allTasks.value = current.copy(columns = finalColumns)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Add `standardKanbanColumns()` helper**
|
|
||||||
|
|
||||||
Look at the backend response — `internal/repositories/task_repo.go` `GetKanbanDataForMultipleResidences` defines the column order. Mirror it in Kotlin:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
private fun standardKanbanColumns(): List<Column> = listOf(
|
|
||||||
Column(name = "overdue_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
|
||||||
Column(name = "due_soon_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
|
||||||
Column(name = "in_progress_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
|
||||||
Column(name = "upcoming_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
|
||||||
Column(name = "completed_tasks", tasks = emptyList(), count = 0, /* defaults */),
|
|
||||||
// archived/cancelled are hidden from kanban — see honeyDueAPI-go/CLAUDE.md
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Look at `Column` in `CustomTask.kt` for its required fields (display label, color, etc.). Fill in matching defaults.
|
|
||||||
|
|
||||||
**Step 3: Drop the second branch (`_tasksByResidence`) — it's going away in Phase 3**
|
|
||||||
|
|
||||||
Remove lines 500-515 entirely. The `_tasksByResidence` slot is still there for now (Phase 3 deletes it), but `updateTask` should not write to it any more.
|
|
||||||
|
|
||||||
**Step 4: Run the test — must PASS**
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask seeds _allTasks when cache is empty"`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
**Step 5: Run the full test suite to confirm no regressions**
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
|
||||||
Expected: same green count as Task 0 baseline + 1 new test.
|
|
||||||
|
|
||||||
**Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "fix: DataManager.updateTask seeds _allTasks when cache is empty
|
|
||||||
|
|
||||||
Closes the silent no-op when _allTasks is null on first launch (the
|
|
||||||
onboarding bulkCreateTasks path). The function now upserts: builds an
|
|
||||||
empty kanban shell if needed and places the task in its target column.
|
|
||||||
Adds an unknown column at the end for forward compatibility with future
|
|
||||||
column names from the server.
|
|
||||||
|
|
||||||
Refs gitea#2"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 3: Add upsert test for `_tasksByResidence` deletion guard
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
|
|
||||||
|
|
||||||
**Step 1: Add a test asserting `updateTask` does NOT touch `_tasksByResidence` any more**
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun `updateTask no longer mutates _tasksByResidence`() {
|
|
||||||
val before = DataManager.tasksByResidence.value
|
|
||||||
DataManager.updateTask(/* sample task */)
|
|
||||||
assertEquals(before, DataManager.tasksByResidence.value,
|
|
||||||
"updateTask must not touch _tasksByResidence — it's deprecated")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run — must PASS** (we already removed the branch in Task 2)
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.updateTask no longer mutates _tasksByResidence"`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: lock down that updateTask no longer writes _tasksByResidence"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — Belt-and-suspenders: force-refresh after mutations
|
|
||||||
|
|
||||||
The Phase 1 upsert handles the fresh-cache case correctly, but it makes assumptions about kanban column placement based on the response's `kanbanColumn` field. The server is the authoritative kanban categorizer. To eliminate any drift, also force a `_allTasks` refresh after multi-task mutations.
|
|
||||||
|
|
||||||
### Task 4: Force `_allTasks` refresh after `bulkCreateTasks`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:647-655`
|
|
||||||
|
|
||||||
**Step 1: Add post-success refresh**
|
|
||||||
|
|
||||||
Current:
|
|
||||||
```kotlin
|
|
||||||
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
|
||||||
val result = taskApi.bulkCreateTasks(token, request)
|
|
||||||
|
|
||||||
if (result is ApiResult.Success) {
|
|
||||||
DataManager.setTotalSummary(result.data.summary)
|
|
||||||
result.data.tasks.forEach { DataManager.updateTask(it) }
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
New:
|
|
||||||
```kotlin
|
|
||||||
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
|
||||||
val result = taskApi.bulkCreateTasks(token, request)
|
|
||||||
|
|
||||||
if (result is ApiResult.Success) {
|
|
||||||
DataManager.setTotalSummary(result.data.summary)
|
|
||||||
// Authoritative refresh — server knows the right kanban placement.
|
|
||||||
// Cheap (one round-trip) and eliminates any client-side drift between
|
|
||||||
// the per-task kanbanColumn hint and the global kanban view.
|
|
||||||
getTasks(forceRefresh = true)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Drop the `forEach { updateTask }` — it becomes redundant with the force-refresh.
|
|
||||||
|
|
||||||
**Step 2: Run the full test suite**
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
|
||||||
Expected: all green (the Phase 1 upsert tests still pass because they exercise `updateTask` directly, not `bulkCreateTasks`).
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "fix: bulkCreateTasks force-refreshes _allTasks instead of merging task-by-task
|
|
||||||
|
|
||||||
Server is the authoritative kanban categorizer. After a bulk insert,
|
|
||||||
re-fetch /api/tasks/ so the kanban view reflects exactly what the
|
|
||||||
server sees, including any column re-categorizations the client's
|
|
||||||
in-memory upsert wouldn't compute. One extra round-trip per onboarding
|
|
||||||
submission, called once per session typically.
|
|
||||||
|
|
||||||
Refs gitea#2"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 — Collapse the dual cache
|
|
||||||
|
|
||||||
### Task 5: Characterization test for `getTasksForResidence`
|
|
||||||
|
|
||||||
`getTasksForResidence` already implements the filter we want to use everywhere. Lock it down with a test before we make it the primary path.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt`
|
|
||||||
|
|
||||||
**Step 1: Add the test**
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Test
|
|
||||||
fun `getTasksForResidence filters _allTasks by residence id`() {
|
|
||||||
DataManager.setAllTasks(/* response with tasks across residences 100 and 200 */)
|
|
||||||
|
|
||||||
val r100 = DataManager.getTasksForResidence(100)
|
|
||||||
assertNotNull(r100)
|
|
||||||
assertTrue(r100.columns.flatMap { it.tasks }.all { it.residenceId == 100 })
|
|
||||||
|
|
||||||
val r999 = DataManager.getTasksForResidence(999)
|
|
||||||
assertNotNull(r999)
|
|
||||||
assertEquals(0, r999.columns.sumOf { it.tasks.size }) // valid id, just no tasks
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `getTasksForResidence returns null when _allTasks is null`() {
|
|
||||||
DataManager.clearAllData()
|
|
||||||
assertEquals(null, DataManager.getTasksForResidence(100))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run — must PASS** (no implementation change yet)
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:testDebugUnitTest --tests "com.tt.honeyDue.data.DataManagerTaskCacheTest.getTasksForResidence*"`
|
|
||||||
Expected: PASS for both.
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "test: characterize getTasksForResidence filter contract"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 6: Simplify `APILayer.getTasksByResidence`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt:591-621`
|
|
||||||
|
|
||||||
**Step 1: Replace the 3-path implementation with "ensure _allTasks fresh, then filter"**
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
|
||||||
// Ensure _allTasks is loaded and reasonably fresh.
|
|
||||||
// getTasks itself respects forceRefresh and the global tasksCacheTime.
|
|
||||||
val allTasksResult = getTasks(forceRefresh = forceRefresh)
|
|
||||||
if (allTasksResult is ApiResult.Error) return allTasksResult
|
|
||||||
|
|
||||||
val filtered = DataManager.getTasksForResidence(residenceId)
|
|
||||||
?: return ApiResult.Error("Tasks unavailable", 0)
|
|
||||||
return ApiResult.Success(filtered)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This deletes the per-residence cache reliance entirely. `_tasksByResidence` is no longer written by this path.
|
|
||||||
|
|
||||||
**Step 2: Run the test suite**
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
|
||||||
Expected: all green.
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "refactor: getTasksByResidence is now a thin filter over _allTasks
|
|
||||||
|
|
||||||
Was 3 fallback paths (per-residence cache → filter from allTasks →
|
|
||||||
network). Now: ensure _allTasks fresh, return filter. The per-residence
|
|
||||||
cache becomes write-only by this path, scheduled for deletion in the
|
|
||||||
next commit."
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 7: iOS — `TaskViewModel` observes `$allTasks` with filter
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `iosApp/iosApp/Task/TaskViewModel.swift:46-74`
|
|
||||||
|
|
||||||
**Step 1: Replace dual-sink with single-sink + filter**
|
|
||||||
|
|
||||||
Current logic uses two Combine sinks: `$allTasks` (only when `currentResidenceId == nil`) and `$tasksByResidence` (only when set).
|
|
||||||
|
|
||||||
Replace with one sink on `$allTasks` that conditionally filters:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
DataManagerObservable.shared.$allTasks
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] allTasks in
|
|
||||||
guard let self else { return }
|
|
||||||
guard !self.isAnimatingCompletion else { return }
|
|
||||||
|
|
||||||
if let allTasks {
|
|
||||||
if let resId = self.currentResidenceId {
|
|
||||||
self.tasksResponse = self.filterByResidence(allTasks, residenceId: resId)
|
|
||||||
} else {
|
|
||||||
self.tasksResponse = allTasks
|
|
||||||
}
|
|
||||||
self.isLoadingTasks = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Add the `filterByResidence` helper**
|
|
||||||
|
|
||||||
```swift
|
|
||||||
private func filterByResidence(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse {
|
|
||||||
let filteredColumns = response.columns.map { column -> Column in
|
|
||||||
let filteredTasks = column.tasks.filter { Int32($0.residenceId ?? 0) == residenceId }
|
|
||||||
return column.copy(tasks: filteredTasks, count: Int32(filteredTasks.count))
|
|
||||||
}
|
|
||||||
return response.copy(columns: filteredColumns, residenceId: String(residenceId))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
(Use `.doCopy(...)` SKIE syntax if `.copy` isn't directly callable from Swift — check what other Swift code does with TaskColumnsResponse copies.)
|
|
||||||
|
|
||||||
**Step 3: Drop the `$tasksByResidence` subscription block entirely**
|
|
||||||
|
|
||||||
Remove the second `.sink` on `$tasksByResidence` (currently lines 62-74).
|
|
||||||
|
|
||||||
**Step 4: Build iOS**
|
|
||||||
|
|
||||||
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
|
|
||||||
Expected: `** BUILD SUCCEEDED **`
|
|
||||||
|
|
||||||
**Step 5: Run iOS unit tests**
|
|
||||||
|
|
||||||
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20`
|
|
||||||
Expected: TEST SUCCEEDED.
|
|
||||||
|
|
||||||
**Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add iosApp/iosApp/Task/TaskViewModel.swift
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "ios: TaskViewModel observes \$allTasks and filters by residence in-memory
|
|
||||||
|
|
||||||
Single source of truth eliminates the race window where the residence
|
|
||||||
detail screen could mount before the per-residence cache slot existed.
|
|
||||||
After this, every emit of _allTasks rerenders every observing view —
|
|
||||||
kanban tab, residence detail, dashboards — atomically.
|
|
||||||
|
|
||||||
Refs gitea#2"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 8: Android — `ResidenceViewModel` feeds `residenceTasksState` from a combined flow
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt:31-94`
|
|
||||||
|
|
||||||
**Step 1: Replace the imperative `loadResidenceTasks` with a derived flow**
|
|
||||||
|
|
||||||
Look at how `_residenceTasksState` is currently populated (line 88-94). Instead of imperatively calling `APILayer.getTasksByResidence` and storing the result, derive it:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
private val _currentResidenceId = MutableStateFlow<Int?>(null)
|
|
||||||
|
|
||||||
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
|
|
||||||
combine(DataManager.allTasks, _currentResidenceId) { all, id ->
|
|
||||||
when {
|
|
||||||
id == null -> ApiResult.Idle
|
|
||||||
all == null -> ApiResult.Loading
|
|
||||||
else -> {
|
|
||||||
val filtered = DataManager.getTasksForResidence(id)
|
|
||||||
if (filtered != null) ApiResult.Success(filtered) else ApiResult.Loading
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ApiResult.Idle)
|
|
||||||
|
|
||||||
fun loadResidenceTasks(residenceId: Int, forceRefresh: Boolean = false) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_currentResidenceId.value = residenceId
|
|
||||||
// Trigger the underlying _allTasks refresh; the combine above
|
|
||||||
// re-emits Success when allTasks arrives.
|
|
||||||
APILayer.getTasks(forceRefresh = forceRefresh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The screen contract (`residenceViewModel.residenceTasksState`) is preserved — `ResidenceDetailScreen.kt:59` doesn't need any change.
|
|
||||||
|
|
||||||
**Step 2: Build Android debug**
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:assembleDebug`
|
|
||||||
Expected: BUILD SUCCESSFUL.
|
|
||||||
|
|
||||||
**Step 3: Run commonTest again**
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
|
||||||
Expected: all green. (`ResidenceViewModelTest` may need adjusting — check it.)
|
|
||||||
|
|
||||||
**Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModel.kt
|
|
||||||
# Also stage any test fixes
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "android: ResidenceViewModel.residenceTasksState derives from _allTasks
|
|
||||||
|
|
||||||
Same screen contract, but the data flows from DataManager.allTasks
|
|
||||||
through a combine(...) into the existing StateFlow. No per-residence
|
|
||||||
network call needed; the upstream getTasks() refresh propagates."
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task 9: Delete dead code — `_tasksByResidence` and friends
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt`
|
|
||||||
- Modify: `iosApp/iosApp/Data/DataManagerObservable.swift`
|
|
||||||
|
|
||||||
**Step 1: In DataManager.kt, delete:**
|
|
||||||
|
|
||||||
- `_tasksByResidence` (line 141) and `tasksByResidence` (line 142)
|
|
||||||
- `tasksByResidenceCacheTime` (line 65)
|
|
||||||
- `setTasksForResidence` (lines 448-451)
|
|
||||||
- `invalidateTasksFor` (line 417 — verify it has no other callers first via grep)
|
|
||||||
- `_tasksByResidence` mutations in `removeTask` (lines 533-onwards) — keep only the `_allTasks` removal
|
|
||||||
- `_tasksByResidence.value = emptyMap()` in clearAllData and similar wipes (lines 783, 836)
|
|
||||||
- `tasksByResidenceCacheTime.clear()` in same wipes (lines 814, 849)
|
|
||||||
|
|
||||||
Keep `getTasksForResidence` — it's the public filter API, still used by the new `getTasksByResidence` and Android VM.
|
|
||||||
|
|
||||||
**Step 2: In DataManagerObservable.swift, delete:**
|
|
||||||
|
|
||||||
- `@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]` (line 44)
|
|
||||||
- The `for await tasks in DataManager.shared.tasksByResidence` task (lines 195-201)
|
|
||||||
- The `tasksByResidence[residenceId]` reader at line 524 (replace with `DataManager.shared.getTasksForResidence(residenceId)` or its iOS-friendly equivalent if anything still calls this — grep first)
|
|
||||||
|
|
||||||
**Step 3: Compile both targets**
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:assembleDebug && ./gradlew :composeApp:testDebugUnitTest`
|
|
||||||
Then: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -20`
|
|
||||||
Expected: both BUILD SUCCEEDED, all tests green.
|
|
||||||
|
|
||||||
If anything fails to compile, follow the compiler — there's likely a missed reader. Common suspects: `TaskViewModel.kt` (Kotlin VM, not Swift) line ~38-42 references `_tasksByResidenceState`; verify it's still wired correctly or also delete it.
|
|
||||||
|
|
||||||
**Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP add -A
|
|
||||||
git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP commit -m "refactor: delete _tasksByResidence and per-residence task cache plumbing
|
|
||||||
|
|
||||||
All readers and writers gone after the previous commits. Single source
|
|
||||||
of truth = DataManager._allTasks, residence views derive via
|
|
||||||
getTasksForResidence(id). Net deletion ~100 LOC across DataManager,
|
|
||||||
APILayer, DataManagerObservable, and iOS TaskViewModel.
|
|
||||||
|
|
||||||
Closes gitea#2"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4 — Verification
|
|
||||||
|
|
||||||
### Task 10: Verify disk persistence is forward-compatible
|
|
||||||
|
|
||||||
**Files:** none (verification only)
|
|
||||||
|
|
||||||
**Step 1: Find the persistence model**
|
|
||||||
|
|
||||||
Run: `grep -rn "ignoreUnknownKeys\|Json {\|tasksByResidence" composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/PersistenceManager.kt`
|
|
||||||
Expected: kotlinx.serialization Json config with `ignoreUnknownKeys = true`. If NOT, an existing user upgrading the app will crash on first launch when the persisted blob has the now-removed `tasksByResidence` field.
|
|
||||||
|
|
||||||
**Step 2: If `ignoreUnknownKeys` is missing, ADD IT BEFORE SHIPPING**
|
|
||||||
|
|
||||||
Edit the Json config:
|
|
||||||
```kotlin
|
|
||||||
val json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
// ... existing config
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Commit separately as `chore: persistence Json must ignoreUnknownKeys`.
|
|
||||||
|
|
||||||
**Step 3: Manual test — wipe simulator app data, install old app, then this version**
|
|
||||||
|
|
||||||
If you have a TestFlight build of the previous version:
|
|
||||||
1. Install old version → register → create residence → quit
|
|
||||||
2. Update to this build → launch → confirm no crash, data loads
|
|
||||||
3. Quit → relaunch → confirm persistence works correctly
|
|
||||||
|
|
||||||
If no old TestFlight build available, skip this empirical check but the `ignoreUnknownKeys` setting is sufficient.
|
|
||||||
|
|
||||||
### Task 11: Manual smoke — the actual bug repro
|
|
||||||
|
|
||||||
**Files:** none (manual test)
|
|
||||||
|
|
||||||
**Step 1: Wipe simulator state for the dev build**
|
|
||||||
|
|
||||||
Run: `xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev`
|
|
||||||
|
|
||||||
**Step 2: Confirm iOS is on LOCAL (set during pre-flight, stays uncommitted)**
|
|
||||||
|
|
||||||
Run: `grep "CURRENT_ENV" /Users/treyt/Desktop/code/honeyDue/honeyDueKMP/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiConfig.kt`
|
|
||||||
Expected: `val CURRENT_ENV = Environment.LOCAL`. If it's not, edit the file. DO NOT COMMIT — revert in Step 5.
|
|
||||||
|
|
||||||
**Step 3: Build and install**
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:assembleDebug` and Xcode build to simulator.
|
|
||||||
|
|
||||||
**Step 4: Reproduce the original bug path**
|
|
||||||
|
|
||||||
1. Launch app → land on register screen
|
|
||||||
2. Register a fresh user with a unique email
|
|
||||||
3. Onboarding: create residence → choose 3+ tasks from the catalog → submit
|
|
||||||
4. Land on home/dashboard
|
|
||||||
5. Navigate to the new residence's detail screen WITHOUT visiting the Tasks tab first
|
|
||||||
6. **Expected: tasks visible immediately. No "no tasks" state. No restart needed.**
|
|
||||||
|
|
||||||
If the bug still reproduces, return to Phase 1 — the upsert or refresh isn't working. Capture iOS console with `xclog launch com.myhoneydue.honeyDue.dev` and inspect.
|
|
||||||
|
|
||||||
**Step 5: Revert the ApiConfig change**
|
|
||||||
|
|
||||||
Edit `ApiConfig.kt` back to `Environment.PROD` (or whatever it was). Confirm with `git diff`. Do not commit.
|
|
||||||
|
|
||||||
### Task 12: Final regression sweep
|
|
||||||
|
|
||||||
**Files:** none (verification only)
|
|
||||||
|
|
||||||
**Step 1: Full Kotlin test suite green**
|
|
||||||
|
|
||||||
Run: `./gradlew :composeApp:testDebugUnitTest`
|
|
||||||
|
|
||||||
**Step 2: iOS unit tests green**
|
|
||||||
|
|
||||||
Run: `xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDue -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:HoneyDueTests test 2>&1 | tail -20`
|
|
||||||
|
|
||||||
**Step 3: The Task 0.5 regression XCUITest now passes**
|
|
||||||
|
|
||||||
Wipe state for a clean run (mirrors Task 0.5 Step 1):
|
|
||||||
```
|
|
||||||
xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev
|
|
||||||
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down
|
|
||||||
docker volume rm honeydueapi-go_postgres_data
|
|
||||||
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Wait for `curl -fsS http://127.0.0.1:8000/api/health/` → 200. Then re-run the regression test:
|
|
||||||
|
|
||||||
```
|
|
||||||
xcodebuild -project iosApp/honeyDue.xcodeproj \
|
|
||||||
-scheme HoneyDueUITests \
|
|
||||||
-sdk iphonesimulator \
|
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
|
||||||
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
|
|
||||||
test 2>&1 | tail -20
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: **TEST SUCCEEDED**. The "Tasks created during onboarding must appear on residence detail without restart" assertion now holds.
|
|
||||||
|
|
||||||
**If it FAILS:** the cache fix is incomplete. Inspect the test report video (`xcrun xcresulttool get --path build/reports/...xcresult ...`) and follow the failure point. Common causes: missed updateTask call site in the dual-cache deletion, a residual reader of `_tasksByResidence` in iOS not pruned, or a race between `getTasks(forceRefresh=true)` and the residence detail's first observation. **DO NOT** weaken the test to make it pass — fix the underlying issue.
|
|
||||||
|
|
||||||
**Step 4: Stress run (catch flakiness before merge)**
|
|
||||||
|
|
||||||
Run the test 5× to confirm it's stable, not just lucky:
|
|
||||||
```
|
|
||||||
for i in 1 2 3 4 5; do
|
|
||||||
xcrun simctl uninstall booted com.myhoneydue.honeyDue.dev
|
|
||||||
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml down >/dev/null
|
|
||||||
docker volume rm honeydueapi-go_postgres_data >/dev/null
|
|
||||||
docker compose -f /Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/docker-compose.dev.yml up -d >/dev/null
|
|
||||||
until curl -fsS http://127.0.0.1:8000/api/health/ >/dev/null 2>&1; do sleep 2; done
|
|
||||||
xcodebuild -project iosApp/honeyDue.xcodeproj -scheme HoneyDueUITests -sdk iphonesimulator \
|
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
|
||||||
-only-testing:HoneyDueUITests/Suite11_TaskCacheRegressionTests/test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart \
|
|
||||||
test 2>&1 | tail -3
|
|
||||||
echo "=== run $i done ==="
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 5/5 TEST SUCCEEDED. If even one fails, treat as flaky — don't merge.
|
|
||||||
|
|
||||||
**Step 5: Diff summary**
|
|
||||||
|
|
||||||
Run: `git -C /Users/treyt/Desktop/code/honeyDue/honeyDueKMP diff --stat master...HEAD`
|
|
||||||
Expected: net deletion ~80-150 lines across the listed files. If the diff is much larger, scope creep — review commits.
|
|
||||||
|
|
||||||
**Step 6: Push and open a PR (only if user confirms)**
|
|
||||||
|
|
||||||
Don't push without asking the user. Wait for explicit go-ahead.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollback plan
|
|
||||||
|
|
||||||
If anything goes sideways in production:
|
|
||||||
1. `git revert <merge-commit-sha>` — every commit in this plan is independently revertable in reverse order, but the cleanest rollback is reverting the merge commit.
|
|
||||||
2. Old persistence blob format is preserved by `ignoreUnknownKeys` — no migration required.
|
|
||||||
3. Backend `/api/tasks/by-residence/:id/` was never touched, so a rolled-back client immediately starts using it again with no server change.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes for the executing engineer
|
|
||||||
|
|
||||||
- **Frequent commits.** Every task ends with a commit. If you deviate from the plan, commit before deviating.
|
|
||||||
- **Don't auto-commit any other changes.** Per `honeyDueKMP/CLAUDE.md`: "DO NOT auto-commit code changes." Commit only what's specified.
|
|
||||||
- **Don't push to remote.** Let the user trigger the push after they review.
|
|
||||||
- **TaskColumnsResponse fields.** The `Column` data class in `CustomTask.kt` may have more fields than shown (display label, color, sort order). Read it before writing the standard column shell in Task 2 — the test will fail on missing required constructor args.
|
|
||||||
- **TaskResponse fields.** Same — has many fields. For test fixtures, build a small helper:
|
|
||||||
```kotlin
|
|
||||||
private fun sampleTask(id: Int, residenceId: Int, column: String) = TaskResponse(...)
|
|
||||||
```
|
|
||||||
in the test file rather than repeating the giant constructor.
|
|
||||||
- **SKIE/Swift copy.** TaskColumnsResponse `.copy()` from Swift may need `.doCopy(...)` if SKIE renamed it. Check `iosApp/iosApp/Task/TaskViewModel.swift` line 387 onward for an existing example of how Swift copies a Kotlin data class.
|
|
||||||
- **Don't refactor "while you're here."** This plan is laser-focused on the cache unification. Other smells you spot — log them, don't fix them in this PR.
|
|
||||||
@@ -15,6 +15,6 @@
|
|||||||
<key>manageAppVersionAndBuildNumber</key>
|
<key>manageAppVersionAndBuildNumber</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>teamID</key>
|
<key>teamID</key>
|
||||||
<string>X86BR9WTLD</string>
|
<string>V3PF3M6B6U</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ final class WidgetActionManager {
|
|||||||
static let shared = WidgetActionManager()
|
static let shared = WidgetActionManager()
|
||||||
|
|
||||||
private let appGroupIdentifier: String = {
|
private let appGroupIdentifier: String = {
|
||||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
||||||
}()
|
}()
|
||||||
private let pendingTasksFileName = "widget_pending_tasks.json"
|
private let pendingTasksFileName = "widget_pending_tasks.json"
|
||||||
private let tokenKey = "widget_auth_token"
|
private let tokenKey = "widget_auth_token"
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class CacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let appGroupIdentifier: String = {
|
private static let appGroupIdentifier: String = {
|
||||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
||||||
}()
|
}()
|
||||||
private static let tasksFileName = "widget_tasks.json"
|
private static let tasksFileName = "widget_tasks.json"
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"defaultOptions" : {
|
"defaultOptions" : {
|
||||||
|
"testTimeoutsEnabled" : true,
|
||||||
"defaultTestExecutionTimeAllowance" : 300,
|
"defaultTestExecutionTimeAllowance" : 300,
|
||||||
"targetForVariableExpansion" : {
|
"targetForVariableExpansion" : {
|
||||||
"containerPath" : "container:honeyDue.xcodeproj",
|
"containerPath" : "container:honeyDue.xcodeproj",
|
||||||
"identifier" : "D4ADB376A7A4CFB73469E173",
|
"identifier" : "D4ADB376A7A4CFB73469E173",
|
||||||
"name" : "HoneyDue"
|
"name" : "HoneyDue"
|
||||||
},
|
}
|
||||||
"testTimeoutsEnabled" : true
|
|
||||||
},
|
},
|
||||||
"testTargets" : [
|
"testTargets" : [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Phase 1 — Seed tests run sequentially before parallel suites.
|
||||||
|
/// Ensures the backend is reachable and required test accounts exist.
|
||||||
|
final class AAA_SeedTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gate Check
|
||||||
|
|
||||||
|
func testSeed01_backendReachable() throws {
|
||||||
|
guard TestAccountAPIClient.isBackendReachable() else {
|
||||||
|
throw XCTSkip("Backend is not reachable at \(TestAccountAPIClient.baseURL) — skipping all seed tests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test User
|
||||||
|
|
||||||
|
func testSeed02_ensureTestUserExists() {
|
||||||
|
let username = "testuser"
|
||||||
|
let password = "TestPass123!"
|
||||||
|
let email = "\(username)@honeydue.com"
|
||||||
|
|
||||||
|
// Try logging in first — account may already exist
|
||||||
|
if let _ = TestAccountAPIClient.login(username: username, password: password) {
|
||||||
|
return // already exists and credentials work
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and verify the account
|
||||||
|
let session = TestAccountAPIClient.createVerifiedAccount(
|
||||||
|
username: username,
|
||||||
|
email: email,
|
||||||
|
password: password
|
||||||
|
)
|
||||||
|
XCTAssertNotNil(session, "Failed to create verified test user '\(username)'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Admin User
|
||||||
|
|
||||||
|
func testSeed03_ensureAdminExists() {
|
||||||
|
let username = "admin"
|
||||||
|
let password = "Test1234"
|
||||||
|
let email = "\(username)@honeydue.com"
|
||||||
|
|
||||||
|
if let _ = TestAccountAPIClient.login(username: username, password: password) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = TestAccountAPIClient.createVerifiedAccount(
|
||||||
|
username: username,
|
||||||
|
email: email,
|
||||||
|
password: password
|
||||||
|
)
|
||||||
|
XCTAssertNotNil(session, "Failed to create verified admin user '\(username)'")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
var needsAPISession: Bool { false }
|
var needsAPISession: Bool { false }
|
||||||
|
|
||||||
var apiCredentials: (username: String, password: String) {
|
var apiCredentials: (username: String, password: String) {
|
||||||
("admin", "test1234")
|
("admin", "Test1234")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - API Session
|
// MARK: - API Session
|
||||||
@@ -29,8 +29,8 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
if TestAccountAPIClient.login(username: "testuser", password: "TestPass123!") == nil {
|
if TestAccountAPIClient.login(username: "testuser", password: "TestPass123!") == nil {
|
||||||
_ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!")
|
_ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!")
|
||||||
}
|
}
|
||||||
if TestAccountAPIClient.login(username: "admin", password: "test1234") == nil {
|
if TestAccountAPIClient.login(username: "admin", password: "Test1234") == nil {
|
||||||
_ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "test1234")
|
_ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "Test1234")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,13 +213,49 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
|
|
||||||
/// Dismiss keyboard using the Return key or toolbar Done button.
|
/// Dismiss keyboard using the Return key or toolbar Done button.
|
||||||
func dismissKeyboard() {
|
func dismissKeyboard() {
|
||||||
let returnKey = app.keyboards.buttons["return"]
|
KeyboardDismisser.dismiss(app: app, timeout: defaultTimeout)
|
||||||
let doneKey = app.keyboards.buttons["Done"]
|
}
|
||||||
if returnKey.exists {
|
}
|
||||||
returnKey.tap()
|
|
||||||
} else if doneKey.exists {
|
/// Robust keyboard dismissal. Numeric keyboards (postal, year, cost) often lack
|
||||||
doneKey.tap()
|
/// Return/Done keys, so we fall back through swipe-down and tap-above strategies.
|
||||||
}
|
enum KeyboardDismisser {
|
||||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: defaultTimeout)
|
static func dismiss(app: XCUIApplication, timeout: TimeInterval = 5) {
|
||||||
|
let keyboard = app.keyboards.firstMatch
|
||||||
|
guard keyboard.exists else { return }
|
||||||
|
|
||||||
|
// 1. Prefer the keyboard-toolbar "Done" button (SwiftUI ToolbarItemGroup
|
||||||
|
// on .keyboard placement). Tapping it sets focusedField = nil, which
|
||||||
|
// reliably commits TextField bindings before the keyboard dismisses.
|
||||||
|
// We look outside app.keyboards.buttons because the toolbar is
|
||||||
|
// rendered on the keyboard layer, not inside it.
|
||||||
|
if keyboard.exists {
|
||||||
|
let toolbarDone = app.toolbars.buttons["Done"]
|
||||||
|
if toolbarDone.exists && toolbarDone.isHittable {
|
||||||
|
toolbarDone.tap()
|
||||||
|
if keyboard.waitForNonExistence(timeout: 1.0) { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Tap above the keyboard. This dismisses via focus-loss on the
|
||||||
|
// underlying UITextField, which propagates the typed text to the
|
||||||
|
// SwiftUI binding. Works for numeric keyboards too.
|
||||||
|
if keyboard.exists {
|
||||||
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap()
|
||||||
|
if keyboard.waitForNonExistence(timeout: 1.0) { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Last resort: keyboard Return/Done key. Avoid this first — on
|
||||||
|
// SwiftUI text fields the Return keystroke can dismiss the keyboard
|
||||||
|
// before the binding catches up with the final typed characters.
|
||||||
|
for keyName in ["Return", "return", "Done", "done"] {
|
||||||
|
let button = app.keyboards.buttons[keyName]
|
||||||
|
if button.exists && button.isHittable {
|
||||||
|
button.tap()
|
||||||
|
if keyboard.waitForNonExistence(timeout: 1.0) { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = keyboard.waitForNonExistence(timeout: timeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,13 +268,12 @@ extension XCUIElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss any open keyboard first so this field isn't blocked
|
// Dismiss any open keyboard first so this field isn't blocked.
|
||||||
|
// KeyboardDismisser tries a toolbar Done + tap-above strategy before
|
||||||
|
// falling back to the Return key — this avoids scroll-to-visible
|
||||||
|
// errors when the keyboard is mid-transition.
|
||||||
if app.keyboards.firstMatch.exists {
|
if app.keyboards.firstMatch.exists {
|
||||||
let returnKey = app.keyboards.buttons["return"]
|
KeyboardDismisser.dismiss(app: app)
|
||||||
let doneKey = app.keyboards.buttons["Done"]
|
|
||||||
if returnKey.exists { returnKey.tap() }
|
|
||||||
else if doneKey.exists { doneKey.tap() }
|
|
||||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the element to be hittable (form may need to adjust after keyboard dismiss)
|
// Wait for the element to be hittable (form may need to adjust after keyboard dismiss)
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ struct ResidenceFormScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
|
KeyboardDismisser.dismiss(app: app)
|
||||||
|
if !saveButton.exists || !saveButton.isHittable { app.swipeUp() }
|
||||||
saveButton.waitForExistenceOrFail(timeout: 10)
|
saveButton.waitForExistenceOrFail(timeout: 10)
|
||||||
saveButton.forceTap()
|
saveButton.forceTap()
|
||||||
_ = saveButton.waitForNonExistence(timeout: 15)
|
_ = saveButton.waitForNonExistence(timeout: 15)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ enum TestAccountManager {
|
|||||||
/// Login with a pre-seeded account that already exists in the database.
|
/// Login with a pre-seeded account that already exists in the database.
|
||||||
static func loginSeededAccount(
|
static func loginSeededAccount(
|
||||||
username: String = "admin",
|
username: String = "admin",
|
||||||
password: String = "test1234",
|
password: String = "Test1234",
|
||||||
file: StaticString = #filePath,
|
file: StaticString = #filePath,
|
||||||
line: UInt = #line
|
line: UInt = #line
|
||||||
) -> TestSession? {
|
) -> TestSession? {
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ struct TaskFormScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
|
KeyboardDismisser.dismiss(app: app)
|
||||||
|
// Scroll the form so any focused-field state commits before the
|
||||||
|
// submit action reads it. Without this the title binding can lag.
|
||||||
app.swipeUp()
|
app.swipeUp()
|
||||||
saveButton.waitForExistenceOrFail(timeout: 10)
|
saveButton.waitForExistenceOrFail(timeout: 10)
|
||||||
saveButton.forceTap()
|
saveButton.forceTap()
|
||||||
@@ -232,7 +235,8 @@ struct ContractorFormScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
app.swipeUp()
|
KeyboardDismisser.dismiss(app: app)
|
||||||
|
if !saveButton.exists || !saveButton.isHittable { app.swipeUp() }
|
||||||
saveButton.waitForExistenceOrFail(timeout: 10)
|
saveButton.waitForExistenceOrFail(timeout: 10)
|
||||||
saveButton.forceTap()
|
saveButton.forceTap()
|
||||||
_ = saveButton.waitForNonExistence(timeout: 15)
|
_ = saveButton.waitForNonExistence(timeout: 15)
|
||||||
@@ -399,13 +403,10 @@ struct DocumentFormScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
// Dismiss keyboard first
|
KeyboardDismisser.dismiss(app: app)
|
||||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)).tap()
|
// Unconditional swipe-up matches the task form fix — forces SwiftUI
|
||||||
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
|
// state to commit before the submit button reads it.
|
||||||
|
|
||||||
if !saveButton.exists || !saveButton.isHittable {
|
|
||||||
app.swipeUp()
|
app.swipeUp()
|
||||||
}
|
|
||||||
saveButton.waitForExistenceOrFail(timeout: 10)
|
saveButton.waitForExistenceOrFail(timeout: 10)
|
||||||
if saveButton.isHittable {
|
if saveButton.isHittable {
|
||||||
saveButton.tap()
|
saveButton.tap()
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
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)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -107,15 +107,52 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
|||||||
description: String? = nil,
|
description: String? = nil,
|
||||||
scrollToFindFields: Bool = true
|
scrollToFindFields: Bool = true
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard openTaskForm() else { return false }
|
// Mirror Suite5's proven-working inline flow to avoid page-object drift.
|
||||||
|
// Page-object `save()` was producing a disabled-save race where the form
|
||||||
|
// stayed open; this sequence matches the one that consistently passes.
|
||||||
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||||
|
guard addButton.waitForExistence(timeout: defaultTimeout) && addButton.isEnabled else { return false }
|
||||||
|
addButton.tap()
|
||||||
|
|
||||||
taskForm.enterTitle(title)
|
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
||||||
|
guard titleField.waitForExistence(timeout: defaultTimeout) else { return false }
|
||||||
|
fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: title)
|
||||||
|
|
||||||
if let desc = description {
|
if let desc = description {
|
||||||
taskForm.enterDescription(desc)
|
dismissKeyboard()
|
||||||
|
app.swipeUp()
|
||||||
|
let descField = app.textViews[AccessibilityIdentifiers.Task.descriptionField].firstMatch
|
||||||
|
if descField.waitForExistence(timeout: 5) {
|
||||||
|
descField.focusAndType(desc, app: app)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
taskForm.save()
|
dismissKeyboard()
|
||||||
|
app.swipeUp()
|
||||||
|
|
||||||
|
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
||||||
|
guard saveButton.waitForExistence(timeout: defaultTimeout) else { return false }
|
||||||
|
|
||||||
|
saveButton.tap()
|
||||||
|
|
||||||
|
// If the first tap is a no-op (canSave=false because SwiftUI's title
|
||||||
|
// binding hasn't caught up with XCUITest typing under parallel load),
|
||||||
|
// nudge the form so the binding flushes, then re-tap. Up to 2 retries.
|
||||||
|
if !saveButton.waitForNonExistence(timeout: navigationTimeout) {
|
||||||
|
for _ in 0..<2 {
|
||||||
|
let stillOpenTitle = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
||||||
|
if stillOpenTitle.exists && stillOpenTitle.isHittable {
|
||||||
|
stillOpenTitle.tap()
|
||||||
|
_ = app.keyboards.firstMatch.waitForExistence(timeout: 2)
|
||||||
|
app.typeText(" ")
|
||||||
|
app.typeText(XCUIKeyboardKey.delete.rawValue)
|
||||||
|
dismissKeyboard()
|
||||||
|
app.swipeUp()
|
||||||
|
}
|
||||||
|
saveButton.tap()
|
||||||
|
if saveButton.waitForNonExistence(timeout: navigationTimeout) { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createdTaskTitles.append(title)
|
createdTaskTitles.append(title)
|
||||||
|
|
||||||
@@ -125,8 +162,11 @@ final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
|||||||
cleaner.trackTask(created.id)
|
cleaner.trackTask(created.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to tasks tab to trigger list refresh and reset scroll position
|
// Navigate to tasks tab to trigger list refresh and reset scroll position.
|
||||||
|
// Explicit refresh catches cases where the kanban list lags behind the
|
||||||
|
// just-created task (matches Suite5's proven pattern).
|
||||||
navigateToTasks()
|
navigateToTasks()
|
||||||
|
refreshTasks()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,29 +75,87 @@ final class Suite8_DocumentWarrantyTests: AuthenticatedUITestCase {
|
|||||||
|
|
||||||
/// Select a property from the residence picker. Fails the test if picker is missing or empty.
|
/// Select a property from the residence picker. Fails the test if picker is missing or empty.
|
||||||
private func selectProperty(file: StaticString = #filePath, line: UInt = #line) {
|
private func selectProperty(file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// Look up the seeded residence name so we can match it by text in
|
||||||
|
// whichever picker variant iOS renders (menu, list, or wheel).
|
||||||
|
let residences = TestAccountAPIClient.listResidences(token: session.token) ?? []
|
||||||
|
let residenceName = residences.first?.name
|
||||||
|
|
||||||
let pickerButton = app.buttons[AccessibilityIdentifiers.Document.residencePicker].firstMatch
|
let pickerButton = app.buttons[AccessibilityIdentifiers.Document.residencePicker].firstMatch
|
||||||
pickerButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Property picker should exist", file: file, line: line)
|
pickerButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Property picker should exist", file: file, line: line)
|
||||||
|
|
||||||
pickerButton.tap()
|
pickerButton.tap()
|
||||||
|
|
||||||
// SwiftUI Picker in Form pushes a selection list — find any row to select
|
// Fast path: the residence option is often rendered as a plain Button
|
||||||
// Try menu items first (menu style), then static texts (list style)
|
// or StaticText whose label is the residence name itself. Finding it
|
||||||
|
// by text works across menu, list, and wheel picker variants.
|
||||||
|
if let name = residenceName {
|
||||||
|
let byButton = app.buttons[name].firstMatch
|
||||||
|
if byButton.waitForExistence(timeout: 3) && byButton.isHittable {
|
||||||
|
byButton.tap()
|
||||||
|
_ = docForm.titleField.waitForExistence(timeout: navigationTimeout)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let byText = app.staticTexts[name].firstMatch
|
||||||
|
if byText.exists && byText.isHittable {
|
||||||
|
byText.tap()
|
||||||
|
_ = docForm.titleField.waitForExistence(timeout: navigationTimeout)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwiftUI Picker in Form renders either a menu (iOS 18+ default) or a
|
||||||
|
// pushed selection list. Detecting the menu requires a slightly longer
|
||||||
|
// wait because the dropdown animates in after the tap. Also: the form
|
||||||
|
// rows themselves are `cells`, so we can't use `cells.firstMatch` to
|
||||||
|
// detect list mode — we must wait longer for a real menu before
|
||||||
|
// falling back.
|
||||||
let menuItem = app.menuItems.firstMatch
|
let menuItem = app.menuItems.firstMatch
|
||||||
if menuItem.waitForExistence(timeout: navigationTimeout) {
|
// Give the menu a bit longer to animate; 5s covers the usual case.
|
||||||
|
if menuItem.waitForExistence(timeout: 5) {
|
||||||
|
// Tap the last menu item (the residence option; the placeholder is
|
||||||
|
// index 0 and carries the "Select a Property" label).
|
||||||
let allItems = app.menuItems.allElementsBoundByIndex
|
let allItems = app.menuItems.allElementsBoundByIndex
|
||||||
allItems[max(allItems.count - 1, 0)].tap()
|
let target = allItems.last ?? menuItem
|
||||||
|
if target.isHittable {
|
||||||
|
target.tap()
|
||||||
} else {
|
} else {
|
||||||
// List-style picker — find a cell/row with a residence name
|
target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
// Ensure the menu actually dismissed; a lingering overlay blocks
|
||||||
|
// hit-testing on the form below.
|
||||||
|
_ = app.menuItems.firstMatch.waitForNonExistence(timeout: 2)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// List-style picker — find a cell/row with a residence name.
|
||||||
|
// Cells can take a moment to become hittable during the push
|
||||||
|
// animation; retry the tap until the picker dismisses (titleField
|
||||||
|
// reappears on the form) or the attempt budget runs out.
|
||||||
let cells = app.cells
|
let cells = app.cells
|
||||||
guard cells.firstMatch.waitForExistence(timeout: navigationTimeout) else {
|
guard cells.firstMatch.waitForExistence(timeout: navigationTimeout) else {
|
||||||
XCTFail("No residence options appeared in picker", file: file, line: line)
|
XCTFail("No residence options appeared in picker", file: file, line: line)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Tap the first non-placeholder cell
|
|
||||||
if cells.count > 1 {
|
let hittable = NSPredicate(format: "isHittable == true")
|
||||||
cells.element(boundBy: 1).tap()
|
for attempt in 0..<5 {
|
||||||
} else {
|
let targetCell = cells.count > 1 ? cells.element(boundBy: 1) : cells.element(boundBy: 0)
|
||||||
cells.element(boundBy: 0).tap()
|
guard targetCell.exists else {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = XCTWaiter().wait(
|
||||||
|
for: [XCTNSPredicateExpectation(predicate: hittable, object: targetCell)],
|
||||||
|
timeout: 2.0 + Double(attempt)
|
||||||
|
)
|
||||||
|
if targetCell.isHittable {
|
||||||
|
targetCell.tap()
|
||||||
|
if docForm.titleField.waitForExistence(timeout: 2) { break }
|
||||||
|
}
|
||||||
|
// Reopen picker if it dismissed without selection.
|
||||||
|
if docForm.titleField.exists, attempt < 4, pickerButton.exists, pickerButton.isHittable {
|
||||||
|
pickerButton.tap()
|
||||||
|
_ = cells.firstMatch.waitForExistence(timeout: 3)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Phase 3 — Cleanup tests run sequentially after all parallel suites.
|
||||||
|
/// Clears test data via the admin API, then re-seeds the required accounts.
|
||||||
|
final class SuiteZZ_CleanupTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
continueAfterFailure = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Clear All Data
|
||||||
|
|
||||||
|
func testCleanup01_clearAllTestData() {
|
||||||
|
let baseURL = TestAccountAPIClient.baseURL
|
||||||
|
|
||||||
|
// 1. Login to admin panel (admin API uses Bearer token)
|
||||||
|
// Try re-seeded password first, then fallback to default
|
||||||
|
var adminToken = adminLogin(baseURL: baseURL, password: "test1234")
|
||||||
|
if adminToken == nil {
|
||||||
|
adminToken = adminLogin(baseURL: baseURL, password: "password123")
|
||||||
|
}
|
||||||
|
XCTAssertNotNil(adminToken, "Admin login failed — cannot clear test data")
|
||||||
|
guard let token = adminToken else { return }
|
||||||
|
|
||||||
|
// 2. Call clear-all-data
|
||||||
|
let clearResult = adminClearAllData(baseURL: baseURL, token: token)
|
||||||
|
XCTAssertTrue(clearResult, "Failed to clear all test data via admin API")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Re-Seed Accounts
|
||||||
|
|
||||||
|
func testCleanup02_reSeedTestUser() {
|
||||||
|
let session = TestAccountAPIClient.createVerifiedAccount(
|
||||||
|
username: "testuser",
|
||||||
|
email: "testuser@honeydue.com",
|
||||||
|
password: "TestPass123!"
|
||||||
|
)
|
||||||
|
XCTAssertNotNil(session, "Failed to re-seed testuser account after cleanup")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCleanup03_reSeedAdmin() {
|
||||||
|
let session = TestAccountAPIClient.createVerifiedAccount(
|
||||||
|
username: "admin",
|
||||||
|
email: "admin@honeydue.com",
|
||||||
|
password: "Test1234"
|
||||||
|
)
|
||||||
|
XCTAssertNotNil(session, "Failed to re-seed admin account after cleanup")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
/// Admin API uses `Bearer` token (not `Token` prefix), so we use inline URLRequest.
|
||||||
|
private func adminLogin(baseURL: String, password: String = "test1234") -> String? {
|
||||||
|
guard let url = URL(string: "\(baseURL)/admin/auth/login") else { return nil }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.timeoutInterval = 15
|
||||||
|
|
||||||
|
let body: [String: Any] = [
|
||||||
|
"email": "admin@honeydue.com",
|
||||||
|
"password": password
|
||||||
|
]
|
||||||
|
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||||
|
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var token: String?
|
||||||
|
|
||||||
|
URLSession.shared.dataTask(with: request) { data, response, _ in
|
||||||
|
defer { semaphore.signal() }
|
||||||
|
guard let data = data,
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode,
|
||||||
|
(200...299).contains(status),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let t = json["token"] as? String else { return }
|
||||||
|
token = t
|
||||||
|
}.resume()
|
||||||
|
semaphore.wait()
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
private func adminClearAllData(baseURL: String, token: String) -> Bool {
|
||||||
|
guard let url = URL(string: "\(baseURL)/admin/settings/clear-all-data") else { return false }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.timeoutInterval = 30
|
||||||
|
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var success = false
|
||||||
|
|
||||||
|
URLSession.shared.dataTask(with: request) { _, response, _ in
|
||||||
|
defer { semaphore.signal() }
|
||||||
|
if let status = (response as? HTTPURLResponse)?.statusCode {
|
||||||
|
success = (200...299).contains(status)
|
||||||
|
}
|
||||||
|
}.resume()
|
||||||
|
semaphore.wait()
|
||||||
|
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ import XCTest
|
|||||||
/// Data is seeded via API and cleaned up in tearDown.
|
/// Data is seeded via API and cleaned up in tearDown.
|
||||||
final class ContractorIntegrationTests: AuthenticatedUITestCase {
|
final class ContractorIntegrationTests: AuthenticatedUITestCase {
|
||||||
override var needsAPISession: Bool { true }
|
override var needsAPISession: Bool { true }
|
||||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
|
|
||||||
// MARK: - CON-002: Create Contractor
|
// MARK: - CON-002: Create Contractor
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ private enum DataLayerTestError: Error {
|
|||||||
/// All tests run against the real local backend via `AuthenticatedUITestCase` with UI-driven login.
|
/// All tests run against the real local backend via `AuthenticatedUITestCase` with UI-driven login.
|
||||||
final class DataLayerTests: AuthenticatedUITestCase {
|
final class DataLayerTests: AuthenticatedUITestCase {
|
||||||
override var needsAPISession: Bool { true }
|
override var needsAPISession: Bool { true }
|
||||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
// Tests 08/09 restart the app (testing persistence) — relaunch ensures clean state for subsequent tests
|
// Tests 08/09 restart the app (testing persistence) — relaunch ensures clean state for subsequent tests
|
||||||
override var relaunchBetweenTests: Bool { true }
|
override var relaunchBetweenTests: Bool { true }
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ final class DataLayerTests: AuthenticatedUITestCase {
|
|||||||
let login = LoginScreenObject(app: app)
|
let login = LoginScreenObject(app: app)
|
||||||
login.waitForLoad(timeout: defaultTimeout)
|
login.waitForLoad(timeout: defaultTimeout)
|
||||||
login.enterUsername("admin")
|
login.enterUsername("admin")
|
||||||
login.enterPassword("test1234")
|
login.enterPassword("Test1234")
|
||||||
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].waitForExistenceOrFail(timeout: defaultTimeout).forceTap()
|
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].waitForExistenceOrFail(timeout: defaultTimeout).forceTap()
|
||||||
|
|
||||||
let verificationScreen = VerificationScreen(app: app)
|
let verificationScreen = VerificationScreen(app: app)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import XCTest
|
|||||||
/// Data is seeded via API and cleaned up in tearDown.
|
/// Data is seeded via API and cleaned up in tearDown.
|
||||||
final class DocumentIntegrationTests: AuthenticatedUITestCase {
|
final class DocumentIntegrationTests: AuthenticatedUITestCase {
|
||||||
override var needsAPISession: Bool { true }
|
override var needsAPISession: Bool { true }
|
||||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import XCTest
|
|||||||
/// and theme selection.
|
/// and theme selection.
|
||||||
final class FeatureCoverageTests: AuthenticatedUITestCase {
|
final class FeatureCoverageTests: AuthenticatedUITestCase {
|
||||||
override var needsAPISession: Bool { true }
|
override var needsAPISession: Bool { true }
|
||||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ final class OnboardingTests: BaseUITestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
login.enterUsername("admin")
|
login.enterUsername("admin")
|
||||||
login.enterPassword("test1234")
|
login.enterPassword("Test1234")
|
||||||
|
|
||||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||||
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
|
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import XCTest
|
|||||||
/// Uses a seeded admin account. Data is seeded via API and cleaned up in tearDown.
|
/// Uses a seeded admin account. Data is seeded via API and cleaned up in tearDown.
|
||||||
final class ResidenceIntegrationTests: AuthenticatedUITestCase {
|
final class ResidenceIntegrationTests: AuthenticatedUITestCase {
|
||||||
override var needsAPISession: Bool { true }
|
override var needsAPISession: Bool { true }
|
||||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
|
|
||||||
// MARK: - Create Residence
|
// MARK: - Create Residence
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import XCTest
|
|||||||
/// Data is seeded via API and cleaned up in tearDown.
|
/// Data is seeded via API and cleaned up in tearDown.
|
||||||
final class TaskIntegrationTests: AuthenticatedUITestCase {
|
final class TaskIntegrationTests: AuthenticatedUITestCase {
|
||||||
override var needsAPISession: Bool { true }
|
override var needsAPISession: Bool { true }
|
||||||
override var testCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
override var apiCredentials: (username: String, password: String) { ("admin", "test1234") }
|
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
||||||
|
|
||||||
// MARK: - Create Task
|
// MARK: - Create Task
|
||||||
|
|
||||||
|
|||||||
@@ -663,7 +663,7 @@
|
|||||||
0248CABA5A5197845F2E5C26 /* Release */ = {
|
0248CABA5A5197845F2E5C26 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue;
|
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue;
|
||||||
ARCHS = arm64;
|
ARCHS = arm64;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
@@ -671,15 +671,14 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs access to your camera to take photos of completed tasks";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs access to your photo library to select photos of completed tasks";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -689,7 +688,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -699,14 +698,14 @@
|
|||||||
1C0789552EBC218D00392B46 /* Debug */ = {
|
1C0789552EBC218D00392B46 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue.dev;
|
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue.dev;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDue/Info.plist;
|
INFOPLIST_FILE = HoneyDue/Info.plist;
|
||||||
@@ -720,7 +719,7 @@
|
|||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -735,14 +734,14 @@
|
|||||||
1C0789562EBC218D00392B46 /* Release */ = {
|
1C0789562EBC218D00392B46 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue;
|
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = HoneyDueExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDue/Info.plist;
|
INFOPLIST_FILE = HoneyDue/Info.plist;
|
||||||
@@ -756,7 +755,7 @@
|
|||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -775,12 +774,12 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
@@ -801,12 +800,12 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
@@ -826,7 +825,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
|
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
|
||||||
@@ -839,7 +838,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueQLPreview;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueQLPreview;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -857,7 +856,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
|
INFOPLIST_FILE = HoneyDueQLPreview/Info.plist;
|
||||||
@@ -870,7 +869,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueQLPreview;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueQLPreview;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -888,7 +887,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
|
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
|
||||||
@@ -901,7 +900,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev.HoneyDueQLThumbnail;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev.HoneyDueQLThumbnail;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -919,7 +918,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
|
INFOPLIST_FILE = HoneyDueQLThumbnail/Info.plist;
|
||||||
@@ -932,7 +931,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.HoneyDueQLThumbnail;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.HoneyDueQLThumbnail;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -949,13 +948,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.1;
|
MACOSX_DEPLOYMENT_TARGET = 26.1;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -975,13 +974,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.1;
|
MACOSX_DEPLOYMENT_TARGET = 26.1;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.HoneyDueUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.HoneyDueUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -1124,7 +1123,7 @@
|
|||||||
E767E942685C7832D51FF978 /* Debug */ = {
|
E767E942685C7832D51FF978 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
APP_GROUP_IDENTIFIER = group.com.myhoneydue.honeyDue.dev;
|
APP_GROUP_IDENTIFIER = group.com.tt.honeyDue.dev;
|
||||||
ARCHS = arm64;
|
ARCHS = arm64;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
@@ -1132,15 +1131,14 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = X86BR9WTLD;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs access to your camera to take photos of completed tasks";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs access to your photo library to select photos of completed tasks";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -1150,7 +1148,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.myhoneydue.honeyDue.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tt.honeyDue.dev;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ final class BackgroundTaskManager {
|
|||||||
static let shared = BackgroundTaskManager()
|
static let shared = BackgroundTaskManager()
|
||||||
|
|
||||||
/// Background task identifier - must match Info.plist BGTaskSchedulerPermittedIdentifiers
|
/// Background task identifier - must match Info.plist BGTaskSchedulerPermittedIdentifiers
|
||||||
static let taskIdentifier = "com.myhoneydue.honeyDue.refresh"
|
static let taskIdentifier = "com.tt.honeyDue.refresh"
|
||||||
|
|
||||||
/// Time window for overnight refresh (12:00 AM - 4:00 AM)
|
/// Time window for overnight refresh (12:00 AM - 4:00 AM)
|
||||||
private let refreshWindowStartHour = 0 // 12:00 AM
|
private let refreshWindowStartHour = 0 // 12:00 AM
|
||||||
@@ -187,7 +187,7 @@ final class BackgroundTaskManager {
|
|||||||
|
|
||||||
/// Force a background refresh for testing (only works in debug builds with Xcode)
|
/// Force a background refresh for testing (only works in debug builds with Xcode)
|
||||||
/// Usage: In Xcode debugger console:
|
/// Usage: In Xcode debugger console:
|
||||||
/// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.myhoneydue.honeyDue.refresh"]
|
/// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.tt.honeyDue.refresh"]
|
||||||
func debugInfo() -> String {
|
func debugInfo() -> String {
|
||||||
return """
|
return """
|
||||||
Background Task Debug Info:
|
Background Task Debug Info:
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class DataManagerObservable: ObservableObject {
|
|||||||
// MARK: - Tasks
|
// MARK: - Tasks
|
||||||
|
|
||||||
@Published var allTasks: TaskColumnsResponse?
|
@Published var allTasks: TaskColumnsResponse?
|
||||||
|
@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]
|
||||||
|
|
||||||
// MARK: - Documents
|
// MARK: - Documents
|
||||||
|
|
||||||
@@ -190,6 +191,15 @@ class DataManagerObservable: ObservableObject {
|
|||||||
}
|
}
|
||||||
observationTasks.append(allTasksTask)
|
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
|
// Documents
|
||||||
let documentsTask = Task { [weak self] in
|
let documentsTask = Task { [weak self] in
|
||||||
for await docs in DataManager.shared.documents {
|
for await docs in DataManager.shared.documents {
|
||||||
@@ -509,27 +519,9 @@ class DataManagerObservable: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Task Helpers
|
// MARK: - Task Helpers
|
||||||
|
|
||||||
/// Get tasks for a specific residence — derived from `_allTasks`
|
/// Get tasks for a specific residence
|
||||||
/// (single source of truth) by filtering in-memory.
|
|
||||||
func tasks(for residenceId: Int32) -> TaskColumnsResponse? {
|
func tasks(for residenceId: Int32) -> TaskColumnsResponse? {
|
||||||
guard let all = allTasks else { return nil }
|
return tasksByResidence[residenceId]
|
||||||
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
|
/// Get documents for a specific residence
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ struct AccessibilityIdentifiers {
|
|||||||
static let addButton = "Residence.AddButton"
|
static let addButton = "Residence.AddButton"
|
||||||
static let residencesList = "Residence.List"
|
static let residencesList = "Residence.List"
|
||||||
static let residenceCard = "Residence.Card"
|
static let residenceCard = "Residence.Card"
|
||||||
/// Prefix for individual residence cells in the list. Suffix with the
|
|
||||||
/// residence id to address a specific cell (e.g. "Residence.Cell.42").
|
|
||||||
static let cellPrefix = "Residence.Cell"
|
|
||||||
static let emptyStateView = "Residence.EmptyState"
|
static let emptyStateView = "Residence.EmptyState"
|
||||||
static let emptyStateButton = "Residence.EmptyState.AddButton"
|
static let emptyStateButton = "Residence.EmptyState.AddButton"
|
||||||
|
|
||||||
@@ -90,15 +87,7 @@ struct AccessibilityIdentifiers {
|
|||||||
static let refreshButton = "Task.RefreshButton"
|
static let refreshButton = "Task.RefreshButton"
|
||||||
static let tasksList = "Task.List"
|
static let tasksList = "Task.List"
|
||||||
static let taskCard = "Task.Card"
|
static let taskCard = "Task.Card"
|
||||||
/// Prefix for individual task rows. Suffix with the task id to
|
|
||||||
/// address a specific row (e.g. "Task.Row.42"). Use `BEGINSWITH`
|
|
||||||
/// in tests to detect "any task row exists".
|
|
||||||
static let rowPrefix = "Task.Row"
|
|
||||||
static let emptyStateView = "Task.EmptyState"
|
static let emptyStateView = "Task.EmptyState"
|
||||||
/// Label rendered when a residence-detail tasks section has no tasks
|
|
||||||
/// in any kanban column. Asserted ABSENT after onboarding bulk-create
|
|
||||||
/// in the gitea#2 regression test.
|
|
||||||
static let noTasksLabel = "Task.NoTasksLabel"
|
|
||||||
static let kanbanView = "Task.KanbanView"
|
static let kanbanView = "Task.KanbanView"
|
||||||
static let overdueColumn = "Task.Column.Overdue"
|
static let overdueColumn = "Task.Column.Overdue"
|
||||||
static let upcomingColumn = "Task.Column.Upcoming"
|
static let upcomingColumn = "Task.Column.Upcoming"
|
||||||
@@ -240,24 +229,8 @@ struct AccessibilityIdentifiers {
|
|||||||
static let taskSelectionCounter = "Onboarding.TaskSelectionCounter"
|
static let taskSelectionCounter = "Onboarding.TaskSelectionCounter"
|
||||||
static let addPopularTasksButton = "Onboarding.AddPopularTasksButton"
|
static let addPopularTasksButton = "Onboarding.AddPopularTasksButton"
|
||||||
static let addTasksContinueButton = "Onboarding.AddTasksContinueButton"
|
static let addTasksContinueButton = "Onboarding.AddTasksContinueButton"
|
||||||
/// Submit/continue button at the bottom of the First-Task screen.
|
|
||||||
/// Triggers `POST /api/tasks/bulk/` for the selected templates.
|
|
||||||
static let submitTasksButton = "Onboarding.SubmitTasksButton"
|
|
||||||
/// Tab bar control above the task list. The "Browse All" segment is
|
|
||||||
/// addressed via `app.buttons["Browse All"]` from the segmented
|
|
||||||
/// picker once this identifier is set.
|
|
||||||
static let firstTaskTabBar = "Onboarding.FirstTaskTabBar"
|
|
||||||
/// Tab segment that shows the full template catalog.
|
|
||||||
/// Tap from a test by addressing the Picker's segment label
|
|
||||||
/// "Browse All" within the element identified above.
|
|
||||||
static let browseAllTab = "Onboarding.BrowseAllTab"
|
|
||||||
static let taskCategorySection = "Onboarding.TaskCategorySection"
|
static let taskCategorySection = "Onboarding.TaskCategorySection"
|
||||||
static let taskTemplateRow = "Onboarding.TaskTemplateRow"
|
static let taskTemplateRow = "Onboarding.TaskTemplateRow"
|
||||||
/// Prefix for individual template rows on the First-Task screen
|
|
||||||
/// (Browse All tab). Suffix with the backend template id —
|
|
||||||
/// e.g. `"Onboarding.TemplateRow.123"`. Tests use `BEGINSWITH` to
|
|
||||||
/// pick the first N rows deterministically without knowing ids.
|
|
||||||
static let templateRowPrefix = "Onboarding.TemplateRow"
|
|
||||||
|
|
||||||
// Subscription Screen
|
// Subscription Screen
|
||||||
static let subscriptionTitle = "Onboarding.SubscriptionTitle"
|
static let subscriptionTitle = "Onboarding.SubscriptionTitle"
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
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) ?? ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -61,7 +61,7 @@ enum ThemeID: String, CaseIterable, Codable {
|
|||||||
|
|
||||||
// MARK: - Shared App Group UserDefaults
|
// MARK: - Shared App Group UserDefaults
|
||||||
private let appGroupID: String = {
|
private let appGroupID: String = {
|
||||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
||||||
}()
|
}()
|
||||||
private let sharedDefaults: UserDefaults = {
|
private let sharedDefaults: UserDefaults = {
|
||||||
guard let defaults = UserDefaults(suiteName: appGroupID) else {
|
guard let defaults = UserDefaults(suiteName: appGroupID) else {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ final class WidgetActionProcessor {
|
|||||||
notes: "Completed from widget",
|
notes: "Completed from widget",
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
rating: nil,
|
rating: nil,
|
||||||
uploadIds: nil
|
imageUrls: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ final class WidgetDataManager {
|
|||||||
static let cancelledColumn = "cancelled_tasks"
|
static let cancelledColumn = "cancelled_tasks"
|
||||||
|
|
||||||
private let appGroupIdentifier: String = {
|
private let appGroupIdentifier: String = {
|
||||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.tt.honeyDue.dev"
|
||||||
}()
|
}()
|
||||||
private let tasksFileName = "widget_tasks.json"
|
private let tasksFileName = "widget_tasks.json"
|
||||||
private let actionsFileName = "widget_pending_actions.json"
|
private let actionsFileName = "widget_pending_actions.json"
|
||||||
|
|||||||
@@ -6,8 +6,14 @@
|
|||||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>com.myhoneydue.honeyDue.refresh</string>
|
<string>com.tt.honeyDue.refresh</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>HONEYDUE_IAP_ANNUAL_PRODUCT_ID</key>
|
||||||
|
<string>com.tt.honeyDue.pro.annual</string>
|
||||||
|
<key>HONEYDUE_IAP_MONTHLY_PRODUCT_ID</key>
|
||||||
|
<string>com.tt.honeyDue.pro.monthly</string>
|
||||||
|
<key>HONEYDUE_GOOGLE_WEB_CLIENT_ID</key>
|
||||||
|
<string></string>
|
||||||
<key>CFBundleDocumentTypes</key>
|
<key>CFBundleDocumentTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -34,17 +40,17 @@
|
|||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>HONEYDUE_GOOGLE_WEB_CLIENT_ID</key>
|
|
||||||
<string></string>
|
|
||||||
<key>HONEYDUE_IAP_ANNUAL_PRODUCT_ID</key>
|
|
||||||
<string>com.myhoneydue.honeyDue.pro.annual</string>
|
|
||||||
<key>HONEYDUE_IAP_MONTHLY_PRODUCT_ID</key>
|
|
||||||
<string>com.myhoneydue.honeyDue.pro.monthly</string>
|
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>honeyDue needs camera access to take photos of tasks, documents, and receipts.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>honeyDue needs photo library access to attach photos to tasks and documents.</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>honeyDue needs permission to save photos to your library.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
|||||||
@@ -74,18 +74,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"%@, %@, %lld%% match" : {
|
|
||||||
"comment" : "A row that displays a suggestion with a title, frequency, and relevance percentage.",
|
|
||||||
"isCommentAutoGenerated" : true,
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "new",
|
|
||||||
"value" : "%1$@, %2$@, %3$lld%% match"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"%@, %@%@" : {
|
"%@, %@%@" : {
|
||||||
"comment" : "A button that displays the name of a product and its price.",
|
"comment" : "A button that displays the name of a product and its price.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@@ -166,10 +154,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"%lld%%" : {
|
|
||||||
"comment" : "A badge that shows the relevance of a suggestion. The argument is the relevance percentage.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"•" : {
|
"•" : {
|
||||||
"comment" : "A separator between different pieces of information in a text.",
|
"comment" : "A separator between different pieces of information in a text.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -237,6 +221,9 @@
|
|||||||
},
|
},
|
||||||
"Add document" : {
|
"Add document" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Add Most Popular" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Add new property" : {
|
"Add new property" : {
|
||||||
"comment" : "A label displayed as a button in the toolbar.",
|
"comment" : "A label displayed as a button in the toolbar.",
|
||||||
@@ -17697,6 +17684,10 @@
|
|||||||
"comment" : "A button that generates a new share code.",
|
"comment" : "A button that generates a new share code.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Generating suggestions..." : {
|
||||||
|
"comment" : "Text displayed while the app is generating personalized task suggestions.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Get notified when someone joins your property" : {
|
"Get notified when someone joins your property" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -17719,8 +17710,16 @@
|
|||||||
"comment" : "A label for the back button.",
|
"comment" : "A label for the back button.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Good match" : {
|
||||||
|
"comment" : "A label describing a task's relevance.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Google Sign-In Error" : {
|
"Google Sign-In Error" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Great match" : {
|
||||||
|
"comment" : "A label describing a high-relevance task.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Help improve honeyDue by sharing anonymous usage data" : {
|
"Help improve honeyDue by sharing anonymous usage data" : {
|
||||||
|
|
||||||
@@ -17863,6 +17862,10 @@
|
|||||||
},
|
},
|
||||||
"No personal data is collected. Analytics are fully anonymous." : {
|
"No personal data is collected. Analytics are fully anonymous." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"No personalized suggestions yet" : {
|
||||||
|
"comment" : "A message displayed when the user has not yet been personalized.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"No properties yet" : {
|
"No properties yet" : {
|
||||||
|
|
||||||
@@ -25441,10 +25444,6 @@
|
|||||||
"comment" : "A button label that allows users to skip the current onboarding step.",
|
"comment" : "A button label that allows users to skip the current onboarding step.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Skip for now" : {
|
|
||||||
"comment" : "A button label that skips onboarding.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Skip for Now" : {
|
"Skip for Now" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -30618,6 +30617,10 @@
|
|||||||
"comment" : "A button label that says \"Try Again\".",
|
"comment" : "A button label that says \"Try Again\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Try the Browse tab to explore tasks by category,\nor add home details for better suggestions." : {
|
||||||
|
"comment" : "A description of the benefits of using the",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Unarchive" : {
|
"Unarchive" : {
|
||||||
"comment" : "A button that unarchives a task.",
|
"comment" : "A button that unarchives a task.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|||||||
@@ -366,12 +366,7 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
||||||
if isRegistered {
|
if isRegistered {
|
||||||
// Registration successful — server gave us a token, so we ARE
|
// Registration successful - user is authenticated but not verified
|
||||||
// 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)
|
onAccountCreated(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,13 +451,7 @@ private struct OrganicOnboardingSecureField: View {
|
|||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
var isFocused: Bool = false
|
var isFocused: Bool = false
|
||||||
var accessibilityIdentifier: String? = nil
|
var accessibilityIdentifier: String? = nil
|
||||||
// iOS 26 has a known bug where tapping a SwiftUI SecureField with
|
@State private var showPassword = false
|
||||||
// `.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 {
|
var body: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
|
|
||||||
OnboardingTaskTabBar(selectedTab: $selectedTab)
|
OnboardingTaskTabBar(selectedTab: $selectedTab)
|
||||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.firstTaskTabBar)
|
|
||||||
|
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
case .forYou:
|
case .forYou:
|
||||||
@@ -363,7 +362,6 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.naturalShadow(selectedCount > 0 ? .medium : .subtle)
|
.naturalShadow(selectedCount > 0 ? .medium : .subtle)
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.submitTasksButton)
|
|
||||||
.disabled(vm.isSubmitting)
|
.disabled(vm.isSubmitting)
|
||||||
.animation(.easeInOut(duration: 0.2), value: selectedCount)
|
.animation(.easeInOut(duration: 0.2), value: selectedCount)
|
||||||
}
|
}
|
||||||
@@ -633,7 +631,6 @@ private struct OnboardingSuggestionRow: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(suggestion.template.id)")
|
|
||||||
.accessibilityLabel("\(suggestion.template.title), \(suggestion.template.frequencyDisplay), \(relevancePercent)% match")
|
.accessibilityLabel("\(suggestion.template.title), \(suggestion.template.frequencyDisplay), \(relevancePercent)% match")
|
||||||
.accessibilityValue(isSelected ? "selected" : "not selected")
|
.accessibilityValue(isSelected ? "selected" : "not selected")
|
||||||
}
|
}
|
||||||
@@ -779,7 +776,6 @@ private struct OnboardingTemplateRow: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(template.id)")
|
|
||||||
.accessibilityLabel("\(template.title), \(template.frequencyLabel)")
|
.accessibilityLabel("\(template.title), \(template.frequencyLabel)")
|
||||||
.accessibilityValue(isSelected ? "selected" : "not selected")
|
.accessibilityValue(isSelected ? "selected" : "not selected")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
|||||||
notes: nil,
|
notes: nil,
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
rating: nil,
|
rating: nil,
|
||||||
uploadIds: nil
|
imageUrls: nil
|
||||||
)
|
)
|
||||||
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||||
|
|
||||||
|
|||||||
@@ -231,7 +231,6 @@ private struct ResidencesContent: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
.buttonStyle(OrganicCardButtonStyle())
|
.buttonStyle(OrganicCardButtonStyle())
|
||||||
.accessibilityIdentifier("\(AccessibilityIdentifiers.Residence.cellPrefix).\(residence.id)")
|
|
||||||
.transition(.asymmetric(
|
.transition(.asymmetric(
|
||||||
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||||
removal: .opacity
|
removal: .opacity
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ComposeApp
|
|||||||
final class KeychainHelper: NSObject, KeychainDelegate {
|
final class KeychainHelper: NSObject, KeychainDelegate {
|
||||||
static let shared = KeychainHelper()
|
static let shared = KeychainHelper()
|
||||||
|
|
||||||
private let service = "com.myhoneydue.honeyDue"
|
private let service = "com.tt.honeyDue"
|
||||||
|
|
||||||
func save(key: String, value: String) -> Bool {
|
func save(key: String, value: String) -> Bool {
|
||||||
guard let data = value.data(using: .utf8) else { return false }
|
guard let data = value.data(using: .utf8) else { return false }
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ class StoreKitManager: ObservableObject {
|
|||||||
// Canonical source: SubscriptionProducts in commonMain (Kotlin shared code).
|
// Canonical source: SubscriptionProducts in commonMain (Kotlin shared code).
|
||||||
// Keep these in sync with SubscriptionProducts.MONTHLY / SubscriptionProducts.ANNUAL.
|
// Keep these in sync with SubscriptionProducts.MONTHLY / SubscriptionProducts.ANNUAL.
|
||||||
private let fallbackProductIDs = [
|
private let fallbackProductIDs = [
|
||||||
"com.myhoneydue.honeyDue.pro.monthly",
|
"com.tt.honeyDue.pro.monthly",
|
||||||
"com.myhoneydue.honeyDue.pro.annual"
|
"com.tt.honeyDue.pro.annual"
|
||||||
]
|
]
|
||||||
|
|
||||||
private var configuredProductIDs: [String] {
|
private var configuredProductIDs: [String] {
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ struct DynamicTaskCard: View {
|
|||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
|
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
|
||||||
.simultaneousGesture(TapGesture(), including: .subviews)
|
.simultaneousGesture(TapGesture(), including: .subviews)
|
||||||
.accessibilityIdentifier("\(AccessibilityIdentifiers.Task.rowPrefix).\(task.id)")
|
|
||||||
.sheet(isPresented: $showCompletionHistory) {
|
.sheet(isPresented: $showCompletionHistory) {
|
||||||
CompletionHistorySheet(
|
CompletionHistorySheet(
|
||||||
taskTitle: task.title,
|
taskTitle: task.title,
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ struct EmptyTasksView: View {
|
|||||||
.background(Color.appBackgroundSecondary)
|
.background(Color.appBackgroundSecondary)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.naturalShadow(.subtle)
|
.naturalShadow(.subtle)
|
||||||
.accessibilityElement(children: .combine)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.noTasksLabel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -337,110 +337,39 @@ struct CompleteTaskView: View {
|
|||||||
|
|
||||||
isSubmitting = true
|
isSubmitting = true
|
||||||
|
|
||||||
// New direct-to-B2 upload path: downsample on-device, presign, POST
|
// Create request with simplified Go API format
|
||||||
// straight to B2, pass the resulting upload_ids to the completion
|
// Note: completedAt defaults to now on server if not provided
|
||||||
// create call. Bytes never traverse our API server. See
|
let request = TaskCompletionCreateRequest(
|
||||||
// /api/uploads/presign in honeyDueAPI-go.
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use TaskCompletionViewModel to create completion
|
||||||
if !selectedImages.isEmpty {
|
if !selectedImages.isEmpty {
|
||||||
uploadAndCreate()
|
// 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 {
|
} 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)),
|
|
||||||
uploadIds: nil
|
|
||||||
)
|
|
||||||
completionViewModel.createTaskCompletion(request: request)
|
|
||||||
observeCompletionState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)
|
completionViewModel.createTaskCompletion(request: request)
|
||||||
}
|
}
|
||||||
await observeCompletionStateAsync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Observe the createCompletionState StateFlow until a terminal value
|
// Observe the result — store the Task so it can be cancelled on dismiss
|
||||||
/// arrives, then dismiss or surface an error. Called from the
|
|
||||||
/// no-images path.
|
|
||||||
private func observeCompletionState() {
|
|
||||||
observationTask?.cancel()
|
observationTask?.cancel()
|
||||||
observationTask = Task {
|
observationTask = Task {
|
||||||
await observeCompletionStateAsync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observeCompletionStateAsync() async {
|
|
||||||
for await state in completionViewModel.createCompletionState {
|
for await state in completionViewModel.createCompletionState {
|
||||||
if Task.isCancelled { break }
|
if Task.isCancelled { break }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
|
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
|
||||||
self.isSubmitting = false
|
self.isSubmitting = false
|
||||||
self.onComplete(success.data?.updatedTask)
|
self.onComplete(success.data?.updatedTask) // Pass back updated task
|
||||||
self.dismiss()
|
self.dismiss()
|
||||||
} else if let error = ApiResultBridge.error(from: state) {
|
} else if let error = ApiResultBridge.error(from: state) {
|
||||||
self.errorMessage = error.message
|
self.errorMessage = error.message
|
||||||
@@ -448,11 +377,14 @@ struct CompleteTaskView: View {
|
|||||||
self.isSubmitting = false
|
self.isSubmitting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Break out of loop on terminal states
|
||||||
if state is ApiResultSuccess<TaskCompletionResponse> || ApiResultBridge.isError(state) {
|
if state is ApiResultSuccess<TaskCompletionResponse> || ApiResultBridge.isError(state) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,24 +42,33 @@ class TaskViewModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init() {
|
init() {
|
||||||
// Single source of truth = DataManager._allTasks. When this VM is
|
// Observe DataManagerObservable for all tasks data
|
||||||
// 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
|
DataManagerObservable.shared.$allTasks
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] allTasks in
|
.sink { [weak self] allTasks in
|
||||||
guard let self else { return }
|
// Skip DataManager updates during completion animation to prevent
|
||||||
guard !self.isAnimatingCompletion else { return }
|
// the task from being moved out of its column before the animation finishes
|
||||||
|
guard self?.isAnimatingCompletion != true else { return }
|
||||||
if let allTasks {
|
// Only update if we're showing all tasks (no residence filter)
|
||||||
if let resId = self.currentResidenceId {
|
if self?.currentResidenceId == nil {
|
||||||
self.tasksResponse = self.filterTasks(allTasks, residenceId: resId)
|
self?.tasksResponse = allTasks
|
||||||
} else {
|
if allTasks != nil {
|
||||||
self.tasksResponse = allTasks
|
self?.isLoadingTasks = false
|
||||||
}
|
}
|
||||||
self.isLoadingTasks = false
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -373,28 +382,6 @@ 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
|
/// Updates a task in the kanban board by moving it to the correct column based on kanban_column
|
||||||
func updateTaskInKanban(_ updatedTask: TaskResponse) {
|
func updateTaskInKanban(_ updatedTask: TaskResponse) {
|
||||||
guard let currentResponse = tasksResponse else { return }
|
guard let currentResponse = tasksResponse else { return }
|
||||||
|
|||||||
+31
-4
@@ -14,7 +14,10 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
PROJECT="$SCRIPT_DIR/honeyDue.xcodeproj"
|
PROJECT="$SCRIPT_DIR/honeyDue.xcodeproj"
|
||||||
SCHEME="HoneyDueUITests"
|
SCHEME="HoneyDueUITests"
|
||||||
DESTINATION="platform=iOS Simulator,name=iPhone 17 Pro"
|
DESTINATION="platform=iOS Simulator,name=iPhone 17 Pro"
|
||||||
WORKERS=4
|
# 2 workers avoids simulator contention that caused intermittent XCUITest
|
||||||
|
# typing / UI-update races (Suite5/7/8 flakes under 4-worker load). Phase 2b
|
||||||
|
# isolates Suite6 further.
|
||||||
|
WORKERS=2
|
||||||
|
|
||||||
SKIP_SEED=false
|
SKIP_SEED=false
|
||||||
SKIP_CLEANUP=false
|
SKIP_CLEANUP=false
|
||||||
@@ -73,13 +76,20 @@ PARALLEL_TESTS=(
|
|||||||
"-only-testing:HoneyDueUITests/Suite3_ResidenceRebuildTests"
|
"-only-testing:HoneyDueUITests/Suite3_ResidenceRebuildTests"
|
||||||
"-only-testing:HoneyDueUITests/Suite4_ComprehensiveResidenceTests"
|
"-only-testing:HoneyDueUITests/Suite4_ComprehensiveResidenceTests"
|
||||||
"-only-testing:HoneyDueUITests/Suite5_TaskTests"
|
"-only-testing:HoneyDueUITests/Suite5_TaskTests"
|
||||||
"-only-testing:HoneyDueUITests/Suite6_ComprehensiveTaskTests"
|
|
||||||
"-only-testing:HoneyDueUITests/Suite7_ContractorTests"
|
"-only-testing:HoneyDueUITests/Suite7_ContractorTests"
|
||||||
"-only-testing:HoneyDueUITests/Suite8_DocumentWarrantyTests"
|
"-only-testing:HoneyDueUITests/Suite8_DocumentWarrantyTests"
|
||||||
"-only-testing:HoneyDueUITests/Suite9_IntegrationE2ETests"
|
"-only-testing:HoneyDueUITests/Suite9_IntegrationE2ETests"
|
||||||
"-only-testing:HoneyDueUITests/Suite10_ComprehensiveE2ETests"
|
"-only-testing:HoneyDueUITests/Suite10_ComprehensiveE2ETests"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Suite6 runs in a smaller-parallel phase of its own. Under 4-worker contention
|
||||||
|
# with 14 other classes, SwiftUI's TextField binding intermittently lags behind
|
||||||
|
# XCUITest typing, leaving the Add-Task form un-submittable. Isolating Suite6
|
||||||
|
# to 2 workers gives the binding enough time to flush reliably.
|
||||||
|
SUITE6_TESTS=(
|
||||||
|
"-only-testing:HoneyDueUITests/Suite6_ComprehensiveTaskTests"
|
||||||
|
)
|
||||||
|
|
||||||
# Cleanup tests — must run last, sequentially
|
# Cleanup tests — must run last, sequentially
|
||||||
CLEANUP_TESTS=(
|
CLEANUP_TESTS=(
|
||||||
"-only-testing:HoneyDueUITests/SuiteZZ_CleanupTests"
|
"-only-testing:HoneyDueUITests/SuiteZZ_CleanupTests"
|
||||||
@@ -140,6 +150,23 @@ else
|
|||||||
PARALLEL_PASSED=false
|
PARALLEL_PASSED=false
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Phase 2b: Suite6 (isolated parallel) ──────────────────────
|
||||||
|
phase_header "Phase 2b: Suite6 task tests (2 workers, isolated)"
|
||||||
|
SUITE6_START=$(date +%s)
|
||||||
|
|
||||||
|
if run_phase "Suite6Tests" \
|
||||||
|
-parallel-testing-enabled YES \
|
||||||
|
-parallel-testing-worker-count 2 \
|
||||||
|
"${SUITE6_TESTS[@]}"; then
|
||||||
|
SUITE6_END=$(date +%s)
|
||||||
|
echo -e "\n${GREEN}✓ Suite6 phase passed ($(( SUITE6_END - SUITE6_START ))s)${RESET}"
|
||||||
|
SUITE6_PASSED=true
|
||||||
|
else
|
||||||
|
SUITE6_END=$(date +%s)
|
||||||
|
echo -e "\n${RED}✗ Suite6 phase FAILED ($(( SUITE6_END - SUITE6_START ))s)${RESET}"
|
||||||
|
SUITE6_PASSED=false
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Phase 3: Cleanup ──────────────────────────────────────────
|
# ── Phase 3: Cleanup ──────────────────────────────────────────
|
||||||
if [ "$SKIP_CLEANUP" = false ]; then
|
if [ "$SKIP_CLEANUP" = false ]; then
|
||||||
phase_header "Phase 3/3: Cleaning up test data (sequential)"
|
phase_header "Phase 3/3: Cleaning up test data (sequential)"
|
||||||
@@ -164,11 +191,11 @@ echo " Workers: $WORKERS"
|
|||||||
echo " Results: $RESULTS_DIR/"
|
echo " Results: $RESULTS_DIR/"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [ "$PARALLEL_PASSED" = true ]; then
|
if [ "$PARALLEL_PASSED" = true ] && [ "${SUITE6_PASSED:-true}" = true ]; then
|
||||||
echo -e " ${GREEN}${BOLD}ALL TESTS PASSED${RESET}"
|
echo -e " ${GREEN}${BOLD}ALL TESTS PASSED${RESET}"
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
echo -e " ${RED}${BOLD}TESTS FAILED${RESET}"
|
echo -e " ${RED}${BOLD}TESTS FAILED${RESET}"
|
||||||
echo -e " Check results: open $RESULTS_DIR/ParallelTests.xcresult"
|
echo -e " Check results: open $RESULTS_DIR/"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user