Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2d03ef8b2 | |||
| fa0ce30257 | |||
| 49e2397e85 | |||
| 170a6d0e40 | |||
| 16096f4b70 |
@@ -91,7 +91,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
||||
notes = null,
|
||||
actualCost = null,
|
||||
rating = null,
|
||||
imageUrls = null
|
||||
)
|
||||
|
||||
when (val result = APILayer.createTaskCompletion(request)) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
-1
@@ -105,7 +105,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
||||
notes = "Completed from notification",
|
||||
actualCost = null,
|
||||
rating = null,
|
||||
imageUrls = null
|
||||
)
|
||||
when (val result = APILayer.createTaskCompletion(request)) {
|
||||
is ApiResult.Success -> {
|
||||
|
||||
@@ -13,6 +13,37 @@ data class TaskCompletionCreateRequest(
|
||||
val notes: String? = null,
|
||||
@SerialName("actual_cost") val actualCost: Double? = null,
|
||||
val rating: Int? = null, // 1-5 star rating
|
||||
@SerialName("image_urls") val imageUrls: List<String>? = null // Multiple image URLs
|
||||
@SerialName("upload_ids") val uploadIds: List<Int>? = null // pending_uploads.id values from /api/uploads/presign + direct B2 POST
|
||||
)
|
||||
|
||||
/**
|
||||
* Presigned upload session — request body for POST /api/uploads/presign.
|
||||
*
|
||||
* Category: "completion" | "document_image" | "document_file"
|
||||
* ContentType: the MIME type the client will upload (must match the policy
|
||||
* exactly when POSTing to B2).
|
||||
* ContentLength: byte count of the upload (server permits ±256 bytes slack).
|
||||
*/
|
||||
@Serializable
|
||||
data class PresignUploadRequest(
|
||||
val category: String,
|
||||
@SerialName("content_type") val contentType: String,
|
||||
@SerialName("content_length") val contentLength: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Presigned upload session — response from POST /api/uploads/presign.
|
||||
*
|
||||
* The client uses [uploadUrl] + [fields] to perform a multipart/form-data
|
||||
* POST directly to B2, then passes [id] back in the upload_ids[] field of
|
||||
* the next /api/task-completions/ or /api/documents/ create call.
|
||||
*/
|
||||
@Serializable
|
||||
data class PresignUploadResponse(
|
||||
val id: Int,
|
||||
@SerialName("upload_url") val uploadUrl: String,
|
||||
val fields: Map<String, String>,
|
||||
val key: String,
|
||||
@SerialName("expires_at") val expiresAt: String
|
||||
)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ object APILayer {
|
||||
private val notificationApi = NotificationApi()
|
||||
private val subscriptionApi = SubscriptionApi()
|
||||
private val taskTemplateApi = TaskTemplateApi()
|
||||
private val uploadApi = UploadApi()
|
||||
|
||||
// ==================== Initialization Guards ====================
|
||||
|
||||
@@ -815,30 +816,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
|
||||
*/
|
||||
@@ -1416,6 +1393,42 @@ object APILayer {
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== Upload Operations ====================
|
||||
|
||||
/**
|
||||
* Direct-to-B2 image upload. The bytes are POSTed straight to Backblaze
|
||||
* — they never touch our API server. Returns the pending_uploads.id
|
||||
* which the caller passes back via `upload_ids[]` on the next entity-
|
||||
* creation call (task completion, document, etc.).
|
||||
*
|
||||
* Caller responsibilities:
|
||||
* - Pre-downsample to a sensible size before calling. Use the
|
||||
* platform-specific ImageDownsampler (Android) or
|
||||
* ImageDownsampler.swift (iOS).
|
||||
* - Pass [contentType] matching the bytes (typically "image/jpeg").
|
||||
* - Pass a [fileName] for B2's metadata. Need not be unique — the
|
||||
* server picks the actual storage key.
|
||||
*
|
||||
* Errors at either step (presign or B2 POST) surface as ApiResult.Error.
|
||||
* Partial state (presign succeeded but B2 POST failed) is reaped by
|
||||
* the server-side cleanup cron within an hour.
|
||||
*/
|
||||
suspend fun uploadImage(
|
||||
category: String,
|
||||
contentType: String,
|
||||
bytes: ByteArray,
|
||||
fileName: String,
|
||||
): ApiResult<Int> {
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return uploadApi.uploadOne(
|
||||
token = token,
|
||||
category = category,
|
||||
contentType = contentType,
|
||||
data = bytes,
|
||||
fileName = fileName,
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Notification Operations ====================
|
||||
|
||||
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {
|
||||
|
||||
@@ -94,47 +94,4 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createCompletionWithImages(
|
||||
token: String,
|
||||
request: TaskCompletionCreateRequest,
|
||||
images: List<ByteArray> = emptyList(),
|
||||
imageFileNames: List<String> = emptyList()
|
||||
): ApiResult<WithSummaryResponse<TaskCompletionResponse>> {
|
||||
return try {
|
||||
val response = client.submitFormWithBinaryData(
|
||||
url = "$baseUrl/task-completions/",
|
||||
formData = formData {
|
||||
// Add text fields
|
||||
append("task_id", request.taskId.toString())
|
||||
request.completedAt?.let { append("completed_at", it) }
|
||||
request.actualCost?.let { append("actual_cost", it.toString()) }
|
||||
request.notes?.let { append("notes", it) }
|
||||
request.rating?.let { append("rating", it.toString()) }
|
||||
|
||||
// Add image files
|
||||
images.forEachIndexed { index, imageBytes ->
|
||||
val fileName = imageFileNames.getOrNull(index) ?: "image_$index.jpg"
|
||||
append(
|
||||
"images",
|
||||
imageBytes,
|
||||
Headers.build {
|
||||
append(HttpHeaders.ContentType, "image/jpeg")
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to create completion with images", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.models.PresignUploadRequest
|
||||
import com.tt.honeyDue.models.PresignUploadResponse
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.utils.io.core.*
|
||||
|
||||
/**
|
||||
* Three-step direct-to-B2 upload helper.
|
||||
*
|
||||
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a
|
||||
* B2 POST policy plus form fields the client needs to perform the
|
||||
* direct upload.
|
||||
* Step 2: [postToStorage] — multipart/form-data POST straight to B2.
|
||||
* Bytes never traverse our API server.
|
||||
* Step 3: caller invokes the relevant entity-creation endpoint
|
||||
* (POST /api/task-completions/, POST /api/documents/) with the
|
||||
* returned upload_id in the `upload_ids` field.
|
||||
*
|
||||
* iOS uses its own native equivalent (PresignedUploader.swift) for memory
|
||||
* reasons — Swift can stream a multipart body without buffering. Android
|
||||
* uses this Kotlin path which works fine for ≤10 MB images.
|
||||
*/
|
||||
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
private val baseUrl = ApiClient.getBaseUrl()
|
||||
|
||||
/** Step 1 — request a signed POST policy. */
|
||||
suspend fun presign(
|
||||
token: String,
|
||||
category: String,
|
||||
contentType: String,
|
||||
contentLength: Long,
|
||||
): ApiResult<PresignUploadResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/uploads/presign/") {
|
||||
header("Authorization", "Token $token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(PresignUploadRequest(category, contentType, contentLength))
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error(
|
||||
when (response.status.value) {
|
||||
413 -> "That photo is too large after resizing."
|
||||
422 -> "That image format isn't supported."
|
||||
429 -> "Too many uploads in flight; try again shortly."
|
||||
else -> "Couldn't start upload (HTTP ${response.status.value})."
|
||||
},
|
||||
response.status.value,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Network error during presign")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 — POST `data` directly to B2 using the signed policy fields.
|
||||
*
|
||||
* The S3 POST policy spec requires every signed field to appear before
|
||||
* the file part, and `key` + `Content-Type` must match the policy
|
||||
* exactly. Ktor's MultiPartFormDataContent preserves insertion order
|
||||
* for the appended parts.
|
||||
*/
|
||||
suspend fun postToStorage(
|
||||
uploadUrl: String,
|
||||
fields: Map<String, String>,
|
||||
data: ByteArray,
|
||||
contentType: String,
|
||||
fileName: String,
|
||||
): ApiResult<Unit> {
|
||||
return try {
|
||||
val parts = formData {
|
||||
// Stable order: signed fields first, then file. We rely on
|
||||
// Ktor preserving the order in which append() is called.
|
||||
fields.forEach { (k, v) -> append(k, v) }
|
||||
append(
|
||||
key = "file",
|
||||
value = data,
|
||||
headers = Headers.build {
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
||||
append(HttpHeaders.ContentType, contentType)
|
||||
},
|
||||
)
|
||||
}
|
||||
val response = client.submitFormWithBinaryData(
|
||||
url = uploadUrl,
|
||||
formData = parts,
|
||||
)
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
val body = try {
|
||||
response.bodyAsText()
|
||||
} catch (_: Throwable) {
|
||||
""
|
||||
}
|
||||
ApiResult.Error(
|
||||
"Upload to storage failed (HTTP ${response.status.value}): $body",
|
||||
response.status.value,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Network error during upload")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 + Step 2 in one call. Returns the upload_id the caller passes
|
||||
* back via upload_ids[] on the entity-creation endpoint.
|
||||
*
|
||||
* Errors at either step short-circuit and surface up — the partial
|
||||
* pending_uploads row created at presign time will be reaped by the
|
||||
* server-side hourly cleanup cron.
|
||||
*/
|
||||
suspend fun uploadOne(
|
||||
token: String,
|
||||
category: String,
|
||||
contentType: String,
|
||||
data: ByteArray,
|
||||
fileName: String,
|
||||
): ApiResult<Int> {
|
||||
val presignResult = presign(token, category, contentType, data.size.toLong())
|
||||
val presigned = (presignResult as? ApiResult.Success)?.data
|
||||
?: return ApiResult.Error(
|
||||
(presignResult as? ApiResult.Error)?.message ?: "Presign failed",
|
||||
(presignResult as? ApiResult.Error)?.code,
|
||||
)
|
||||
|
||||
val postResult = postToStorage(
|
||||
uploadUrl = presigned.uploadUrl,
|
||||
fields = presigned.fields,
|
||||
data = data,
|
||||
contentType = contentType,
|
||||
fileName = fileName,
|
||||
)
|
||||
return when (postResult) {
|
||||
is ApiResult.Success -> ApiResult.Success(presigned.id)
|
||||
is ApiResult.Error -> postResult
|
||||
else -> ApiResult.Error("Upload failed in unknown state")
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -377,7 +377,8 @@ fun CompleteTaskDialog(
|
||||
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
||||
notes = notesWithContractor,
|
||||
rating = rating,
|
||||
imageUrls = null // Images uploaded separately and URLs added by handler
|
||||
// upload_ids populated by the ViewModel after each
|
||||
// image is uploaded directly to B2.
|
||||
),
|
||||
selectedImages
|
||||
)
|
||||
|
||||
@@ -421,7 +421,8 @@ fun CompleteTaskScreen(
|
||||
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
||||
notes = notesWithContractor,
|
||||
rating = rating,
|
||||
imageUrls = null
|
||||
// upload_ids populated by the ViewModel after each
|
||||
// image is uploaded directly to B2.
|
||||
),
|
||||
selectedImages
|
||||
)
|
||||
|
||||
+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 images List of ImageData (from platform-specific image pickers)
|
||||
*/
|
||||
fun createTaskCompletionWithImages(
|
||||
request: TaskCompletionCreateRequest,
|
||||
images: List<com.tt.honeyDue.platform.ImageData> = emptyList()
|
||||
images: List<com.tt.honeyDue.platform.ImageData> = emptyList(),
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_createCompletionState.value = ApiResult.Loading
|
||||
|
||||
// Compress images and prepare for upload
|
||||
val compressedImages = images.map { ImageCompressor.compressImage(it) }
|
||||
val imageFileNames = images.mapIndexed { index, image ->
|
||||
// Always use .jpg extension since we compress to JPEG
|
||||
val baseName = image.fileName.ifBlank { "completion_$index" }
|
||||
if (baseName.endsWith(".jpg", ignoreCase = true) ||
|
||||
baseName.endsWith(".jpeg", ignoreCase = true)) {
|
||||
baseName
|
||||
} else {
|
||||
// Remove any existing extension and add .jpg
|
||||
baseName.substringBeforeLast('.', baseName) + ".jpg"
|
||||
val uploadIds = mutableListOf<Int>()
|
||||
for ((index, image) in images.withIndex()) {
|
||||
val compressed = ImageCompressor.compressImage(image)
|
||||
val fileName = run {
|
||||
val base = image.fileName.ifBlank { "completion_$index" }
|
||||
if (base.endsWith(".jpg", ignoreCase = true) ||
|
||||
base.endsWith(".jpeg", ignoreCase = true)
|
||||
) base else base.substringBeforeLast('.', base) + ".jpg"
|
||||
}
|
||||
val uploadResult = APILayer.uploadImage(
|
||||
category = "completion",
|
||||
contentType = "image/jpeg",
|
||||
bytes = compressed,
|
||||
fileName = fileName,
|
||||
)
|
||||
when (uploadResult) {
|
||||
is ApiResult.Success -> uploadIds += uploadResult.data
|
||||
is ApiResult.Error -> {
|
||||
_createCompletionState.value = ApiResult.Error(uploadResult.message, uploadResult.code)
|
||||
return@launch
|
||||
}
|
||||
else -> {
|
||||
_createCompletionState.value = ApiResult.Error("Upload failed in unexpected state")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use APILayer which handles DataManager updates and summary refresh
|
||||
_createCompletionState.value = APILayer.createTaskCompletionWithImages(
|
||||
request = request,
|
||||
images = compressedImages,
|
||||
imageFileNames = imageFileNames
|
||||
)
|
||||
val withUploads = if (uploadIds.isNotEmpty()) {
|
||||
request.copy(uploadIds = uploadIds.toList())
|
||||
} else {
|
||||
request
|
||||
}
|
||||
_createCompletionState.value = APILayer.createTaskCompletion(withUploads)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+110
-110
@@ -58,8 +58,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/login_light.png" alt="login_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_login.login_light.png" alt="login_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/login_dark.png" alt="login_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_login.login_dark.png" alt="login_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -69,8 +69,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/register_light.png" alt="register_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_register.register_light.png" alt="register_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/register_dark.png" alt="register_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_register.register_dark.png" alt="register_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -80,8 +80,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/forgot_password_light.png" alt="forgot_password_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_forgot_password.forgot_password_light.png" alt="forgot_password_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/forgot_password_dark.png" alt="forgot_password_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_forgot_password.forgot_password_dark.png" alt="forgot_password_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -91,8 +91,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/verify_reset_code_light.png" alt="verify_reset_code_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_verify_reset_code.verify_reset_code_light.png" alt="verify_reset_code_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/verify_reset_code_dark.png" alt="verify_reset_code_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_verify_reset_code.verify_reset_code_dark.png" alt="verify_reset_code_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -102,8 +102,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/reset_password_light.png" alt="reset_password_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_reset_password.reset_password_light.png" alt="reset_password_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/reset_password_dark.png" alt="reset_password_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_reset_password.reset_password_dark.png" alt="reset_password_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -113,8 +113,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/verify_email_light.png" alt="verify_email_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_verify_email.verify_email_light.png" alt="verify_email_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/verify_email_dark.png" alt="verify_email_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_verify_email.verify_email_dark.png" alt="verify_email_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -124,8 +124,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_welcome_light.png" alt="onboarding_welcome_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_welcome.onboarding_welcome_light.png" alt="onboarding_welcome_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_welcome_dark.png" alt="onboarding_welcome_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_welcome.onboarding_welcome_dark.png" alt="onboarding_welcome_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -135,8 +135,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_value_props_light.png" alt="onboarding_value_props_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_value_props.onboarding_value_props_light.png" alt="onboarding_value_props_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_value_props_dark.png" alt="onboarding_value_props_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_value_props.onboarding_value_props_dark.png" alt="onboarding_value_props_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -146,8 +146,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_create_account_light.png" alt="onboarding_create_account_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_create_account.onboarding_create_account_light.png" alt="onboarding_create_account_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_create_account_dark.png" alt="onboarding_create_account_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_create_account.onboarding_create_account_dark.png" alt="onboarding_create_account_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -157,8 +157,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_verify_email_light.png" alt="onboarding_verify_email_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_verify_email.onboarding_verify_email_light.png" alt="onboarding_verify_email_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_verify_email_dark.png" alt="onboarding_verify_email_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_verify_email.onboarding_verify_email_dark.png" alt="onboarding_verify_email_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -168,8 +168,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_location_light.png" alt="onboarding_location_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_location.onboarding_location_light.png" alt="onboarding_location_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_location_dark.png" alt="onboarding_location_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_location.onboarding_location_dark.png" alt="onboarding_location_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -179,8 +179,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_name_residence_light.png" alt="onboarding_name_residence_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_name_residence.onboarding_name_residence_light.png" alt="onboarding_name_residence_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_name_residence_dark.png" alt="onboarding_name_residence_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_name_residence.onboarding_name_residence_dark.png" alt="onboarding_name_residence_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -190,8 +190,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_home_profile_light.png" alt="onboarding_home_profile_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_home_profile.onboarding_home_profile_light.png" alt="onboarding_home_profile_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_home_profile_dark.png" alt="onboarding_home_profile_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_home_profile.onboarding_home_profile_dark.png" alt="onboarding_home_profile_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -201,8 +201,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_join_residence_light.png" alt="onboarding_join_residence_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_join_residence.onboarding_join_residence_light.png" alt="onboarding_join_residence_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_join_residence_dark.png" alt="onboarding_join_residence_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_join_residence.onboarding_join_residence_dark.png" alt="onboarding_join_residence_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -212,10 +212,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** |  |  |
|
||||
| **empty / dark** |  |  |
|
||||
| **populated / light** |  |  |
|
||||
| **populated / dark** |  |  |
|
||||
| **empty / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_first_task_empty_light.png" alt="onboarding_first_task_empty_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_first_task_empty.onboarding_first_task_empty_light.png" alt="onboarding_first_task_empty_light iOS" width="260" height="560"> |
|
||||
| **empty / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_first_task_empty_dark.png" alt="onboarding_first_task_empty_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_first_task_empty.onboarding_first_task_empty_dark.png" alt="onboarding_first_task_empty_dark iOS" width="260" height="560"> |
|
||||
| **populated / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_first_task_populated_light.png" alt="onboarding_first_task_populated_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_first_task.onboarding_first_task_populated_light.png" alt="onboarding_first_task_populated_light iOS" width="260" height="560"> |
|
||||
| **populated / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_first_task_populated_dark.png" alt="onboarding_first_task_populated_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_first_task.onboarding_first_task_populated_dark.png" alt="onboarding_first_task_populated_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -225,8 +225,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_subscription_light.png" alt="onboarding_subscription_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_subscription.onboarding_subscription_light.png" alt="onboarding_subscription_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/onboarding_subscription_dark.png" alt="onboarding_subscription_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_onboarding_subscription.onboarding_subscription_dark.png" alt="onboarding_subscription_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -236,10 +236,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** |  | _(not on ios)_ |
|
||||
| **empty / dark** |  | _(not on ios)_ |
|
||||
| **populated / light** |  | _(not on ios)_ |
|
||||
| **populated / dark** |  | _(not on ios)_ |
|
||||
| **empty / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/home_empty_light.png" alt="home_empty_light Android" width="260" height="560"> | _(not on ios)_ |
|
||||
| **empty / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/home_empty_dark.png" alt="home_empty_dark Android" width="260" height="560"> | _(not on ios)_ |
|
||||
| **populated / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/home_populated_light.png" alt="home_populated_light Android" width="260" height="560"> | _(not on ios)_ |
|
||||
| **populated / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/home_populated_dark.png" alt="home_populated_dark Android" width="260" height="560"> | _(not on ios)_ |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -249,10 +249,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** |  |  |
|
||||
| **empty / dark** |  |  |
|
||||
| **populated / light** |  |  |
|
||||
| **populated / dark** |  |  |
|
||||
| **empty / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/residences_empty_light.png" alt="residences_empty_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_residences.residences_empty_light.png" alt="residences_empty_light iOS" width="260" height="560"> |
|
||||
| **empty / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/residences_empty_dark.png" alt="residences_empty_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_residences.residences_empty_dark.png" alt="residences_empty_dark iOS" width="260" height="560"> |
|
||||
| **populated / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/residences_populated_light.png" alt="residences_populated_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_residences.residences_populated_light.png" alt="residences_populated_light iOS" width="260" height="560"> |
|
||||
| **populated / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/residences_populated_dark.png" alt="residences_populated_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_residences.residences_populated_dark.png" alt="residences_populated_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -262,10 +262,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** |  |  |
|
||||
| **empty / dark** |  |  |
|
||||
| **populated / light** |  |  |
|
||||
| **populated / dark** |  |  |
|
||||
| **empty / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/residence_detail_empty_light.png" alt="residence_detail_empty_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_residence_detail.residence_detail_empty_light.png" alt="residence_detail_empty_light iOS" width="260" height="560"> |
|
||||
| **empty / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/residence_detail_empty_dark.png" alt="residence_detail_empty_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_residence_detail.residence_detail_empty_dark.png" alt="residence_detail_empty_dark iOS" width="260" height="560"> |
|
||||
| **populated / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/residence_detail_populated_light.png" alt="residence_detail_populated_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_residence_detail.residence_detail_populated_light.png" alt="residence_detail_populated_light iOS" width="260" height="560"> |
|
||||
| **populated / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/residence_detail_populated_dark.png" alt="residence_detail_populated_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_residence_detail.residence_detail_populated_dark.png" alt="residence_detail_populated_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -275,8 +275,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/add_residence_light.png" alt="add_residence_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_add_residence.add_residence_light.png" alt="add_residence_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/add_residence_dark.png" alt="add_residence_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_add_residence.add_residence_dark.png" alt="add_residence_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -286,8 +286,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/edit_residence_light.png" alt="edit_residence_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_edit_residence.edit_residence_light.png" alt="edit_residence_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/edit_residence_dark.png" alt="edit_residence_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_edit_residence.edit_residence_dark.png" alt="edit_residence_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -297,8 +297,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/join_residence_light.png" alt="join_residence_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_join_residence.join_residence_light.png" alt="join_residence_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/join_residence_dark.png" alt="join_residence_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_join_residence.join_residence_dark.png" alt="join_residence_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -308,8 +308,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  | _\[missing — ios\]_ |
|
||||
| **dark** |  | _\[missing — ios\]_ |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/manage_users_light.png" alt="manage_users_light Android" width="260" height="560"> | _\[missing — ios\]_ |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/manage_users_dark.png" alt="manage_users_dark Android" width="260" height="560"> | _\[missing — ios\]_ |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -319,10 +319,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** |  |  |
|
||||
| **empty / dark** |  |  |
|
||||
| **populated / light** |  |  |
|
||||
| **populated / dark** |  |  |
|
||||
| **empty / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/all_tasks_empty_light.png" alt="all_tasks_empty_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_all_tasks_empty.all_tasks_empty_light.png" alt="all_tasks_empty_light iOS" width="260" height="560"> |
|
||||
| **empty / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/all_tasks_empty_dark.png" alt="all_tasks_empty_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_all_tasks.all_tasks_empty_dark.png" alt="all_tasks_empty_dark iOS" width="260" height="560"> |
|
||||
| **populated / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/all_tasks_populated_light.png" alt="all_tasks_populated_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_all_tasks.all_tasks_populated_light.png" alt="all_tasks_populated_light iOS" width="260" height="560"> |
|
||||
| **populated / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/all_tasks_populated_dark.png" alt="all_tasks_populated_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_all_tasks_populated.all_tasks_populated_dark.png" alt="all_tasks_populated_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -332,8 +332,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** | _(not on android)_ |  |
|
||||
| **dark** | _(not on android)_ |  |
|
||||
| **light** | _(not on android)_ | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_add_task.add_task_light.png" alt="add_task_light iOS" width="260" height="560"> |
|
||||
| **dark** | _(not on android)_ | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_add_task.add_task_dark.png" alt="add_task_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -343,8 +343,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/add_task_with_residence_light.png" alt="add_task_with_residence_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_add_task_with_residence.add_task_with_residence_light.png" alt="add_task_with_residence_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/add_task_with_residence_dark.png" alt="add_task_with_residence_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_add_task_with_residence.add_task_with_residence_dark.png" alt="add_task_with_residence_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -354,8 +354,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/edit_task_light.png" alt="edit_task_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_edit_task.edit_task_light.png" alt="edit_task_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/edit_task_dark.png" alt="edit_task_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_edit_task.edit_task_dark.png" alt="edit_task_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -365,8 +365,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/complete_task_light.png" alt="complete_task_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_complete_task.complete_task_light.png" alt="complete_task_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/complete_task_dark.png" alt="complete_task_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_complete_task.complete_task_dark.png" alt="complete_task_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -376,8 +376,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  | _\[missing — ios\]_ |
|
||||
| **dark** |  | _\[missing — ios\]_ |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/task_suggestions_light.png" alt="task_suggestions_light Android" width="260" height="560"> | _\[missing — ios\]_ |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/task_suggestions_dark.png" alt="task_suggestions_dark Android" width="260" height="560"> | _\[missing — ios\]_ |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -387,10 +387,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** |  |  |
|
||||
| **empty / dark** |  |  |
|
||||
| **populated / light** |  |  |
|
||||
| **populated / dark** |  |  |
|
||||
| **empty / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/task_templates_browser_empty_light.png" alt="task_templates_browser_empty_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_task_templates_browser_empty.task_templates_browser_empty_light.png" alt="task_templates_browser_empty_light iOS" width="260" height="560"> |
|
||||
| **empty / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/task_templates_browser_empty_dark.png" alt="task_templates_browser_empty_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_task_templates_browser.task_templates_browser_empty_dark.png" alt="task_templates_browser_empty_dark iOS" width="260" height="560"> |
|
||||
| **populated / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/task_templates_browser_populated_light.png" alt="task_templates_browser_populated_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_task_templates_browser_populated.task_templates_browser_populated_light.png" alt="task_templates_browser_populated_light iOS" width="260" height="560"> |
|
||||
| **populated / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/task_templates_browser_populated_dark.png" alt="task_templates_browser_populated_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_task_templates_browser.task_templates_browser_populated_dark.png" alt="task_templates_browser_populated_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -400,10 +400,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** |  |  |
|
||||
| **empty / dark** |  |  |
|
||||
| **populated / light** |  |  |
|
||||
| **populated / dark** |  |  |
|
||||
| **empty / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/contractors_empty_light.png" alt="contractors_empty_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_contractors.contractors_empty_light.png" alt="contractors_empty_light iOS" width="260" height="560"> |
|
||||
| **empty / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/contractors_empty_dark.png" alt="contractors_empty_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_contractors.contractors_empty_dark.png" alt="contractors_empty_dark iOS" width="260" height="560"> |
|
||||
| **populated / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/contractors_populated_light.png" alt="contractors_populated_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_contractors.contractors_populated_light.png" alt="contractors_populated_light iOS" width="260" height="560"> |
|
||||
| **populated / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/contractors_populated_dark.png" alt="contractors_populated_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_contractors.contractors_populated_dark.png" alt="contractors_populated_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -413,10 +413,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** |  |  |
|
||||
| **empty / dark** |  |  |
|
||||
| **populated / light** |  |  |
|
||||
| **populated / dark** |  |  |
|
||||
| **empty / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/contractor_detail_empty_light.png" alt="contractor_detail_empty_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_contractor_detail.contractor_detail_empty_light.png" alt="contractor_detail_empty_light iOS" width="260" height="560"> |
|
||||
| **empty / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/contractor_detail_empty_dark.png" alt="contractor_detail_empty_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_contractor_detail.contractor_detail_empty_dark.png" alt="contractor_detail_empty_dark iOS" width="260" height="560"> |
|
||||
| **populated / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/contractor_detail_populated_light.png" alt="contractor_detail_populated_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_contractor_detail.contractor_detail_populated_light.png" alt="contractor_detail_populated_light iOS" width="260" height="560"> |
|
||||
| **populated / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/contractor_detail_populated_dark.png" alt="contractor_detail_populated_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_contractor_detail.contractor_detail_populated_dark.png" alt="contractor_detail_populated_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -426,10 +426,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** |  | _(not on ios)_ |
|
||||
| **empty / dark** |  | _(not on ios)_ |
|
||||
| **populated / light** |  | _(not on ios)_ |
|
||||
| **populated / dark** |  | _(not on ios)_ |
|
||||
| **empty / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/documents_empty_light.png" alt="documents_empty_light Android" width="260" height="560"> | _(not on ios)_ |
|
||||
| **empty / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/documents_empty_dark.png" alt="documents_empty_dark Android" width="260" height="560"> | _(not on ios)_ |
|
||||
| **populated / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/documents_populated_light.png" alt="documents_populated_light Android" width="260" height="560"> | _(not on ios)_ |
|
||||
| **populated / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/documents_populated_dark.png" alt="documents_populated_dark Android" width="260" height="560"> | _(not on ios)_ |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -439,10 +439,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** | _(not on android)_ |  |
|
||||
| **empty / dark** | _(not on android)_ |  |
|
||||
| **populated / light** | _(not on android)_ |  |
|
||||
| **populated / dark** | _(not on android)_ |  |
|
||||
| **empty / light** | _(not on android)_ | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_documents_warranties.documents_warranties_empty_light.png" alt="documents_warranties_empty_light iOS" width="260" height="560"> |
|
||||
| **empty / dark** | _(not on android)_ | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_documents_warranties_empty.documents_warranties_empty_dark.png" alt="documents_warranties_empty_dark iOS" width="260" height="560"> |
|
||||
| **populated / light** | _(not on android)_ | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_documents_warranties_populated.documents_warranties_populated_light.png" alt="documents_warranties_populated_light iOS" width="260" height="560"> |
|
||||
| **populated / dark** | _(not on android)_ | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_documents_warranties_populated.documents_warranties_populated_dark.png" alt="documents_warranties_populated_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -452,10 +452,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** |  |  |
|
||||
| **empty / dark** |  |  |
|
||||
| **populated / light** |  |  |
|
||||
| **populated / dark** |  |  |
|
||||
| **empty / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/document_detail_empty_light.png" alt="document_detail_empty_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_document_detail.document_detail_empty_light.png" alt="document_detail_empty_light iOS" width="260" height="560"> |
|
||||
| **empty / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/document_detail_empty_dark.png" alt="document_detail_empty_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_document_detail.document_detail_empty_dark.png" alt="document_detail_empty_dark iOS" width="260" height="560"> |
|
||||
| **populated / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/document_detail_populated_light.png" alt="document_detail_populated_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_document_detail.document_detail_populated_light.png" alt="document_detail_populated_light iOS" width="260" height="560"> |
|
||||
| **populated / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/document_detail_populated_dark.png" alt="document_detail_populated_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_document_detail.document_detail_populated_dark.png" alt="document_detail_populated_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -465,8 +465,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/add_document_light.png" alt="add_document_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_add_document.add_document_light.png" alt="add_document_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/add_document_dark.png" alt="add_document_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_add_document.add_document_dark.png" alt="add_document_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -476,8 +476,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/edit_document_light.png" alt="edit_document_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_edit_document.edit_document_light.png" alt="edit_document_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/edit_document_dark.png" alt="edit_document_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_edit_document.edit_document_dark.png" alt="edit_document_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -487,10 +487,10 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **empty / light** |  |  |
|
||||
| **empty / dark** |  |  |
|
||||
| **populated / light** |  |  |
|
||||
| **populated / dark** |  |  |
|
||||
| **empty / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/profile_empty_light.png" alt="profile_empty_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_profile.profile_empty_light.png" alt="profile_empty_light iOS" width="260" height="560"> |
|
||||
| **empty / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/profile_empty_dark.png" alt="profile_empty_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_profile.profile_empty_dark.png" alt="profile_empty_dark iOS" width="260" height="560"> |
|
||||
| **populated / light** | <img src="../composeApp/src/androidUnitTest/roborazzi/profile_populated_light.png" alt="profile_populated_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_profile.profile_populated_light.png" alt="profile_populated_light iOS" width="260" height="560"> |
|
||||
| **populated / dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/profile_populated_dark.png" alt="profile_populated_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_profile.profile_populated_dark.png" alt="profile_populated_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -500,8 +500,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** | _(not on android)_ |  |
|
||||
| **dark** | _(not on android)_ |  |
|
||||
| **light** | _(not on android)_ | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_profile_edit.profile_edit_light.png" alt="profile_edit_light iOS" width="260" height="560"> |
|
||||
| **dark** | _(not on android)_ | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_profile_edit.profile_edit_dark.png" alt="profile_edit_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -511,8 +511,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/notification_preferences_light.png" alt="notification_preferences_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_notification_preferences.notification_preferences_light.png" alt="notification_preferences_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/notification_preferences_dark.png" alt="notification_preferences_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_notification_preferences.notification_preferences_dark.png" alt="notification_preferences_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -522,8 +522,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/theme_selection_light.png" alt="theme_selection_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_theme_selection.theme_selection_light.png" alt="theme_selection_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/theme_selection_dark.png" alt="theme_selection_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_theme_selection.theme_selection_dark.png" alt="theme_selection_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -533,8 +533,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  | _(not on ios)_ |
|
||||
| **dark** |  | _(not on ios)_ |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/biometric_lock_light.png" alt="biometric_lock_light Android" width="260" height="560"> | _(not on ios)_ |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/biometric_lock_dark.png" alt="biometric_lock_dark Android" width="260" height="560"> | _(not on ios)_ |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
@@ -544,8 +544,8 @@ See [parity-gallery.md](parity-gallery.md) for the workflow guide.
|
||||
|
||||
| State / Mode | Android | iOS |
|
||||
|---|---|---|
|
||||
| **light** |  |  |
|
||||
| **dark** |  |  |
|
||||
| **light** | <img src="../composeApp/src/androidUnitTest/roborazzi/feature_comparison_light.png" alt="feature_comparison_light Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_feature_comparison.feature_comparison_light.png" alt="feature_comparison_light iOS" width="260" height="560"> |
|
||||
| **dark** | <img src="../composeApp/src/androidUnitTest/roborazzi/feature_comparison_dark.png" alt="feature_comparison_dark Android" width="260" height="560"> | <img src="../iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/test_feature_comparison.feature_comparison_dark.png" alt="feature_comparison_dark iOS" width="260" height="560"> |
|
||||
|
||||
[top](#honeydue-parity-gallery)
|
||||
|
||||
|
||||
@@ -23,10 +23,17 @@
|
||||
.row { display: grid; grid-template-columns: 140px 1fr 1fr; gap: 12px;
|
||||
margin-bottom: 8px; align-items: start; }
|
||||
.label { font-size: 12px; color: #c9d1d9; padding-top: 4px; }
|
||||
.row img { width: 100%; border: 1px solid #30363d; border-radius: 4px; display: block; }
|
||||
/* Force every screenshot — Android and iOS — into the same display box.
|
||||
Native capture sizes differ (Android 360×800 @1x, iOS 390×844 @2x) so
|
||||
without a forced aspect-ratio + object-fit the row heights shift by a
|
||||
few percent per platform, making side-by-side comparisons noisy. */
|
||||
.row img { width: 100%; aspect-ratio: 9 / 19.5; object-fit: contain;
|
||||
background: #0d1117; border: 1px solid #30363d; border-radius: 4px;
|
||||
display: block; }
|
||||
.missing { display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-height: 200px; background: #21262d; border-radius: 4px;
|
||||
font-size: 13px; font-weight: 600; padding: 8px; }
|
||||
aspect-ratio: 9 / 19.5; width: 100%;
|
||||
background: #21262d; border-radius: 4px;
|
||||
font-size: 13px; font-weight: 600; padding: 8px; box-sizing: border-box; }
|
||||
.missing.missing-needed { border: 2px dashed #f85149; color: #f85149; }
|
||||
.missing.missing-platform { border: 1px solid #30363d; color: #8b949e; }
|
||||
.missing .hint { color: #6e7681; font-size: 10px; font-weight: 400;
|
||||
|
||||
@@ -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",
|
||||
actualCost: nil,
|
||||
rating: nil,
|
||||
imageUrls: nil
|
||||
uploadIds: nil
|
||||
)
|
||||
|
||||
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||
|
||||
@@ -388,7 +388,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
notes: nil,
|
||||
actualCost: nil,
|
||||
rating: nil,
|
||||
imageUrls: nil
|
||||
uploadIds: nil
|
||||
)
|
||||
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||
|
||||
|
||||
@@ -337,51 +337,119 @@ struct CompleteTaskView: View {
|
||||
|
||||
isSubmitting = true
|
||||
|
||||
// Create request with simplified Go API format
|
||||
// Note: completedAt defaults to now on server if not provided
|
||||
let request = TaskCompletionCreateRequest(
|
||||
taskId: task.id,
|
||||
completedAt: nil,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
|
||||
rating: KotlinInt(int: Int32(rating)),
|
||||
imageUrls: nil // Images uploaded separately and URLs added by handler
|
||||
)
|
||||
|
||||
// Use TaskCompletionViewModel to create completion
|
||||
// New direct-to-B2 upload path: downsample on-device, presign, POST
|
||||
// straight to B2, pass the resulting upload_ids to the completion
|
||||
// create call. Bytes never traverse our API server. See
|
||||
// /api/uploads/presign in honeyDueAPI-go.
|
||||
if !selectedImages.isEmpty {
|
||||
// Convert images to ImageData for Kotlin
|
||||
let imageDataList = selectedImages.compactMap { uiImage -> ComposeApp.ImageData? in
|
||||
guard let jpegData = uiImage.jpegData(compressionQuality: 0.8) else { return nil }
|
||||
let byteArray = KotlinByteArray(data: jpegData)
|
||||
return ComposeApp.ImageData(bytes: byteArray, fileName: "completion_image.jpg")
|
||||
}
|
||||
completionViewModel.createTaskCompletionWithImages(request: request, images: imageDataList)
|
||||
uploadAndCreate()
|
||||
} else {
|
||||
// No images — go straight to the completion create.
|
||||
let request = TaskCompletionCreateRequest(
|
||||
taskId: task.id,
|
||||
completedAt: nil,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
|
||||
rating: KotlinInt(int: Int32(rating)),
|
||||
uploadIds: nil
|
||||
)
|
||||
completionViewModel.createTaskCompletion(request: request)
|
||||
observeCompletionState()
|
||||
}
|
||||
}
|
||||
|
||||
// Observe the result — store the Task so it can be cancelled on dismiss
|
||||
/// Async pipeline: downsample → presign+upload to B2 → create completion
|
||||
/// with the returned upload_ids. Errors at any stage become a single
|
||||
/// alert; partial uploads (1 of 3 succeeded) currently fail the whole
|
||||
/// flow — server-side cleanup reaps the orphans within the hour.
|
||||
private func uploadAndCreate() {
|
||||
observationTask?.cancel()
|
||||
observationTask = Task {
|
||||
for await state in completionViewModel.createCompletionState {
|
||||
if Task.isCancelled { break }
|
||||
// Step 1: downsample each image. Runs on the calling task; the
|
||||
// ImageDownsampler is memory-bounded so this is safe for the
|
||||
// expected batch sizes (≤5 images).
|
||||
let payloads: [(Data, String)] = selectedImages.compactMap { uiImage -> (Data, String)? in
|
||||
guard let data = ImageDownsampler.downsample(uiImage: uiImage, profile: .completion) else {
|
||||
return nil
|
||||
}
|
||||
return (data, "completion_\(UUID().uuidString).jpg")
|
||||
}
|
||||
guard payloads.count == selectedImages.count else {
|
||||
await MainActor.run {
|
||||
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
|
||||
self.isSubmitting = false
|
||||
self.onComplete(success.data?.updatedTask) // Pass back updated task
|
||||
self.dismiss()
|
||||
} else if let error = ApiResultBridge.error(from: state) {
|
||||
self.errorMessage = error.message
|
||||
self.showError = true
|
||||
self.isSubmitting = false
|
||||
}
|
||||
errorMessage = "One or more photos couldn't be processed."
|
||||
showError = true
|
||||
isSubmitting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Break out of loop on terminal states
|
||||
if state is ApiResultSuccess<TaskCompletionResponse> || ApiResultBridge.isError(state) {
|
||||
break
|
||||
// Step 2: presign + upload each to B2. PresignedUploader runs
|
||||
// them in parallel under a server-enforced concurrency cap of 10.
|
||||
guard let uploader = PresignedUploader() else {
|
||||
await MainActor.run {
|
||||
errorMessage = "Not authenticated"
|
||||
showError = true
|
||||
isSubmitting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
let uploadIds: [Int32]
|
||||
do {
|
||||
uploadIds = try await uploader.uploadAll(items: payloads, category: .completion)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = (error as? PresignedUploaderError)?.errorDescription
|
||||
?? error.localizedDescription
|
||||
showError = true
|
||||
isSubmitting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: create completion via the existing endpoint, passing
|
||||
// upload_ids so the server claims the pending_uploads rows and
|
||||
// turns them into TaskCompletionImage rows.
|
||||
let request = TaskCompletionCreateRequest(
|
||||
taskId: task.id,
|
||||
completedAt: nil,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
|
||||
rating: KotlinInt(int: Int32(rating)),
|
||||
uploadIds: uploadIds.map { KotlinInt(int: $0) }
|
||||
)
|
||||
await MainActor.run {
|
||||
completionViewModel.createTaskCompletion(request: request)
|
||||
}
|
||||
await observeCompletionStateAsync()
|
||||
}
|
||||
}
|
||||
|
||||
/// Observe the createCompletionState StateFlow until a terminal value
|
||||
/// arrives, then dismiss or surface an error. Called from the
|
||||
/// no-images path.
|
||||
private func observeCompletionState() {
|
||||
observationTask?.cancel()
|
||||
observationTask = Task {
|
||||
await observeCompletionStateAsync()
|
||||
}
|
||||
}
|
||||
|
||||
private func observeCompletionStateAsync() async {
|
||||
for await state in completionViewModel.createCompletionState {
|
||||
if Task.isCancelled { break }
|
||||
await MainActor.run {
|
||||
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
|
||||
self.isSubmitting = false
|
||||
self.onComplete(success.data?.updatedTask)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,10 +254,31 @@ def write_markdown(
|
||||
ios: dict[str, str],
|
||||
manifest: list[tuple[str, str, set[str]]],
|
||||
) -> None:
|
||||
"""Gitea-renderable grid as markdown tables."""
|
||||
"""Gitea-renderable grid as markdown tables.
|
||||
|
||||
Images are emitted as raw `<img>` tags with explicit `width` and
|
||||
`height` attributes rather than markdown `![]()` syntax. Gitea's
|
||||
markdown renderer (goldmark + bluemonday) strips inline `style`
|
||||
attributes, but keeps the `width`/`height` HTML attributes. Forcing
|
||||
both dimensions guarantees identical cell sizes regardless of the
|
||||
underlying PNG resolution (Android is 360×800 @1x, iOS is 780×1688
|
||||
@2x; without this, row heights would shift a few percent per
|
||||
platform and break side-by-side comparisons).
|
||||
"""
|
||||
# Fixed display size for every image cell. Kept at a ~9:19.5 aspect
|
||||
# ratio (modern phone proportions). Width chosen to fit two tall
|
||||
# portrait screens side-by-side in a typical Gitea markdown pane.
|
||||
img_w, img_h = 260, 560
|
||||
|
||||
out = os.path.join(REPO_ROOT, OUT_MD)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
|
||||
def img_tag(src: str, alt: str) -> str:
|
||||
return (
|
||||
f'<img src="{html.escape(src)}" alt="{html.escape(alt)}" '
|
||||
f'width="{img_w}" height="{img_h}">'
|
||||
)
|
||||
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
f.write("# honeyDue parity gallery\n\n")
|
||||
f.write(
|
||||
@@ -284,10 +305,10 @@ def write_markdown(
|
||||
key = f"{name}_{suffix}"
|
||||
a = android.get(key)
|
||||
i = ios.get(key)
|
||||
a_cell = f"" if a else (
|
||||
a_cell = img_tag(a, f"{key} Android") if a else (
|
||||
"_\\[missing — android\\]_" if "android" in plats else "_(not on android)_"
|
||||
)
|
||||
i_cell = f"" if i else (
|
||||
i_cell = img_tag(i, f"{key} iOS") if i else (
|
||||
"_\\[missing — ios\\]_" if "ios" in plats else "_(not on ios)_"
|
||||
)
|
||||
f.write(f"| **{state_label}** | {a_cell} | {i_cell} |\n")
|
||||
@@ -329,10 +350,17 @@ PAGE_HEAD = """<!DOCTYPE html>
|
||||
.row { display: grid; grid-template-columns: 140px 1fr 1fr; gap: 12px;
|
||||
margin-bottom: 8px; align-items: start; }
|
||||
.label { font-size: 12px; color: #c9d1d9; padding-top: 4px; }
|
||||
.row img { width: 100%; border: 1px solid #30363d; border-radius: 4px; display: block; }
|
||||
/* Force every screenshot — Android and iOS — into the same display box.
|
||||
Native capture sizes differ (Android 360×800 @1x, iOS 390×844 @2x) so
|
||||
without a forced aspect-ratio + object-fit the row heights shift by a
|
||||
few percent per platform, making side-by-side comparisons noisy. */
|
||||
.row img { width: 100%; aspect-ratio: 9 / 19.5; object-fit: contain;
|
||||
background: #0d1117; border: 1px solid #30363d; border-radius: 4px;
|
||||
display: block; }
|
||||
.missing { display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-height: 200px; background: #21262d; border-radius: 4px;
|
||||
font-size: 13px; font-weight: 600; padding: 8px; }
|
||||
aspect-ratio: 9 / 19.5; width: 100%;
|
||||
background: #21262d; border-radius: 4px;
|
||||
font-size: 13px; font-weight: 600; padding: 8px; box-sizing: border-box; }
|
||||
.missing.missing-needed { border: 2px dashed #f85149; color: #f85149; }
|
||||
.missing.missing-platform { border: 1px solid #30363d; color: #8b949e; }
|
||||
.missing .hint { color: #6e7681; font-size: 10px; font-weight: 400;
|
||||
|
||||
Reference in New Issue
Block a user