Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c9e6009c7 | |||
| 498e6b8064 | |||
| fdcf82757d | |||
| 3890dd6f52 | |||
| d5041492a9 | |||
| ec5d93efab | |||
| b90533c535 | |||
| 03a9dfa0de | |||
| 1884853e4b | |||
| 882801c71d | |||
| dea8eed184 | |||
| 915a5d4742 | |||
| 4f9b910a94 | |||
| 3df5645f73 | |||
| 5f7498b755 | |||
| 733d4c8d36 |
@@ -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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
+140
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -504,45 +504,60 @@ object DataManager : IDataManager {
|
|||||||
* Also refreshes the summary from the updated kanban data.
|
* Also refreshes the summary from the updated kanban data.
|
||||||
*/
|
*/
|
||||||
fun updateTask(task: TaskResponse) {
|
fun updateTask(task: TaskResponse) {
|
||||||
// Update in allTasks
|
|
||||||
_allTasks.value?.let { current ->
|
|
||||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||||
val newColumns = current.columns.map { column ->
|
|
||||||
// Remove task from this column if present
|
// Upsert into _allTasks. Crucially, when _allTasks is null (fresh
|
||||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
// launch, kanban never fetched — the gitea#2 bug scenario), seed
|
||||||
// Add task if this is the target column
|
// an empty kanban shell so the new task isn't silently dropped.
|
||||||
val updatedTasks = if (column.name == targetColumn) {
|
// The Phase 2 force-refresh after bulkCreateTasks/createTask will
|
||||||
filteredTasks + task
|
// replace this shell with authoritative server data shortly.
|
||||||
|
val current = _allTasks.value ?: emptyKanbanShell()
|
||||||
|
val columnsWithTarget = if (current.columns.any { it.name == targetColumn }) {
|
||||||
|
current.columns
|
||||||
} else {
|
} else {
|
||||||
filteredTasks
|
// Server returned a kanban_column the client doesn't know about
|
||||||
|
// yet — append it so the task is still reachable.
|
||||||
|
current.columns + emptyColumn(targetColumn)
|
||||||
}
|
}
|
||||||
|
val newColumns = columnsWithTarget.map { column ->
|
||||||
|
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||||
|
val updatedTasks = if (column.name == targetColumn) filteredTasks + task else filteredTasks
|
||||||
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||||
}
|
}
|
||||||
_allTasks.value = current.copy(columns = newColumns)
|
_allTasks.value = current.copy(columns = newColumns)
|
||||||
}
|
|
||||||
|
|
||||||
// Update in tasksByResidence if this task's residence is cached
|
|
||||||
task.residenceId?.let { residenceId ->
|
|
||||||
_tasksByResidence.value[residenceId]?.let { current ->
|
|
||||||
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
|
||||||
val newColumns = current.columns.map { column ->
|
|
||||||
val filteredTasks = column.tasks.filter { it.id != task.id }
|
|
||||||
val updatedTasks = if (column.name == targetColumn) {
|
|
||||||
filteredTasks + task
|
|
||||||
} else {
|
|
||||||
filteredTasks
|
|
||||||
}
|
|
||||||
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
|
||||||
}
|
|
||||||
_tasksByResidence.value = _tasksByResidence.value + (residenceId to current.copy(columns = newColumns))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
|
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
|
||||||
refreshSummaryFromKanban()
|
refreshSummaryFromKanban()
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default kanban skeleton used when `_allTasks` was never populated.
|
||||||
|
/// Display metadata is intentionally placeholder — the Phase 2 force-refresh
|
||||||
|
/// in `APILayer.bulkCreateTasks` / `createTask` replaces these shortly with
|
||||||
|
/// authoritative server values. The `name` field is the contract — every
|
||||||
|
/// observer keys off it.
|
||||||
|
private fun emptyKanbanShell(): TaskColumnsResponse = TaskColumnsResponse(
|
||||||
|
columns = listOf(
|
||||||
|
emptyColumn("overdue_tasks"),
|
||||||
|
emptyColumn("due_soon_tasks"),
|
||||||
|
emptyColumn("in_progress_tasks"),
|
||||||
|
emptyColumn("upcoming_tasks"),
|
||||||
|
emptyColumn("completed_tasks")
|
||||||
|
),
|
||||||
|
daysThreshold = 30,
|
||||||
|
residenceId = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun emptyColumn(name: String): TaskColumn = TaskColumn(
|
||||||
|
name = name,
|
||||||
|
displayName = "",
|
||||||
|
buttonTypes = emptyList(),
|
||||||
|
icons = emptyMap(),
|
||||||
|
color = "",
|
||||||
|
tasks = emptyList(),
|
||||||
|
count = 0
|
||||||
|
)
|
||||||
|
|
||||||
fun removeTask(taskId: Int) {
|
fun removeTask(taskId: Int) {
|
||||||
// Remove from allTasks
|
// Remove from allTasks
|
||||||
_allTasks.value?.let { current ->
|
_allTasks.value?.let { current ->
|
||||||
|
|||||||
@@ -34,15 +34,20 @@ data class PresignUploadRequest(
|
|||||||
/**
|
/**
|
||||||
* Presigned upload session — response from POST /api/uploads/presign.
|
* Presigned upload session — response from POST /api/uploads/presign.
|
||||||
*
|
*
|
||||||
* The client uses [uploadUrl] + [fields] to perform a multipart/form-data
|
* The client makes one PUT request to [uploadUrl] with the raw object
|
||||||
* POST directly to B2, then passes [id] back in the upload_ids[] field of
|
* bytes as the body and [headers] as the request headers. On success,
|
||||||
* the next /api/task-completions/ or /api/documents/ create call.
|
* pass [id] back in the upload_ids[] field of the next
|
||||||
|
* /api/task-completions/ or /api/documents/ create call.
|
||||||
|
*
|
||||||
|
* PUT (not POST) because B2's S3-compatible endpoint does not implement
|
||||||
|
* the S3 POST Object form upload (returns HTTP 501).
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PresignUploadResponse(
|
data class PresignUploadResponse(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
@SerialName("upload_url") val uploadUrl: String,
|
@SerialName("upload_url") val uploadUrl: String,
|
||||||
val fields: Map<String, String>,
|
val method: String = "PUT",
|
||||||
|
val headers: Map<String, String> = emptyMap(),
|
||||||
val key: String,
|
val key: String,
|
||||||
@SerialName("expires_at") val expiresAt: String
|
@SerialName("expires_at") val expiresAt: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -615,37 +615,23 @@ object APILayer {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns kanban data for a single residence. Single source of truth
|
||||||
|
* is `_allTasks`; this function ensures it's fresh, then filters.
|
||||||
|
*
|
||||||
|
* Replaces the previous 3-path implementation (per-residence cache →
|
||||||
|
* filter from allTasks → API) that produced inconsistent results
|
||||||
|
* when the per-residence cache slot was empty but `_allTasks` was
|
||||||
|
* stale. Phase 3 deletes the per-residence cache entirely.
|
||||||
|
*/
|
||||||
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
||||||
// 1. Check residence-specific cache first
|
val allTasksResult = getTasks(forceRefresh = forceRefresh)
|
||||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) {
|
if (allTasksResult is ApiResult.Error) return allTasksResult
|
||||||
val cached = DataManager.tasksByResidence.value[residenceId]
|
|
||||||
if (cached != null) {
|
|
||||||
return ApiResult.Success(cached)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try filtering from allTasks cache before hitting API (optimization)
|
|
||||||
// This avoids a redundant API call when we already have all tasks loaded
|
|
||||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) {
|
|
||||||
val filtered = DataManager.getTasksForResidence(residenceId)
|
val filtered = DataManager.getTasksForResidence(residenceId)
|
||||||
if (filtered != null) {
|
?: return ApiResult.Error("Tasks unavailable", 0)
|
||||||
// Cache the filtered result for future use
|
|
||||||
DataManager.setTasksForResidence(residenceId, filtered)
|
|
||||||
return ApiResult.Success(filtered)
|
return ApiResult.Success(filtered)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fallback: Fetch from API
|
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
|
||||||
val result = taskApi.getTasksByResidence(token, residenceId)
|
|
||||||
|
|
||||||
// Update DataManager on success
|
|
||||||
if (result is ApiResult.Success) {
|
|
||||||
DataManager.setTasksForResidence(residenceId, result.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createTask(request: TaskCreateRequest): ApiResult<TaskResponse> {
|
suspend fun createTask(request: TaskCreateRequest): ApiResult<TaskResponse> {
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
@@ -667,9 +653,15 @@ object APILayer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically creates 1-50 tasks via POST /api/tasks/bulk/. The whole
|
* Atomically creates 1-50 tasks via POST /api/tasks/bulk/. The whole
|
||||||
* batch succeeds or fails together on the server. On success, every
|
* batch succeeds or fails together on the server. On success, force-
|
||||||
* returned task is merged into DataManager.allTasks so observing views
|
* refreshes _allTasks from the server — the server is the
|
||||||
* render the new batch immediately.
|
* authoritative kanban categorizer, and a single round-trip
|
||||||
|
* eliminates any drift between the per-task `kanbanColumn` hint and
|
||||||
|
* the global kanban view.
|
||||||
|
*
|
||||||
|
* This is the bug-class fix for gitea#2: the previous per-task
|
||||||
|
* updateTask loop was a no-op when _allTasks was null (fresh launch
|
||||||
|
* after onboarding), silently dropping the new tasks from cache.
|
||||||
*/
|
*/
|
||||||
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
suspend fun bulkCreateTasks(request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
@@ -677,7 +669,9 @@ object APILayer {
|
|||||||
|
|
||||||
if (result is ApiResult.Success) {
|
if (result is ApiResult.Success) {
|
||||||
DataManager.setTotalSummary(result.data.summary)
|
DataManager.setTotalSummary(result.data.summary)
|
||||||
result.data.tasks.forEach { DataManager.updateTask(it) }
|
// Authoritative refresh — replaces any placeholder kanban
|
||||||
|
// shell from updateTask with proper server data.
|
||||||
|
getTasks(forceRefresh = true)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ package com.tt.honeyDue.network
|
|||||||
*/
|
*/
|
||||||
object ApiConfig {
|
object ApiConfig {
|
||||||
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
||||||
val CURRENT_ENV = Environment.LOCAL
|
val CURRENT_ENV = Environment.PROD
|
||||||
|
|
||||||
enum class Environment {
|
enum class Environment {
|
||||||
LOCAL,
|
LOCAL,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import com.tt.honeyDue.models.PresignUploadResponse
|
|||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.request.forms.*
|
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.utils.io.core.*
|
import io.ktor.utils.io.core.*
|
||||||
@@ -14,17 +13,16 @@ import io.ktor.utils.io.core.*
|
|||||||
* Three-step direct-to-B2 upload helper.
|
* Three-step direct-to-B2 upload helper.
|
||||||
*
|
*
|
||||||
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a
|
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a
|
||||||
* B2 POST policy plus form fields the client needs to perform the
|
* signed PUT URL plus the headers the client must send.
|
||||||
* direct upload.
|
* Step 2: [putToStorage] — single PUT straight to B2. Bytes never traverse
|
||||||
* Step 2: [postToStorage] — multipart/form-data POST straight to B2.
|
* our API server.
|
||||||
* Bytes never traverse our API server.
|
|
||||||
* Step 3: caller invokes the relevant entity-creation endpoint
|
* Step 3: caller invokes the relevant entity-creation endpoint
|
||||||
* (POST /api/task-completions/, POST /api/documents/) with the
|
* (POST /api/task-completions/, POST /api/documents/) with the
|
||||||
* returned upload_id in the `upload_ids` field.
|
* returned upload_id in the `upload_ids` field.
|
||||||
*
|
*
|
||||||
* iOS uses its own native equivalent (PresignedUploader.swift) for memory
|
* iOS uses its own native equivalent (PresignedUploader.swift). Both paths
|
||||||
* reasons — Swift can stream a multipart body without buffering. Android
|
* use PUT because B2's S3-compatible endpoint does not implement the S3
|
||||||
* uses this Kotlin path which works fine for ≤10 MB images.
|
* POST Object form upload (returns HTTP 501 for any POST).
|
||||||
*/
|
*/
|
||||||
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||||
private val baseUrl = ApiClient.getBaseUrl()
|
private val baseUrl = ApiClient.getBaseUrl()
|
||||||
@@ -61,38 +59,36 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 2 — POST `data` directly to B2 using the signed policy fields.
|
* Step 2 — PUT `data` directly to B2 using the signed URL + headers.
|
||||||
*
|
*
|
||||||
* The S3 POST policy spec requires every signed field to appear before
|
* The presign signature binds the headers exactly, so we send them
|
||||||
* the file part, and `key` + `Content-Type` must match the policy
|
* verbatim. Content-Length is filled in automatically by Ktor from
|
||||||
* exactly. Ktor's MultiPartFormDataContent preserves insertion order
|
* the body size, but we still pass through Content-Type which Ktor
|
||||||
* for the appended parts.
|
* would otherwise default to application/octet-stream.
|
||||||
*/
|
*/
|
||||||
suspend fun postToStorage(
|
suspend fun putToStorage(
|
||||||
uploadUrl: String,
|
uploadUrl: String,
|
||||||
fields: Map<String, String>,
|
headers: Map<String, String>,
|
||||||
data: ByteArray,
|
data: ByteArray,
|
||||||
contentType: String,
|
contentType: String,
|
||||||
fileName: String,
|
|
||||||
): ApiResult<Unit> {
|
): ApiResult<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val parts = formData {
|
val response = client.put(uploadUrl) {
|
||||||
// Stable order: signed fields first, then file. We rely on
|
// Apply server-supplied headers verbatim. Skip Content-Length
|
||||||
// Ktor preserving the order in which append() is called.
|
// — Ktor sets it automatically from the body and will refuse
|
||||||
fields.forEach { (k, v) -> append(k, v) }
|
// a manual override on most engines.
|
||||||
append(
|
headers.forEach { (k, v) ->
|
||||||
key = "file",
|
if (!k.equals("Content-Length", ignoreCase = true)) {
|
||||||
value = data,
|
header(k, v)
|
||||||
headers = Headers.build {
|
}
|
||||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
}
|
||||||
append(HttpHeaders.ContentType, contentType)
|
// Defensive: ensure Content-Type is set even if the server
|
||||||
},
|
// omits it. The signed value (if present) takes precedence.
|
||||||
)
|
if (!headers.keys.any { it.equals("Content-Type", ignoreCase = true) }) {
|
||||||
|
header(HttpHeaders.ContentType, contentType)
|
||||||
|
}
|
||||||
|
setBody(data)
|
||||||
}
|
}
|
||||||
val response = client.submitFormWithBinaryData(
|
|
||||||
url = uploadUrl,
|
|
||||||
formData = parts,
|
|
||||||
)
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
ApiResult.Success(Unit)
|
ApiResult.Success(Unit)
|
||||||
} else {
|
} else {
|
||||||
@@ -124,7 +120,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
category: String,
|
category: String,
|
||||||
contentType: String,
|
contentType: String,
|
||||||
data: ByteArray,
|
data: ByteArray,
|
||||||
fileName: String,
|
@Suppress("UNUSED_PARAMETER") fileName: String,
|
||||||
): ApiResult<Int> {
|
): ApiResult<Int> {
|
||||||
val presignResult = presign(token, category, contentType, data.size.toLong())
|
val presignResult = presign(token, category, contentType, data.size.toLong())
|
||||||
val presigned = (presignResult as? ApiResult.Success)?.data
|
val presigned = (presignResult as? ApiResult.Success)?.data
|
||||||
@@ -133,16 +129,15 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
(presignResult as? ApiResult.Error)?.code,
|
(presignResult as? ApiResult.Error)?.code,
|
||||||
)
|
)
|
||||||
|
|
||||||
val postResult = postToStorage(
|
val putResult = putToStorage(
|
||||||
uploadUrl = presigned.uploadUrl,
|
uploadUrl = presigned.uploadUrl,
|
||||||
fields = presigned.fields,
|
headers = presigned.headers,
|
||||||
data = data,
|
data = data,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
fileName = fileName,
|
|
||||||
)
|
)
|
||||||
return when (postResult) {
|
return when (putResult) {
|
||||||
is ApiResult.Success -> ApiResult.Success(presigned.id)
|
is ApiResult.Success -> ApiResult.Success(presigned.id)
|
||||||
is ApiResult.Error -> postResult
|
is ApiResult.Error -> putResult
|
||||||
else -> ApiResult.Error("Upload failed in unknown state")
|
else -> ApiResult.Error("Upload failed in unknown state")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,15 +70,26 @@ class ResidenceViewModel(
|
|||||||
/** Drives the residence-scoped projections. */
|
/** Drives the residence-scoped projections. */
|
||||||
private val _selectedResidenceId = MutableStateFlow<Int?>(null)
|
private val _selectedResidenceId = MutableStateFlow<Int?>(null)
|
||||||
|
|
||||||
|
/// Residence-scoped kanban derived from `DataManager.allTasks` filtered
|
||||||
|
/// by `_selectedResidenceId`. Single source of truth — eliminates the
|
||||||
|
/// gitea#2 race window where the per-residence cache slot could be
|
||||||
|
/// empty while `_allTasks` was populated. The per-residence cache
|
||||||
|
/// (`tasksByResidence`) was deleted in cec521b.
|
||||||
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
|
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
|
||||||
combine(_selectedResidenceId, dataManager.tasksByResidence) { id, map ->
|
combine(_selectedResidenceId, DataManager.allTasks) { id, all ->
|
||||||
if (id == null) ApiResult.Idle
|
when {
|
||||||
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
|
id == null -> ApiResult.Idle
|
||||||
|
all == null -> ApiResult.Loading
|
||||||
|
else -> {
|
||||||
|
val filtered = DataManager.getTasksForResidence(id)
|
||||||
|
if (filtered != null) ApiResult.Success(filtered) else ApiResult.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
SharingStarted.Eagerly,
|
SharingStarted.Eagerly,
|
||||||
_selectedResidenceId.value?.let { id ->
|
_selectedResidenceId.value?.let { id ->
|
||||||
dataManager.tasksByResidence.value[id]?.let { ApiResult.Success(it) }
|
DataManager.getTasksForResidence(id)?.let { ApiResult.Success(it) }
|
||||||
} ?: ApiResult.Idle,
|
} ?: ApiResult.Idle,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package com.tt.honeyDue.data
|
||||||
|
|
||||||
|
import com.tt.honeyDue.models.TaskResponse
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for the gitea#2 task-cache bug:
|
||||||
|
* `DataManager.updateTask` was a no-op when both `_allTasks` was null AND
|
||||||
|
* `_tasksByResidence[residenceId]` was empty — exactly the cache state
|
||||||
|
* after a fresh register-then-bulkCreateTasks flow. The just-created
|
||||||
|
* tasks would only appear after an app restart.
|
||||||
|
*
|
||||||
|
* After the fix, `updateTask` must seed `_allTasks` from empty rather
|
||||||
|
* than skipping the update.
|
||||||
|
*/
|
||||||
|
class DataManagerTaskCacheTest {
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun resetState() {
|
||||||
|
DataManager.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Onboarding-flow scenario: brand-new user, fresh launch, no kanban
|
||||||
|
/// has ever been fetched, then a task arrives via bulkCreateTasks →
|
||||||
|
/// DataManager.updateTask. The new task MUST land in `_allTasks` and
|
||||||
|
/// be visible to any observer.
|
||||||
|
@Test
|
||||||
|
fun updateTask_seedsAllTasks_whenCacheIsEmpty() {
|
||||||
|
// Given: fresh DataManager, kanban never loaded
|
||||||
|
assertEquals(null, DataManager.allTasks.value, "_allTasks must start null after clear()")
|
||||||
|
|
||||||
|
// When: a new task arrives via the same path bulkCreateTasks uses
|
||||||
|
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||||
|
|
||||||
|
// Then: _allTasks must contain that task in the right column
|
||||||
|
val allTasks = DataManager.allTasks.value
|
||||||
|
assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null")
|
||||||
|
|
||||||
|
val upcoming = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" }
|
||||||
|
assertNotNull(upcoming, "the seeded kanban must include an upcoming_tasks column")
|
||||||
|
assertTrue(
|
||||||
|
upcoming.tasks.any { it.id == 1 },
|
||||||
|
"the new task must land in upcoming_tasks; got columns=${allTasks.columns.map { it.name to it.tasks.map { t -> t.id } }}"
|
||||||
|
)
|
||||||
|
assertEquals(upcoming.tasks.size, upcoming.count, "column count must match tasks.size")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reasonable-defaults sanity check for the bulk-create scenario:
|
||||||
|
/// multiple tasks land across different kanban columns and end up
|
||||||
|
/// distributed correctly. This exercises the upsert when _allTasks
|
||||||
|
/// was seeded by a previous call.
|
||||||
|
@Test
|
||||||
|
fun updateTask_distributesAcrossColumns_whenSeedingThenAdding() {
|
||||||
|
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "overdue_tasks"))
|
||||||
|
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "upcoming_tasks"))
|
||||||
|
DataManager.updateTask(sampleTask(id = 3, residenceId = 100, column = "upcoming_tasks"))
|
||||||
|
|
||||||
|
val allTasks = DataManager.allTasks.value
|
||||||
|
assertNotNull(allTasks)
|
||||||
|
|
||||||
|
val overdue = allTasks.columns.first { it.name == "overdue_tasks" }
|
||||||
|
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
|
||||||
|
|
||||||
|
assertEquals(setOf(1), overdue.tasks.map { it.id }.toSet())
|
||||||
|
assertEquals(setOf(2, 3), upcoming.tasks.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replacement contract: calling updateTask with the same id twice
|
||||||
|
/// must not duplicate; the second call replaces the first wherever it
|
||||||
|
/// lives. Catches the "always-append" implementation mistake.
|
||||||
|
@Test
|
||||||
|
fun updateTask_replacesExistingTaskById_acrossColumns() {
|
||||||
|
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "upcoming_tasks", title = "v1"))
|
||||||
|
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "in_progress_tasks", title = "v2"))
|
||||||
|
|
||||||
|
val allTasks = DataManager.allTasks.value
|
||||||
|
assertNotNull(allTasks)
|
||||||
|
|
||||||
|
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
|
||||||
|
val inProgress = allTasks.columns.first { it.name == "in_progress_tasks" }
|
||||||
|
|
||||||
|
assertTrue(upcoming.tasks.none { it.id == 5 }, "task 5 must move out of upcoming_tasks")
|
||||||
|
assertEquals(1, inProgress.tasks.count { it.id == 5 }, "task 5 must appear once in in_progress_tasks")
|
||||||
|
assertEquals("v2", inProgress.tasks.first { it.id == 5 }.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characterization: getTasksForResidence filters _allTasks by
|
||||||
|
/// residence id. This is the helper that becomes the primary path
|
||||||
|
/// for residence-detail in Phase 3 (collapse the dual cache).
|
||||||
|
@Test
|
||||||
|
fun getTasksForResidence_filtersAllTasksByResidenceId() {
|
||||||
|
// Seed _allTasks with tasks across two residences via the upsert path.
|
||||||
|
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||||
|
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "overdue_tasks"))
|
||||||
|
DataManager.updateTask(sampleTask(id = 3, residenceId = 200, column = "upcoming_tasks"))
|
||||||
|
|
||||||
|
val r100 = DataManager.getTasksForResidence(100)
|
||||||
|
assertNotNull(r100)
|
||||||
|
val r100Ids = r100.columns.flatMap { it.tasks }.map { it.id }.toSet()
|
||||||
|
assertEquals(setOf(1, 2), r100Ids)
|
||||||
|
|
||||||
|
val r200 = DataManager.getTasksForResidence(200)
|
||||||
|
assertNotNull(r200)
|
||||||
|
val r200Ids = r200.columns.flatMap { it.tasks }.map { it.id }.toSet()
|
||||||
|
assertEquals(setOf(3), r200Ids)
|
||||||
|
|
||||||
|
// Counts on each column must match the filtered task lists.
|
||||||
|
for (column in r100.columns) {
|
||||||
|
assertEquals(column.tasks.size, column.count, "column ${column.name} count mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characterization: residenceId with no tasks returns a non-null
|
||||||
|
/// shell so the residence-detail screen can distinguish "loading"
|
||||||
|
/// (null) from "loaded, no tasks" (non-null with empty columns).
|
||||||
|
@Test
|
||||||
|
fun getTasksForResidence_returnsEmptyShellForResidenceWithNoTasks() {
|
||||||
|
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||||
|
|
||||||
|
val r999 = DataManager.getTasksForResidence(999)
|
||||||
|
assertNotNull(r999, "residence with no tasks must return an empty shell, not null")
|
||||||
|
assertEquals(0, r999.columns.sumOf { it.tasks.size })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characterization: when _allTasks is null entirely (cache never
|
||||||
|
/// populated), getTasksForResidence returns null — caller must call
|
||||||
|
/// the API path. Phase 3's getTasksByResidence relies on this.
|
||||||
|
@Test
|
||||||
|
fun getTasksForResidence_returnsNullWhenAllTasksIsNull() {
|
||||||
|
DataManager.clear()
|
||||||
|
assertEquals(null, DataManager.getTasksForResidence(100))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lockdown: updateTask must NOT touch `_tasksByResidence`. That cache
|
||||||
|
/// is being deleted in Phase 3; until then, updateTask must leave it
|
||||||
|
/// alone. If a future commit re-introduces the conditional write
|
||||||
|
/// branch this test catches it.
|
||||||
|
@Test
|
||||||
|
fun updateTask_doesNotMutate_tasksByResidence() {
|
||||||
|
val before = DataManager.tasksByResidence.value
|
||||||
|
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
||||||
|
assertEquals(
|
||||||
|
before,
|
||||||
|
DataManager.tasksByResidence.value,
|
||||||
|
"updateTask must not write to _tasksByResidence — that cache is deprecated"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleTask(
|
||||||
|
id: Int,
|
||||||
|
residenceId: Int,
|
||||||
|
column: String,
|
||||||
|
title: String = "Task $id"
|
||||||
|
) = TaskResponse(
|
||||||
|
id = id,
|
||||||
|
residenceId = residenceId,
|
||||||
|
createdById = 1,
|
||||||
|
title = title,
|
||||||
|
kanbanColumn = column,
|
||||||
|
createdAt = "2026-04-25T00:00:00Z",
|
||||||
|
updatedAt = "2026-04-25T00:00:00Z"
|
||||||
|
)
|
||||||
|
}
|
||||||
+10
-2
@@ -1,9 +1,17 @@
|
|||||||
#Kotlin
|
#Kotlin
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
kotlin.daemon.jvmargs=-Xmx3072M
|
# Heap sizing for KMP builds.
|
||||||
|
# Kotlin daemon runs the K2 compiler + native linker; 4 GB headroom
|
||||||
|
# prevents long-tail OOMs during iosArm64 framework link.
|
||||||
|
# MaxMetaspaceSize caps slow class-loading leaks across daemon reuse;
|
||||||
|
# G1GC keeps pauses short during incremental builds.
|
||||||
|
kotlin.daemon.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=1g -XX:+UseG1GC
|
||||||
|
|
||||||
#Gradle
|
#Gradle
|
||||||
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
# Gradle daemon drives configuration cache + dependency resolution +
|
||||||
|
# Compose/Android compilers. OOMs at 4 GB during ComposeApp.framework
|
||||||
|
# generation; 6 GB is the usual safe size for projects this size.
|
||||||
|
org.gradle.jvmargs=-Xmx6144M -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -Dfile.encoding=UTF-8
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,79 @@ import AppIntents
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Widget Configuration Intent
|
// MARK: - Widget Configuration Intent
|
||||||
|
|
||||||
|
/// Per-instance widget configuration. The `residence` parameter (added
|
||||||
|
/// for gitea#6) lets users with multiple residences pick which one a
|
||||||
|
/// given widget tile shows tasks for. When unset the widget continues
|
||||||
|
/// to display tasks across every residence — that's the single-home
|
||||||
|
/// default and matches pre-#6 behaviour for users who only have one
|
||||||
|
/// property.
|
||||||
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
||||||
static var title: LocalizedStringResource { "honeyDue Configuration" }
|
static var title: LocalizedStringResource { "honeyDue Configuration" }
|
||||||
static var description: IntentDescription { "Configure your honeyDue widget" }
|
static var description: IntentDescription {
|
||||||
|
IntentDescription("Pick which residence this widget shows tasks for.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parameter(title: "Residence")
|
||||||
|
var residence: WidgetResidenceEntity?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Residence Entity (configuration picker)
|
||||||
|
|
||||||
|
/// `AppEntity` exposing the user's residences to the widget's
|
||||||
|
/// configuration sheet. Reads from the `widget_residences.json`
|
||||||
|
/// sidecar that the main app writes via
|
||||||
|
/// `WidgetDataManager.saveResidences(...)`.
|
||||||
|
struct WidgetResidenceEntity: AppEntity, Identifiable, Hashable {
|
||||||
|
/// Backing residence id (matches `Residence.id` on the server). Stored
|
||||||
|
/// as `String` because `AppEntity.id` requires `Hashable`-conformance
|
||||||
|
/// for stable widget reconfiguration — Apple's docs recommend a stable
|
||||||
|
/// string identifier over `Int` so the widget timeline survives
|
||||||
|
/// device-id changes.
|
||||||
|
var id: String
|
||||||
|
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
/// Convenience integer form for `CacheManager.getUpcomingTasks`.
|
||||||
|
var intId: Int? { Int(id) }
|
||||||
|
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation {
|
||||||
|
TypeDisplayRepresentation(name: "Residence")
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
DisplayRepresentation(title: "\(name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var defaultQuery = WidgetResidenceEntityQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provides the residence choices the configuration sheet displays. The
|
||||||
|
/// list is sourced from the App-Group-shared `widget_residences.json`
|
||||||
|
/// the main app maintains; on a brand-new install (or signed-out state)
|
||||||
|
/// the sheet falls back to showing only the "All residences" implicit
|
||||||
|
/// option exposed by the optional parameter.
|
||||||
|
struct WidgetResidenceEntityQuery: EntityQuery {
|
||||||
|
/// Look up specific residences by id (used when the system needs to
|
||||||
|
/// re-resolve a saved configuration after the user reopens the
|
||||||
|
/// widget edit sheet).
|
||||||
|
func entities(for identifiers: [WidgetResidenceEntity.ID]) async throws -> [WidgetResidenceEntity] {
|
||||||
|
let known = loadAll()
|
||||||
|
return identifiers.compactMap { id in known.first(where: { $0.id == id }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populate the picker. Sorted alphabetically so the list is stable
|
||||||
|
/// across refreshes — `WidgetDataManager.saveResidences` writes in
|
||||||
|
/// the order the API returned, which can shuffle on server-side
|
||||||
|
/// re-sorts.
|
||||||
|
func suggestedEntities() async throws -> [WidgetResidenceEntity] {
|
||||||
|
loadAll().sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadAll() -> [WidgetResidenceEntity] {
|
||||||
|
let raw = CacheManager.getResidences()
|
||||||
|
return raw.map { WidgetResidenceEntity(id: String($0.id), name: $0.name) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Complete Task Intent
|
// MARK: - Complete Task Intent
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ class CacheManager {
|
|||||||
let inProgress: Bool
|
let inProgress: Bool
|
||||||
let dueDate: String?
|
let dueDate: String?
|
||||||
let category: String?
|
let category: String?
|
||||||
|
/// Owning residence id. Decoded from `residence_id` in the widget
|
||||||
|
/// cache. Optional so older app builds (pre-gitea#6) that omitted
|
||||||
|
/// the key still decode successfully — in that case the widget
|
||||||
|
/// behaves like the legacy "all residences" mode regardless of
|
||||||
|
/// what the configuration intent picks.
|
||||||
|
let residenceId: Int?
|
||||||
let residenceName: String?
|
let residenceName: String?
|
||||||
let isOverdue: Bool
|
let isOverdue: Bool
|
||||||
let isDueWithin7Days: Bool
|
let isDueWithin7Days: Bool
|
||||||
@@ -93,12 +99,45 @@ class CacheManager {
|
|||||||
case id, title, description, priority, category
|
case id, title, description, priority, category
|
||||||
case inProgress = "in_progress"
|
case inProgress = "in_progress"
|
||||||
case dueDate = "due_date"
|
case dueDate = "due_date"
|
||||||
|
case residenceId = "residence_id"
|
||||||
case residenceName = "residence_name"
|
case residenceName = "residence_name"
|
||||||
case isOverdue = "is_overdue"
|
case isOverdue = "is_overdue"
|
||||||
case isDueWithin7Days = "is_due_within_7_days"
|
case isDueWithin7Days = "is_due_within_7_days"
|
||||||
case isDue8To30Days = "is_due_8_to_30_days"
|
case isDue8To30Days = "is_due_8_to_30_days"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Custom init with a default `residenceId` so the existing
|
||||||
|
/// #Preview literal-task sites compile without each having to
|
||||||
|
/// add the new parameter. Production decode uses the synthesized
|
||||||
|
/// `Decodable` path.
|
||||||
|
init(
|
||||||
|
id: Int,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
priority: String?,
|
||||||
|
inProgress: Bool,
|
||||||
|
dueDate: String?,
|
||||||
|
category: String?,
|
||||||
|
residenceId: Int? = nil,
|
||||||
|
residenceName: String?,
|
||||||
|
isOverdue: Bool,
|
||||||
|
isDueWithin7Days: Bool,
|
||||||
|
isDue8To30Days: Bool
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.priority = priority
|
||||||
|
self.inProgress = inProgress
|
||||||
|
self.dueDate = dueDate
|
||||||
|
self.category = category
|
||||||
|
self.residenceId = residenceId
|
||||||
|
self.residenceName = residenceName
|
||||||
|
self.isOverdue = isOverdue
|
||||||
|
self.isDueWithin7Days = isDueWithin7Days
|
||||||
|
self.isDue8To30Days = isDue8To30Days
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this task is pending completion (tapped on widget, waiting for sync)
|
/// Whether this task is pending completion (tapped on widget, waiting for sync)
|
||||||
var isPendingCompletion: Bool {
|
var isPendingCompletion: Bool {
|
||||||
WidgetActionManager.shared.isTaskPendingCompletion(taskId: id)
|
WidgetActionManager.shared.isTaskPendingCompletion(taskId: id)
|
||||||
@@ -147,14 +186,19 @@ class CacheManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getUpcomingTasks() -> [CustomTask] {
|
static func getUpcomingTasks(forResidenceId residenceId: Int? = nil) -> [CustomTask] {
|
||||||
let allTasks = getData()
|
let allTasks = getData()
|
||||||
|
|
||||||
// Filter for actionable tasks (not completed, including in-progress and overdue)
|
// Filter for actionable tasks (not completed, including in-progress and overdue)
|
||||||
// Also exclude tasks that are pending completion via widget
|
// Also exclude tasks that are pending completion via widget.
|
||||||
|
// When a residence is configured for this widget instance
|
||||||
|
// (gitea#6), drop tasks owned by other residences.
|
||||||
let upcoming = allTasks.filter { task in
|
let upcoming = allTasks.filter { task in
|
||||||
// Include if: not pending completion
|
guard task.shouldShow else { return false }
|
||||||
return task.shouldShow
|
if let residenceId, let taskResidenceId = task.residenceId {
|
||||||
|
return taskResidenceId == residenceId
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by due date (earliest first), with overdue at top
|
// Sort by due date (earliest first), with overdue at top
|
||||||
@@ -171,6 +215,36 @@ class CacheManager {
|
|||||||
return date1 < date2
|
return date1 < date2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Residence sidecar (gitea#6)
|
||||||
|
|
||||||
|
private static let residencesFileName = "widget_residences.json"
|
||||||
|
|
||||||
|
private static var residencesFileURL: URL? {
|
||||||
|
sharedContainerURL?.appendingPathComponent(residencesFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WidgetResidence: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronously load every residence the main app has persisted for
|
||||||
|
/// widget configuration. Empty when the user is signed out or the
|
||||||
|
/// sidecar has not yet been written.
|
||||||
|
static func getResidences() -> [WidgetResidence] {
|
||||||
|
guard let fileURL = residencesFileURL,
|
||||||
|
FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: fileURL)
|
||||||
|
return try JSONDecoder().decode([WidgetResidence].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("CacheManager: Error decoding residences - \(error)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Provider: AppIntentTimelineProvider {
|
struct Provider: AppIntentTimelineProvider {
|
||||||
@@ -184,7 +258,7 @@ struct Provider: AppIntentTimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
|
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
|
||||||
let tasks = CacheManager.getUpcomingTasks()
|
let tasks = CacheManager.getUpcomingTasks(forResidenceId: configuration.residence?.intId)
|
||||||
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
|
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
|
||||||
return SimpleEntry(
|
return SimpleEntry(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
@@ -195,7 +269,7 @@ struct Provider: AppIntentTimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
|
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
|
||||||
let tasks = CacheManager.getUpcomingTasks()
|
let tasks = CacheManager.getUpcomingTasks(forResidenceId: configuration.residence?.intId)
|
||||||
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
|
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
|
||||||
|
|
||||||
// Use a longer refresh interval during overnight hours (11pm-6am)
|
// Use a longer refresh interval during overnight hours (11pm-6am)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import honeyDue
|
||||||
|
|
||||||
|
/// Tests for the per-residence widget filter added in gitea#6.
|
||||||
|
///
|
||||||
|
/// `WidgetDataManager.filterTasks(_:forResidenceId:)` is the pure
|
||||||
|
/// function the widget timeline provider calls when a configuration
|
||||||
|
/// intent has a residence selected. These tests guarantee the contract
|
||||||
|
/// stays stable: nil → pass-through, matching id → only matching tasks,
|
||||||
|
/// no match → empty, missing residenceId on a task → never leaks into
|
||||||
|
/// a residence-scoped widget.
|
||||||
|
final class WidgetResidenceFilterTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeTask(
|
||||||
|
id: Int,
|
||||||
|
residenceId: Int? = nil
|
||||||
|
) -> WidgetDataManager.WidgetTask {
|
||||||
|
WidgetDataManager.WidgetTask(
|
||||||
|
id: id,
|
||||||
|
title: "Task \(id)",
|
||||||
|
description: nil,
|
||||||
|
priority: nil,
|
||||||
|
inProgress: false,
|
||||||
|
dueDate: nil,
|
||||||
|
category: nil,
|
||||||
|
residenceId: residenceId,
|
||||||
|
residenceName: nil,
|
||||||
|
isOverdue: false,
|
||||||
|
isDueWithin7Days: false,
|
||||||
|
isDue8To30Days: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNilResidenceReturnsAllTasks() {
|
||||||
|
// "All residences" config — widget passes nil, gets every task.
|
||||||
|
let tasks = [
|
||||||
|
makeTask(id: 1, residenceId: 10),
|
||||||
|
makeTask(id: 2, residenceId: 20),
|
||||||
|
makeTask(id: 3, residenceId: nil),
|
||||||
|
]
|
||||||
|
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: nil)
|
||||||
|
XCTAssertEqual(result.map(\.id), [1, 2, 3])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMatchingResidenceKeepsOnlyMatchingTasks() {
|
||||||
|
let tasks = [
|
||||||
|
makeTask(id: 1, residenceId: 10),
|
||||||
|
makeTask(id: 2, residenceId: 20),
|
||||||
|
makeTask(id: 3, residenceId: 10),
|
||||||
|
makeTask(id: 4, residenceId: 30),
|
||||||
|
]
|
||||||
|
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 10)
|
||||||
|
XCTAssertEqual(result.map(\.id), [1, 3])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnknownResidenceReturnsEmpty() {
|
||||||
|
let tasks = [
|
||||||
|
makeTask(id: 1, residenceId: 10),
|
||||||
|
makeTask(id: 2, residenceId: 20),
|
||||||
|
]
|
||||||
|
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 999)
|
||||||
|
XCTAssertTrue(result.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNilResidenceIdOnTaskDoesNotMatchScopedConfiguration() {
|
||||||
|
// A task written by an older app build (no `residence_id` in JSON)
|
||||||
|
// must NOT leak into a residence-scoped widget — we'd rather hide
|
||||||
|
// it than misattribute it to the wrong home.
|
||||||
|
let tasks = [
|
||||||
|
makeTask(id: 1, residenceId: 10),
|
||||||
|
makeTask(id: 2, residenceId: nil),
|
||||||
|
]
|
||||||
|
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 10)
|
||||||
|
XCTAssertEqual(result.map(\.id), [1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFilterPreservesInputOrder() {
|
||||||
|
// The filter is a pure subset op — no sorting side effects.
|
||||||
|
// Timeline provider relies on this so its sort step (overdue
|
||||||
|
// first, then by due date) operates on already-filtered tasks.
|
||||||
|
let tasks = [
|
||||||
|
makeTask(id: 5, residenceId: 1),
|
||||||
|
makeTask(id: 3, residenceId: 1),
|
||||||
|
makeTask(id: 7, residenceId: 1),
|
||||||
|
makeTask(id: 1, residenceId: 2),
|
||||||
|
]
|
||||||
|
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 1)
|
||||||
|
XCTAssertEqual(result.map(\.id), [5, 3, 7])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,23 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When `true`, every test in the suite forces a logout → login cycle
|
||||||
|
/// in `setUp`, guaranteeing a freshly-issued auth token on each run.
|
||||||
|
///
|
||||||
|
/// Default is `false`: tests reuse the existing logged-in session
|
||||||
|
/// from the previous test in the same suite — much faster (one login
|
||||||
|
/// per suite, not one per test) and resilient to suites where the
|
||||||
|
/// current screen has no logout affordance (`UITestHelpers.ensureLoggedOut`
|
||||||
|
/// times out → the test fails before its body runs).
|
||||||
|
///
|
||||||
|
/// Override to `true` in suites that have observed transient
|
||||||
|
/// `Invalid token` 401s on POST/PATCH while reads continue to work.
|
||||||
|
/// The recipe was added after a 2026-05 incident where the API
|
||||||
|
/// container was rebuilt mid-suite and in-memory tokens went stale.
|
||||||
|
/// In normal CI runs against a stable API + freshly-erased simulator,
|
||||||
|
/// session reuse is the correct default.
|
||||||
|
var forceFreshLoginPerTest: Bool { false }
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
guard TestAccountAPIClient.isBackendReachable() else {
|
guard TestAccountAPIClient.isBackendReachable() else {
|
||||||
throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)")
|
throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)")
|
||||||
@@ -41,27 +58,27 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
|||||||
|
|
||||||
try super.setUpWithError()
|
try super.setUpWithError()
|
||||||
|
|
||||||
// If already logged in (tab bar visible), skip the login flow
|
|
||||||
let tabBar = app.tabBars.firstMatch
|
let tabBar = app.tabBars.firstMatch
|
||||||
if tabBar.waitForExistence(timeout: defaultTimeout) {
|
let alreadyLoggedIn = tabBar.waitForExistence(timeout: defaultTimeout)
|
||||||
// Already logged in — just set up API session if needed
|
|
||||||
if needsAPISession {
|
|
||||||
guard let apiSession = TestAccountManager.loginSeededAccount(
|
|
||||||
username: apiCredentials.username,
|
|
||||||
password: apiCredentials.password
|
|
||||||
) else {
|
|
||||||
XCTFail("Could not login API account '\(apiCredentials.username)'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
session = apiSession
|
|
||||||
cleaner = TestDataCleaner(token: apiSession.token)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not logged in — do the full login flow
|
// Force-fresh path: log out (if needed) and re-authenticate per
|
||||||
|
// test so every test starts with a freshly-issued JWT. Catches
|
||||||
|
// server-side token invalidation that would otherwise surface
|
||||||
|
// mid-suite as opaque 401s on the first mutation call.
|
||||||
|
if forceFreshLoginPerTest {
|
||||||
|
if alreadyLoggedIn {
|
||||||
|
UITestHelpers.ensureLoggedOut(app: app)
|
||||||
|
} else {
|
||||||
|
UITestHelpers.ensureLoggedOut(app: app)
|
||||||
|
}
|
||||||
|
loginToMainApp()
|
||||||
|
} else if !alreadyLoggedIn {
|
||||||
|
// Legacy session-reuse path: only log in when not already in.
|
||||||
UITestHelpers.ensureLoggedOut(app: app)
|
UITestHelpers.ensureLoggedOut(app: app)
|
||||||
loginToMainApp()
|
loginToMainApp()
|
||||||
|
}
|
||||||
|
// (When `forceFreshLoginPerTest == false` AND we're already
|
||||||
|
// logged in, fall through with the existing session.)
|
||||||
|
|
||||||
if needsAPISession {
|
if needsAPISession {
|
||||||
guard let apiSession = TestAccountManager.loginSeededAccount(
|
guard let apiSession = TestAccountManager.loginSeededAccount(
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Suite 11 — captures the gitea#2 regression at the user-visible level:
|
||||||
|
/// after onboarding (register → name residence → bulk-create tasks → land
|
||||||
|
/// on home), tapping the residence cell shows "no tasks" even though the
|
||||||
|
/// server has them. Restarting the app fixes it. This test reproduces the
|
||||||
|
/// flow without an app restart and asserts that tasks render on the
|
||||||
|
/// residence detail screen.
|
||||||
|
///
|
||||||
|
/// CRITICAL: this test must FAIL at the cache-unification fix's first
|
||||||
|
/// commit and must PASS after Phase 1-3 lands. The failing assertion is
|
||||||
|
/// pinned to a specific message so the regression is unambiguous.
|
||||||
|
///
|
||||||
|
/// The test deliberately does NOT visit the Tasks tab between onboarding
|
||||||
|
/// and tapping the residence cell. Visiting the Tasks tab would prime
|
||||||
|
/// `_allTasks` and mask the bug — the bug is that residence detail
|
||||||
|
/// cannot recover from the empty-cache + sink-timing window on its own.
|
||||||
|
final class Suite11_TaskCacheRegressionTests: BaseUITestCase {
|
||||||
|
// We need to start at the onboarding welcome screen, not the standalone
|
||||||
|
// login screen — `completeOnboarding` would skip the entire flow.
|
||||||
|
override var completeOnboarding: Bool { false }
|
||||||
|
// Single test in this suite — relaunch isn't necessary, but we want a
|
||||||
|
// clean state every time (handled by the default --reset-state).
|
||||||
|
override var relaunchBetweenTests: Bool { true }
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
/// DEBUG_FIXED_CODES=true on the local Go API hardcodes this code.
|
||||||
|
private let debugVerificationCode = "123456"
|
||||||
|
|
||||||
|
/// Stable name for the residence we create in onboarding. Used both for
|
||||||
|
/// the form input and to address the cell on the home screen via
|
||||||
|
/// `app.staticTexts[residenceName]` if the id-based identifier doesn't
|
||||||
|
/// resolve in time.
|
||||||
|
private let residenceName = "UI Test Property"
|
||||||
|
|
||||||
|
// MARK: - Test
|
||||||
|
|
||||||
|
/// Reproduces gitea#2: tasks created via the onboarding bulk endpoint
|
||||||
|
/// must appear on the residence detail screen without an app restart
|
||||||
|
/// and without first visiting the Tasks tab.
|
||||||
|
@MainActor
|
||||||
|
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
|
||||||
|
// Step 1 — Register a fresh user via the onboarding Start Fresh flow.
|
||||||
|
// The flow is: Welcome → ValueProps → NameResidence → CreateAccount
|
||||||
|
// → VerifyEmail → HomeProfile → FirstTask → main app.
|
||||||
|
let createAccount = TestFlows.navigateStartFreshToCreateAccount(
|
||||||
|
app: app,
|
||||||
|
residenceName: residenceName
|
||||||
|
)
|
||||||
|
createAccount.waitForLoad(timeout: navigationTimeout)
|
||||||
|
|
||||||
|
// Step 2 — Fill the create-account form. We address the onboarding
|
||||||
|
// form's fields (not the standalone register sheet's fields).
|
||||||
|
let creds = TestAccountManager.uniqueCredentials(prefix: "gitea2")
|
||||||
|
|
||||||
|
createAccount.expandEmailSignup()
|
||||||
|
|
||||||
|
// Use the same focusAndType path that OnboardingTests uses — it
|
||||||
|
// already handles SecureTextField + iOS strong-password panel.
|
||||||
|
// Under --ui-testing, OrganicOnboardingSecureField defaults to
|
||||||
|
// visibility=ON (renders as TextField) to dodge the iOS 26 SecureField
|
||||||
|
// keyboard bug. Query textFields, not secureTextFields.
|
||||||
|
let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
|
||||||
|
let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
|
||||||
|
let passwordField = app.textFields[AccessibilityIdentifiers.Onboarding.passwordField]
|
||||||
|
let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
|
||||||
|
|
||||||
|
usernameField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
usernameField.focusAndType(creds.username, app: app)
|
||||||
|
emailField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
emailField.focusAndType(creds.email, app: app)
|
||||||
|
passwordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
passwordField.focusAndType(creds.password, app: app)
|
||||||
|
confirmPasswordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
confirmPasswordField.focusAndType(creds.password, app: app)
|
||||||
|
|
||||||
|
let createAccountButton = app.descendants(matching: .any)
|
||||||
|
.matching(identifier: AccessibilityIdentifiers.Onboarding.createAccountButton)
|
||||||
|
.firstMatch
|
||||||
|
createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
createAccountButton.forceTap()
|
||||||
|
|
||||||
|
// Step 3 — Verify email with the debug fixed code.
|
||||||
|
let verification = VerificationScreen(app: app)
|
||||||
|
verification.waitForLoad(timeout: loginTimeout)
|
||||||
|
verification.enterCode(debugVerificationCode)
|
||||||
|
// Many onboarding verification screens auto-submit on a 6-digit
|
||||||
|
// code. If a verify button still exists and a code field is still
|
||||||
|
// visible, tap it to push past edge cases.
|
||||||
|
if verification.codeField.waitForExistence(timeout: 1) && verification.verifyButton.exists {
|
||||||
|
verification.submitCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 — Skip the home-profile step. The home-profile screen has
|
||||||
|
// its own Skip button (the shared onboarding skip in the nav bar)
|
||||||
|
// which routes to the first-task step without making us pick climate
|
||||||
|
// / appliance fields.
|
||||||
|
let onboardingSkipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton]
|
||||||
|
XCTAssertTrue(
|
||||||
|
onboardingSkipButton.waitForExistence(timeout: loginTimeout),
|
||||||
|
"Onboarding skip button should exist on the home-profile screen"
|
||||||
|
)
|
||||||
|
// The skip button can briefly be non-hittable during the screen-in
|
||||||
|
// transition. Use forceTap() to bypass the strict hittable check.
|
||||||
|
// We confirmed existence above; if the tap doesn't land on the
|
||||||
|
// intended button the next assertion (Browse All tab) will catch it.
|
||||||
|
onboardingSkipButton.forceTap()
|
||||||
|
|
||||||
|
// Step 5 — Switch to the "Browse All" tab on the First-Task screen.
|
||||||
|
// "For You" suggestions can be empty for a fresh residence with no
|
||||||
|
// home-profile data, so deterministic browsing is required.
|
||||||
|
// The tab bar is a SwiftUI segmented Picker — its segments are
|
||||||
|
// exposed as buttons with the segment label, regardless of an
|
||||||
|
// identifier on the parent.
|
||||||
|
let browseAllTab = app.buttons["Browse All"]
|
||||||
|
XCTAssertTrue(
|
||||||
|
browseAllTab.waitForExistence(timeout: loginTimeout),
|
||||||
|
"Browse All tab should appear on the first-task screen"
|
||||||
|
)
|
||||||
|
browseAllTab.tap()
|
||||||
|
|
||||||
|
// Step 6 — Pick 3 templates by accessibility identifier prefix.
|
||||||
|
// The catalog is loaded via GET /api/tasks/templates/grouped/, so
|
||||||
|
// we need to wait for at least one row to render before tapping.
|
||||||
|
let templateRowQuery = app.buttons.matching(
|
||||||
|
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||||
|
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for the catalog to load. The grouped endpoint returns first
|
||||||
|
// category expanded by default in the view, so rows should appear
|
||||||
|
// shortly after Browse All becomes visible. Network call: 10s.
|
||||||
|
let firstRow = templateRowQuery.element(boundBy: 0)
|
||||||
|
XCTAssertTrue(
|
||||||
|
firstRow.waitForExistence(timeout: loginTimeout),
|
||||||
|
"At least one template row must render on the Browse All tab. " +
|
||||||
|
"If no rows appear, the catalog endpoint failed — bug repro is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tap the first 3 visible rows. Some categories may collapse rows
|
||||||
|
// we never see; we only need at least 1, so the floor is 1 with a
|
||||||
|
// soft cap of 3.
|
||||||
|
let rowCount = templateRowQuery.count
|
||||||
|
let toPick = min(3, rowCount)
|
||||||
|
XCTAssertGreaterThanOrEqual(toPick, 1, "Expected at least one template row")
|
||||||
|
for index in 0..<toPick {
|
||||||
|
let row = templateRowQuery.element(boundBy: index)
|
||||||
|
row.waitUntilHittable(timeout: navigationTimeout)
|
||||||
|
row.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7 — Submit the bulk-create. This is the
|
||||||
|
// POST /api/tasks/bulk/ call that produces the inconsistent client
|
||||||
|
// cache state at the heart of gitea#2.
|
||||||
|
let submitButton = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
|
||||||
|
XCTAssertTrue(
|
||||||
|
submitButton.waitForExistence(timeout: navigationTimeout),
|
||||||
|
"Submit-tasks button must exist on the first-task screen"
|
||||||
|
)
|
||||||
|
submitButton.waitUntilHittable(timeout: navigationTimeout).tap()
|
||||||
|
|
||||||
|
// Step 8 — Land on the main app (Residences tab is selected by
|
||||||
|
// default). CRITICAL: do NOT tap the Tasks tab. Tapping it would
|
||||||
|
// populate `_allTasks` and mask the bug.
|
||||||
|
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||||
|
let tabBar = app.tabBars.firstMatch
|
||||||
|
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|
||||||
|
|| tabBar.waitForExistence(timeout: navigationTimeout)
|
||||||
|
XCTAssertTrue(reachedMain, "App should reach main tabs after onboarding submit")
|
||||||
|
|
||||||
|
// Step 9 — Tap the residence cell directly. Prefer the
|
||||||
|
// identifier-prefix match for any cell; fall back to the static
|
||||||
|
// text match by name.
|
||||||
|
let residenceCellQuery = app.buttons.matching(
|
||||||
|
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||||
|
AccessibilityIdentifiers.Residence.cellPrefix)
|
||||||
|
)
|
||||||
|
let residenceCell = residenceCellQuery.firstMatch
|
||||||
|
if residenceCell.waitForExistence(timeout: navigationTimeout) && residenceCell.isHittable {
|
||||||
|
residenceCell.tap()
|
||||||
|
} else {
|
||||||
|
// Fallback: tap the static text inside the card. The
|
||||||
|
// NavigationLink wraps the entire card so a tap on the name
|
||||||
|
// still routes into the detail view.
|
||||||
|
let residenceText = app.staticTexts[residenceName]
|
||||||
|
XCTAssertTrue(
|
||||||
|
residenceText.waitForExistence(timeout: navigationTimeout),
|
||||||
|
"Residence cell or name '\(residenceName)' must exist on the residences list"
|
||||||
|
)
|
||||||
|
residenceText.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 10 — THE BUG ASSERTION. With the bug present:
|
||||||
|
// - `_allTasks` is null on the client (never primed).
|
||||||
|
// - `_tasksByResidence[id]` is empty (cache miss).
|
||||||
|
// - residence detail attempts to load, hits the iOS Combine sink
|
||||||
|
// timing window, and renders the empty state.
|
||||||
|
// With the fix, both `_allTasks` is populated by `bulkCreateTasks`
|
||||||
|
// and residence detail filters from it in-memory, so the empty
|
||||||
|
// state must not appear.
|
||||||
|
let taskRowQuery = app.descendants(matching: .any).matching(
|
||||||
|
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||||
|
AccessibilityIdentifiers.Task.rowPrefix)
|
||||||
|
)
|
||||||
|
let firstTaskRow = taskRowQuery.element(boundBy: 0)
|
||||||
|
let anyTaskAppeared = firstTaskRow.waitForExistence(timeout: 10)
|
||||||
|
|
||||||
|
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.noTasksLabel]
|
||||||
|
let emptyStateVisible = emptyState.exists
|
||||||
|
|
||||||
|
// Pin the failure message so the bug-capture is unambiguous. This
|
||||||
|
// is the assertion that should FAIL at this commit and PASS after
|
||||||
|
// the cache fix lands. Don't change the message — Task 12 grep's
|
||||||
|
// for it.
|
||||||
|
XCTAssertTrue(
|
||||||
|
anyTaskAppeared && !emptyStateVisible,
|
||||||
|
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -274,11 +274,14 @@ class DataManagerObservable: ObservableObject {
|
|||||||
}
|
}
|
||||||
observationTasks.append(residencesTask)
|
observationTasks.append(residencesTask)
|
||||||
|
|
||||||
// MyResidences
|
// MyResidences. Mirror every update into the widget App Group
|
||||||
|
// sidecar so the widget's configuration intent (gitea#6) can
|
||||||
|
// offer the current residence list without making a network call.
|
||||||
let myResidencesTask = Task { [weak self] in
|
let myResidencesTask = Task { [weak self] in
|
||||||
for await response in DataManager.shared.myResidences {
|
for await response in DataManager.shared.myResidences {
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.myResidences = response
|
self.myResidences = response
|
||||||
|
WidgetDataManager.shared.saveResidences(from: response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
observationTasks.append(myResidencesTask)
|
observationTasks.append(myResidencesTask)
|
||||||
@@ -732,6 +735,7 @@ class DataManagerObservable: ObservableObject {
|
|||||||
inProgress: task.inProgress,
|
inProgress: task.inProgress,
|
||||||
dueDate: task.effectiveDueDate,
|
dueDate: task.effectiveDueDate,
|
||||||
category: task.categoryName,
|
category: task.categoryName,
|
||||||
|
residenceId: Int(task.residenceId),
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: overdueIds.contains(task.id),
|
isOverdue: overdueIds.contains(task.id),
|
||||||
isDueWithin7Days: isDueWithin7Days,
|
isDueWithin7Days: isDueWithin7Days,
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import ComposeApp
|
|||||||
/// Three-step direct-to-B2 image upload.
|
/// Three-step direct-to-B2 image upload.
|
||||||
///
|
///
|
||||||
/// Flow:
|
/// Flow:
|
||||||
/// 1. POST /api/uploads/presign → server returns a B2 POST policy + form
|
/// 1. POST /api/uploads/presign → server returns a signed PUT URL plus
|
||||||
/// fields scoped to a single object key with a content-length-range
|
/// the headers (Content-Type, Content-Length) the client must send.
|
||||||
/// condition that B2 enforces at the protocol level.
|
/// The signature binds those headers — B2 rejects the upload if the
|
||||||
/// 2. Multipart POST the bytes directly to B2, no API server in the data
|
/// bytes/headers don't match exactly.
|
||||||
/// path. B2 rejects the upload if the bytes don't match the policy.
|
/// 2. PUT the bytes directly to B2, no API server in the data path.
|
||||||
/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
|
/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
|
||||||
/// /api/documents/ via `upload_ids[]`. The server HEADs the object,
|
/// /api/documents/ via `upload_ids[]`. The server HEADs the object,
|
||||||
/// confirms the size, and creates the linked entity rows.
|
/// confirms the size, and creates the linked entity rows.
|
||||||
///
|
///
|
||||||
|
/// We use PUT (not POST) because B2's S3-compatible endpoint does not
|
||||||
|
/// implement the S3 POST Object form upload — every POST returns HTTP 501.
|
||||||
|
///
|
||||||
/// All errors map to `PresignedUploaderError` — the Swift call site can
|
/// All errors map to `PresignedUploaderError` — the Swift call site can
|
||||||
/// translate to user-facing copy without parsing nested HTTP details.
|
/// translate to user-facing copy without parsing nested HTTP details.
|
||||||
enum PresignedUploaderError: Error, LocalizedError {
|
enum PresignedUploaderError: Error, LocalizedError {
|
||||||
@@ -33,7 +36,7 @@ enum PresignedUploaderError: Error, LocalizedError {
|
|||||||
default: return "Couldn't start upload (server returned \(status))."
|
default: return "Couldn't start upload (server returned \(status))."
|
||||||
}
|
}
|
||||||
case .uploadFailed(let status, _):
|
case .uploadFailed(let status, _):
|
||||||
return "Upload failed (B2 returned \(status))."
|
return "Upload failed (storage returned \(status))."
|
||||||
case .sessionError(let err):
|
case .sessionError(let err):
|
||||||
return err.localizedDescription
|
return err.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -95,13 +98,12 @@ final class PresignedUploader {
|
|||||||
contentLength: Int64(data.count)
|
contentLength: Int64(data.count)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 2: direct POST to B2
|
// Step 2: direct PUT to B2
|
||||||
try await postToStorage(
|
try await putToStorage(
|
||||||
uploadURL: presigned.uploadUrl,
|
uploadURL: presigned.uploadUrl,
|
||||||
fields: presigned.fields,
|
headers: presigned.headers,
|
||||||
data: data,
|
data: data,
|
||||||
contentType: contentType,
|
contentType: contentType
|
||||||
fileName: fileName
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return Int32(presigned.id)
|
return Int32(presigned.id)
|
||||||
@@ -146,7 +148,8 @@ final class PresignedUploader {
|
|||||||
private struct PresignResponse: Decodable {
|
private struct PresignResponse: Decodable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let upload_url: String
|
let upload_url: String
|
||||||
let fields: [String: String]
|
let method: String?
|
||||||
|
let headers: [String: String]
|
||||||
let key: String
|
let key: String
|
||||||
let expires_at: String
|
let expires_at: String
|
||||||
|
|
||||||
@@ -196,64 +199,39 @@ final class PresignedUploader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Step 2: POST to B2
|
// MARK: - Step 2: PUT to B2
|
||||||
|
//
|
||||||
|
// The presign response includes the exact headers (Content-Type +
|
||||||
|
// Content-Length) that were signed. Send them verbatim — any deviation
|
||||||
|
// invalidates the signature and B2 will reject the upload.
|
||||||
|
//
|
||||||
|
// Content-Length is set automatically by URLSession from httpBody.count,
|
||||||
|
// so we don't manually echo it back; we still send Content-Type because
|
||||||
|
// URLSession will otherwise default it to application/x-www-form-urlencoded.
|
||||||
|
|
||||||
private func postToStorage(
|
private func putToStorage(
|
||||||
uploadURL: String,
|
uploadURL: String,
|
||||||
fields: [String: String],
|
headers: [String: String],
|
||||||
data: Data,
|
data: Data,
|
||||||
contentType: String,
|
contentType: String
|
||||||
fileName: String
|
|
||||||
) async throws {
|
) async throws {
|
||||||
guard let url = URL(string: uploadURL) else {
|
guard let url = URL(string: uploadURL) else {
|
||||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
|
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a multipart/form-data body with all policy fields followed
|
|
||||||
// by a single "file" part (S3 POST policy mandates the file part
|
|
||||||
// come last).
|
|
||||||
let boundary = "Boundary-\(UUID().uuidString)"
|
|
||||||
var body = Data()
|
|
||||||
let crlf = "\r\n"
|
|
||||||
let appendString: (String) -> Void = { s in
|
|
||||||
body.append(s.data(using: .utf8) ?? Data())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stable order: ensure "key" and "Content-Type" appear before the
|
|
||||||
// file part so the policy signature validates. Unspecified order
|
|
||||||
// for the rest — S3 accepts any.
|
|
||||||
let orderedKeys = ["key", "Content-Type", "policy", "x-amz-algorithm",
|
|
||||||
"x-amz-credential", "x-amz-date", "x-amz-signature",
|
|
||||||
"x-amz-meta-uid"]
|
|
||||||
var emitted = Set<String>()
|
|
||||||
for k in orderedKeys {
|
|
||||||
if let v = fields[k] {
|
|
||||||
appendString("--\(boundary)\(crlf)")
|
|
||||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
|
||||||
appendString(v)
|
|
||||||
appendString(crlf)
|
|
||||||
emitted.insert(k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (k, v) in fields where !emitted.contains(k) {
|
|
||||||
appendString("--\(boundary)\(crlf)")
|
|
||||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
|
||||||
appendString(v)
|
|
||||||
appendString(crlf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// file part — must be last
|
|
||||||
appendString("--\(boundary)\(crlf)")
|
|
||||||
appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\(crlf)")
|
|
||||||
appendString("Content-Type: \(contentType)\(crlf)\(crlf)")
|
|
||||||
body.append(data)
|
|
||||||
appendString(crlf)
|
|
||||||
appendString("--\(boundary)--\(crlf)")
|
|
||||||
|
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.httpMethod = "POST"
|
req.httpMethod = "PUT"
|
||||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
req.httpBody = data
|
||||||
req.httpBody = body
|
|
||||||
|
// Apply server-supplied headers verbatim. Skip Content-Length —
|
||||||
|
// URLSession sets it automatically and will refuse to override it.
|
||||||
|
for (k, v) in headers where k.lowercased() != "content-length" {
|
||||||
|
req.setValue(v, forHTTPHeaderField: k)
|
||||||
|
}
|
||||||
|
// Defensive: ensure Content-Type is set even if the server omits it.
|
||||||
|
if req.value(forHTTPHeaderField: "Content-Type") == nil {
|
||||||
|
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
let (respBody, response): (Data, URLResponse)
|
let (respBody, response): (Data, URLResponse)
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ final class WidgetDataManager {
|
|||||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
||||||
}()
|
}()
|
||||||
private let tasksFileName = "widget_tasks.json"
|
private let tasksFileName = "widget_tasks.json"
|
||||||
|
private let residencesFileName = "widget_residences.json"
|
||||||
private let actionsFileName = "widget_pending_actions.json"
|
private let actionsFileName = "widget_pending_actions.json"
|
||||||
private let pendingTasksFileName = "widget_pending_tasks.json"
|
private let pendingTasksFileName = "widget_pending_tasks.json"
|
||||||
private let tokenKey = "widget_auth_token"
|
private let tokenKey = "widget_auth_token"
|
||||||
@@ -295,7 +296,15 @@ final class WidgetDataManager {
|
|||||||
!loadPendingActionsSync().isEmpty
|
!loadPendingActionsSync().isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Task model for widget display - simplified version of TaskDetail
|
/// Task model for widget display - simplified version of TaskDetail.
|
||||||
|
///
|
||||||
|
/// `residenceId` (added for gitea#6 per-residence widget selection)
|
||||||
|
/// is encoded as `residence_id` to match the widget extension's
|
||||||
|
/// `CacheManager.CustomTask` JSON shape. The extension uses it to
|
||||||
|
/// filter the timeline when the user picks a specific residence in
|
||||||
|
/// the widget configuration intent. Older JSON written by previous
|
||||||
|
/// app versions omitted the key — the field is optional so decode
|
||||||
|
/// of pre-existing widget caches still succeeds.
|
||||||
struct WidgetTask: Codable {
|
struct WidgetTask: Codable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let title: String
|
let title: String
|
||||||
@@ -304,6 +313,7 @@ final class WidgetDataManager {
|
|||||||
let inProgress: Bool
|
let inProgress: Bool
|
||||||
let dueDate: String?
|
let dueDate: String?
|
||||||
let category: String?
|
let category: String?
|
||||||
|
let residenceId: Int?
|
||||||
let residenceName: String?
|
let residenceName: String?
|
||||||
let isOverdue: Bool
|
let isOverdue: Bool
|
||||||
let isDueWithin7Days: Bool
|
let isDueWithin7Days: Bool
|
||||||
@@ -313,11 +323,53 @@ final class WidgetDataManager {
|
|||||||
case id, title, description, priority, category
|
case id, title, description, priority, category
|
||||||
case inProgress = "in_progress"
|
case inProgress = "in_progress"
|
||||||
case dueDate = "due_date"
|
case dueDate = "due_date"
|
||||||
|
case residenceId = "residence_id"
|
||||||
case residenceName = "residence_name"
|
case residenceName = "residence_name"
|
||||||
case isOverdue = "is_overdue"
|
case isOverdue = "is_overdue"
|
||||||
case isDueWithin7Days = "is_due_within_7_days"
|
case isDueWithin7Days = "is_due_within_7_days"
|
||||||
case isDue8To30Days = "is_due_8_to_30_days"
|
case isDue8To30Days = "is_due_8_to_30_days"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Custom init with a default `residenceId` so existing test
|
||||||
|
/// literals (TaskMetricsTests) compile without each adding the
|
||||||
|
/// new field. Production code that has the residence id passes
|
||||||
|
/// it explicitly.
|
||||||
|
init(
|
||||||
|
id: Int,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
priority: String?,
|
||||||
|
inProgress: Bool,
|
||||||
|
dueDate: String?,
|
||||||
|
category: String?,
|
||||||
|
residenceId: Int? = nil,
|
||||||
|
residenceName: String?,
|
||||||
|
isOverdue: Bool,
|
||||||
|
isDueWithin7Days: Bool,
|
||||||
|
isDue8To30Days: Bool
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.priority = priority
|
||||||
|
self.inProgress = inProgress
|
||||||
|
self.dueDate = dueDate
|
||||||
|
self.category = category
|
||||||
|
self.residenceId = residenceId
|
||||||
|
self.residenceName = residenceName
|
||||||
|
self.isOverdue = isOverdue
|
||||||
|
self.isDueWithin7Days = isDueWithin7Days
|
||||||
|
self.isDue8To30Days = isDue8To30Days
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightweight residence identifier for widget configuration. Persists
|
||||||
|
/// `(id, name)` of every residence the user belongs to so the widget
|
||||||
|
/// extension can populate its `ResidenceEntityQuery` without making
|
||||||
|
/// a network call (gitea#6).
|
||||||
|
struct WidgetResidence: Codable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metrics calculated from an array of tasks - shared between app and widget
|
/// Metrics calculated from an array of tasks - shared between app and widget
|
||||||
@@ -418,6 +470,12 @@ final class WidgetDataManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `task.residenceId` is non-optional Int32 on Kotlin so always
|
||||||
|
// promotes safely. `residenceName` is left blank because the
|
||||||
|
// widget already resolves it via the saved residences file
|
||||||
|
// (gitea#6) — keeping the field around for forward-compat
|
||||||
|
// with the existing JSON shape consumed by older widget
|
||||||
|
// builds.
|
||||||
let widgetTask = WidgetTask(
|
let widgetTask = WidgetTask(
|
||||||
id: Int(task.id),
|
id: Int(task.id),
|
||||||
title: task.title,
|
title: task.title,
|
||||||
@@ -426,6 +484,7 @@ final class WidgetDataManager {
|
|||||||
inProgress: task.inProgress,
|
inProgress: task.inProgress,
|
||||||
dueDate: task.effectiveDueDate,
|
dueDate: task.effectiveDueDate,
|
||||||
category: task.categoryName ?? "",
|
category: task.categoryName ?? "",
|
||||||
|
residenceId: Int(task.residenceId),
|
||||||
residenceName: "",
|
residenceName: "",
|
||||||
isOverdue: isOverdue,
|
isOverdue: isOverdue,
|
||||||
isDueWithin7Days: isDueWithin7Days,
|
isDueWithin7Days: isDueWithin7Days,
|
||||||
@@ -540,10 +599,94 @@ final class WidgetDataManager {
|
|||||||
print("WidgetDataManager: Error clearing cache - \(error)")
|
print("WidgetDataManager: Error clearing cache - \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also clear residences so the configuration intent stops
|
||||||
|
// offering stale options after sign-out.
|
||||||
|
if let resURL = self.residencesFileURL {
|
||||||
|
try? FileManager.default.removeItem(at: resURL)
|
||||||
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Residences (per-residence widget selection, gitea#6)
|
||||||
|
|
||||||
|
/// Path to the residence sidecar file inside the App Group container.
|
||||||
|
private var residencesFileURL: URL? {
|
||||||
|
sharedContainerURL?.appendingPathComponent(residencesFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the user's residences (id + name) to the App Group so the
|
||||||
|
/// widget extension's configuration intent can offer them as choices.
|
||||||
|
/// Call whenever `DataManagerObservable.myResidences` updates.
|
||||||
|
func saveResidences(_ residences: [WidgetResidence]) {
|
||||||
|
guard let fileURL = residencesFileURL else {
|
||||||
|
print("WidgetDataManager: Unable to access shared container for residences")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileQueue.async {
|
||||||
|
do {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = .prettyPrinted
|
||||||
|
let data = try encoder.encode(residences)
|
||||||
|
try data.write(to: fileURL, options: .atomic)
|
||||||
|
print("WidgetDataManager: Saved \(residences.count) residences for widget config")
|
||||||
|
} catch {
|
||||||
|
print("WidgetDataManager: Error saving residences - \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// Configuration intent reads on-demand, but reload the
|
||||||
|
// currently-pinned widgets so the visible task list
|
||||||
|
// refreshes against any rename.
|
||||||
|
self.reloadWidgetTimelinesIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: save from a Kotlin `MyResidencesResponse` directly.
|
||||||
|
func saveResidences(from myResidences: MyResidencesResponse?) {
|
||||||
|
let residences = (myResidences?.residences ?? []).map { r in
|
||||||
|
WidgetResidence(id: Int(r.id), name: r.name)
|
||||||
|
}
|
||||||
|
saveResidences(residences)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the persisted residences synchronously. Used by the widget
|
||||||
|
/// extension's `ResidenceEntityQuery` (`AppIntents` requires sync
|
||||||
|
/// reads).
|
||||||
|
func loadResidencesSync() -> [WidgetResidence] {
|
||||||
|
guard let fileURL = residencesFileURL else { return [] }
|
||||||
|
|
||||||
|
return fileQueue.sync {
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: fileURL)
|
||||||
|
return try JSONDecoder().decode([WidgetResidence].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("WidgetDataManager: Error loading residences - \(error)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pure filter (covered by tests)
|
||||||
|
|
||||||
|
/// Return only the tasks for `residenceId`. When `residenceId` is
|
||||||
|
/// `nil`, returns the input unchanged — that's the "All residences"
|
||||||
|
/// configuration option in the widget.
|
||||||
|
///
|
||||||
|
/// Factored out as a pure function so it can be exercised from unit
|
||||||
|
/// tests without booting the widget timeline provider.
|
||||||
|
static func filterTasks(
|
||||||
|
_ tasks: [WidgetTask],
|
||||||
|
forResidenceId residenceId: Int?
|
||||||
|
) -> [WidgetTask] {
|
||||||
|
guard let residenceId else { return tasks }
|
||||||
|
return tasks.filter { $0.residenceId == residenceId }
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,7 +366,12 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
||||||
if isRegistered {
|
if isRegistered {
|
||||||
// Registration successful - user is authenticated but not verified
|
// Registration successful — server gave us a token, so we ARE
|
||||||
|
// authenticated (just not verified yet). Mark the iOS-side auth
|
||||||
|
// state to match, otherwise OnboardingState.completeOnboarding's
|
||||||
|
// auth guard silently no-ops at the end of the flow and the
|
||||||
|
// user gets stuck on the firstTask screen.
|
||||||
|
AuthenticationManager.shared.login(verified: false)
|
||||||
onAccountCreated(false)
|
onAccountCreated(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,7 +456,13 @@ private struct OrganicOnboardingSecureField: View {
|
|||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
var isFocused: Bool = false
|
var isFocused: Bool = false
|
||||||
var accessibilityIdentifier: String? = nil
|
var accessibilityIdentifier: String? = nil
|
||||||
@State private var showPassword = false
|
// iOS 26 has a known bug where tapping a SwiftUI SecureField with
|
||||||
|
// `.textContentType(.password)` doesn't reliably bring up the keyboard
|
||||||
|
// — the strong-password autofill panel steals focus. Under UI tests
|
||||||
|
// we force the visibility toggle ON, rendering as a plain TextField,
|
||||||
|
// which has reliable focus behavior. The plaintext isn't a security
|
||||||
|
// concern in test mode (test creds are throwaway).
|
||||||
|
@State private var showPassword = UITestRuntime.isEnabled
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ class TaskViewModel: ObservableObject {
|
|||||||
private let dataManager: DataManagerObservable
|
private let dataManager: DataManagerObservable
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
/// Single source of truth = DataManager._allTasks. When this VM is
|
||||||
|
/// residence-scoped (currentResidenceId set), filter in-memory by
|
||||||
|
/// residence id. Eliminates the gitea#2 race window where the
|
||||||
|
/// per-residence cache slot could be empty while _allTasks was
|
||||||
|
/// populated. The per-residence cache is gone (cec521b).
|
||||||
|
///
|
||||||
/// - Parameter dataManager: Observable cache the VM subscribes to.
|
/// - Parameter dataManager: Observable cache the VM subscribes to.
|
||||||
/// Defaults to the shared singleton. Tests inject a fixture-backed
|
/// Defaults to the shared singleton. Tests inject a fixture-backed
|
||||||
/// instance so populated-state snapshots render real data.
|
/// instance so populated-state snapshots render real data.
|
||||||
@@ -50,35 +56,26 @@ class TaskViewModel: ObservableObject {
|
|||||||
|
|
||||||
// Seed from current cache so snapshot tests/previews render
|
// Seed from current cache so snapshot tests/previews render
|
||||||
// populated state without waiting for Combine's async dispatch.
|
// populated state without waiting for Combine's async dispatch.
|
||||||
|
// The seed path mirrors the steady-state filter below — if this
|
||||||
|
// VM is residence-scoped at construction time the seed has to
|
||||||
|
// pre-filter too, but currentResidenceId is set after init via
|
||||||
|
// setResidenceFilter(...), so seeding the unfiltered list is fine.
|
||||||
self.tasksResponse = dataManager.allTasks
|
self.tasksResponse = dataManager.allTasks
|
||||||
|
|
||||||
// Observe injected DataManagerObservable for all tasks data
|
// Observe injected DataManagerObservable for all tasks data.
|
||||||
dataManager.$allTasks
|
dataManager.$allTasks
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] allTasks in
|
.sink { [weak self] allTasks in
|
||||||
// Skip DataManager updates during completion animation to prevent
|
guard let self else { return }
|
||||||
// the task from being moved out of its column before the animation finishes
|
guard !self.isAnimatingCompletion else { return }
|
||||||
guard self?.isAnimatingCompletion != true else { return }
|
|
||||||
// Only update if we're showing all tasks (no residence filter)
|
|
||||||
if self?.currentResidenceId == nil {
|
|
||||||
self?.tasksResponse = allTasks
|
|
||||||
if allTasks != nil {
|
|
||||||
self?.isLoadingTasks = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
// Observe tasks by residence
|
if let allTasks {
|
||||||
dataManager.$tasksByResidence
|
if let resId = self.currentResidenceId {
|
||||||
.receive(on: DispatchQueue.main)
|
self.tasksResponse = self.filterTasks(allTasks, residenceId: resId)
|
||||||
.sink { [weak self] tasksByResidence in
|
} else {
|
||||||
guard self?.isAnimatingCompletion != true else { return }
|
self.tasksResponse = allTasks
|
||||||
// Only update if we're filtering by residence
|
}
|
||||||
if let resId = self?.currentResidenceId,
|
self.isLoadingTasks = false
|
||||||
let tasks = tasksByResidence[resId] {
|
|
||||||
self?.tasksResponse = tasks
|
|
||||||
self?.isLoadingTasks = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -392,6 +389,28 @@ class TaskViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filter the all-tasks kanban down to a single residence in-memory.
|
||||||
|
/// Mirrors `DataManager.getTasksForResidence` on the Kotlin side.
|
||||||
|
private func filterTasks(_ response: TaskColumnsResponse, residenceId: Int32) -> TaskColumnsResponse {
|
||||||
|
let filteredColumns = response.columns.map { column -> TaskColumn in
|
||||||
|
let filteredTasks = column.tasks.filter { Int32($0.residenceId) == residenceId }
|
||||||
|
return TaskColumn(
|
||||||
|
name: column.name,
|
||||||
|
displayName: column.displayName,
|
||||||
|
buttonTypes: column.buttonTypes,
|
||||||
|
icons: column.icons,
|
||||||
|
color: column.color,
|
||||||
|
tasks: filteredTasks,
|
||||||
|
count: Int32(filteredTasks.count)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return TaskColumnsResponse(
|
||||||
|
columns: filteredColumns,
|
||||||
|
daysThreshold: response.daysThreshold,
|
||||||
|
residenceId: String(residenceId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates a task in the kanban board by moving it to the correct column based on kanban_column
|
/// Updates a task in the kanban board by moving it to the correct column based on kanban_column
|
||||||
func updateTaskInKanban(_ updatedTask: TaskResponse) {
|
func updateTaskInKanban(_ updatedTask: TaskResponse) {
|
||||||
guard let currentResponse = tasksResponse else { return }
|
guard let currentResponse = tasksResponse else { return }
|
||||||
|
|||||||
Reference in New Issue
Block a user