6 Commits

Author SHA1 Message Date
Trey T 9c9e6009c7 feat(widget): per-residence widget configuration (Android, gitea#6)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Mirrors the iOS implementation. Adds a Glance configuration activity
that launches when the user pins a new honeyDue widget tile and again
on "Edit Widget", lets them pick one of their residences (or "All
residences"), and persists the choice per-`appWidgetId`. Each tile's
`provideGlance` resolves its own scope and filters tasks (and stats,
on the large widget) accordingly.

Pieces:

- `WidgetConfigActivity` — Compose `ComponentActivity` hosting the
  residence-picker UI; reads the persisted residences sidecar, reads
  any prior scope for the current `appWidgetId`, writes the new
  selection on Save, and re-renders every widget tile.
- `WidgetDataStore` — new `widget_residences_json` key + a per-instance
  `widget_residence_id_<appWidgetId>` key. `clearAll()` sweeps the
  per-instance keys by prefix so logout doesn't leave dangling state.
- `WidgetDataRepository`:
  * `saveResidences(_)` / `loadResidences()` for the picker.
  * `saveResidenceIdFor(appWidgetId, residenceId)` /
    `loadResidenceIdFor(appWidgetId)` /
    `clearResidenceIdFor(appWidgetId)` for per-tile scope.
  * `loadTasksForResidence(residenceId)` and the
    `appWidgetId`-driven `loadTasksForWidget(appWidgetId)`.
  * `computeStatsFromTasks(tasks)` so the large widget's tiles
    reflect only the scoped task list (instead of the whole cache).
  * Pure `Filter.filterTasksForResidence(_, _)` on the companion
    object — easy to exercise from unit tests.
- `WidgetTaskDto` already carries `residenceId`. New `WidgetResidenceDto`
  added (id + name) — JSON-persisted via the sidecar.
- `WidgetRefreshWorker` / `DefaultWidgetRefreshDataSource` — pull
  `myResidences` alongside tasks/tier on each refresh and write the
  sidecar (best-effort; non-fatal if the call fails).
- `HoneyDue{Small,Medium,Large}Widget.provideGlance` — resolve
  `appWidgetId` via `GlanceAppWidgetManager(context).getAppWidgetId(id)`
  and call `loadTasksForWidget(appWidgetId)`.
- `HoneyDue{Small,Medium,Large}WidgetReceiver.onDeleted` — purge the
  per-instance residence scope key when the tile is removed.
- Manifest: register the configure activity with the
  `APPWIDGET_CONFIGURE` action.
- `honeydue_{small,medium,large}_widget_info.xml` — declare
  `android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"`.

Migration / safety:
- A tile that's never been through the picker has no residence id
  saved → `loadTasksForWidget` returns every task (legacy "All
  residences" behaviour). Existing tiles keep working without the
  user touching anything.
- The picker handles an empty residences list (signed-out / first
  install before background refresh) with an explicit helper message
  pointing at the main app.

Tests: new `WidgetResidenceFilterTest` (commonTest-style under
`androidUnitTest`, 9 cases). All green.

  $ ./gradlew :composeApp:testDebugUnitTest \\
      --tests "com.tt.honeyDue.widget.WidgetResidenceFilterTest"
  BUILD SUCCESSFUL

  $ ./gradlew :composeApp:assembleDebug
  BUILD SUCCESSFUL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:31:46 -05:00
Trey T 498e6b8064 feat(widget): per-residence widget configuration (iOS, gitea#6)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Users with multiple residences can now pick which one a given home-
screen widget shows tasks for. Pinning two widgets — one per house —
lets each surface tasks for only that residence; users who keep the
configuration untouched continue to see all residences (the previous
default), so single-home users see no behavioural change.

Implementation (iOS only — Android Glance follow-up is scoped in the
issue):

* `ConfigurationAppIntent` (HoneyDue widget extension) gains an
  optional `@Parameter` of type `WidgetResidenceEntity`. `AppIntents`
  renders it as a residence picker in the widget edit sheet.
* `WidgetResidenceEntity` + `WidgetResidenceEntityQuery` resolve the
  user's residences from a new `widget_residences.json` sidecar in the
  App Group container (avoids a network call at config time).
* `WidgetDataManager.saveResidences(from:)` writes that sidecar from
  the main app whenever `DataManagerObservable.myResidences` updates.
  Logout clears it along with the rest of the widget cache.
* `WidgetDataManager.WidgetTask` + the widget extension's
  `CacheManager.CustomTask` both gain an optional `residence_id`
  field. Optional so older app builds that wrote pre-#6 widget cache
  continue to decode — those tasks pass through the filter for
  unscoped widgets and are hidden from scoped ones (safer than
  guessing).
* `CacheManager.getUpcomingTasks(forResidenceId:)` and the pure
  helper `WidgetDataManager.filterTasks(_:forResidenceId:)` apply the
  filter. `Provider.timeline` / `snapshot` read
  `configuration.residence?.intId` and pass it through.

Tests: new `WidgetResidenceFilterTests` (HoneyDueTests target, 5
cases) cover nil-passthrough, matching-id, no-match, missing-residence
on a task, and order preservation. All five green.

No Android changes in this commit — Glance widgets need a separate
configuration activity and an actionStartActivity wiring that's
non-trivial; tracking as a follow-up in the same issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:14:58 -05:00
Trey t fdcf82757d fix(uploads): switch from S3 multipart POST to presigned PUT
Android UI Tests / ui-tests (push) Has been cancelled
Backblaze B2's S3-compatible endpoint does not implement the S3 POST
Object operation — every POST returns HTTP 501 regardless of URL form
(path-style or virtual-hosted-style). The previous multipart-POST flow
has been failing for every task-completion image upload.

Server-side companion change (honeyDueAPI master @7cc5448) replaces
PresignedPostPolicy with PresignHeader/PUT and renames the response
field from "fields" to "headers". This commit aligns both clients.

PresignUploadResponse model: field renamed `fields` → `headers`,
added `method` (default "PUT"). Both new fields have defaults so a
build talking to a stale server still decodes — albeit with empty
headers, which would then 403 at signature time. The server is
already on the new shape in prod.

iOS PresignedUploader.swift: dropped the ~70-line multipart body
builder and S3 form-field ordering logic. Replaced with a single PUT
request that applies server-supplied headers verbatim (skipping
Content-Length, which URLSession sets automatically and refuses to
override).

Android UploadApi.kt: same shape change. `postToStorage` →
`putToStorage`. Single Ktor `client.put()` with headers passthrough.
`uploadOne`'s `fileName` parameter kept for source compatibility but
marked @Suppress("UNUSED_PARAMETER") since PUT doesn't need it.

Verified end-to-end against api.myhoneydue.com:
  presign → PUT 12 bytes → HTTP 200 in 0.6s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:48:54 -05:00
Trey t 3890dd6f52 chore(network): point ApiConfig at PROD by default
Was on Environment.LOCAL — useful for local-against-127.0.0.1 dev but
means a release build off main hits a server the device can't reach.
Switch to Environment.PROD so the app talks to api.myhoneydue.com.
LOCAL/DEV are still one-line toggles in ApiConfig.kt for development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:48:54 -05:00
Trey T d5041492a9 test: add forceFreshLoginPerTest opt-in flag to AuthenticatedUITestCase
Android UI Tests / ui-tests (push) Has been cancelled
Default is `false` (current session-reuse behaviour) so tests reuse the
existing logged-in session — fast, and resilient to suites where the
current screen lacks a logout affordance (`UITestHelpers.ensureLoggedOut`
times out → tests fail before their bodies run).

Override to `true` in suites that observe transient `Invalid token` 401s
on POST/PATCH while reads continue to work. Recipe added after a 2026-05
incident where the API container was rebuilt mid-suite and in-memory
JWT tokens went stale; the diagnostic value is having an explicit lever
to reach for next time, not flipping the default.

Net effect on a clean simulator + stable API: 244/253 → 244/253 (no
behaviour change in the default path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:14:37 -05:00
admin ec5d93efab Merge pull request 'feat: bundle ID migration + gitea#2 task-cache fix (recovered from fix/task-cache-unification)' (#4) from feat/bundle-id-and-task-cache into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #4
2026-05-01 20:48:28 -05:00
23 changed files with 1177 additions and 138 deletions
@@ -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" />
@@ -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())
}
}
@@ -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
) )
@@ -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")
} }
} }
+71 -1
View File
@@ -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
+80 -6
View File
@@ -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(
@@ -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,
+39 -61
View File
@@ -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 {
+144 -1
View File
@@ -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 }
}
} }