Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c9e6009c7 | |||
| 498e6b8064 | |||
| fdcf82757d | |||
| 3890dd6f52 | |||
| d5041492a9 | |||
| ec5d93efab | |||
| b90533c535 | |||
| 03a9dfa0de | |||
| 1884853e4b | |||
| 882801c71d | |||
| dea8eed184 | |||
| 915a5d4742 | |||
| 4f9b910a94 | |||
| 3df5645f73 | |||
| 5f7498b755 | |||
| 733d4c8d36 | |||
| 87771ef7f3 | |||
| 65803a2180 | |||
| ef8eab4a07 | |||
| 2064e70d75 | |||
| b2d03ef8b2 | |||
| fa0ce30257 | |||
| 49e2397e85 | |||
| 170a6d0e40 | |||
| 16096f4b70 | |||
| 9fa58352c0 | |||
| 316b1f709d | |||
| 42ccbdcbd6 | |||
| f0f8dfb68b | |||
| 2230cde071 | |||
| f83e89bee3 | |||
| ab0e5c450c | |||
| b24469bf38 | |||
| 6c3c9d3e0c | |||
| 3944223a5e | |||
| 031d61157f | |||
| f77c41f07a | |||
| fec0c4384a | |||
| 7a04ad4ff2 | |||
| 707a90e5f1 | |||
| 6cc5295db8 | |||
| 3bac38449c | |||
| 6f2fb629c9 | |||
| 47eaf5a0c0 | |||
| c57743dca0 | |||
| f56d854acc | |||
| 00e215920a |
@@ -18,7 +18,8 @@
|
|||||||
"Bash(ps:*)",
|
"Bash(ps:*)",
|
||||||
"Bash(stdbuf:*)",
|
"Bash(stdbuf:*)",
|
||||||
"Bash(sysctl:*)",
|
"Bash(sysctl:*)",
|
||||||
"Bash(tee:*)"
|
"Bash(tee:*)",
|
||||||
|
"Bash(codesign -d --entitlements :- /Users/treyt/Library/Developer/Xcode/DerivedData/honeyDue-buvczbpttcfkxxcmxbnqkqrmujyh/Build/Products/Debug-iphonesimulator/honeyDue.app)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Makefile for HoneyDue KMM — wraps the long-form commands you actually
|
||||||
|
# use every day. If you find yourself copy/pasting a command twice, it
|
||||||
|
# belongs here.
|
||||||
|
#
|
||||||
|
# Quick reference
|
||||||
|
# ---------------
|
||||||
|
# make verify-snapshots # fast; run on every PR, CI runs this
|
||||||
|
# make record-snapshots # slow; regenerate baselines after UI change
|
||||||
|
# make optimize-goldens # rarely needed — record-snapshots runs this
|
||||||
|
#
|
||||||
|
.PHONY: help record-snapshots verify-snapshots optimize-goldens \
|
||||||
|
record-ios record-android verify-ios verify-android
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "HoneyDue KMM — common tasks"
|
||||||
|
@echo ""
|
||||||
|
@echo " make verify-snapshots Verify iOS + Android parity goldens (CI gate)"
|
||||||
|
@echo " make record-snapshots Regenerate iOS + Android goldens + optimize"
|
||||||
|
@echo " make optimize-goldens Run the PNG optimizer across both directories"
|
||||||
|
@echo ""
|
||||||
|
@echo " make verify-ios Verify just the iOS gallery"
|
||||||
|
@echo " make verify-android Verify just the Android gallery"
|
||||||
|
@echo " make record-ios Regenerate just the iOS gallery"
|
||||||
|
@echo " make record-android Regenerate just the Android gallery"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
# ---- Parity gallery (combined) ----
|
||||||
|
|
||||||
|
# Regenerate every parity-gallery golden (iOS + Android) and shrink the
|
||||||
|
# output PNGs. Slow (~3 min); run after intentional UI changes only.
|
||||||
|
record-snapshots:
|
||||||
|
@./scripts/record_snapshots.sh
|
||||||
|
|
||||||
|
# Verify current UI matches committed goldens. Fast (~1 min). PR gate.
|
||||||
|
verify-snapshots:
|
||||||
|
@./scripts/verify_snapshots.sh
|
||||||
|
|
||||||
|
# Optimize every PNG golden in-place (idempotent). Usually invoked
|
||||||
|
# automatically by record-snapshots; exposed here for one-off cleanup.
|
||||||
|
optimize-goldens:
|
||||||
|
@./scripts/optimize_goldens.sh
|
||||||
|
|
||||||
|
# ---- Parity gallery (single platform) ----
|
||||||
|
|
||||||
|
record-ios:
|
||||||
|
@./scripts/record_snapshots.sh --ios-only
|
||||||
|
|
||||||
|
record-android:
|
||||||
|
@./scripts/record_snapshots.sh --android-only
|
||||||
|
|
||||||
|
verify-ios:
|
||||||
|
@./scripts/verify_snapshots.sh --ios-only
|
||||||
|
|
||||||
|
verify-android:
|
||||||
|
@./scripts/verify_snapshots.sh --android-only
|
||||||
@@ -223,11 +223,21 @@ compose.desktop {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Roborazzi screenshot-regression plugin (P8). Pin the golden-image
|
// Roborazzi screenshot-regression plugin (parity gallery, P2). Pin the
|
||||||
// output directory inside the test source set so goldens live in git
|
// golden-image output directory inside the test source set so goldens live
|
||||||
// alongside the tests themselves. Anything under build/ is gitignored
|
// in git alongside the tests themselves. Anything under build/ is
|
||||||
// and gets blown away by `gradle clean` — not where committed goldens
|
// gitignored and gets blown away by `gradle clean` — not where committed
|
||||||
// belong.
|
// goldens belong.
|
||||||
|
//
|
||||||
|
// NOTE on path mismatch: `captureRoboImage(filePath = ...)` in
|
||||||
|
// ScreenshotTests.kt takes a *relative path* resolved against the Gradle
|
||||||
|
// test task's working directory (`composeApp/`). We intentionally point
|
||||||
|
// that same path at `src/androidUnitTest/roborazzi/...` — and configure
|
||||||
|
// the plugin extension below to match — so record and verify read from
|
||||||
|
// and write to the exact same committed-golden location. Any other
|
||||||
|
// arrangement results in the "original file was not found" error because
|
||||||
|
// the plugin doesn't currently auto-copy between `build/outputs/roborazzi`
|
||||||
|
// and the extension outputDir for the KMM Android target.
|
||||||
roborazzi {
|
roborazzi {
|
||||||
outputDir.set(layout.projectDirectory.dir("src/androidUnitTest/roborazzi"))
|
outputDir.set(layout.projectDirectory.dir("src/androidUnitTest/roborazzi"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,19 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Per-widget residence picker (gitea#6). Each widget provider
|
||||||
|
XML declares `android:configure` pointing at this activity,
|
||||||
|
so the system launches it whenever the user pins a new
|
||||||
|
tile or hits "Edit Widget" on an existing one. -->
|
||||||
|
<activity
|
||||||
|
android:name=".widget.WidgetConfigActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
<!-- Small Widget Receiver (2x1) -->
|
<!-- Small Widget Receiver (2x1) -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.HoneyDueSmallWidgetReceiver"
|
android:name=".widget.HoneyDueSmallWidgetReceiver"
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
|||||||
notes = null,
|
notes = null,
|
||||||
actualCost = null,
|
actualCost = null,
|
||||||
rating = null,
|
rating = null,
|
||||||
imageUrls = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
when (val result = APILayer.createTaskCompletion(request)) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,7 +105,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
|||||||
notes = "Completed from notification",
|
notes = "Completed from notification",
|
||||||
actualCost = null,
|
actualCost = null,
|
||||||
rating = null,
|
rating = null,
|
||||||
imageUrls = null
|
|
||||||
)
|
)
|
||||||
when (val result = APILayer.createTaskCompletion(request)) {
|
when (val result = APILayer.createTaskCompletion(request)) {
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -58,6 +59,15 @@ actual fun rememberImagePicker(
|
|||||||
actual fun rememberCameraPicker(
|
actual fun rememberCameraPicker(
|
||||||
onImageCaptured: (ImageData) -> Unit
|
onImageCaptured: (ImageData) -> Unit
|
||||||
): () -> Unit {
|
): () -> Unit {
|
||||||
|
// Compose previews and Roborazzi snapshot tests run without a
|
||||||
|
// `FileProvider` resolvable cache path — `getUriForFile` throws
|
||||||
|
// `Failed to find configured root...` because the test cache dir
|
||||||
|
// isn't registered in the manifest's `file_paths.xml`. The launch
|
||||||
|
// callback is never invoked in a preview/snapshot anyway, so
|
||||||
|
// returning a no-op keeps the composition clean.
|
||||||
|
if (LocalInspectionMode.current) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
// Create a temp file URI for the camera to save to
|
// Create a temp file URI for the camera to save to
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.tt.honeyDue.ui.support
|
||||||
|
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
|
|
||||||
|
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||||
|
actual fun Modifier.enableTestTagsAsResourceId(): Modifier =
|
||||||
|
this.semantics { testTagsAsResourceId = true }
|
||||||
@@ -45,8 +45,14 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val repo = WidgetDataRepository.get(context)
|
val repo = WidgetDataRepository.get(context)
|
||||||
val tasks = repo.loadTasks()
|
// Per-instance residence scoping (gitea#6). Stats are computed
|
||||||
val stats = repo.computeStats()
|
// off the same filtered list so the bottom-tile counters
|
||||||
|
// ("Overdue / 7 days / 30 days") match the visible tasks
|
||||||
|
// instead of aggregating across every residence.
|
||||||
|
val appWidgetId =
|
||||||
|
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||||
|
val tasks = repo.loadTasksForWidget(appWidgetId)
|
||||||
|
val stats = repo.computeStatsFromTasks(tasks)
|
||||||
val tier = repo.loadTierState()
|
val tier = repo.loadTierState()
|
||||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||||
|
|
||||||
@@ -135,4 +141,9 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
|
|||||||
/** AppWidget receiver for the large widget. */
|
/** AppWidget receiver for the large widget. */
|
||||||
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
|
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
|
||||||
|
|
||||||
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
|
super.onDeleted(context, appWidgetIds)
|
||||||
|
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ class HoneyDueMediumWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val repo = WidgetDataRepository.get(context)
|
val repo = WidgetDataRepository.get(context)
|
||||||
val tasks = repo.loadTasks()
|
// Per-instance residence scoping (gitea#6). See small widget for rationale.
|
||||||
|
val appWidgetId =
|
||||||
|
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||||
|
val tasks = repo.loadTasksForWidget(appWidgetId)
|
||||||
val tier = repo.loadTierState()
|
val tier = repo.loadTierState()
|
||||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||||
|
|
||||||
@@ -122,4 +125,9 @@ class HoneyDueMediumWidget : GlanceAppWidget() {
|
|||||||
/** AppWidget receiver for the medium widget. */
|
/** AppWidget receiver for the medium widget. */
|
||||||
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
|
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
|
||||||
|
|
||||||
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
|
super.onDeleted(context, appWidgetIds)
|
||||||
|
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.tt.honeyDue.widget
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
@@ -43,7 +44,13 @@ class HoneyDueSmallWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val repo = WidgetDataRepository.get(context)
|
val repo = WidgetDataRepository.get(context)
|
||||||
val tasks = repo.loadTasks()
|
// Resolve which residence this widget instance is scoped to
|
||||||
|
// (gitea#6). `loadTasksForWidget` falls back to "All residences"
|
||||||
|
// when no scope is saved, matching pre-#6 behaviour for tiles
|
||||||
|
// that haven't been configured yet.
|
||||||
|
val appWidgetId =
|
||||||
|
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||||
|
val tasks = repo.loadTasksForWidget(appWidgetId)
|
||||||
val tier = repo.loadTierState()
|
val tier = repo.loadTierState()
|
||||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||||
|
|
||||||
@@ -125,4 +132,35 @@ class OpenAppAction : ActionCallback {
|
|||||||
/** AppWidget receiver for the small widget. */
|
/** AppWidget receiver for the small widget. */
|
||||||
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget()
|
override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget()
|
||||||
|
|
||||||
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
|
super.onDeleted(context, appWidgetIds)
|
||||||
|
// Clean per-instance residence scope when the user removes a tile
|
||||||
|
// so dangling `widget_residence_id_<n>` keys don't accumulate in
|
||||||
|
// the DataStore (gitea#6).
|
||||||
|
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared helpers for honeyDue Glance widget receivers. Kept in a
|
||||||
|
* top-level utility so every receiver size (Small / Medium / Large)
|
||||||
|
* uses identical cleanup logic.
|
||||||
|
*/
|
||||||
|
internal object WidgetReceiverHelpers {
|
||||||
|
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||||
|
fun purgeResidenceScopes(context: Context, appWidgetIds: IntArray) {
|
||||||
|
if (appWidgetIds.isEmpty()) return
|
||||||
|
val repo = WidgetDataRepository.get(context)
|
||||||
|
// Fire-and-forget on a background dispatcher — `onDeleted` runs
|
||||||
|
// on the broadcast thread which doesn't permit suspend calls.
|
||||||
|
// GlobalScope is correct here: the IO is short-lived (one
|
||||||
|
// DataStore edit per removed appWidgetId) and there's no
|
||||||
|
// coroutine-scope tied to a long-lived receiver to attach to.
|
||||||
|
kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
|
for (id in appWidgetIds) {
|
||||||
|
repo.clearResidenceIdFor(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package com.tt.honeyDue.widget
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||||
|
import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
||||||
|
import com.tt.honeyDue.ui.theme.ThemeManager
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-widget residence selector. Launched by the system when the user
|
||||||
|
* pins a new honeyDue widget (because each widget provider XML now
|
||||||
|
* declares `android:configure`) and again when they hit "Edit Widget".
|
||||||
|
*
|
||||||
|
* Saves the chosen residence id under
|
||||||
|
* `widget_residence_id_<appWidgetId>` in [WidgetDataStore] so each
|
||||||
|
* widget instance can independently scope its task list (gitea#6).
|
||||||
|
* Selecting "All residences" clears the key and the widget reverts to
|
||||||
|
* the legacy unscoped behaviour.
|
||||||
|
*/
|
||||||
|
class WidgetConfigActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// The system passes the just-created widget id in the extras.
|
||||||
|
// Without it we don't know which widget to configure — bail
|
||||||
|
// with CANCELED so the system removes the placeholder tile.
|
||||||
|
val appWidgetId = intent?.extras?.getInt(
|
||||||
|
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||||
|
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
|
||||||
|
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||||
|
setResult(Activity.RESULT_CANCELED)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default a CANCEL result so the widget is removed if the user
|
||||||
|
// dismisses without saving (matches the Android convention).
|
||||||
|
setResult(
|
||||||
|
Activity.RESULT_CANCELED,
|
||||||
|
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
|
)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val theme = ThemeManager.currentTheme
|
||||||
|
HoneyDueTheme(themeColors = theme) {
|
||||||
|
WidgetConfigScreen(
|
||||||
|
appWidgetId = appWidgetId,
|
||||||
|
onCommit = { residenceId ->
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val repo = WidgetDataRepository.get(this@WidgetConfigActivity)
|
||||||
|
repo.saveResidenceIdFor(appWidgetId, residenceId)
|
||||||
|
// Repaint every widget tile so this one
|
||||||
|
// picks up the new scope on the next frame
|
||||||
|
// (Glance handles which `appWidgetId` we
|
||||||
|
// belong to via the per-instance state).
|
||||||
|
HoneyDueSmallWidget().updateAll(this@WidgetConfigActivity)
|
||||||
|
HoneyDueMediumWidget().updateAll(this@WidgetConfigActivity)
|
||||||
|
HoneyDueLargeWidget().updateAll(this@WidgetConfigActivity)
|
||||||
|
|
||||||
|
setResult(
|
||||||
|
Activity.RESULT_OK,
|
||||||
|
Intent().putExtra(
|
||||||
|
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||||
|
appWidgetId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actual picker UI. Loads residences from [WidgetDataRepository]
|
||||||
|
* and offers an "All residences" option above them. Empty state shows
|
||||||
|
* a helper message instead of an empty list (user hasn't created any
|
||||||
|
* residences yet, or the main app hasn't synced).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WidgetConfigScreen(
|
||||||
|
appWidgetId: Int,
|
||||||
|
onCommit: (Long?) -> Unit
|
||||||
|
) {
|
||||||
|
var residences by remember { mutableStateOf<List<WidgetResidenceDto>?>(null) }
|
||||||
|
var selectedId by remember { mutableStateOf<Long?>(null) }
|
||||||
|
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
LaunchedEffect(appWidgetId) {
|
||||||
|
val repo = WidgetDataRepository.get(context)
|
||||||
|
// Pre-select whatever the user picked last time they configured
|
||||||
|
// this same widget; falls back to "All residences" on first run.
|
||||||
|
selectedId = repo.loadResidenceIdFor(appWidgetId)
|
||||||
|
residences = repo.loadResidences()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.padding(AppSpacing.lg)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Choose a residence",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(AppSpacing.sm))
|
||||||
|
Text(
|
||||||
|
text = "This widget will only show tasks for the residence you pick. " +
|
||||||
|
"Choose \"All residences\" to keep showing every home.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(AppSpacing.lg))
|
||||||
|
|
||||||
|
val items = residences
|
||||||
|
if (items == null) {
|
||||||
|
// Loading state — DataStore reads off the IO dispatcher.
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f, fill = true),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
|
||||||
|
) {
|
||||||
|
// "All residences" — selecting clears the per-widget key.
|
||||||
|
item {
|
||||||
|
ResidenceRow(
|
||||||
|
title = "All residences",
|
||||||
|
isSelected = selectedId == null,
|
||||||
|
onClick = { selectedId = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
item {
|
||||||
|
EmptyResidencesNote()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(items, key = { it.id }) { residence ->
|
||||||
|
ResidenceRow(
|
||||||
|
title = residence.name,
|
||||||
|
isSelected = selectedId == residence.id,
|
||||||
|
onClick = { selectedId = residence.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(AppSpacing.lg))
|
||||||
|
Button(
|
||||||
|
onClick = { onCommit(selectedId) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResidenceRow(
|
||||||
|
title: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(AppSpacing.md))
|
||||||
|
.background(
|
||||||
|
if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(AppSpacing.lg)
|
||||||
|
) {
|
||||||
|
androidx.compose.foundation.layout.Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Home,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.fillMaxWidth(0.04f))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyResidencesNote() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(AppSpacing.md))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(AppSpacing.lg)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No residences yet — open honeyDue and add a property first, " +
|
||||||
|
"then come back to configure this widget.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,66 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
|||||||
return all.filterNot { it.id in pending }
|
return all.filterNot { it.id in pending }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the cached task list filtered by [residenceId]. Pass `null` to
|
||||||
|
* return every residence's tasks (the "All residences" widget option,
|
||||||
|
* matching pre-gitea#6 behaviour).
|
||||||
|
*
|
||||||
|
* Pending completions are excluded — same contract as [loadTasks].
|
||||||
|
*/
|
||||||
|
suspend fun loadTasksForResidence(residenceId: Long?): List<WidgetTaskDto> {
|
||||||
|
val all = loadTasks()
|
||||||
|
return filterTasksForResidence(all, residenceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the residence scope for [appWidgetId] and return only its
|
||||||
|
* tasks. The widget [GlanceAppWidget.provideGlance] looks up its
|
||||||
|
* `appWidgetId` and calls this; configuration changes take effect on
|
||||||
|
* the next `updateAll` invocation.
|
||||||
|
*/
|
||||||
|
suspend fun loadTasksForWidget(appWidgetId: Int): List<WidgetTaskDto> {
|
||||||
|
val residenceId = store.readResidenceIdFor(appWidgetId)
|
||||||
|
return loadTasksForResidence(residenceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Residence sidecar (gitea#6)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the user's residences so [WidgetConfigActivity] can offer
|
||||||
|
* them in its picker. Called from the main app whenever
|
||||||
|
* `DataManager.myResidences` updates.
|
||||||
|
*/
|
||||||
|
suspend fun saveResidences(residences: List<WidgetResidenceDto>) {
|
||||||
|
store.writeResidencesJson(json.encodeToString(residences))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the persisted residence list (empty when never written or after logout). */
|
||||||
|
suspend fun loadResidences(): List<WidgetResidenceDto> {
|
||||||
|
val raw = store.readResidencesJson()
|
||||||
|
return try {
|
||||||
|
json.decodeFromString<List<WidgetResidenceDto>>(raw)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read which residence this widget instance is currently scoped to (null = All). */
|
||||||
|
suspend fun loadResidenceIdFor(appWidgetId: Int): Long? =
|
||||||
|
store.readResidenceIdFor(appWidgetId)
|
||||||
|
|
||||||
|
/** Persist the chosen residence for this widget instance. */
|
||||||
|
suspend fun saveResidenceIdFor(appWidgetId: Int, residenceId: Long?) {
|
||||||
|
store.writeResidenceIdFor(appWidgetId, residenceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the per-widget residence selection when the widget is removed. */
|
||||||
|
suspend fun clearResidenceIdFor(appWidgetId: Int) {
|
||||||
|
store.clearResidenceIdFor(appWidgetId)
|
||||||
|
}
|
||||||
|
|
||||||
/** Queue a task id for optimistic completion. See [loadTasks]. */
|
/** Queue a task id for optimistic completion. See [loadTasks]. */
|
||||||
suspend fun markPendingCompletion(taskId: Long) {
|
suspend fun markPendingCompletion(taskId: Long) {
|
||||||
val current = store.readPendingCompletionIds().toMutableSet()
|
val current = store.readPendingCompletionIds().toMutableSet()
|
||||||
@@ -141,8 +201,15 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
|||||||
*
|
*
|
||||||
* Pending-completion tasks are excluded (via [loadTasks]).
|
* Pending-completion tasks are excluded (via [loadTasks]).
|
||||||
*/
|
*/
|
||||||
suspend fun computeStats(): WidgetStats {
|
suspend fun computeStats(): WidgetStats = computeStatsFromTasks(loadTasks())
|
||||||
val tasks = loadTasks()
|
|
||||||
|
/**
|
||||||
|
* Compute the same stats off a pre-filtered task list. Used by
|
||||||
|
* [HoneyDueLargeWidget] after applying the per-widget residence
|
||||||
|
* scope (gitea#6) so the stat tiles reflect only the residence the
|
||||||
|
* user picked.
|
||||||
|
*/
|
||||||
|
fun computeStatsFromTasks(tasks: List<WidgetTaskDto>): WidgetStats {
|
||||||
var overdue = 0
|
var overdue = 0
|
||||||
var within7 = 0
|
var within7 = 0
|
||||||
var within8To30 = 0
|
var within8To30 = 0
|
||||||
@@ -257,5 +324,18 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
|||||||
|
|
||||||
/** Legacy accessor — delegates to [get]. */
|
/** Legacy accessor — delegates to [get]. */
|
||||||
fun getInstance(context: Context): WidgetDataRepository = get(context)
|
fun getInstance(context: Context): WidgetDataRepository = get(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure filter — exposed for unit-test coverage. Mirrors iOS'
|
||||||
|
* `WidgetDataManager.filterTasks(_:forResidenceId:)` semantics
|
||||||
|
* (gitea#6).
|
||||||
|
*/
|
||||||
|
fun filterTasksForResidence(
|
||||||
|
tasks: List<WidgetTaskDto>,
|
||||||
|
residenceId: Long?
|
||||||
|
): List<WidgetTaskDto> {
|
||||||
|
if (residenceId == null) return tasks
|
||||||
|
return tasks.filter { it.residenceId == residenceId }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ internal object WidgetDataStoreKeys {
|
|||||||
val PENDING_COMPLETION_IDS = stringPreferencesKey("pending_completion_ids")
|
val PENDING_COMPLETION_IDS = stringPreferencesKey("pending_completion_ids")
|
||||||
val LAST_REFRESH_TIME = longPreferencesKey("last_refresh_time")
|
val LAST_REFRESH_TIME = longPreferencesKey("last_refresh_time")
|
||||||
val USER_TIER = stringPreferencesKey("user_tier")
|
val USER_TIER = stringPreferencesKey("user_tier")
|
||||||
|
/** JSON-serialized List<WidgetResidenceDto> for the configuration picker (gitea#6). */
|
||||||
|
val WIDGET_RESIDENCES_JSON = stringPreferencesKey("widget_residences_json")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a key for the `Long` residence id this `appWidgetId` is
|
||||||
|
* scoped to. Missing key = "All residences" (legacy behaviour).
|
||||||
|
*/
|
||||||
|
fun residenceIdKeyFor(appWidgetId: Int) =
|
||||||
|
longPreferencesKey("widget_residence_id_$appWidgetId")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,6 +99,56 @@ class WidgetDataStore(private val context: Context) {
|
|||||||
prefs.remove(WidgetDataStoreKeys.PENDING_COMPLETION_IDS)
|
prefs.remove(WidgetDataStoreKeys.PENDING_COMPLETION_IDS)
|
||||||
prefs.remove(WidgetDataStoreKeys.LAST_REFRESH_TIME)
|
prefs.remove(WidgetDataStoreKeys.LAST_REFRESH_TIME)
|
||||||
prefs.remove(WidgetDataStoreKeys.USER_TIER)
|
prefs.remove(WidgetDataStoreKeys.USER_TIER)
|
||||||
|
prefs.remove(WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON)
|
||||||
|
// Per-widget residence ids are added dynamically as
|
||||||
|
// `widget_residence_id_<appWidgetId>` keys; sweep them by
|
||||||
|
// prefix so logout doesn't leave dangling per-instance
|
||||||
|
// scoping behind.
|
||||||
|
prefs.asMap().keys
|
||||||
|
.filter { it.name.startsWith("widget_residence_id_") }
|
||||||
|
.forEach { prefs.remove(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Per-residence widget configuration (gitea#6)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the user's residences (id + name) as persisted by the main
|
||||||
|
* app. Used by [WidgetConfigActivity] to populate its picker.
|
||||||
|
*/
|
||||||
|
suspend fun readResidencesJson(): String =
|
||||||
|
store.data.first()[WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON] ?: "[]"
|
||||||
|
|
||||||
|
suspend fun writeResidencesJson(json: String) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON] = json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the residence id this widget instance is scoped to, or `null`
|
||||||
|
* for "All residences" (no scoping — the legacy default).
|
||||||
|
*/
|
||||||
|
suspend fun readResidenceIdFor(appWidgetId: Int): Long? =
|
||||||
|
store.data.first()[WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId)]
|
||||||
|
|
||||||
|
suspend fun writeResidenceIdFor(appWidgetId: Int, residenceId: Long?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val key = WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId)
|
||||||
|
if (residenceId == null) {
|
||||||
|
prefs.remove(key)
|
||||||
|
} else {
|
||||||
|
prefs[key] = residenceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear scoping for a removed widget instance (called from `onDeleted`). */
|
||||||
|
suspend fun clearResidenceIdFor(appWidgetId: Int) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs.remove(WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ interface WidgetRefreshDataSource {
|
|||||||
suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>>
|
suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>>
|
||||||
/** Fetch the current user's subscription tier ("free" | "premium"). */
|
/** Fetch the current user's subscription tier ("free" | "premium"). */
|
||||||
suspend fun fetchTier(): String
|
suspend fun fetchTier(): String
|
||||||
|
/**
|
||||||
|
* Fetch the user's residences for the widget configuration picker
|
||||||
|
* (gitea#6). Returning an empty list is non-fatal — the worker will
|
||||||
|
* just skip the residence sidecar update and pre-existing scopes
|
||||||
|
* keep working until next refresh.
|
||||||
|
*/
|
||||||
|
suspend fun fetchResidences(): List<WidgetResidenceDto> = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +55,16 @@ internal object DefaultWidgetRefreshDataSource : WidgetRefreshDataSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchResidences(): List<WidgetResidenceDto> {
|
||||||
|
val result = APILayer.getMyResidences(forceRefresh = false)
|
||||||
|
return when (result) {
|
||||||
|
is ApiResult.Success -> result.data.residences.map { r ->
|
||||||
|
WidgetResidenceDto(id = r.id.toLong(), name = r.name)
|
||||||
|
}
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun mapToWidgetTasks(response: TaskColumnsResponse): List<WidgetTaskDto> {
|
private fun mapToWidgetTasks(response: TaskColumnsResponse): List<WidgetTaskDto> {
|
||||||
val out = mutableListOf<WidgetTaskDto>()
|
val out = mutableListOf<WidgetTaskDto>()
|
||||||
for (column in response.columns) {
|
for (column in response.columns) {
|
||||||
@@ -112,6 +129,16 @@ class WidgetRefreshWorker(
|
|||||||
val repo = WidgetDataRepository.get(ctx)
|
val repo = WidgetDataRepository.get(ctx)
|
||||||
repo.saveTasks(tasksResult.data)
|
repo.saveTasks(tasksResult.data)
|
||||||
repo.saveTierState(tier)
|
repo.saveTierState(tier)
|
||||||
|
// Best-effort residence sidecar update — failure is
|
||||||
|
// non-fatal because pre-existing scopes (and the
|
||||||
|
// "All residences" fallback) keep working with stale
|
||||||
|
// data until the next refresh succeeds (gitea#6).
|
||||||
|
runCatching {
|
||||||
|
val residences = dataSource.fetchResidences()
|
||||||
|
if (residences.isNotEmpty()) {
|
||||||
|
repo.saveResidences(residences)
|
||||||
|
}
|
||||||
|
}
|
||||||
refreshGlanceWidgets(ctx)
|
refreshGlanceWidgets(ctx)
|
||||||
// Chain the next scheduled refresh so cadence keeps ticking
|
// Chain the next scheduled refresh so cadence keeps ticking
|
||||||
// even if the OS evicts our periodic request. Wrapped in
|
// even if the OS evicts our periodic request. Wrapped in
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ data class WidgetTaskDto(
|
|||||||
val completed: Boolean
|
val completed: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight residence identifier persisted to the widget DataStore.
|
||||||
|
*
|
||||||
|
* Written by the main app whenever [com.tt.honeyDue.data.DataManager.myResidences]
|
||||||
|
* updates so the widget configuration activity can offer the current
|
||||||
|
* residence list (gitea#6 — per-residence widget selection). Mirrors
|
||||||
|
* iOS' `WidgetDataManager.WidgetResidence` shape.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class WidgetResidenceDto(
|
||||||
|
val id: Long,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Summary metrics computed from the cached task list.
|
* Summary metrics computed from the cached task list.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,4 +14,5 @@
|
|||||||
android:previewLayout="@layout/widget_large_preview"
|
android:previewLayout="@layout/widget_large_preview"
|
||||||
android:description="@string/widget_large_description"
|
android:description="@string/widget_large_description"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||||
android:widgetFeatures="reconfigurable" />
|
android:widgetFeatures="reconfigurable" />
|
||||||
|
|||||||
@@ -14,4 +14,5 @@
|
|||||||
android:previewLayout="@layout/widget_medium_preview"
|
android:previewLayout="@layout/widget_medium_preview"
|
||||||
android:description="@string/widget_medium_description"
|
android:description="@string/widget_medium_description"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||||
android:widgetFeatures="reconfigurable" />
|
android:widgetFeatures="reconfigurable" />
|
||||||
|
|||||||
@@ -14,4 +14,5 @@
|
|||||||
android:previewLayout="@layout/widget_small_preview"
|
android:previewLayout="@layout/widget_small_preview"
|
||||||
android:description="@string/widget_small_description"
|
android:description="@string/widget_small_description"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||||
android:widgetFeatures="reconfigurable" />
|
android:widgetFeatures="reconfigurable" />
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.tt.honeyDue.architecture
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Architecture regression gate.
|
||||||
|
*
|
||||||
|
* Scans `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/` and
|
||||||
|
* asserts every ViewModel either:
|
||||||
|
* a) accepts `dataManager: IDataManager` as a constructor parameter, or
|
||||||
|
* b) is explicitly allowlisted in [WORKFLOW_ONLY_VMS] as a
|
||||||
|
* workflow/mutation-only VM.
|
||||||
|
*
|
||||||
|
* Prevents the Dec 3 2025 regression (4 VMs holding independent
|
||||||
|
* `MutableStateFlow` read-state instead of deriving from DataManager).
|
||||||
|
* See `docs/parity-gallery.md` "Known limitations" for the history.
|
||||||
|
*
|
||||||
|
* Workflow / write-only (no read-state to mirror):
|
||||||
|
* * `TaskCompletionViewModel` — single-shot create mutation
|
||||||
|
* * `OnboardingViewModel` — wizard form + per-step ApiResult
|
||||||
|
* * `PasswordResetViewModel` — wizard form + per-step ApiResult
|
||||||
|
*
|
||||||
|
* Everyone else must accept the `dataManager` ctor param.
|
||||||
|
*/
|
||||||
|
class NoIndependentViewModelStateFileScanTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun every_read_state_vm_accepts_iDataManager_ctor_param() {
|
||||||
|
val vmSources = findViewModelSources()
|
||||||
|
val violations = mutableListOf<String>()
|
||||||
|
|
||||||
|
vmSources.forEach { file ->
|
||||||
|
val name = file.name
|
||||||
|
if (name in WORKFLOW_ONLY_VMS) return@forEach
|
||||||
|
|
||||||
|
val body = file.readText()
|
||||||
|
val hasCtorParam = body.contains(Regex("""dataManager:\s*IDataManager"""))
|
||||||
|
if (!hasCtorParam) {
|
||||||
|
violations.add(
|
||||||
|
"$name — expected `dataManager: IDataManager = DataManager` " +
|
||||||
|
"constructor parameter. Without this, read-state can't derive " +
|
||||||
|
"from the DataManager single source of truth and snapshot " +
|
||||||
|
"tests can't substitute a fixture. " +
|
||||||
|
"If this VM genuinely has no read-state (workflow / mutation only), " +
|
||||||
|
"add its filename to WORKFLOW_ONLY_VMS with justification.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.isNotEmpty()) {
|
||||||
|
fail(
|
||||||
|
"ViewModel architecture regression (see docs/parity-gallery.md):\n" +
|
||||||
|
violations.joinToString(separator = "\n") { " - $it" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun read_state_flows_should_be_derived_not_independent() {
|
||||||
|
val vmSources = findViewModelSources()
|
||||||
|
val violations = mutableListOf<String>()
|
||||||
|
|
||||||
|
// Names of fields that track one-shot mutation/workflow feedback —
|
||||||
|
// exempt from the "must be derived" rule.
|
||||||
|
val mutationFieldPrefixes = listOf(
|
||||||
|
"create", "update", "delete", "toggle", "download",
|
||||||
|
"upload", "archive", "unarchive", "cancel", "uncancel",
|
||||||
|
"mark", "generate", "request", "submit", "login", "register",
|
||||||
|
"reset", "forgot", "verify", "apple", "google",
|
||||||
|
"join", "addNew", "addTask", "taskAddNew",
|
||||||
|
"action", "currentStep", "resetToken", "email", "selected",
|
||||||
|
// Local-only state not cached by DataManager:
|
||||||
|
"category", // NotificationPreferencesViewModel per-channel local toggles
|
||||||
|
)
|
||||||
|
|
||||||
|
vmSources.forEach { file ->
|
||||||
|
val name = file.name
|
||||||
|
if (name in WORKFLOW_ONLY_VMS) return@forEach
|
||||||
|
if (name == "AuthViewModel.kt") return@forEach // 11 one-shot states, all mutation-feedback; allowlisted as a file
|
||||||
|
|
||||||
|
val body = file.readText()
|
||||||
|
val mutableReads = Regex("""private val (_[a-zA-Z]+State)\s*=\s*MutableStateFlow""")
|
||||||
|
.findAll(body).map { it.groupValues[1] }.toList()
|
||||||
|
|
||||||
|
mutableReads.forEach { fieldName ->
|
||||||
|
val root = fieldName.removePrefix("_").removeSuffix("State")
|
||||||
|
val isMutationFeedback = mutationFieldPrefixes.any {
|
||||||
|
root.lowercase().startsWith(it.lowercase())
|
||||||
|
}
|
||||||
|
if (!isMutationFeedback) {
|
||||||
|
violations.add(
|
||||||
|
"$name — field `$fieldName` looks like cached read-state " +
|
||||||
|
"(not matching any mutation-feedback prefix). Derive it from " +
|
||||||
|
"DataManager via `dataManager.xxx.map { ... }.stateIn(...)` " +
|
||||||
|
"instead of owning a MutableStateFlow. If this field really " +
|
||||||
|
"is mutation feedback, add its name prefix to " +
|
||||||
|
"mutationFieldPrefixes in this test.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.isNotEmpty()) {
|
||||||
|
fail(
|
||||||
|
"ViewModel state-ownership regression (see docs/parity-gallery.md):\n" +
|
||||||
|
violations.joinToString(separator = "\n") { " - $it" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findViewModelSources(): List<File> {
|
||||||
|
// Test cwd is `composeApp/` — resolve from the project dir.
|
||||||
|
val vmDir = File("src/commonMain/kotlin/com/tt/honeyDue/viewmodel")
|
||||||
|
check(vmDir.exists()) {
|
||||||
|
"expected VM source directory not found: ${vmDir.absolutePath} (cwd=${File(".").absolutePath})"
|
||||||
|
}
|
||||||
|
return vmDir.listFiles { f -> f.extension == "kt" }?.toList().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** VMs that legitimately don't need DataManager injection. */
|
||||||
|
val WORKFLOW_ONLY_VMS: Set<String> = setOf(
|
||||||
|
"TaskCompletionViewModel.kt",
|
||||||
|
"OnboardingViewModel.kt",
|
||||||
|
"PasswordResetViewModel.kt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.tt.honeyDue.screenshot
|
||||||
|
|
||||||
|
import com.tt.honeyDue.testing.GalleryScreens
|
||||||
|
import com.tt.honeyDue.testing.Platform
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parity gate — asserts [gallerySurfaces] is exactly the set of screens
|
||||||
|
* declared in [GalleryScreens] with [Platform.ANDROID] in their platforms.
|
||||||
|
*
|
||||||
|
* If this fails, either:
|
||||||
|
* - A new screen was added to [gallerySurfaces] but missing from the
|
||||||
|
* canonical manifest — update [GalleryScreens.all].
|
||||||
|
* - A new screen was added to the manifest but not wired into
|
||||||
|
* [gallerySurfaces] — add the corresponding `GallerySurface(...)`
|
||||||
|
* entry.
|
||||||
|
* - A rename landed on only one side — reconcile.
|
||||||
|
*
|
||||||
|
* This keeps Android and iOS from silently drifting apart in coverage.
|
||||||
|
* The iOS equivalent (`GalleryManifestParityTest.swift`) enforces the
|
||||||
|
* same invariant on the Swift test file.
|
||||||
|
*/
|
||||||
|
class GalleryManifestParityTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun android_surfaces_match_manifest_exactly() {
|
||||||
|
val actual = gallerySurfaces.map { it.name }.toSet()
|
||||||
|
val expected = GalleryScreens.forAndroid.keys
|
||||||
|
|
||||||
|
val missing = expected - actual
|
||||||
|
val extra = actual - expected
|
||||||
|
|
||||||
|
if (missing.isNotEmpty() || extra.isNotEmpty()) {
|
||||||
|
val message = buildString {
|
||||||
|
appendLine("Android GallerySurfaces drifted from canonical manifest.")
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
appendLine()
|
||||||
|
appendLine("Screens in manifest but missing from GallerySurfaces.kt:")
|
||||||
|
missing.sorted().forEach { appendLine(" - $it") }
|
||||||
|
}
|
||||||
|
if (extra.isNotEmpty()) {
|
||||||
|
appendLine()
|
||||||
|
appendLine("Screens in GallerySurfaces.kt but missing from manifest:")
|
||||||
|
extra.sorted().forEach { appendLine(" - $it") }
|
||||||
|
}
|
||||||
|
appendLine()
|
||||||
|
appendLine("Reconcile by editing com.tt.honeyDue.testing.GalleryScreens and/or")
|
||||||
|
appendLine("com.tt.honeyDue.screenshot.GallerySurfaces so both agree.")
|
||||||
|
}
|
||||||
|
kotlin.test.fail(message)
|
||||||
|
}
|
||||||
|
assertEquals(expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun no_duplicate_surface_names() {
|
||||||
|
val duplicates = gallerySurfaces.map { it.name }
|
||||||
|
.groupingBy { it }
|
||||||
|
.eachCount()
|
||||||
|
.filterValues { it > 1 }
|
||||||
|
assertTrue(
|
||||||
|
duplicates.isEmpty(),
|
||||||
|
"Duplicate surface names in GallerySurfaces.kt: $duplicates",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||||
|
package com.tt.honeyDue.screenshot
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import com.tt.honeyDue.testing.Fixtures
|
||||||
|
import com.tt.honeyDue.ui.screens.AddDocumentScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.AddResidenceScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.AllTasksScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.BiometricLockScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.CompleteTaskScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.ContractorDetailScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.ContractorsScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.DocumentDetailScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.DocumentsScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.EditDocumentScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.EditResidenceScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.EditTaskScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.ForgotPasswordScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.HomeScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.LoginScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.ManageUsersScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.NotificationPreferencesScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.ProfileScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.RegisterScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.ResetPasswordScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.ResidenceDetailScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.ResidencesScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.VerifyEmailScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingCreateAccountContent
|
||||||
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingFirstTaskContent
|
||||||
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingHomeProfileContent
|
||||||
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingJoinResidenceContent
|
||||||
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingLocationContent
|
||||||
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingNameResidenceContent
|
||||||
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingSubscriptionContent
|
||||||
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingValuePropsContent
|
||||||
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingVerifyEmailContent
|
||||||
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingWelcomeContent
|
||||||
|
import com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.task.AddTaskWithResidenceScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.task.TaskSuggestionsScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.task.TaskTemplatesBrowserScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen
|
||||||
|
import com.tt.honeyDue.viewmodel.ContractorViewModel
|
||||||
|
import com.tt.honeyDue.viewmodel.DocumentViewModel
|
||||||
|
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
||||||
|
import com.tt.honeyDue.viewmodel.PasswordResetViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declarative manifest of every Android gallery surface. Must stay in sync
|
||||||
|
* with the canonical [com.tt.honeyDue.testing.GalleryScreens] manifest —
|
||||||
|
* [GalleryManifestParityTest] fails CI if the two drift.
|
||||||
|
*
|
||||||
|
* Scope: the screens users land on. We deliberately skip:
|
||||||
|
* - dialogs that live inside a host screen (already captured on the host),
|
||||||
|
* - animation sub-views / decorative components in AnimationTesting/,
|
||||||
|
* - widget views (Android Glance / iOS WidgetKit — separate surface),
|
||||||
|
* - shared helper composables (loaders, error rows, thumbnails — they
|
||||||
|
* only appear as part of a parent screen).
|
||||||
|
*
|
||||||
|
* Detail-VM pattern (contractor_detail, document_detail, edit_document):
|
||||||
|
* the VM is created with the fixture id already pre-selected, so
|
||||||
|
* `stateIn(SharingStarted.Eagerly, initialValue = dataManager.x[id])`
|
||||||
|
* emits `Success(entity)` on first composition. Without this pre-select,
|
||||||
|
* the screens' own `LaunchedEffect(id) { vm.loadX(id) }` dispatches the id
|
||||||
|
* assignment to a coroutine that runs *after* Roborazzi captures the
|
||||||
|
* frame — so both empty and populated captures would render the `Idle`
|
||||||
|
* state and be byte-identical.
|
||||||
|
*
|
||||||
|
* Screens that require a construction-time ViewModel
|
||||||
|
* ([OnboardingViewModel], [PasswordResetViewModel]) instantiate it inline
|
||||||
|
* here. The production code paths start the viewmodel's own
|
||||||
|
* `launch { APILayer.xxx() }` on first composition — those calls fail fast
|
||||||
|
* in the hermetic Robolectric environment, but the composition itself
|
||||||
|
* renders the surface from the injected
|
||||||
|
* [com.tt.honeyDue.data.LocalDataManager] before any network result
|
||||||
|
* arrives, which is exactly what we want to compare against iOS.
|
||||||
|
*/
|
||||||
|
data class GallerySurface(
|
||||||
|
/** Snake-case identifier; used as the golden file-name prefix. */
|
||||||
|
val name: String,
|
||||||
|
val content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* ParameterizedRobolectricTestRunner uses `toString()` in the test
|
||||||
|
* display name when the `{0}` pattern is set. The default data-class
|
||||||
|
* toString includes the composable lambda hash — not useful. Override
|
||||||
|
* so test reports show `ScreenshotTests[login]` instead of
|
||||||
|
* `ScreenshotTests[GallerySurface(name=login, content=...@abc123)]`.
|
||||||
|
*/
|
||||||
|
override fun toString(): String = name
|
||||||
|
}
|
||||||
|
|
||||||
|
val gallerySurfaces: List<GallerySurface> = listOf(
|
||||||
|
// ---------- Auth ----------
|
||||||
|
GallerySurface("login") {
|
||||||
|
LoginScreen(
|
||||||
|
onLoginSuccess = {},
|
||||||
|
onNavigateToRegister = {},
|
||||||
|
onNavigateToForgotPassword = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("register") {
|
||||||
|
RegisterScreen(
|
||||||
|
onRegisterSuccess = {},
|
||||||
|
onNavigateBack = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("forgot_password") {
|
||||||
|
ForgotPasswordScreen(
|
||||||
|
onNavigateBack = {},
|
||||||
|
onNavigateToVerify = {},
|
||||||
|
onNavigateToReset = {},
|
||||||
|
viewModel = PasswordResetViewModel(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("verify_reset_code") {
|
||||||
|
VerifyResetCodeScreen(
|
||||||
|
onNavigateBack = {},
|
||||||
|
onNavigateToReset = {},
|
||||||
|
viewModel = PasswordResetViewModel(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("reset_password") {
|
||||||
|
ResetPasswordScreen(
|
||||||
|
onPasswordResetSuccess = {},
|
||||||
|
onNavigateBack = {},
|
||||||
|
viewModel = PasswordResetViewModel(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("verify_email") {
|
||||||
|
VerifyEmailScreen(
|
||||||
|
onVerifySuccess = {},
|
||||||
|
onLogout = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Onboarding ----------
|
||||||
|
GallerySurface("onboarding_welcome") {
|
||||||
|
OnboardingWelcomeContent(
|
||||||
|
onStartFresh = {},
|
||||||
|
onJoinExisting = {},
|
||||||
|
onLogin = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("onboarding_value_props") {
|
||||||
|
OnboardingValuePropsContent(onContinue = {})
|
||||||
|
},
|
||||||
|
GallerySurface("onboarding_create_account") {
|
||||||
|
OnboardingCreateAccountContent(
|
||||||
|
viewModel = OnboardingViewModel(),
|
||||||
|
onAccountCreated = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("onboarding_verify_email") {
|
||||||
|
OnboardingVerifyEmailContent(
|
||||||
|
viewModel = OnboardingViewModel(),
|
||||||
|
onVerified = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("onboarding_location") {
|
||||||
|
OnboardingLocationContent(
|
||||||
|
viewModel = OnboardingViewModel(),
|
||||||
|
onLocationDetected = {},
|
||||||
|
onSkip = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("onboarding_name_residence") {
|
||||||
|
OnboardingNameResidenceContent(
|
||||||
|
viewModel = OnboardingViewModel(),
|
||||||
|
onContinue = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("onboarding_home_profile") {
|
||||||
|
OnboardingHomeProfileContent(
|
||||||
|
viewModel = OnboardingViewModel(),
|
||||||
|
onContinue = {},
|
||||||
|
onSkip = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("onboarding_join_residence") {
|
||||||
|
OnboardingJoinResidenceContent(
|
||||||
|
viewModel = OnboardingViewModel(),
|
||||||
|
onJoined = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("onboarding_first_task") {
|
||||||
|
OnboardingFirstTaskContent(
|
||||||
|
viewModel = OnboardingViewModel(),
|
||||||
|
onTasksAdded = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("onboarding_subscription") {
|
||||||
|
OnboardingSubscriptionContent(
|
||||||
|
onSubscribe = {},
|
||||||
|
onSkip = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Home (Android-only dashboard) ----------
|
||||||
|
GallerySurface("home") {
|
||||||
|
HomeScreen(
|
||||||
|
onNavigateToResidences = {},
|
||||||
|
onNavigateToTasks = {},
|
||||||
|
onLogout = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Residences ----------
|
||||||
|
GallerySurface("residences") {
|
||||||
|
ResidencesScreen(
|
||||||
|
onResidenceClick = {},
|
||||||
|
onAddResidence = {},
|
||||||
|
onJoinResidence = {},
|
||||||
|
onLogout = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("residence_detail") {
|
||||||
|
ResidenceDetailScreen(
|
||||||
|
residenceId = Fixtures.primaryHome.id,
|
||||||
|
onNavigateBack = {},
|
||||||
|
onNavigateToEditResidence = {},
|
||||||
|
onNavigateToEditTask = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("add_residence") {
|
||||||
|
AddResidenceScreen(
|
||||||
|
onNavigateBack = {},
|
||||||
|
onResidenceCreated = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("edit_residence") {
|
||||||
|
EditResidenceScreen(
|
||||||
|
residence = Fixtures.primaryHome,
|
||||||
|
onNavigateBack = {},
|
||||||
|
onResidenceUpdated = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("join_residence") {
|
||||||
|
JoinResidenceScreen(
|
||||||
|
onNavigateBack = {},
|
||||||
|
onJoined = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("manage_users") {
|
||||||
|
ManageUsersScreen(
|
||||||
|
residenceId = Fixtures.primaryHome.id,
|
||||||
|
residenceName = Fixtures.primaryHome.name,
|
||||||
|
isPrimaryOwner = true,
|
||||||
|
residenceOwnerId = Fixtures.primaryHome.ownerId,
|
||||||
|
onNavigateBack = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Tasks ----------
|
||||||
|
GallerySurface("all_tasks") {
|
||||||
|
AllTasksScreen(onNavigateToEditTask = {})
|
||||||
|
},
|
||||||
|
GallerySurface("add_task_with_residence") {
|
||||||
|
AddTaskWithResidenceScreen(
|
||||||
|
residenceId = Fixtures.primaryHome.id,
|
||||||
|
onNavigateBack = {},
|
||||||
|
onCreated = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("edit_task") {
|
||||||
|
EditTaskScreen(
|
||||||
|
task = Fixtures.tasks.first(),
|
||||||
|
onNavigateBack = {},
|
||||||
|
onTaskUpdated = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("complete_task") {
|
||||||
|
val task = Fixtures.tasks.first()
|
||||||
|
CompleteTaskScreen(
|
||||||
|
taskId = task.id,
|
||||||
|
taskTitle = task.title,
|
||||||
|
residenceName = Fixtures.primaryHome.name,
|
||||||
|
onNavigateBack = {},
|
||||||
|
onComplete = { _, _ -> },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("task_suggestions") {
|
||||||
|
TaskSuggestionsScreen(
|
||||||
|
residenceId = Fixtures.primaryHome.id,
|
||||||
|
onNavigateBack = {},
|
||||||
|
onSuggestionAccepted = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("task_templates_browser") {
|
||||||
|
TaskTemplatesBrowserScreen(
|
||||||
|
residenceId = Fixtures.primaryHome.id,
|
||||||
|
onNavigateBack = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Contractors ----------
|
||||||
|
GallerySurface("contractors") {
|
||||||
|
ContractorsScreen(
|
||||||
|
onNavigateBack = {},
|
||||||
|
onNavigateToContractorDetail = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("contractor_detail") {
|
||||||
|
val id = Fixtures.contractors.first().id
|
||||||
|
// Pass `initialSelectedContractorId` at VM construction so the
|
||||||
|
// synchronous `stateIn` initial-value closure observes both the
|
||||||
|
// id AND the fixture-seeded `dataManager.contractorDetail[id]`,
|
||||||
|
// emitting `Success(contractor)` on first composition. Without
|
||||||
|
// this the screen's own `LaunchedEffect(id) { vm.loadContractorDetail(id) }`
|
||||||
|
// dispatches the id assignment to a coroutine that runs after
|
||||||
|
// the frame is captured, leaving both empty and populated
|
||||||
|
// captures byte-identical on the `Idle` branch.
|
||||||
|
val vm = remember { ContractorViewModel(initialSelectedContractorId = id) }
|
||||||
|
ContractorDetailScreen(
|
||||||
|
contractorId = id,
|
||||||
|
onNavigateBack = {},
|
||||||
|
viewModel = vm,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Documents ----------
|
||||||
|
GallerySurface("documents") {
|
||||||
|
DocumentsScreen(
|
||||||
|
onNavigateBack = {},
|
||||||
|
residenceId = Fixtures.primaryHome.id,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("document_detail") {
|
||||||
|
val id = Fixtures.documents.first().id ?: 0
|
||||||
|
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
|
||||||
|
DocumentDetailScreen(
|
||||||
|
documentId = id,
|
||||||
|
onNavigateBack = {},
|
||||||
|
onNavigateToEdit = { _ -> },
|
||||||
|
documentViewModel = vm,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("add_document") {
|
||||||
|
AddDocumentScreen(
|
||||||
|
residenceId = Fixtures.primaryHome.id,
|
||||||
|
onNavigateBack = {},
|
||||||
|
onDocumentCreated = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("edit_document") {
|
||||||
|
val id = Fixtures.documents.first().id ?: 0
|
||||||
|
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
|
||||||
|
EditDocumentScreen(
|
||||||
|
documentId = id,
|
||||||
|
onNavigateBack = {},
|
||||||
|
documentViewModel = vm,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Profile / settings ----------
|
||||||
|
GallerySurface("profile") {
|
||||||
|
ProfileScreen(
|
||||||
|
onNavigateBack = {},
|
||||||
|
onLogout = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
GallerySurface("notification_preferences") {
|
||||||
|
NotificationPreferencesScreen(onNavigateBack = {})
|
||||||
|
},
|
||||||
|
GallerySurface("theme_selection") {
|
||||||
|
ThemeSelectionScreen(onNavigateBack = {})
|
||||||
|
},
|
||||||
|
GallerySurface("biometric_lock") {
|
||||||
|
BiometricLockScreen(onUnlocked = {})
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Subscription ----------
|
||||||
|
GallerySurface("feature_comparison") {
|
||||||
|
FeatureComparisonScreen(
|
||||||
|
onNavigateBack = {},
|
||||||
|
onNavigateToUpgrade = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,485 +1,236 @@
|
|||||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||||
package com.tt.honeyDue.screenshot
|
package com.tt.honeyDue.screenshot
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.Home
|
|
||||||
import androidx.compose.material.icons.filled.Task
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import com.github.takahirom.roborazzi.RoborazziOptions
|
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import com.tt.honeyDue.data.IDataManager
|
||||||
|
import com.tt.honeyDue.data.LocalDataManager
|
||||||
|
import com.tt.honeyDue.testing.FixtureDataManager
|
||||||
|
import com.tt.honeyDue.testing.GalleryCategory
|
||||||
|
import com.tt.honeyDue.testing.GalleryScreens
|
||||||
import com.tt.honeyDue.ui.theme.AppThemes
|
import com.tt.honeyDue.ui.theme.AppThemes
|
||||||
import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
||||||
import com.tt.honeyDue.ui.theme.ThemeColors
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows.shadowOf
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Roborazzi-driven screenshot regression tests (P8).
|
* Parity-gallery Roborazzi snapshot tests.
|
||||||
*
|
*
|
||||||
* Runs entirely on the Robolectric unit-test classpath — no emulator
|
* Variant matrix (driven by [GalleryCategory] in the canonical
|
||||||
* required. The goal is to catch accidental UI drift (colour, spacing,
|
* [GalleryScreens] manifest):
|
||||||
* typography) on PRs by diffing generated PNGs against a committed
|
|
||||||
* golden set.
|
|
||||||
*
|
*
|
||||||
* Matrix: 6 surfaces × 3 themes (Default / Ocean / Midnight) × 2 modes
|
* DataCarrying surfaces — capture 4 variants:
|
||||||
* (light / dark) = 36 images. This is a conservative baseline; the full
|
* surface_empty_light.png (empty fixture, no lookups, light)
|
||||||
* 11-theme matrix would produce 132+ images and is deferred.
|
* surface_empty_dark.png (empty fixture, no lookups, dark)
|
||||||
|
* surface_populated_light.png (populated fixture, light)
|
||||||
|
* surface_populated_dark.png (populated fixture, dark)
|
||||||
*
|
*
|
||||||
* Implementation notes:
|
* DataFree surfaces — capture 2 variants:
|
||||||
* - We use the top-level `captureRoboImage(path) { composable }` form
|
* surface_light.png (empty fixture, lookups seeded, light)
|
||||||
* from roborazzi-compose. That helper registers
|
* surface_dark.png (empty fixture, lookups seeded, dark)
|
||||||
* `RoborazziTransparentActivity` at runtime via Robolectric's shadow
|
|
||||||
* PackageManager, so we don't need `createComposeRule()` /
|
|
||||||
* `ActivityScenarioRule<ComponentActivity>` and therefore avoid the
|
|
||||||
* "Unable to resolve activity for Intent MAIN/LAUNCHER cmp=.../ComponentActivity"
|
|
||||||
* failure that bit the initial scaffolding (RoboMonitoringInstrumentation:102).
|
|
||||||
* - Goldens land under `composeApp/build/outputs/roborazzi/`, which the
|
|
||||||
* Roborazzi Gradle plugin picks up for record / verify / compare.
|
|
||||||
*
|
*
|
||||||
* Workflow:
|
* The `empty` fixture for DataCarrying variants passes
|
||||||
* - Initial record: `./gradlew :composeApp:recordRoborazziDebug`
|
* `seedLookups = false` so form dropdowns render their empty state
|
||||||
* - Verify in CI: `./gradlew :composeApp:verifyRoborazziDebug`
|
* (yielding a visible populated-vs-empty diff for forms that read
|
||||||
* - View diffs: `./gradlew :composeApp:compareRoborazziDebug`
|
* lookups from `DataManager`). The `empty` fixture for DataFree
|
||||||
|
* variants passes `seedLookups = true` because those screens expect
|
||||||
|
* realistic production lookups even when the user has no entities yet.
|
||||||
|
*
|
||||||
|
* DataFree surfaces omit the populated variant entirely — the screens
|
||||||
|
* render no entity data, so `populated` would be byte-identical to
|
||||||
|
* `empty` and add zero signal.
|
||||||
|
*
|
||||||
|
* Granular CI failures: one parameterized test per surface means the
|
||||||
|
* report shows `ScreenshotTests[login]`, `ScreenshotTests[tasks]`, etc.
|
||||||
|
* rather than one monolithic failure when any surface drifts.
|
||||||
|
*
|
||||||
|
* Why the goldens land directly under `src/androidUnitTest/roborazzi/`:
|
||||||
|
* Roborazzi resolves `captureRoboImage(filePath = …)` relative to the
|
||||||
|
* Gradle test task's working directory (the module root). Writing to
|
||||||
|
* the same directory where goldens are committed means record and
|
||||||
|
* verify round-trip through one canonical location.
|
||||||
*/
|
*/
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@Config(qualifiers = "w360dp-h800dp-mdpi")
|
@Config(qualifiers = "w360dp-h800dp-mdpi")
|
||||||
class ScreenshotTests {
|
class ScreenshotTests(
|
||||||
|
private val surface: GallerySurface,
|
||||||
// ---------- Login screen showcase ----------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loginScreen_default_light() = runScreen("login_default_light", AppThemes.Default, darkTheme = false) {
|
|
||||||
LoginShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loginScreen_default_dark() = runScreen("login_default_dark", AppThemes.Default, darkTheme = true) {
|
|
||||||
LoginShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loginScreen_ocean_light() = runScreen("login_ocean_light", AppThemes.Ocean, darkTheme = false) {
|
|
||||||
LoginShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loginScreen_ocean_dark() = runScreen("login_ocean_dark", AppThemes.Ocean, darkTheme = true) {
|
|
||||||
LoginShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loginScreen_midnight_light() = runScreen("login_midnight_light", AppThemes.Midnight, darkTheme = false) {
|
|
||||||
LoginShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loginScreen_midnight_dark() = runScreen("login_midnight_dark", AppThemes.Midnight, darkTheme = true) {
|
|
||||||
LoginShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Tasks list showcase ----------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tasksScreen_default_light() = runScreen("tasks_default_light", AppThemes.Default, darkTheme = false) {
|
|
||||||
TasksShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tasksScreen_default_dark() = runScreen("tasks_default_dark", AppThemes.Default, darkTheme = true) {
|
|
||||||
TasksShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tasksScreen_ocean_light() = runScreen("tasks_ocean_light", AppThemes.Ocean, darkTheme = false) {
|
|
||||||
TasksShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tasksScreen_ocean_dark() = runScreen("tasks_ocean_dark", AppThemes.Ocean, darkTheme = true) {
|
|
||||||
TasksShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tasksScreen_midnight_light() = runScreen("tasks_midnight_light", AppThemes.Midnight, darkTheme = false) {
|
|
||||||
TasksShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tasksScreen_midnight_dark() = runScreen("tasks_midnight_dark", AppThemes.Midnight, darkTheme = true) {
|
|
||||||
TasksShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Residences list showcase ----------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun residencesScreen_default_light() = runScreen("residences_default_light", AppThemes.Default, darkTheme = false) {
|
|
||||||
ResidencesShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun residencesScreen_default_dark() = runScreen("residences_default_dark", AppThemes.Default, darkTheme = true) {
|
|
||||||
ResidencesShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun residencesScreen_ocean_light() = runScreen("residences_ocean_light", AppThemes.Ocean, darkTheme = false) {
|
|
||||||
ResidencesShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun residencesScreen_ocean_dark() = runScreen("residences_ocean_dark", AppThemes.Ocean, darkTheme = true) {
|
|
||||||
ResidencesShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun residencesScreen_midnight_light() = runScreen("residences_midnight_light", AppThemes.Midnight, darkTheme = false) {
|
|
||||||
ResidencesShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun residencesScreen_midnight_dark() = runScreen("residences_midnight_dark", AppThemes.Midnight, darkTheme = true) {
|
|
||||||
ResidencesShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Profile/theme-selection / complete-task showcases ----------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun profileScreen_default_light() = runScreen("profile_default_light", AppThemes.Default, darkTheme = false) {
|
|
||||||
ProfileShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun profileScreen_default_dark() = runScreen("profile_default_dark", AppThemes.Default, darkTheme = true) {
|
|
||||||
ProfileShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun profileScreen_ocean_light() = runScreen("profile_ocean_light", AppThemes.Ocean, darkTheme = false) {
|
|
||||||
ProfileShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun profileScreen_ocean_dark() = runScreen("profile_ocean_dark", AppThemes.Ocean, darkTheme = true) {
|
|
||||||
ProfileShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun profileScreen_midnight_light() = runScreen("profile_midnight_light", AppThemes.Midnight, darkTheme = false) {
|
|
||||||
ProfileShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun profileScreen_midnight_dark() = runScreen("profile_midnight_dark", AppThemes.Midnight, darkTheme = true) {
|
|
||||||
ProfileShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun themeSelection_default_light() = runScreen("themes_default_light", AppThemes.Default, darkTheme = false) {
|
|
||||||
ThemePaletteShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun themeSelection_default_dark() = runScreen("themes_default_dark", AppThemes.Default, darkTheme = true) {
|
|
||||||
ThemePaletteShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun themeSelection_ocean_light() = runScreen("themes_ocean_light", AppThemes.Ocean, darkTheme = false) {
|
|
||||||
ThemePaletteShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun themeSelection_ocean_dark() = runScreen("themes_ocean_dark", AppThemes.Ocean, darkTheme = true) {
|
|
||||||
ThemePaletteShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun themeSelection_midnight_light() = runScreen("themes_midnight_light", AppThemes.Midnight, darkTheme = false) {
|
|
||||||
ThemePaletteShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun themeSelection_midnight_dark() = runScreen("themes_midnight_dark", AppThemes.Midnight, darkTheme = true) {
|
|
||||||
ThemePaletteShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun completeTask_default_light() = runScreen("complete_task_default_light", AppThemes.Default, darkTheme = false) {
|
|
||||||
CompleteTaskShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun completeTask_default_dark() = runScreen("complete_task_default_dark", AppThemes.Default, darkTheme = true) {
|
|
||||||
CompleteTaskShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun completeTask_ocean_light() = runScreen("complete_task_ocean_light", AppThemes.Ocean, darkTheme = false) {
|
|
||||||
CompleteTaskShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun completeTask_ocean_dark() = runScreen("complete_task_ocean_dark", AppThemes.Ocean, darkTheme = true) {
|
|
||||||
CompleteTaskShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun completeTask_midnight_light() = runScreen("complete_task_midnight_light", AppThemes.Midnight, darkTheme = false) {
|
|
||||||
CompleteTaskShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun completeTask_midnight_dark() = runScreen("complete_task_midnight_dark", AppThemes.Midnight, darkTheme = true) {
|
|
||||||
CompleteTaskShowcase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Shared runner ----------
|
|
||||||
|
|
||||||
private fun runScreen(
|
|
||||||
name: String,
|
|
||||||
theme: ThemeColors,
|
|
||||||
darkTheme: Boolean,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
captureRoboImage(
|
|
||||||
filePath = "build/outputs/roborazzi/$name.png",
|
/**
|
||||||
roborazziOptions = RoborazziOptions(),
|
* Compose Multiplatform's `stringResource()` loads text via a
|
||||||
) {
|
* JVM-static context held by `AndroidContextProvider`. Under
|
||||||
HoneyDueTheme(darkTheme = darkTheme, themeColors = theme) {
|
* Robolectric unit tests the `ContentProvider` that normally
|
||||||
content()
|
* populates it never runs, so every `stringResource(...)` call throws
|
||||||
}
|
* "Android context is not initialized."
|
||||||
}
|
*
|
||||||
}
|
* Install the context eagerly via reflection before each test.
|
||||||
|
* `AndroidContextProvider` is `internal`, but its static slot is
|
||||||
|
* writable through the generated `Companion.setANDROID_CONTEXT`
|
||||||
|
* accessor.
|
||||||
|
*/
|
||||||
|
@Before
|
||||||
|
fun bootstrapComposeResources() {
|
||||||
|
val appContext = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||||
|
val providerClass = Class.forName("org.jetbrains.compose.resources.AndroidContextProvider")
|
||||||
|
val companionField = providerClass.getDeclaredField("Companion").apply { isAccessible = true }
|
||||||
|
val companion = companionField.get(null)
|
||||||
|
val setter = companion.javaClass.getMethod("setANDROID_CONTEXT", android.content.Context::class.java)
|
||||||
|
setter.invoke(companion, appContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Theme-agnostic showcase composables ============
|
@Test
|
||||||
//
|
fun captureAllVariants() {
|
||||||
// Each mirrors the *surface* (not the full data pipeline) of its named
|
val screen = GalleryScreens.forAndroid[surface.name]
|
||||||
// production screen. This keeps Roborazzi tests hermetic — no Ktor
|
?: error(
|
||||||
// client, no DataManager, no ViewModel — while still exercising every
|
"Surface '${surface.name}' is in GallerySurfaces.kt but not in " +
|
||||||
// colour slot in the MaterialTheme that ships with the app.
|
"GalleryScreens.all (canonical manifest). " +
|
||||||
|
"GalleryManifestParityTest should have caught this.",
|
||||||
@Composable
|
|
||||||
private fun LoginShowcase() {
|
|
||||||
Scaffold { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"honeyDue",
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
"Keep your home running",
|
val variants = when (screen.category) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
GalleryCategory.DataCarrying -> Variant.dataCarrying
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
GalleryCategory.DataFree -> Variant.dataFree
|
||||||
)
|
|
||||||
OutlinedTextField(value = "testuser", onValueChange = {}, label = { Text("Username") })
|
|
||||||
OutlinedTextField(value = "•••••••••", onValueChange = {}, label = { Text("Password") })
|
|
||||||
Button(onClick = {}, modifier = Modifier.fillMaxSize(1f)) {
|
|
||||||
Text("Sign In")
|
|
||||||
}
|
}
|
||||||
TextButton(onClick = {}) { Text("Forgot password?") }
|
|
||||||
|
variants.forEach { variant ->
|
||||||
|
val fileName = "${surface.name}${variant.fileSuffix}.png"
|
||||||
|
val fixture = variant.dataManager()
|
||||||
|
seedSingleton(fixture)
|
||||||
|
// Flush the main-thread Looper so any `stateIn(... Eagerly)`
|
||||||
|
// collectors on VMs reused across captures have processed the
|
||||||
|
// DataManager update before we snapshot. Without this, VMs
|
||||||
|
// might see the previous variant's data because coroutine
|
||||||
|
// emissions race the capture call.
|
||||||
|
shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") {
|
||||||
|
HoneyDueTheme(
|
||||||
|
themeColors = AppThemes.Default,
|
||||||
|
darkTheme = variant.darkTheme,
|
||||||
|
) {
|
||||||
|
// `LocalInspectionMode = true` signals to production
|
||||||
|
// composables that they're rendering in a hermetic
|
||||||
|
// preview/test environment. Camera pickers, gated push
|
||||||
|
// registrations, and animation callbacks use this flag
|
||||||
|
// to short-circuit calls that require real Android
|
||||||
|
// subsystems (e.g. `FileProvider` paths that aren't
|
||||||
|
// resolvable under Robolectric's test data dir).
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalDataManager provides fixture,
|
||||||
|
LocalInspectionMode provides true,
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
surface.content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset after suite so other tests don't inherit state.
|
||||||
|
com.tt.honeyDue.data.DataManager.setSubscription(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror every StateFlow on `fixture` onto the `DataManager` singleton
|
||||||
|
* so code paths that bypass `LocalDataManager` (screens that call
|
||||||
|
* `DataManager.x` directly, VMs whose default-arg resolves to the
|
||||||
|
* singleton, `SubscriptionHelper` free-tier gate) see the same data.
|
||||||
|
*
|
||||||
|
* Critical: clear the singleton first so the previous variant's
|
||||||
|
* writes don't leak into this variant's `empty` render.
|
||||||
|
*/
|
||||||
|
private fun seedSingleton(fixture: IDataManager) {
|
||||||
|
val dm = com.tt.honeyDue.data.DataManager
|
||||||
|
dm.clear()
|
||||||
|
dm.setSubscription(fixture.subscription.value)
|
||||||
|
dm.setCurrentUser(fixture.currentUser.value)
|
||||||
|
fixture.myResidences.value?.let { dm.setMyResidences(it) }
|
||||||
|
dm.setResidences(fixture.residences.value)
|
||||||
|
fixture.totalSummary.value?.let { dm.setTotalSummary(it) }
|
||||||
|
fixture.allTasks.value?.let { dm.setAllTasks(it) }
|
||||||
|
dm.setDocuments(fixture.documents.value)
|
||||||
|
dm.setContractors(fixture.contractors.value)
|
||||||
|
dm.setFeatureBenefits(fixture.featureBenefits.value)
|
||||||
|
dm.setUpgradeTriggers(fixture.upgradeTriggers.value)
|
||||||
|
dm.setTaskCategories(fixture.taskCategories.value)
|
||||||
|
dm.setTaskPriorities(fixture.taskPriorities.value)
|
||||||
|
dm.setTaskFrequencies(fixture.taskFrequencies.value)
|
||||||
|
fixture.contractorsByResidence.value.forEach { (rid, list) ->
|
||||||
|
dm.setContractorsForResidence(rid, list)
|
||||||
|
}
|
||||||
|
fixture.contractorDetail.value.values.forEach { dm.setContractorDetail(it) }
|
||||||
|
fixture.documentDetail.value.values.forEach { dm.setDocumentDetail(it) }
|
||||||
|
fixture.taskCompletions.value.forEach { (taskId, completions) ->
|
||||||
|
dm.setTaskCompletions(taskId, completions)
|
||||||
|
}
|
||||||
|
fixture.tasksByResidence.value.forEach { (rid, cols) ->
|
||||||
|
dm.setTasksForResidence(rid, cols)
|
||||||
|
}
|
||||||
|
fixture.notificationPreferences.value?.let { dm.setNotificationPreferences(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||||
|
fun surfaces(): List<Array<Any>> =
|
||||||
|
gallerySurfaces.map { arrayOf<Any>(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
/**
|
||||||
private fun TasksShowcase() {
|
* One render-variant captured per surface. The `dataManager` factory is
|
||||||
Scaffold(topBar = {
|
* invoked lazily so each capture gets a pristine fixture (avoiding
|
||||||
TopAppBar(
|
* cross-test StateFlow mutation).
|
||||||
title = { Text("Tasks") },
|
*
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
* @property fileSuffix Appended to the surface name to form the PNG
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
* filename. Includes a leading `_`. Examples: `_empty_light`,
|
||||||
),
|
* `_populated_dark`, `_light`, `_dark`.
|
||||||
|
*/
|
||||||
|
private data class Variant(
|
||||||
|
val fileSuffix: String,
|
||||||
|
val darkTheme: Boolean,
|
||||||
|
val dataManager: () -> IDataManager,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* DataCarrying surfaces: 4 variants. `empty` captures pass
|
||||||
|
* `seedLookups = false` so form dropdowns render empty in the
|
||||||
|
* empty-variant PNGs — letting screens that read lookups produce
|
||||||
|
* a visible diff against the populated variant.
|
||||||
|
*/
|
||||||
|
val dataCarrying: List<Variant> = listOf(
|
||||||
|
Variant("_empty_light", darkTheme = false) {
|
||||||
|
FixtureDataManager.empty(seedLookups = false)
|
||||||
|
},
|
||||||
|
Variant("_empty_dark", darkTheme = true) {
|
||||||
|
FixtureDataManager.empty(seedLookups = false)
|
||||||
|
},
|
||||||
|
Variant("_populated_light", darkTheme = false) { FixtureDataManager.populated() },
|
||||||
|
Variant("_populated_dark", darkTheme = true) { FixtureDataManager.populated() },
|
||||||
)
|
)
|
||||||
}) { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
listOf("Replace HVAC filter", "Test smoke alarms", "Clean gutters").forEach { title ->
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Filled.Task, null, tint = MaterialTheme.colorScheme.primary)
|
|
||||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(onClick = {}, colors = ButtonDefaults.buttonColors()) {
|
|
||||||
Icon(Icons.Filled.Add, null)
|
|
||||||
Text("New task", modifier = Modifier.padding(start = 8.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
/**
|
||||||
private fun ResidencesShowcase() {
|
* DataFree surfaces: 2 variants (light/dark only). Lookups are
|
||||||
Scaffold(topBar = {
|
* seeded because forms expect them to be present in production
|
||||||
TopAppBar(title = { Text("Residences") })
|
* (a user with zero entities still sees the priority picker).
|
||||||
}) { padding ->
|
* The populated variant is deliberately omitted — DataFree
|
||||||
Column(
|
* surfaces render no entity data, so `populated` would be
|
||||||
modifier = Modifier
|
* byte-identical to `empty`.
|
||||||
.fillMaxSize()
|
*/
|
||||||
.padding(padding)
|
val dataFree: List<Variant> = listOf(
|
||||||
.padding(16.dp),
|
Variant("_light", darkTheme = false) { FixtureDataManager.empty(seedLookups = true) },
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
Variant("_dark", darkTheme = true) { FixtureDataManager.empty(seedLookups = true) },
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Icon(Icons.Filled.Home, null, tint = MaterialTheme.colorScheme.primary)
|
|
||||||
Text("Primary Home", style = MaterialTheme.typography.titleMedium)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
"1234 Sunflower Lane",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OutlinedButton(onClick = {}) { Text("Add residence") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ProfileShowcase() {
|
|
||||||
Scaffold(topBar = { TopAppBar(title = { Text("Profile") }) }) { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"testuser",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"claude@treymail.com",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
listOf("Notifications", "Theme", "Help").forEach { label ->
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(label, modifier = Modifier.padding(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {},
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
) { Text("Log out") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ThemePaletteShowcase() {
|
|
||||||
Scaffold(topBar = { TopAppBar(title = { Text("Theme") }) }) { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
listOf(
|
|
||||||
"Primary" to MaterialTheme.colorScheme.primary,
|
|
||||||
"Secondary" to MaterialTheme.colorScheme.secondary,
|
|
||||||
"Tertiary" to MaterialTheme.colorScheme.tertiary,
|
|
||||||
"Surface" to MaterialTheme.colorScheme.surface,
|
|
||||||
"Error" to MaterialTheme.colorScheme.error,
|
|
||||||
).forEach { (label, color) ->
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(containerColor = color),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(24.dp)) { Text(" ", color = Color.Transparent) }
|
|
||||||
}
|
|
||||||
Text(label, color = MaterialTheme.colorScheme.onBackground)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CompleteTaskShowcase() {
|
|
||||||
Scaffold(topBar = { TopAppBar(title = { Text("Complete Task") }) }) { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Text("Test smoke alarms", style = MaterialTheme.typography.titleMedium)
|
|
||||||
OutlinedTextField(value = "42.50", onValueChange = {}, label = { Text("Actual cost") })
|
|
||||||
OutlinedTextField(value = "All alarms passed.", onValueChange = {}, label = { Text("Notes") })
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedButton(onClick = {}) { Text("Cancel") }
|
|
||||||
Button(onClick = {}) { Text("Mark complete") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.tt.honeyDue.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for the per-residence widget filter added in gitea#6.
|
||||||
|
*
|
||||||
|
* Two surfaces under test:
|
||||||
|
*
|
||||||
|
* 1. `WidgetDataRepository.filterTasksForResidence` — the pure filter
|
||||||
|
* used by `loadTasksForResidence` and (transitively) by the
|
||||||
|
* timeline provider. Mirrors iOS' `WidgetDataManager.filterTasks`.
|
||||||
|
* 2. The per-`appWidgetId` DataStore key — verifies round-tripping
|
||||||
|
* a saved residence id and clearing it for a removed widget.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class WidgetResidenceFilterTest {
|
||||||
|
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var repo: WidgetDataRepository
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() = runTest {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
repo = WidgetDataRepository.get(context)
|
||||||
|
repo.clearAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = runTest {
|
||||||
|
repo.clearAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun task(id: Long, residenceId: Long): WidgetTaskDto =
|
||||||
|
WidgetTaskDto(
|
||||||
|
id = id,
|
||||||
|
title = "Task $id",
|
||||||
|
priority = 0,
|
||||||
|
dueDate = null,
|
||||||
|
isOverdue = false,
|
||||||
|
daysUntilDue = 0,
|
||||||
|
residenceId = residenceId,
|
||||||
|
residenceName = "",
|
||||||
|
categoryIcon = "",
|
||||||
|
completed = false
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- pure filter -------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun filter_nullResidenceReturnsAllTasks() {
|
||||||
|
val tasks = listOf(task(1, 10), task(2, 20), task(3, 30))
|
||||||
|
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = null)
|
||||||
|
assertEquals(listOf(1L, 2L, 3L), result.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun filter_matchingResidenceKeepsOnlyMatchingTasks() {
|
||||||
|
val tasks = listOf(task(1, 10), task(2, 20), task(3, 10), task(4, 30))
|
||||||
|
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 10)
|
||||||
|
assertEquals(listOf(1L, 3L), result.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun filter_unknownResidenceReturnsEmpty() {
|
||||||
|
val tasks = listOf(task(1, 10), task(2, 20))
|
||||||
|
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 999)
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun filter_preservesInputOrder() {
|
||||||
|
// Subset only — the timeline provider relies on this so its
|
||||||
|
// own sort step ("overdue first, then by due date") operates
|
||||||
|
// on already-filtered tasks.
|
||||||
|
val tasks = listOf(task(5, 1), task(3, 1), task(7, 1), task(1, 2))
|
||||||
|
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 1)
|
||||||
|
assertEquals(listOf(5L, 3L, 7L), result.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DataStore round-trip ---------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun perWidgetResidenceId_roundTripsThroughDataStore() = runTest {
|
||||||
|
// Initial state: no scope persisted → returns null ("All residences").
|
||||||
|
assertNull(repo.loadResidenceIdFor(appWidgetId = 42))
|
||||||
|
|
||||||
|
repo.saveResidenceIdFor(appWidgetId = 42, residenceId = 7L)
|
||||||
|
assertEquals(7L, repo.loadResidenceIdFor(appWidgetId = 42))
|
||||||
|
|
||||||
|
// Different widget id stays unscoped — keys are per-instance.
|
||||||
|
assertNull(repo.loadResidenceIdFor(appWidgetId = 99))
|
||||||
|
|
||||||
|
// Save null clears the scope ("All residences" selected after a
|
||||||
|
// previously residence-scoped tile).
|
||||||
|
repo.saveResidenceIdFor(appWidgetId = 42, residenceId = null)
|
||||||
|
assertNull(repo.loadResidenceIdFor(appWidgetId = 42))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loadTasksForWidget_appliesPerInstanceScope() = runTest {
|
||||||
|
repo.saveTasks(listOf(task(1, 10), task(2, 20), task(3, 10)))
|
||||||
|
repo.saveResidenceIdFor(appWidgetId = 1, residenceId = 10L)
|
||||||
|
repo.saveResidenceIdFor(appWidgetId = 2, residenceId = 20L)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L, 3L), repo.loadTasksForWidget(1).map { it.id })
|
||||||
|
assertEquals(listOf(2L), repo.loadTasksForWidget(2).map { it.id })
|
||||||
|
// Unconfigured tile defaults to "All residences" — every task.
|
||||||
|
assertEquals(listOf(1L, 2L, 3L), repo.loadTasksForWidget(3).map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearResidenceIdFor_dropsScope() = runTest {
|
||||||
|
repo.saveResidenceIdFor(appWidgetId = 5, residenceId = 11L)
|
||||||
|
assertEquals(11L, repo.loadResidenceIdFor(5))
|
||||||
|
|
||||||
|
repo.clearResidenceIdFor(5)
|
||||||
|
assertNull(repo.loadResidenceIdFor(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun saveResidences_roundTripsResidenceList() = runTest {
|
||||||
|
val payload = listOf(
|
||||||
|
WidgetResidenceDto(id = 1, name = "Home"),
|
||||||
|
WidgetResidenceDto(id = 2, name = "Cabin")
|
||||||
|
)
|
||||||
|
repo.saveResidences(payload)
|
||||||
|
assertEquals(payload, repo.loadResidences())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 49 KiB |