Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e350467975 | |||
| 3cd115a436 | |||
| 418ffc7772 | |||
| cec521b3e3 | |||
| 1b001323e4 | |||
| ce25c80783 | |||
| 4181b6005d | |||
| 2bd3bd85b6 | |||
| 60ae14c79e | |||
| dc6d3525fa | |||
| 5d0c3597fa | |||
| c9d5c048b7 |
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"ask": [
|
"ask": []
|
||||||
"Bash(git commit:*)",
|
|
||||||
"Bash(git push:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.tt.honeyDue.media
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memory-efficient image resizer for upload preprocessing on Android.
|
||||||
|
*
|
||||||
|
* Why not just decode + Bitmap.createScaledBitmap? createScaledBitmap
|
||||||
|
* decodes the full source bitmap first — a 12 MP photo materializes ~50 MB
|
||||||
|
* in RAM regardless of how big the JPEG is. That OOMs older devices.
|
||||||
|
*
|
||||||
|
* BitmapFactory.Options.inSampleSize, paired with inJustDecodeBounds=true
|
||||||
|
* for a metadata-only first pass, lets us decode at a power-of-two
|
||||||
|
* subsample. Combined with a final scaled-down draw, peak memory is
|
||||||
|
* roughly proportional to the *output* bitmap's pixel count — not the
|
||||||
|
* source's.
|
||||||
|
*
|
||||||
|
* Quality tuning matches WhatsApp-class apps: 2048 px max edge, JPEG 85.
|
||||||
|
*/
|
||||||
|
object ImageDownsampler {
|
||||||
|
|
||||||
|
data class Profile(
|
||||||
|
val maxPixelEdge: Int,
|
||||||
|
/** JPEG quality 0-100. */
|
||||||
|
val jpegQuality: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val Completion = Profile(maxPixelEdge = 2048, jpegQuality = 85)
|
||||||
|
val DocumentImage = Profile(maxPixelEdge = 2560, jpegQuality = 90)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downsample raw image bytes into JPEG bytes ready for upload. */
|
||||||
|
fun downsample(bytes: ByteArray, profile: Profile): ByteArray? {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
|
||||||
|
val sampleSize = computeSampleSize(bounds.outWidth, bounds.outHeight, profile.maxPixelEdge)
|
||||||
|
val decodeOpts = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = sampleSize
|
||||||
|
// ARGB_8888 keeps quality; on memory-constrained devices we
|
||||||
|
// could drop to RGB_565 here, but for upload prep the extra
|
||||||
|
// ~2x peak memory isn't worth the visible quality loss.
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
}
|
||||||
|
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
// Subsample is power-of-two only; the result may still be larger
|
||||||
|
// than maxPixelEdge by up to 2x. One more proportional scale gets
|
||||||
|
// us to the exact target.
|
||||||
|
val scaled = scaleProportional(decoded, profile.maxPixelEdge)
|
||||||
|
|
||||||
|
val out = ByteArrayOutputStream(64 * 1024)
|
||||||
|
val ok = scaled.compress(Bitmap.CompressFormat.JPEG, profile.jpegQuality, out)
|
||||||
|
// Only recycle if scaled is a different bitmap; createScaledBitmap
|
||||||
|
// sometimes returns the input unchanged, and recycling that would
|
||||||
|
// double-recycle below.
|
||||||
|
if (scaled !== decoded) decoded.recycle()
|
||||||
|
scaled.recycle()
|
||||||
|
return if (ok) out.toByteArray() else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same, from a stream (for content:// URIs etc.). */
|
||||||
|
fun downsample(input: InputStream, profile: Profile): ByteArray? {
|
||||||
|
val bytes = input.use { it.readBytes() }
|
||||||
|
return downsample(bytes, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the largest power-of-two sub-sample factor that still yields
|
||||||
|
* an image at least as large as maxPixelEdge on both axes. Mirrors
|
||||||
|
* the canonical Android docs example.
|
||||||
|
*/
|
||||||
|
private fun computeSampleSize(srcW: Int, srcH: Int, maxEdge: Int): Int {
|
||||||
|
var sample = 1
|
||||||
|
var halfW = srcW / 2
|
||||||
|
var halfH = srcH / 2
|
||||||
|
while (halfW >= maxEdge && halfH >= maxEdge) {
|
||||||
|
sample *= 2
|
||||||
|
halfW /= 2
|
||||||
|
halfH /= 2
|
||||||
|
}
|
||||||
|
return sample
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scaleProportional(src: Bitmap, maxEdge: Int): Bitmap {
|
||||||
|
val w = src.width
|
||||||
|
val h = src.height
|
||||||
|
val longest = maxOf(w, h)
|
||||||
|
if (longest <= maxEdge) return src
|
||||||
|
val ratio = maxEdge.toFloat() / longest.toFloat()
|
||||||
|
val newW = (w * ratio).toInt().coerceAtLeast(1)
|
||||||
|
val newH = (h * ratio).toInt().coerceAtLeast(1)
|
||||||
|
return Bitmap.createScaledBitmap(src, newW, newH, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,8 +62,6 @@ object DataManager {
|
|||||||
private set
|
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
|
||||||
@@ -138,8 +136,6 @@ 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 ====================
|
||||||
|
|
||||||
@@ -414,7 +410,6 @@ 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
|
||||||
|
|
||||||
@@ -445,16 +440,10 @@ 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 to avoid separate API call.
|
* Filter cached allTasks by residence ID. Single source of truth for
|
||||||
* Returns null if allTasks not cached.
|
* residence-scoped kanban data; returns null when _allTasks is null
|
||||||
* This enables client-side filtering when we already have all tasks loaded.
|
* (caller must hit the API to populate).
|
||||||
*/
|
*/
|
||||||
fun getTasksForResidence(residenceId: Int): TaskColumnsResponse? {
|
fun getTasksForResidence(residenceId: Int): TaskColumnsResponse? {
|
||||||
val allTasksData = _allTasks.value ?: return null
|
val allTasksData = _allTasks.value ?: return null
|
||||||
@@ -480,45 +469,60 @@ 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
|
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||||
_allTasks.value?.let { current ->
|
|
||||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
|
||||||
val newColumns = current.columns.map { column ->
|
|
||||||
// Remove task from this column if present
|
|
||||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
|
||||||
// Add task if this is the target column
|
|
||||||
val updatedTasks = if (column.name == targetColumn) {
|
|
||||||
filteredTasks + task
|
|
||||||
} else {
|
|
||||||
filteredTasks
|
|
||||||
}
|
|
||||||
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
|
||||||
}
|
|
||||||
_allTasks.value = current.copy(columns = newColumns)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update in tasksByResidence if this task's residence is cached
|
// Upsert into _allTasks. Crucially, when _allTasks is null (fresh
|
||||||
task.residenceId?.let { residenceId ->
|
// launch, kanban never fetched — the gitea#2 bug scenario), seed
|
||||||
_tasksByResidence.value[residenceId]?.let { current ->
|
// an empty kanban shell so the new task isn't silently dropped.
|
||||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
// The Phase 2 force-refresh after bulkCreateTasks/createTask will
|
||||||
val newColumns = current.columns.map { column ->
|
// replace this shell with authoritative server data shortly.
|
||||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
val current = _allTasks.value ?: emptyKanbanShell()
|
||||||
val updatedTasks = if (column.name == targetColumn) {
|
val columnsWithTarget = if (current.columns.any { it.name == targetColumn }) {
|
||||||
filteredTasks + task
|
current.columns
|
||||||
} else {
|
} else {
|
||||||
filteredTasks
|
// Server returned a kanban_column the client doesn't know about
|
||||||
}
|
// yet — append it so the task is still reachable.
|
||||||
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
current.columns + emptyColumn(targetColumn)
|
||||||
}
|
|
||||||
_tasksByResidence.value = _tasksByResidence.value + (residenceId to current.copy(columns = newColumns))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val newColumns = columnsWithTarget.map { column ->
|
||||||
|
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||||
|
val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks
|
||||||
|
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||||
|
}
|
||||||
|
_allTasks.value = current.copy(columns = newColumns)
|
||||||
|
|
||||||
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
|
// 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 ->
|
||||||
@@ -529,15 +533,6 @@ 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()
|
||||||
@@ -780,7 +775,6 @@ 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()
|
||||||
@@ -811,7 +805,6 @@ 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
|
||||||
@@ -833,7 +826,6 @@ 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()
|
||||||
@@ -846,7 +838,6 @@ 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,6 +13,37 @@ 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("image_urls") val imageUrls: List<String>? = null // Multiple image URLs
|
@SerialName("upload_ids") val uploadIds: List<Int>? = null // pending_uploads.id values from /api/uploads/presign + direct B2 POST
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presigned upload session — request body for POST /api/uploads/presign.
|
||||||
|
*
|
||||||
|
* Category: "completion" | "document_image" | "document_file"
|
||||||
|
* ContentType: the MIME type the client will upload (must match the policy
|
||||||
|
* exactly when POSTing to B2).
|
||||||
|
* ContentLength: byte count of the upload (server permits ±256 bytes slack).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PresignUploadRequest(
|
||||||
|
val category: String,
|
||||||
|
@SerialName("content_type") val contentType: String,
|
||||||
|
@SerialName("content_length") val contentLength: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presigned upload session — response from POST /api/uploads/presign.
|
||||||
|
*
|
||||||
|
* The client uses [uploadUrl] + [fields] to perform a multipart/form-data
|
||||||
|
* POST directly to B2, then passes [id] back in the upload_ids[] field of
|
||||||
|
* the next /api/task-completions/ or /api/documents/ create call.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PresignUploadResponse(
|
||||||
|
val id: Int,
|
||||||
|
@SerialName("upload_url") val uploadUrl: String,
|
||||||
|
val fields: Map<String, String>,
|
||||||
|
val key: String,
|
||||||
|
@SerialName("expires_at") val expiresAt: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ object APILayer {
|
|||||||
private val notificationApi = NotificationApi()
|
private val 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 ====================
|
||||||
|
|
||||||
@@ -588,36 +589,22 @@ 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> {
|
||||||
// 1. Check residence-specific cache first
|
val allTasksResult = getTasks(forceRefresh = forceRefresh)
|
||||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) {
|
if (allTasksResult is ApiResult.Error) return allTasksResult
|
||||||
val cached = DataManager.tasksByResidence.value[residenceId]
|
|
||||||
if (cached != null) {
|
|
||||||
return ApiResult.Success(cached)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try filtering from allTasks cache before hitting API (optimization)
|
val filtered = DataManager.getTasksForResidence(residenceId)
|
||||||
// This avoids a redundant API call when we already have all tasks loaded
|
?: return ApiResult.Error("Tasks unavailable", 0)
|
||||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) {
|
return ApiResult.Success(filtered)
|
||||||
val filtered = DataManager.getTasksForResidence(residenceId)
|
|
||||||
if (filtered != null) {
|
|
||||||
// Cache the filtered result for future use
|
|
||||||
DataManager.setTasksForResidence(residenceId, filtered)
|
|
||||||
return ApiResult.Success(filtered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fallback: Fetch from API
|
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
|
||||||
val result = taskApi.getTasksByResidence(token, residenceId)
|
|
||||||
|
|
||||||
// Update DataManager on success
|
|
||||||
if (result is ApiResult.Success) {
|
|
||||||
DataManager.setTasksForResidence(residenceId, result.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createTask(request: TaskCreateRequest): ApiResult<TaskResponse> {
|
suspend fun createTask(request: TaskCreateRequest): ApiResult<TaskResponse> {
|
||||||
@@ -640,9 +627,15 @@ 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, every
|
* batch succeeds or fails together on the server. On success, force-
|
||||||
* returned task is merged into DataManager.allTasks so observing views
|
* refreshes _allTasks from the server — the server is the
|
||||||
* render the new batch immediately.
|
* authoritative kanban categorizer, and a single round-trip
|
||||||
|
* eliminates any drift between the per-task `kanbanColumn` hint and
|
||||||
|
* the global kanban view.
|
||||||
|
*
|
||||||
|
* This is the bug-class fix for gitea#2: the previous per-task
|
||||||
|
* updateTask loop was a no-op when _allTasks was null (fresh launch
|
||||||
|
* after onboarding), silently dropping the new tasks from cache.
|
||||||
*/
|
*/
|
||||||
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
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)
|
||||||
@@ -650,7 +643,9 @@ object APILayer {
|
|||||||
|
|
||||||
if (result is ApiResult.Success) {
|
if (result is ApiResult.Success) {
|
||||||
DataManager.setTotalSummary(result.data.summary)
|
DataManager.setTotalSummary(result.data.summary)
|
||||||
result.data.tasks.forEach { DataManager.updateTask(it) }
|
// Authoritative refresh — replaces any placeholder kanban
|
||||||
|
// shell from updateTask with proper server data.
|
||||||
|
getTasks(forceRefresh = true)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -789,30 +784,6 @@ object APILayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createTaskCompletionWithImages(
|
|
||||||
request: TaskCompletionCreateRequest,
|
|
||||||
images: List<ByteArray>,
|
|
||||||
imageFileNames: List<String>
|
|
||||||
): ApiResult<TaskCompletionResponse> {
|
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
|
||||||
val result = taskCompletionApi.createCompletionWithImages(token, request, images, imageFileNames)
|
|
||||||
|
|
||||||
if (result is ApiResult.Success) {
|
|
||||||
// Update summary from response - eliminates need for separate getSummary call
|
|
||||||
DataManager.setTotalSummary(result.data.summary)
|
|
||||||
// The response includes the updated task, update it in DataManager
|
|
||||||
result.data.data.updatedTask?.let { updatedTask ->
|
|
||||||
DataManager.updateTask(updatedTask)
|
|
||||||
}
|
|
||||||
return ApiResult.Success(result.data.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (result) {
|
|
||||||
is ApiResult.Error -> result
|
|
||||||
else -> ApiResult.Error("Unknown error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all completions for a specific task
|
* Get all completions for a specific task
|
||||||
*/
|
*/
|
||||||
@@ -1381,6 +1352,42 @@ 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,47 +94,4 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createCompletionWithImages(
|
|
||||||
token: String,
|
|
||||||
request: TaskCompletionCreateRequest,
|
|
||||||
images: List<ByteArray> = emptyList(),
|
|
||||||
imageFileNames: List<String> = emptyList()
|
|
||||||
): ApiResult<WithSummaryResponse<TaskCompletionResponse>> {
|
|
||||||
return try {
|
|
||||||
val response = client.submitFormWithBinaryData(
|
|
||||||
url = "$baseUrl/task-completions/",
|
|
||||||
formData = formData {
|
|
||||||
// Add text fields
|
|
||||||
append("task_id", request.taskId.toString())
|
|
||||||
request.completedAt?.let { append("completed_at", it) }
|
|
||||||
request.actualCost?.let { append("actual_cost", it.toString()) }
|
|
||||||
request.notes?.let { append("notes", it) }
|
|
||||||
request.rating?.let { append("rating", it.toString()) }
|
|
||||||
|
|
||||||
// Add image files
|
|
||||||
images.forEachIndexed { index, imageBytes ->
|
|
||||||
val fileName = imageFileNames.getOrNull(index) ?: "image_$index.jpg"
|
|
||||||
append(
|
|
||||||
"images",
|
|
||||||
imageBytes,
|
|
||||||
Headers.build {
|
|
||||||
append(HttpHeaders.ContentType, "image/jpeg")
|
|
||||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
header("Authorization", "Token $token")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
|
||||||
ApiResult.Success(response.body())
|
|
||||||
} else {
|
|
||||||
ApiResult.Error("Failed to create completion with images", response.status.value)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.tt.honeyDue.network
|
||||||
|
|
||||||
|
import com.tt.honeyDue.models.PresignUploadRequest
|
||||||
|
import com.tt.honeyDue.models.PresignUploadResponse
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.request.forms.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.utils.io.core.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three-step direct-to-B2 upload helper.
|
||||||
|
*
|
||||||
|
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a
|
||||||
|
* B2 POST policy plus form fields the client needs to perform the
|
||||||
|
* direct upload.
|
||||||
|
* Step 2: [postToStorage] — multipart/form-data POST straight to B2.
|
||||||
|
* Bytes never traverse our API server.
|
||||||
|
* Step 3: caller invokes the relevant entity-creation endpoint
|
||||||
|
* (POST /api/task-completions/, POST /api/documents/) with the
|
||||||
|
* returned upload_id in the `upload_ids` field.
|
||||||
|
*
|
||||||
|
* iOS uses its own native equivalent (PresignedUploader.swift) for memory
|
||||||
|
* reasons — Swift can stream a multipart body without buffering. Android
|
||||||
|
* uses this Kotlin path which works fine for ≤10 MB images.
|
||||||
|
*/
|
||||||
|
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||||
|
private val baseUrl = ApiClient.getBaseUrl()
|
||||||
|
|
||||||
|
/** Step 1 — request a signed POST policy. */
|
||||||
|
suspend fun presign(
|
||||||
|
token: String,
|
||||||
|
category: String,
|
||||||
|
contentType: String,
|
||||||
|
contentLength: Long,
|
||||||
|
): ApiResult<PresignUploadResponse> {
|
||||||
|
return try {
|
||||||
|
val response = client.post("$baseUrl/uploads/presign/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(PresignUploadRequest(category, contentType, contentLength))
|
||||||
|
}
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
ApiResult.Error(
|
||||||
|
when (response.status.value) {
|
||||||
|
413 -> "That photo is too large after resizing."
|
||||||
|
422 -> "That image format isn't supported."
|
||||||
|
429 -> "Too many uploads in flight; try again shortly."
|
||||||
|
else -> "Couldn't start upload (HTTP ${response.status.value})."
|
||||||
|
},
|
||||||
|
response.status.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Network error during presign")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2 — POST `data` directly to B2 using the signed policy fields.
|
||||||
|
*
|
||||||
|
* The S3 POST policy spec requires every signed field to appear before
|
||||||
|
* the file part, and `key` + `Content-Type` must match the policy
|
||||||
|
* exactly. Ktor's MultiPartFormDataContent preserves insertion order
|
||||||
|
* for the appended parts.
|
||||||
|
*/
|
||||||
|
suspend fun postToStorage(
|
||||||
|
uploadUrl: String,
|
||||||
|
fields: Map<String, String>,
|
||||||
|
data: ByteArray,
|
||||||
|
contentType: String,
|
||||||
|
fileName: String,
|
||||||
|
): ApiResult<Unit> {
|
||||||
|
return try {
|
||||||
|
val parts = formData {
|
||||||
|
// Stable order: signed fields first, then file. We rely on
|
||||||
|
// Ktor preserving the order in which append() is called.
|
||||||
|
fields.forEach { (k, v) -> append(k, v) }
|
||||||
|
append(
|
||||||
|
key = "file",
|
||||||
|
value = data,
|
||||||
|
headers = Headers.build {
|
||||||
|
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
||||||
|
append(HttpHeaders.ContentType, contentType)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val response = client.submitFormWithBinaryData(
|
||||||
|
url = uploadUrl,
|
||||||
|
formData = parts,
|
||||||
|
)
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
val body = try {
|
||||||
|
response.bodyAsText()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
ApiResult.Error(
|
||||||
|
"Upload to storage failed (HTTP ${response.status.value}): $body",
|
||||||
|
response.status.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Network error during upload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1 + Step 2 in one call. Returns the upload_id the caller passes
|
||||||
|
* back via upload_ids[] on the entity-creation endpoint.
|
||||||
|
*
|
||||||
|
* Errors at either step short-circuit and surface up — the partial
|
||||||
|
* pending_uploads row created at presign time will be reaped by the
|
||||||
|
* server-side hourly cleanup cron.
|
||||||
|
*/
|
||||||
|
suspend fun uploadOne(
|
||||||
|
token: String,
|
||||||
|
category: String,
|
||||||
|
contentType: String,
|
||||||
|
data: ByteArray,
|
||||||
|
fileName: String,
|
||||||
|
): ApiResult<Int> {
|
||||||
|
val presignResult = presign(token, category, contentType, data.size.toLong())
|
||||||
|
val presigned = (presignResult as? ApiResult.Success)?.data
|
||||||
|
?: return ApiResult.Error(
|
||||||
|
(presignResult as? ApiResult.Error)?.message ?: "Presign failed",
|
||||||
|
(presignResult as? ApiResult.Error)?.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
val postResult = postToStorage(
|
||||||
|
uploadUrl = presigned.uploadUrl,
|
||||||
|
fields = presigned.fields,
|
||||||
|
data = data,
|
||||||
|
contentType = contentType,
|
||||||
|
fileName = fileName,
|
||||||
|
)
|
||||||
|
return when (postResult) {
|
||||||
|
is ApiResult.Success -> ApiResult.Success(presigned.id)
|
||||||
|
is ApiResult.Error -> postResult
|
||||||
|
else -> ApiResult.Error("Upload failed in unknown state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -373,7 +373,8 @@ fun CompleteTaskDialog(
|
|||||||
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
||||||
notes = notesWithContractor,
|
notes = notesWithContractor,
|
||||||
rating = rating,
|
rating = rating,
|
||||||
imageUrls = null // Images uploaded separately and URLs added by handler
|
// upload_ids populated by the ViewModel after each
|
||||||
|
// image is uploaded directly to B2.
|
||||||
),
|
),
|
||||||
selectedImages
|
selectedImages
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -405,7 +405,8 @@ fun CompleteTaskScreen(
|
|||||||
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
||||||
notes = notesWithContractor,
|
notes = notesWithContractor,
|
||||||
rating = rating,
|
rating = rating,
|
||||||
imageUrls = null
|
// upload_ids populated by the ViewModel after each
|
||||||
|
// image is uploaded directly to B2.
|
||||||
),
|
),
|
||||||
selectedImages
|
selectedImages
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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
|
||||||
@@ -11,7 +12,10 @@ 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() {
|
||||||
@@ -28,8 +32,24 @@ 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
|
||||||
|
|
||||||
private val _residenceTasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
|
/// Residence-scoped kanban derived from `DataManager.allTasks` filtered
|
||||||
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _residenceTasksState
|
/// by `_currentResidenceId`. Re-emits whenever either upstream changes,
|
||||||
|
/// so the residence detail screen reacts to new tasks (created or
|
||||||
|
/// completed elsewhere) without manual refresh. Replaces the previous
|
||||||
|
/// imperative `_residenceTasksState` that was only written by
|
||||||
|
/// loadResidenceTasks's API result and stayed stale otherwise.
|
||||||
|
private val _currentResidenceId = MutableStateFlow<Int?>(null)
|
||||||
|
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
|
||||||
|
combine(DataManager.allTasks, _currentResidenceId) { all, id ->
|
||||||
|
when {
|
||||||
|
id == null -> ApiResult.Idle
|
||||||
|
all == null -> ApiResult.Loading
|
||||||
|
else -> {
|
||||||
|
val filtered = DataManager.getTasksForResidence(id)
|
||||||
|
if (filtered != null) ApiResult.Success(filtered) else ApiResult.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ApiResult.Idle)
|
||||||
|
|
||||||
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Idle)
|
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Idle)
|
||||||
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState
|
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState
|
||||||
@@ -85,13 +105,16 @@ class ResidenceViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun resetResidenceTasksState() {
|
fun resetResidenceTasksState() {
|
||||||
_residenceTasksState.value = ApiResult.Idle
|
_currentResidenceId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadResidenceTasks(residenceId: Int) {
|
fun loadResidenceTasks(residenceId: Int, forceRefresh: Boolean = false) {
|
||||||
|
_currentResidenceId.value = residenceId
|
||||||
|
// Trigger an _allTasks refresh in the background. The combine flow
|
||||||
|
// above re-emits Success when allTasks lands, so the screen
|
||||||
|
// re-renders without needing the result here.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_residenceTasksState.value = ApiResult.Loading
|
APILayer.getTasks(forceRefresh = forceRefresh)
|
||||||
_residenceTasksState.value = APILayer.getTasksByResidence(residenceId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-19
@@ -25,38 +25,61 @@ class TaskCompletionViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create task completion with images.
|
* Create task completion with images, using the presigned-URL upload flow.
|
||||||
|
*
|
||||||
|
* For each image: compress, presign + POST direct to B2, collect the
|
||||||
|
* upload_id. Once all uploads succeed, create the completion with the
|
||||||
|
* collected upload_ids in a single JSON request. Bytes never traverse
|
||||||
|
* our API server.
|
||||||
|
*
|
||||||
|
* If any individual upload fails, the whole batch fails — partial
|
||||||
|
* pending_uploads rows are reaped server-side by the hourly cleanup
|
||||||
|
* cron, so there's nothing to clean up client-side.
|
||||||
*
|
*
|
||||||
* @param request The completion request data
|
* @param 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
|
||||||
|
|
||||||
// Compress images and prepare for upload
|
val uploadIds = mutableListOf<Int>()
|
||||||
val compressedImages = images.map { ImageCompressor.compressImage(it) }
|
for ((index, image) in images.withIndex()) {
|
||||||
val imageFileNames = images.mapIndexed { index, image ->
|
val compressed = ImageCompressor.compressImage(image)
|
||||||
// Always use .jpg extension since we compress to JPEG
|
val fileName = run {
|
||||||
val baseName = image.fileName.ifBlank { "completion_$index" }
|
val base = image.fileName.ifBlank { "completion_$index" }
|
||||||
if (baseName.endsWith(".jpg", ignoreCase = true) ||
|
if (base.endsWith(".jpg", ignoreCase = true) ||
|
||||||
baseName.endsWith(".jpeg", ignoreCase = true)) {
|
base.endsWith(".jpeg", ignoreCase = true)
|
||||||
baseName
|
) base else base.substringBeforeLast('.', base) + ".jpg"
|
||||||
} else {
|
}
|
||||||
// Remove any existing extension and add .jpg
|
val uploadResult = APILayer.uploadImage(
|
||||||
baseName.substringBeforeLast('.', baseName) + ".jpg"
|
category = "completion",
|
||||||
|
contentType = "image/jpeg",
|
||||||
|
bytes = compressed,
|
||||||
|
fileName = fileName,
|
||||||
|
)
|
||||||
|
when (uploadResult) {
|
||||||
|
is ApiResult.Success -> uploadIds += uploadResult.data
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
_createCompletionState.value = ApiResult.Error(uploadResult.message, uploadResult.code)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
_createCompletionState.value = ApiResult.Error("Upload failed in unexpected state")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use APILayer which handles DataManager updates and summary refresh
|
val withUploads = if (uploadIds.isNotEmpty()) {
|
||||||
_createCompletionState.value = APILayer.createTaskCompletionWithImages(
|
request.copy(uploadIds = uploadIds.toList())
|
||||||
request = request,
|
} else {
|
||||||
images = compressedImages,
|
request
|
||||||
imageFileNames = imageFileNames
|
}
|
||||||
)
|
_createCompletionState.value = APILayer.createTaskCompletion(withUploads)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ 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
|
||||||
|
|
||||||
@@ -35,16 +32,6 @@ class TaskViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadTasksByResidence(residenceId: Int, forceRefresh: Boolean = false) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_tasksByResidenceState.value = ApiResult.Loading
|
|
||||||
_tasksByResidenceState.value = APILayer.getTasksByResidence(
|
|
||||||
residenceId = residenceId,
|
|
||||||
forceRefresh = forceRefresh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createNewTask(request: TaskCreateRequest) {
|
fun createNewTask(request: TaskCreateRequest) {
|
||||||
println("TaskViewModel: createNewTask called with $request")
|
println("TaskViewModel: createNewTask called with $request")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.tt.honeyDue.data
|
||||||
|
|
||||||
|
import com.tt.honeyDue.models.TaskResponse
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for the gitea#2 task-cache bug:
|
||||||
|
* `DataManager.updateTask` was a no-op when both `_allTasks` was null AND
|
||||||
|
* `_tasksByResidence[residenceId]` was empty — exactly the cache state
|
||||||
|
* after a fresh register-then-bulkCreateTasks flow. The just-created
|
||||||
|
* tasks would only appear after an app restart.
|
||||||
|
*
|
||||||
|
* After the fix, `updateTask` must seed `_allTasks` from empty rather
|
||||||
|
* than skipping the update.
|
||||||
|
*/
|
||||||
|
class DataManagerTaskCacheTest {
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun resetState() {
|
||||||
|
DataManager.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Onboarding-flow scenario: brand-new user, fresh launch, no kanban
|
||||||
|
/// has ever been fetched, then a task arrives via bulkCreateTasks →
|
||||||
|
/// DataManager.updateTask. The new task MUST land in `_allTasks` and
|
||||||
|
/// be visible to any observer.
|
||||||
|
@Test
|
||||||
|
fun updateTask_seedsAllTasks_whenCacheIsEmpty() {
|
||||||
|
// Given: fresh DataManager, kanban never loaded
|
||||||
|
assertEquals(null, DataManager.allTasks.value, "_allTasks must start null after clear()")
|
||||||
|
|
||||||
|
// When: a new task arrives via the same path bulkCreateTasks uses
|
||||||
|
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||||
|
|
||||||
|
// Then: _allTasks must contain that task in the right column
|
||||||
|
val allTasks = DataManager.allTasks.value
|
||||||
|
assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null")
|
||||||
|
|
||||||
|
val upcoming = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" }
|
||||||
|
assertNotNull(upcoming, "the seeded kanban must include an upcoming_tasks column")
|
||||||
|
assertTrue(
|
||||||
|
upcoming.tasks.any { it.id == 1 },
|
||||||
|
"the new task must land in upcoming_tasks; got columns=${allTasks.columns.map { it.name to it.tasks.map { t -> t.id } }}"
|
||||||
|
)
|
||||||
|
assertEquals(upcoming.tasks.size, upcoming.count, "column count must match tasks.size")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reasonable-defaults sanity check for the bulk-create scenario:
|
||||||
|
/// multiple tasks land across different kanban columns and end up
|
||||||
|
/// distributed correctly. This exercises the upsert when _allTasks
|
||||||
|
/// was seeded by a previous call.
|
||||||
|
@Test
|
||||||
|
fun updateTask_distributesAcrossColumns_whenSeedingThenAdding() {
|
||||||
|
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "overdue_tasks"))
|
||||||
|
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "upcoming_tasks"))
|
||||||
|
DataManager.updateTask(sampleTask(id = 3, residenceId = 100, column = "upcoming_tasks"))
|
||||||
|
|
||||||
|
val allTasks = DataManager.allTasks.value
|
||||||
|
assertNotNull(allTasks)
|
||||||
|
|
||||||
|
val overdue = allTasks.columns.first { it.name == "overdue_tasks" }
|
||||||
|
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
|
||||||
|
|
||||||
|
assertEquals(setOf(1), overdue.tasks.map { it.id }.toSet())
|
||||||
|
assertEquals(setOf(2, 3), upcoming.tasks.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replacement contract: calling updateTask with the same id twice
|
||||||
|
/// must not duplicate; the second call replaces the first wherever it
|
||||||
|
/// lives. Catches the "always-append" implementation mistake.
|
||||||
|
@Test
|
||||||
|
fun updateTask_replacesExistingTaskById_acrossColumns() {
|
||||||
|
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "upcoming_tasks", title = "v1"))
|
||||||
|
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "in_progress_tasks", title = "v2"))
|
||||||
|
|
||||||
|
val allTasks = DataManager.allTasks.value
|
||||||
|
assertNotNull(allTasks)
|
||||||
|
|
||||||
|
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
|
||||||
|
val inProgress = allTasks.columns.first { it.name == "in_progress_tasks" }
|
||||||
|
|
||||||
|
assertTrue(upcoming.tasks.none { it.id == 5 }, "task 5 must move out of upcoming_tasks")
|
||||||
|
assertEquals(1, inProgress.tasks.count { it.id == 5 }, "task 5 must appear once in in_progress_tasks")
|
||||||
|
assertEquals("v2", inProgress.tasks.first { it.id == 5 }.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characterization: getTasksForResidence filters _allTasks by
|
||||||
|
/// residence id. This is the helper that becomes the primary path
|
||||||
|
/// for residence-detail in Phase 3 (collapse the dual cache).
|
||||||
|
@Test
|
||||||
|
fun getTasksForResidence_filtersAllTasksByResidenceId() {
|
||||||
|
// Seed _allTasks with tasks across two residences via the upsert path.
|
||||||
|
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||||
|
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "overdue_tasks"))
|
||||||
|
DataManager.updateTask(sampleTask(id = 3, residenceId = 200, column = "upcoming_tasks"))
|
||||||
|
|
||||||
|
val r100 = DataManager.getTasksForResidence(100)
|
||||||
|
assertNotNull(r100)
|
||||||
|
val r100Ids = r100.columns.flatMap { it.tasks }.map { it.id }.toSet()
|
||||||
|
assertEquals(setOf(1, 2), r100Ids)
|
||||||
|
|
||||||
|
val r200 = DataManager.getTasksForResidence(200)
|
||||||
|
assertNotNull(r200)
|
||||||
|
val r200Ids = r200.columns.flatMap { it.tasks }.map { it.id }.toSet()
|
||||||
|
assertEquals(setOf(3), r200Ids)
|
||||||
|
|
||||||
|
// Counts on each column must match the filtered task lists.
|
||||||
|
for (column in r100.columns) {
|
||||||
|
assertEquals(column.tasks.size, column.count, "column ${column.name} count mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characterization: residenceId with no tasks returns a non-null
|
||||||
|
/// shell so the residence-detail screen can distinguish "loading"
|
||||||
|
/// (null) from "loaded, no tasks" (non-null with empty columns).
|
||||||
|
@Test
|
||||||
|
fun getTasksForResidence_returnsEmptyShellForResidenceWithNoTasks() {
|
||||||
|
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||||
|
|
||||||
|
val r999 = DataManager.getTasksForResidence(999)
|
||||||
|
assertNotNull(r999, "residence with no tasks must return an empty shell, not null")
|
||||||
|
assertEquals(0, r999.columns.sumOf { it.tasks.size })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characterization: when _allTasks is null entirely (cache never
|
||||||
|
/// populated), getTasksForResidence returns null — caller must call
|
||||||
|
/// the API path. Phase 3's getTasksByResidence relies on this.
|
||||||
|
@Test
|
||||||
|
fun getTasksForResidence_returnsNullWhenAllTasksIsNull() {
|
||||||
|
DataManager.clear()
|
||||||
|
assertEquals(null, DataManager.getTasksForResidence(100))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleTask(
|
||||||
|
id: Int,
|
||||||
|
residenceId: Int,
|
||||||
|
column: String,
|
||||||
|
title: String = "Task $id"
|
||||||
|
) = TaskResponse(
|
||||||
|
id = id,
|
||||||
|
residenceId = residenceId,
|
||||||
|
createdById = 1,
|
||||||
|
title = title,
|
||||||
|
kanbanColumn = column,
|
||||||
|
createdAt = "2026-04-25T00:00:00Z",
|
||||||
|
updatedAt = "2026-04-25T00:00:00Z"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,15 +18,6 @@ class TaskViewModelTest {
|
|||||||
assertIs<ApiResult.Idle>(viewModel.tasksState.value)
|
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
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Suite 11 — captures the gitea#2 regression at the user-visible level:
|
||||||
|
/// after onboarding (register → name residence → bulk-create tasks → land
|
||||||
|
/// on home), tapping the residence cell shows "no tasks" even though the
|
||||||
|
/// server has them. Restarting the app fixes it. This test reproduces the
|
||||||
|
/// flow without an app restart and asserts that tasks render on the
|
||||||
|
/// residence detail screen.
|
||||||
|
///
|
||||||
|
/// CRITICAL: this test must FAIL at the cache-unification fix's first
|
||||||
|
/// commit and must PASS after Phase 1-3 lands. The failing assertion is
|
||||||
|
/// pinned to a specific message so the regression is unambiguous.
|
||||||
|
///
|
||||||
|
/// The test deliberately does NOT visit the Tasks tab between onboarding
|
||||||
|
/// and tapping the residence cell. Visiting the Tasks tab would prime
|
||||||
|
/// `_allTasks` and mask the bug — the bug is that residence detail
|
||||||
|
/// cannot recover from the empty-cache + sink-timing window on its own.
|
||||||
|
final class Suite11_TaskCacheRegressionTests: BaseUITestCase {
|
||||||
|
// We need to start at the onboarding welcome screen, not the standalone
|
||||||
|
// login screen — `completeOnboarding` would skip the entire flow.
|
||||||
|
override var completeOnboarding: Bool { false }
|
||||||
|
// Single test in this suite — relaunch isn't necessary, but we want a
|
||||||
|
// clean state every time (handled by the default --reset-state).
|
||||||
|
override var relaunchBetweenTests: Bool { true }
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
/// DEBUG_FIXED_CODES=true on the local Go API hardcodes this code.
|
||||||
|
private let debugVerificationCode = "123456"
|
||||||
|
|
||||||
|
/// Stable name for the residence we create in onboarding. Used both for
|
||||||
|
/// the form input and to address the cell on the home screen via
|
||||||
|
/// `app.staticTexts[residenceName]` if the id-based identifier doesn't
|
||||||
|
/// resolve in time.
|
||||||
|
private let residenceName = "UI Test Property"
|
||||||
|
|
||||||
|
// MARK: - Test
|
||||||
|
|
||||||
|
/// Reproduces gitea#2: tasks created via the onboarding bulk endpoint
|
||||||
|
/// must appear on the residence detail screen without an app restart
|
||||||
|
/// and without first visiting the Tasks tab.
|
||||||
|
@MainActor
|
||||||
|
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
|
||||||
|
// Step 1 — Register a fresh user via the onboarding Start Fresh flow.
|
||||||
|
// The flow is: Welcome → ValueProps → NameResidence → CreateAccount
|
||||||
|
// → VerifyEmail → HomeProfile → FirstTask → main app.
|
||||||
|
let createAccount = TestFlows.navigateStartFreshToCreateAccount(
|
||||||
|
app: app,
|
||||||
|
residenceName: residenceName
|
||||||
|
)
|
||||||
|
createAccount.waitForLoad(timeout: navigationTimeout)
|
||||||
|
|
||||||
|
// Step 2 — Fill the create-account form. We address the onboarding
|
||||||
|
// form's fields (not the standalone register sheet's fields).
|
||||||
|
let creds = TestAccountManager.uniqueCredentials(prefix: "gitea2")
|
||||||
|
|
||||||
|
createAccount.expandEmailSignup()
|
||||||
|
|
||||||
|
// Use the same focusAndType path that OnboardingTests uses — it
|
||||||
|
// already handles SecureTextField + iOS strong-password panel.
|
||||||
|
// Under --ui-testing, OrganicOnboardingSecureField defaults to
|
||||||
|
// visibility=ON (renders as TextField) to dodge the iOS 26 SecureField
|
||||||
|
// keyboard bug. Query textFields, not secureTextFields.
|
||||||
|
let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
|
||||||
|
let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
|
||||||
|
let passwordField = app.textFields[AccessibilityIdentifiers.Onboarding.passwordField]
|
||||||
|
let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
|
||||||
|
|
||||||
|
usernameField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
usernameField.focusAndType(creds.username, app: app)
|
||||||
|
emailField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
emailField.focusAndType(creds.email, app: app)
|
||||||
|
passwordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
passwordField.focusAndType(creds.password, app: app)
|
||||||
|
confirmPasswordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
confirmPasswordField.focusAndType(creds.password, app: app)
|
||||||
|
|
||||||
|
let createAccountButton = app.descendants(matching: .any)
|
||||||
|
.matching(identifier: AccessibilityIdentifiers.Onboarding.createAccountButton)
|
||||||
|
.firstMatch
|
||||||
|
createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
createAccountButton.forceTap()
|
||||||
|
|
||||||
|
// Step 3 — Verify email with the debug fixed code.
|
||||||
|
let verification = VerificationScreen(app: app)
|
||||||
|
verification.waitForLoad(timeout: loginTimeout)
|
||||||
|
verification.enterCode(debugVerificationCode)
|
||||||
|
// Many onboarding verification screens auto-submit on a 6-digit
|
||||||
|
// code. If a verify button still exists and a code field is still
|
||||||
|
// visible, tap it to push past edge cases.
|
||||||
|
if verification.codeField.waitForExistence(timeout: 1) && verification.verifyButton.exists {
|
||||||
|
verification.submitCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 — Skip the home-profile step. The home-profile screen has
|
||||||
|
// its own Skip button (the shared onboarding skip in the nav bar)
|
||||||
|
// which routes to the first-task step without making us pick climate
|
||||||
|
// / appliance fields.
|
||||||
|
let onboardingSkipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton]
|
||||||
|
XCTAssertTrue(
|
||||||
|
onboardingSkipButton.waitForExistence(timeout: loginTimeout),
|
||||||
|
"Onboarding skip button should exist on the home-profile screen"
|
||||||
|
)
|
||||||
|
// The skip button can briefly be non-hittable during the screen-in
|
||||||
|
// transition. Use forceTap() to bypass the strict hittable check.
|
||||||
|
// We confirmed existence above; if the tap doesn't land on the
|
||||||
|
// intended button the next assertion (Browse All tab) will catch it.
|
||||||
|
onboardingSkipButton.forceTap()
|
||||||
|
|
||||||
|
// Step 5 — Switch to the "Browse All" tab on the First-Task screen.
|
||||||
|
// "For You" suggestions can be empty for a fresh residence with no
|
||||||
|
// home-profile data, so deterministic browsing is required.
|
||||||
|
// The tab bar is a SwiftUI segmented Picker — its segments are
|
||||||
|
// exposed as buttons with the segment label, regardless of an
|
||||||
|
// identifier on the parent.
|
||||||
|
let browseAllTab = app.buttons["Browse All"]
|
||||||
|
XCTAssertTrue(
|
||||||
|
browseAllTab.waitForExistence(timeout: loginTimeout),
|
||||||
|
"Browse All tab should appear on the first-task screen"
|
||||||
|
)
|
||||||
|
browseAllTab.tap()
|
||||||
|
|
||||||
|
// Step 6 — Pick 3 templates by accessibility identifier prefix.
|
||||||
|
// The catalog is loaded via GET /api/tasks/templates/grouped/, so
|
||||||
|
// we need to wait for at least one row to render before tapping.
|
||||||
|
let templateRowQuery = app.buttons.matching(
|
||||||
|
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||||
|
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for the catalog to load. The grouped endpoint returns first
|
||||||
|
// category expanded by default in the view, so rows should appear
|
||||||
|
// shortly after Browse All becomes visible. Network call: 10s.
|
||||||
|
let firstRow = templateRowQuery.element(boundBy: 0)
|
||||||
|
XCTAssertTrue(
|
||||||
|
firstRow.waitForExistence(timeout: loginTimeout),
|
||||||
|
"At least one template row must render on the Browse All tab. " +
|
||||||
|
"If no rows appear, the catalog endpoint failed — bug repro is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tap the first 3 visible rows. Some categories may collapse rows
|
||||||
|
// we never see; we only need at least 1, so the floor is 1 with a
|
||||||
|
// soft cap of 3.
|
||||||
|
let rowCount = templateRowQuery.count
|
||||||
|
let toPick = min(3, rowCount)
|
||||||
|
XCTAssertGreaterThanOrEqual(toPick, 1, "Expected at least one template row")
|
||||||
|
for index in 0..<toPick {
|
||||||
|
let row = templateRowQuery.element(boundBy: index)
|
||||||
|
row.waitUntilHittable(timeout: navigationTimeout)
|
||||||
|
row.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7 — Submit the bulk-create. This is the
|
||||||
|
// POST /api/tasks/bulk/ call that produces the inconsistent client
|
||||||
|
// cache state at the heart of gitea#2.
|
||||||
|
let submitButton = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
|
||||||
|
XCTAssertTrue(
|
||||||
|
submitButton.waitForExistence(timeout: navigationTimeout),
|
||||||
|
"Submit-tasks button must exist on the first-task screen"
|
||||||
|
)
|
||||||
|
submitButton.waitUntilHittable(timeout: navigationTimeout).tap()
|
||||||
|
|
||||||
|
// Step 8 — Land on the main app (Residences tab is selected by
|
||||||
|
// default). CRITICAL: do NOT tap the Tasks tab. Tapping it would
|
||||||
|
// populate `_allTasks` and mask the bug.
|
||||||
|
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||||
|
let tabBar = app.tabBars.firstMatch
|
||||||
|
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|
||||||
|
|| tabBar.waitForExistence(timeout: navigationTimeout)
|
||||||
|
XCTAssertTrue(reachedMain, "App should reach main tabs after onboarding submit")
|
||||||
|
|
||||||
|
// Step 9 — Tap the residence cell directly. Prefer the
|
||||||
|
// identifier-prefix match for any cell; fall back to the static
|
||||||
|
// text match by name.
|
||||||
|
let residenceCellQuery = app.buttons.matching(
|
||||||
|
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||||
|
AccessibilityIdentifiers.Residence.cellPrefix)
|
||||||
|
)
|
||||||
|
let residenceCell = residenceCellQuery.firstMatch
|
||||||
|
if residenceCell.waitForExistence(timeout: navigationTimeout) && residenceCell.isHittable {
|
||||||
|
residenceCell.tap()
|
||||||
|
} else {
|
||||||
|
// Fallback: tap the static text inside the card. The
|
||||||
|
// NavigationLink wraps the entire card so a tap on the name
|
||||||
|
// still routes into the detail view.
|
||||||
|
let residenceText = app.staticTexts[residenceName]
|
||||||
|
XCTAssertTrue(
|
||||||
|
residenceText.waitForExistence(timeout: navigationTimeout),
|
||||||
|
"Residence cell or name '\(residenceName)' must exist on the residences list"
|
||||||
|
)
|
||||||
|
residenceText.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 10 — THE BUG ASSERTION. With the bug present:
|
||||||
|
// - `_allTasks` is null on the client (never primed).
|
||||||
|
// - `_tasksByResidence[id]` is empty (cache miss).
|
||||||
|
// - residence detail attempts to load, hits the iOS Combine sink
|
||||||
|
// timing window, and renders the empty state.
|
||||||
|
// With the fix, both `_allTasks` is populated by `bulkCreateTasks`
|
||||||
|
// and residence detail filters from it in-memory, so the empty
|
||||||
|
// state must not appear.
|
||||||
|
let taskRowQuery = app.descendants(matching: .any).matching(
|
||||||
|
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||||
|
AccessibilityIdentifiers.Task.rowPrefix)
|
||||||
|
)
|
||||||
|
let firstTaskRow = taskRowQuery.element(boundBy: 0)
|
||||||
|
let anyTaskAppeared = firstTaskRow.waitForExistence(timeout: 10)
|
||||||
|
|
||||||
|
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.noTasksLabel]
|
||||||
|
let emptyStateVisible = emptyState.exists
|
||||||
|
|
||||||
|
// Pin the failure message so the bug-capture is unambiguous. This
|
||||||
|
// is the assertion that should FAIL at this commit and PASS after
|
||||||
|
// the cache fix lands. Don't change the message — Task 12 grep's
|
||||||
|
// for it.
|
||||||
|
XCTAssertTrue(
|
||||||
|
anyTaskAppeared && !emptyStateVisible,
|
||||||
|
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,6 @@ class DataManagerObservable: ObservableObject {
|
|||||||
// MARK: - Tasks
|
// MARK: - Tasks
|
||||||
|
|
||||||
@Published var allTasks: TaskColumnsResponse?
|
@Published var allTasks: TaskColumnsResponse?
|
||||||
@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]
|
|
||||||
|
|
||||||
// MARK: - Documents
|
// MARK: - Documents
|
||||||
|
|
||||||
@@ -191,15 +190,6 @@ 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 {
|
||||||
@@ -519,9 +509,27 @@ class DataManagerObservable: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Task Helpers
|
// MARK: - Task Helpers
|
||||||
|
|
||||||
/// Get tasks for a specific residence
|
/// Get tasks for a specific residence — derived from `_allTasks`
|
||||||
|
/// (single source of truth) by filtering in-memory.
|
||||||
func tasks(for residenceId: Int32) -> TaskColumnsResponse? {
|
func tasks(for residenceId: Int32) -> TaskColumnsResponse? {
|
||||||
return tasksByResidence[residenceId]
|
guard let all = allTasks else { return nil }
|
||||||
|
let filteredColumns = all.columns.map { column -> TaskColumn in
|
||||||
|
let filtered = column.tasks.filter { Int32($0.residenceId) == residenceId }
|
||||||
|
return TaskColumn(
|
||||||
|
name: column.name,
|
||||||
|
displayName: column.displayName,
|
||||||
|
buttonTypes: column.buttonTypes,
|
||||||
|
icons: column.icons,
|
||||||
|
color: column.color,
|
||||||
|
tasks: filtered,
|
||||||
|
count: Int32(filtered.count)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return TaskColumnsResponse(
|
||||||
|
columns: filteredColumns,
|
||||||
|
daysThreshold: all.daysThreshold,
|
||||||
|
residenceId: String(residenceId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get documents for a specific residence
|
/// Get documents for a specific residence
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// Memory-efficient image resizer for upload preprocessing.
|
||||||
|
///
|
||||||
|
/// Why not `UIImage.jpegData(compressionQuality:)` directly? UIImage decodes
|
||||||
|
/// the entire source bitmap into RAM before re-encoding — a 12 MP iPhone
|
||||||
|
/// photo decompresses to ~50 MB regardless of how big the JPEG is. With
|
||||||
|
/// multiple selected images this can blow up memory on older devices.
|
||||||
|
///
|
||||||
|
/// `CGImageSourceCreateThumbnailAtIndex` reads the source incrementally and
|
||||||
|
/// only allocates the *resized* bitmap, paying memory proportional to the
|
||||||
|
/// output size (a 2048×1536 thumbnail is ~12 MB, but the source is never
|
||||||
|
/// fully decoded).
|
||||||
|
///
|
||||||
|
/// Reference: https://nshipster.com/image-resizing/ — section "Image I/O".
|
||||||
|
enum ImageDownsampler {
|
||||||
|
|
||||||
|
/// Settings tuned per upload category. Edit here, not at call sites.
|
||||||
|
struct Profile {
|
||||||
|
/// Largest dimension (in points-after-scale, i.e. pixels) of the
|
||||||
|
/// downsampled image. The shorter edge is set proportionally.
|
||||||
|
let maxPixelEdge: CGFloat
|
||||||
|
|
||||||
|
/// JPEG quality, 0...1. 0.85 is the WhatsApp / Slack default —
|
||||||
|
/// visually indistinguishable from quality 1.0 at typical viewing
|
||||||
|
/// sizes; cuts file size by ~3x.
|
||||||
|
let jpegQuality: CGFloat
|
||||||
|
|
||||||
|
static let completion = Profile(maxPixelEdge: 2048, jpegQuality: 0.85)
|
||||||
|
static let documentImage = Profile(maxPixelEdge: 2560, jpegQuality: 0.90)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downsample raw image bytes (e.g. from a `PHPickerResult`'s
|
||||||
|
/// `loadDataRepresentation`) into a JPEG `Data` ready for upload.
|
||||||
|
///
|
||||||
|
/// - Returns: encoded JPEG bytes, or nil if decoding failed.
|
||||||
|
static func downsample(data: Data, profile: Profile) -> Data? {
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceShouldCache: false, // don't keep the full image around
|
||||||
|
kCGImageSourceTypeIdentifierHint: UTType.jpeg.identifier as CFString, // best-effort hint
|
||||||
|
]
|
||||||
|
guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return downsample(source: source, profile: profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downsample from a file URL (e.g. PhotosPicker's
|
||||||
|
/// `loadFileRepresentation`). Avoids materializing the full image in
|
||||||
|
/// memory before resize.
|
||||||
|
static func downsample(url: URL, profile: Profile) -> Data? {
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceShouldCache: false,
|
||||||
|
]
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, options as CFDictionary) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return downsample(source: source, profile: profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience for callers that already have a `UIImage` (e.g. from
|
||||||
|
/// `UIImagePickerController`). We round-trip through PNG to get raw
|
||||||
|
/// data, then use the data path. Slightly less efficient than starting
|
||||||
|
/// from URL/Data, but still avoids the JPEG re-encode penalty for the
|
||||||
|
/// resize step itself.
|
||||||
|
static func downsample(uiImage: UIImage, profile: Profile) -> Data? {
|
||||||
|
// Use PNG for the intermediate to avoid double-JPEG quality loss.
|
||||||
|
// Even though PNG is larger, this stays in memory only briefly.
|
||||||
|
guard let intermediate = uiImage.pngData() else { return nil }
|
||||||
|
return downsample(data: intermediate, profile: profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal
|
||||||
|
|
||||||
|
private static func downsample(source: CGImageSource, profile: Profile) -> Data? {
|
||||||
|
// Compute the max pixel size in screen-resolution-aware units. We
|
||||||
|
// use a fixed pixel cap because uploads are about bytes, not display.
|
||||||
|
let scale: CGFloat = 1.0
|
||||||
|
let maxDimensionInPixels = profile.maxPixelEdge * scale
|
||||||
|
|
||||||
|
let downsampleOptions: [CFString: Any] = [
|
||||||
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
|
kCGImageSourceShouldCacheImmediately: true, // decode on the calling thread
|
||||||
|
kCGImageSourceCreateThumbnailWithTransform: true, // honor EXIF orientation
|
||||||
|
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels,
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(
|
||||||
|
source, 0, downsampleOptions as CFDictionary
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let uiImage = UIImage(cgImage: cgImage)
|
||||||
|
return uiImage.jpegData(compressionQuality: profile.jpegQuality)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import Foundation
|
||||||
|
import ComposeApp
|
||||||
|
|
||||||
|
/// Three-step direct-to-B2 image upload.
|
||||||
|
///
|
||||||
|
/// Flow:
|
||||||
|
/// 1. POST /api/uploads/presign → server returns a B2 POST policy + form
|
||||||
|
/// fields scoped to a single object key with a content-length-range
|
||||||
|
/// condition that B2 enforces at the protocol level.
|
||||||
|
/// 2. Multipart POST the bytes directly to B2, no API server in the data
|
||||||
|
/// path. B2 rejects the upload if the bytes don't match the policy.
|
||||||
|
/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
|
||||||
|
/// /api/documents/ via `upload_ids[]`. The server HEADs the object,
|
||||||
|
/// confirms the size, and creates the linked entity rows.
|
||||||
|
///
|
||||||
|
/// All errors map to `PresignedUploaderError` — the Swift call site can
|
||||||
|
/// translate to user-facing copy without parsing nested HTTP details.
|
||||||
|
enum PresignedUploaderError: Error, LocalizedError {
|
||||||
|
case notAuthenticated
|
||||||
|
case presignFailed(status: Int, body: String)
|
||||||
|
case uploadFailed(status: Int, body: String)
|
||||||
|
case sessionError(Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notAuthenticated:
|
||||||
|
return "You're not signed in."
|
||||||
|
case .presignFailed(let status, _):
|
||||||
|
switch status {
|
||||||
|
case 413: return "That photo is too large after resizing. Try a different one."
|
||||||
|
case 422: return "That image format isn't supported."
|
||||||
|
case 429: return "You're uploading too many photos. Try again in a few minutes."
|
||||||
|
default: return "Couldn't start upload (server returned \(status))."
|
||||||
|
}
|
||||||
|
case .uploadFailed(let status, _):
|
||||||
|
return "Upload failed (B2 returned \(status))."
|
||||||
|
case .sessionError(let err):
|
||||||
|
return err.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Category passed to the presign endpoint. Matches the Go server's
|
||||||
|
/// `UploadCategory` constants in `internal/models/pending_upload.go`.
|
||||||
|
enum UploadCategory: String {
|
||||||
|
case completion = "completion"
|
||||||
|
case documentImage = "document_image"
|
||||||
|
case documentFile = "document_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Presigned-URL upload helper. Stateless — instantiate freely.
|
||||||
|
///
|
||||||
|
/// Concurrency: each `upload(...)` call runs to completion sequentially.
|
||||||
|
/// For multiple images the caller can run several uploads in parallel via
|
||||||
|
/// `withTaskGroup`; the server's per-user concurrency cap (10 in-flight
|
||||||
|
/// presigns) is enforced server-side.
|
||||||
|
final class PresignedUploader {
|
||||||
|
|
||||||
|
/// API base URL — read from KMP's ApiConfig so iOS and Android stay
|
||||||
|
/// in sync (LOCAL vs DEV vs PROD without divergent constants).
|
||||||
|
private let apiBaseURL: String
|
||||||
|
|
||||||
|
/// Bearer token. Read once at init; if the user re-auths mid-session,
|
||||||
|
/// the caller should construct a fresh PresignedUploader.
|
||||||
|
private let authToken: String
|
||||||
|
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init?(session: URLSession = .shared) {
|
||||||
|
// ApiConfig.shared.getBaseUrl() resolves Environment (LOCAL/DEV/PROD).
|
||||||
|
// DataManager.shared.authToken is a StateFlow<String?> — read the
|
||||||
|
// current value via .value (SKIE-exposed property).
|
||||||
|
let baseUrl = ApiConfig.shared.getBaseUrl()
|
||||||
|
guard let token = DataManager.shared.authToken.value as String? else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.apiBaseURL = baseUrl
|
||||||
|
self.authToken = token
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload `data` to B2 in the named category. Returns the
|
||||||
|
/// pending_uploads.id the caller passes via `upload_ids[]` to attach
|
||||||
|
/// the object to a real entity.
|
||||||
|
func upload(
|
||||||
|
data: Data,
|
||||||
|
category: UploadCategory,
|
||||||
|
contentType: String = "image/jpeg",
|
||||||
|
fileName: String = "image.jpg"
|
||||||
|
) async throws -> Int32 {
|
||||||
|
// Step 1: presign
|
||||||
|
let presigned = try await requestPresign(
|
||||||
|
category: category,
|
||||||
|
contentType: contentType,
|
||||||
|
contentLength: Int64(data.count)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 2: direct POST to B2
|
||||||
|
try await postToStorage(
|
||||||
|
uploadURL: presigned.uploadUrl,
|
||||||
|
fields: presigned.fields,
|
||||||
|
data: data,
|
||||||
|
contentType: contentType,
|
||||||
|
fileName: fileName
|
||||||
|
)
|
||||||
|
|
||||||
|
return Int32(presigned.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload several images in parallel, returning their upload_ids in
|
||||||
|
/// input order. Stops at the first failure and surfaces it.
|
||||||
|
func uploadAll(
|
||||||
|
items: [(Data, String)],
|
||||||
|
category: UploadCategory,
|
||||||
|
contentType: String = "image/jpeg"
|
||||||
|
) async throws -> [Int32] {
|
||||||
|
try await withThrowingTaskGroup(of: (Int, Int32).self) { group in
|
||||||
|
for (idx, item) in items.enumerated() {
|
||||||
|
let (data, name) = item
|
||||||
|
group.addTask { [self] in
|
||||||
|
let id = try await upload(
|
||||||
|
data: data,
|
||||||
|
category: category,
|
||||||
|
contentType: contentType,
|
||||||
|
fileName: name
|
||||||
|
)
|
||||||
|
return (idx, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var pairs: [(Int, Int32)] = []
|
||||||
|
for try await pair in group {
|
||||||
|
pairs.append(pair)
|
||||||
|
}
|
||||||
|
return pairs.sorted { $0.0 < $1.0 }.map { $0.1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Step 1: presign
|
||||||
|
|
||||||
|
private struct PresignBody: Encodable {
|
||||||
|
let category: String
|
||||||
|
let content_type: String
|
||||||
|
let content_length: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PresignResponse: Decodable {
|
||||||
|
let id: Int
|
||||||
|
let upload_url: String
|
||||||
|
let fields: [String: String]
|
||||||
|
let key: String
|
||||||
|
let expires_at: String
|
||||||
|
|
||||||
|
// Map snake_case to nicer Swift names at the call site.
|
||||||
|
var uploadUrl: String { upload_url }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestPresign(
|
||||||
|
category: UploadCategory,
|
||||||
|
contentType: String,
|
||||||
|
contentLength: Int64
|
||||||
|
) async throws -> PresignResponse {
|
||||||
|
guard var url = URL(string: apiBaseURL) else {
|
||||||
|
throw PresignedUploaderError.presignFailed(status: 0, body: "invalid base url")
|
||||||
|
}
|
||||||
|
url.appendPathComponent("uploads/presign/")
|
||||||
|
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
req.httpBody = try JSONEncoder().encode(PresignBody(
|
||||||
|
category: category.rawValue,
|
||||||
|
content_type: contentType,
|
||||||
|
content_length: contentLength
|
||||||
|
))
|
||||||
|
|
||||||
|
let (body, response): (Data, URLResponse)
|
||||||
|
do {
|
||||||
|
(body, response) = try await session.data(for: req)
|
||||||
|
} catch {
|
||||||
|
throw PresignedUploaderError.sessionError(error)
|
||||||
|
}
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw PresignedUploaderError.presignFailed(status: 0, body: "no response")
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
throw PresignedUploaderError.presignFailed(
|
||||||
|
status: http.statusCode,
|
||||||
|
body: String(data: body, encoding: .utf8) ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(PresignResponse.self, from: body)
|
||||||
|
} catch {
|
||||||
|
throw PresignedUploaderError.presignFailed(status: http.statusCode, body: "decode failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Step 2: POST to B2
|
||||||
|
|
||||||
|
private func postToStorage(
|
||||||
|
uploadURL: String,
|
||||||
|
fields: [String: String],
|
||||||
|
data: Data,
|
||||||
|
contentType: String,
|
||||||
|
fileName: String
|
||||||
|
) async throws {
|
||||||
|
guard let url = URL(string: uploadURL) else {
|
||||||
|
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a multipart/form-data body with all policy fields followed
|
||||||
|
// by a single "file" part (S3 POST policy mandates the file part
|
||||||
|
// come last).
|
||||||
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
|
var body = Data()
|
||||||
|
let crlf = "\r\n"
|
||||||
|
let appendString: (String) -> Void = { s in
|
||||||
|
body.append(s.data(using: .utf8) ?? Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable order: ensure "key" and "Content-Type" appear before the
|
||||||
|
// file part so the policy signature validates. Unspecified order
|
||||||
|
// for the rest — S3 accepts any.
|
||||||
|
let orderedKeys = ["key", "Content-Type", "policy", "x-amz-algorithm",
|
||||||
|
"x-amz-credential", "x-amz-date", "x-amz-signature",
|
||||||
|
"x-amz-meta-uid"]
|
||||||
|
var emitted = Set<String>()
|
||||||
|
for k in orderedKeys {
|
||||||
|
if let v = fields[k] {
|
||||||
|
appendString("--\(boundary)\(crlf)")
|
||||||
|
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
||||||
|
appendString(v)
|
||||||
|
appendString(crlf)
|
||||||
|
emitted.insert(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (k, v) in fields where !emitted.contains(k) {
|
||||||
|
appendString("--\(boundary)\(crlf)")
|
||||||
|
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
||||||
|
appendString(v)
|
||||||
|
appendString(crlf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// file part — must be last
|
||||||
|
appendString("--\(boundary)\(crlf)")
|
||||||
|
appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\(crlf)")
|
||||||
|
appendString("Content-Type: \(contentType)\(crlf)\(crlf)")
|
||||||
|
body.append(data)
|
||||||
|
appendString(crlf)
|
||||||
|
appendString("--\(boundary)--\(crlf)")
|
||||||
|
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = body
|
||||||
|
|
||||||
|
let (respBody, response): (Data, URLResponse)
|
||||||
|
do {
|
||||||
|
(respBody, response) = try await session.data(for: req)
|
||||||
|
} catch {
|
||||||
|
throw PresignedUploaderError.sessionError(error)
|
||||||
|
}
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw PresignedUploaderError.uploadFailed(status: 0, body: "no response")
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
throw PresignedUploaderError.uploadFailed(
|
||||||
|
status: http.statusCode,
|
||||||
|
body: String(data: respBody, encoding: .utf8) ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ final class WidgetActionProcessor {
|
|||||||
notes: "Completed from widget",
|
notes: "Completed from widget",
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
rating: nil,
|
rating: nil,
|
||||||
imageUrls: nil
|
uploadIds: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||||
|
|||||||
@@ -366,7 +366,12 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
||||||
if isRegistered {
|
if isRegistered {
|
||||||
// Registration successful - user is authenticated but not verified
|
// Registration successful — server gave us a token, so we ARE
|
||||||
|
// authenticated (just not verified yet). Mark the iOS-side auth
|
||||||
|
// state to match, otherwise OnboardingState.completeOnboarding's
|
||||||
|
// auth guard silently no-ops at the end of the flow and the
|
||||||
|
// user gets stuck on the firstTask screen.
|
||||||
|
AuthenticationManager.shared.login(verified: false)
|
||||||
onAccountCreated(false)
|
onAccountCreated(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,7 +456,13 @@ 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
|
||||||
@State private var showPassword = false
|
// iOS 26 has a known bug where tapping a SwiftUI SecureField with
|
||||||
|
// `.textContentType(.password)` doesn't reliably bring up the keyboard
|
||||||
|
// — the strong-password autofill panel steals focus. Under UI tests
|
||||||
|
// we force the visibility toggle ON, rendering as a plain TextField,
|
||||||
|
// which has reliable focus behavior. The plaintext isn't a security
|
||||||
|
// concern in test mode (test creds are throwaway).
|
||||||
|
@State private var showPassword = UITestRuntime.isEnabled
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
|||||||
notes: nil,
|
notes: nil,
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
rating: nil,
|
rating: nil,
|
||||||
imageUrls: nil
|
uploadIds: nil
|
||||||
)
|
)
|
||||||
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||||
|
|
||||||
|
|||||||
@@ -337,51 +337,119 @@ struct CompleteTaskView: View {
|
|||||||
|
|
||||||
isSubmitting = true
|
isSubmitting = true
|
||||||
|
|
||||||
// Create request with simplified Go API format
|
// New direct-to-B2 upload path: downsample on-device, presign, POST
|
||||||
// Note: completedAt defaults to now on server if not provided
|
// straight to B2, pass the resulting upload_ids to the completion
|
||||||
let request = TaskCompletionCreateRequest(
|
// create call. Bytes never traverse our API server. See
|
||||||
taskId: task.id,
|
// /api/uploads/presign in honeyDueAPI-go.
|
||||||
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 {
|
||||||
// Convert images to ImageData for Kotlin
|
uploadAndCreate()
|
||||||
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)
|
completionViewModel.createTaskCompletion(request: request)
|
||||||
|
observeCompletionState()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Observe the result — store the Task so it can be cancelled on dismiss
|
/// Async pipeline: downsample → presign+upload to B2 → create completion
|
||||||
|
/// with the returned upload_ids. Errors at any stage become a single
|
||||||
|
/// alert; partial uploads (1 of 3 succeeded) currently fail the whole
|
||||||
|
/// flow — server-side cleanup reaps the orphans within the hour.
|
||||||
|
private func uploadAndCreate() {
|
||||||
observationTask?.cancel()
|
observationTask?.cancel()
|
||||||
observationTask = Task {
|
observationTask = Task {
|
||||||
for await state in completionViewModel.createCompletionState {
|
// Step 1: downsample each image. Runs on the calling task; the
|
||||||
if Task.isCancelled { break }
|
// 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 {
|
await MainActor.run {
|
||||||
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
|
errorMessage = "One or more photos couldn't be processed."
|
||||||
self.isSubmitting = false
|
showError = true
|
||||||
self.onComplete(success.data?.updatedTask) // Pass back updated task
|
isSubmitting = false
|
||||||
self.dismiss()
|
|
||||||
} else if let error = ApiResultBridge.error(from: state) {
|
|
||||||
self.errorMessage = error.message
|
|
||||||
self.showError = true
|
|
||||||
self.isSubmitting = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Break out of loop on terminal states
|
// Step 2: presign + upload each to B2. PresignedUploader runs
|
||||||
if state is ApiResultSuccess<TaskCompletionResponse> || ApiResultBridge.isError(state) {
|
// them in parallel under a server-enforced concurrency cap of 10.
|
||||||
break
|
guard let uploader = PresignedUploader() else {
|
||||||
|
await MainActor.run {
|
||||||
|
errorMessage = "Not authenticated"
|
||||||
|
showError = true
|
||||||
|
isSubmitting = false
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let uploadIds: [Int32]
|
||||||
|
do {
|
||||||
|
uploadIds = try await uploader.uploadAll(items: payloads, category: .completion)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
errorMessage = (error as? PresignedUploaderError)?.errorDescription
|
||||||
|
?? error.localizedDescription
|
||||||
|
showError = true
|
||||||
|
isSubmitting = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: create completion via the existing endpoint, passing
|
||||||
|
// upload_ids so the server claims the pending_uploads rows and
|
||||||
|
// turns them into TaskCompletionImage rows.
|
||||||
|
let request = TaskCompletionCreateRequest(
|
||||||
|
taskId: task.id,
|
||||||
|
completedAt: nil,
|
||||||
|
notes: notes.isEmpty ? nil : notes,
|
||||||
|
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
|
||||||
|
rating: KotlinInt(int: Int32(rating)),
|
||||||
|
uploadIds: uploadIds.map { KotlinInt(int: $0) }
|
||||||
|
)
|
||||||
|
await MainActor.run {
|
||||||
|
completionViewModel.createTaskCompletion(request: request)
|
||||||
|
}
|
||||||
|
await observeCompletionStateAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe the createCompletionState StateFlow until a terminal value
|
||||||
|
/// arrives, then dismiss or surface an error. Called from the
|
||||||
|
/// no-images path.
|
||||||
|
private func observeCompletionState() {
|
||||||
|
observationTask?.cancel()
|
||||||
|
observationTask = Task {
|
||||||
|
await observeCompletionStateAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeCompletionStateAsync() async {
|
||||||
|
for await state in completionViewModel.createCompletionState {
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await MainActor.run {
|
||||||
|
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
|
||||||
|
self.isSubmitting = false
|
||||||
|
self.onComplete(success.data?.updatedTask)
|
||||||
|
self.dismiss()
|
||||||
|
} else if let error = ApiResultBridge.error(from: state) {
|
||||||
|
self.errorMessage = error.message
|
||||||
|
self.showError = true
|
||||||
|
self.isSubmitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state is ApiResultSuccess<TaskCompletionResponse> || ApiResultBridge.isError(state) {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,33 +42,24 @@ class TaskViewModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init() {
|
init() {
|
||||||
// Observe DataManagerObservable for all tasks data
|
// Single source of truth = DataManager._allTasks. When this VM is
|
||||||
|
// residence-scoped (currentResidenceId set), filter in-memory by
|
||||||
|
// residence id. Eliminates the gitea#2 race window where the
|
||||||
|
// per-residence cache slot could be empty while _allTasks was
|
||||||
|
// populated. Phase 3 deletes the per-residence cache entirely.
|
||||||
DataManagerObservable.shared.$allTasks
|
DataManagerObservable.shared.$allTasks
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] allTasks in
|
.sink { [weak self] allTasks in
|
||||||
// Skip DataManager updates during completion animation to prevent
|
guard let self else { return }
|
||||||
// the task from being moved out of its column before the animation finishes
|
guard !self.isAnimatingCompletion else { return }
|
||||||
guard self?.isAnimatingCompletion != true else { return }
|
|
||||||
// Only update if we're showing all tasks (no residence filter)
|
|
||||||
if self?.currentResidenceId == nil {
|
|
||||||
self?.tasksResponse = allTasks
|
|
||||||
if allTasks != nil {
|
|
||||||
self?.isLoadingTasks = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
// Observe tasks by residence
|
if let allTasks {
|
||||||
DataManagerObservable.shared.$tasksByResidence
|
if let resId = self.currentResidenceId {
|
||||||
.receive(on: DispatchQueue.main)
|
self.tasksResponse = self.filterTasks(allTasks, residenceId: resId)
|
||||||
.sink { [weak self] tasksByResidence in
|
} else {
|
||||||
guard self?.isAnimatingCompletion != true else { return }
|
self.tasksResponse = allTasks
|
||||||
// Only update if we're filtering by residence
|
}
|
||||||
if let resId = self?.currentResidenceId,
|
self.isLoadingTasks = false
|
||||||
let tasks = tasksByResidence[resId] {
|
|
||||||
self?.tasksResponse = tasks
|
|
||||||
self?.isLoadingTasks = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -382,6 +373,28 @@ class TaskViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filter the all-tasks kanban down to a single residence in-memory.
|
||||||
|
/// Mirrors `DataManager.getTasksForResidence` on the Kotlin side.
|
||||||
|
private func filterTasks(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse {
|
||||||
|
let filteredColumns = response.columns.map { column -> TaskColumn in
|
||||||
|
let filteredTasks = column.tasks.filter { Int32($0.residenceId) == residenceId }
|
||||||
|
return TaskColumn(
|
||||||
|
name: column.name,
|
||||||
|
displayName: column.displayName,
|
||||||
|
buttonTypes: column.buttonTypes,
|
||||||
|
icons: column.icons,
|
||||||
|
color: column.color,
|
||||||
|
tasks: filteredTasks,
|
||||||
|
count: Int32(filteredTasks.count)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return TaskColumnsResponse(
|
||||||
|
columns: filteredColumns,
|
||||||
|
daysThreshold: response.daysThreshold,
|
||||||
|
residenceId: String(residenceId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates a task in the kanban board by moving it to the correct column based on kanban_column
|
/// 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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user