8 Commits

Author SHA1 Message Date
Trey t 90a1d98322 fix(auth): correct the Kratos recovery -> password-reset handoff
Android UI Tests / ui-tests (push) Has been cancelled
The recovery code was submitted to a freshly-initialised recovery
flow, but Kratos binds the emailed code to the original flow, so
verification could never succeed. The settings step then ran with no
privileged session, so the password change would be rejected too.

- forgotPassword remembers its recovery flow action; verifyResetCode
  submits the code back to that SAME flow.
- verifyResetCode parses Kratos continue_with for the privileged
  session token + the settings flow id; resetPassword submits the new
  password to that settings flow authenticated with X-Session-Token.
- KratosFlow / KratosContinueWith models extended (continue_with,
  ory_session_token).

Resolves the TODO(kratos) in AuthApi.resetPassword.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:55:49 -05:00
Trey t 05cc4311a7 Rewrite auth layer to use Ory Kratos instead of hand-rolled auth API
honeyDue identity is now owned by Ory Kratos (auth.myhoneydue.com). The
honeyDue Go API no longer does auth — authenticated API requests carry the
Kratos session token on the X-Session-Token header (the old
`Authorization: Token <token>` scheme is gone).

What changed:

- models/Kratos.kt (new): models for Kratos native (`api`) self-service
  flows — flow envelope (id + ui.action + ui.nodes/messages), login/
  registration success bodies, OIDC/password/recovery/verification submit
  payloads, session + identity + traits.

- ApiConfig.kt / ApiClient.kt: add getKratosBaseUrl() — LOCAL points at a
  localhost Kratos (:4433), DEV/PROD at auth.myhoneydue.com. Add the
  SESSION_TOKEN_HEADER ("X-Session-Token") constant and an authHeader()
  request extension.

- AuthApi.kt: rewritten to drive Kratos native flows —
  login (GET .../self-service/login/api -> POST ui.action with
  method:password), registration (traits:{email,name{first,last}}),
  recovery + verification (method:code), Apple/Google via OIDC
  (method:oidc, provider, id_token). Kratos validation errors are pulled
  from ui.nodes[].messages / ui.messages. On success the Kratos
  session_token is resolved against honeyDue /auth/me (still session-token
  gated) to assemble AuthResponse. Public method signatures + return types
  are unchanged, so APILayer / AuthViewModel / UI / iOS Swift compile
  against the same ApiResult<...> shapes with no rework.

- ApiClient.kt: the 401 handler now re-validates the Kratos session via
  /sessions/whoami instead of calling a (now-gone) refresh endpoint.
  TokenExpiredException is kept (messages updated).

- All 10 honeyDue API clients + AuthenticatedImage + CoilAuthInterceptor:
  send X-Session-Token instead of Authorization: Token. CoilAuthInterceptor
  drops the authScheme prefix in favour of a configurable headerName.

- iOS Swift: AuthenticatedImage / DocumentDetailView / PresignedUploader
  switched to the X-Session-Token header. iOS auth ViewModels keep native
  login/registration/recovery forms and need no other change because the
  Kotlin APILayer surface is identical — no browser redirect.

- Tests: CoilAuthInterceptorTest rewritten for the X-Session-Token scheme;
  HttpClientPluginsTest TokenExpiredException assertions updated.

Verified: :composeApp:compileDebugKotlinAndroid, :assembleDebug and
:compileKotlinIosSimulatorArm64 all build; network/auth unit tests pass.
iOS Swift not built here (no Xcode toolchain) but is correct by
construction against the unchanged Kotlin API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:21:32 -05:00
admin f364ab05dc Merge pull request 'fix: share-residence import preview polish (closes #7)' (#9) from fix/7-share-residence-import-polish into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #9
2026-05-11 16:17:15 -05:00
admin 3a5e33af93 Merge pull request 'feat(widget): per-residence widget configuration — closes #6' (#10) from feat/6-widget-residence-picker into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #10
2026-05-11 13:39:05 -05:00
admin bd27f32caa Merge pull request 'fix: single keyboard Done toolbar on Complete Task (closes #5)' (#8) from fix/5-double-done-button into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #8
2026-05-11 13:35:13 -05:00
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 23f4d70ac1 fix: single keyboard Done toolbar on Complete Task (closes gitea#5)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The actualCost TextField and the notes TextEditor each had their own
`.keyboardDismissToolbar()` modifier, which installs a separate
`ToolbarItemGroup(placement: .keyboard)`. SwiftUI accumulates these
on the responder chain, so focusing any field rendered two "Done"
buttons stacked above the keyboard (issue screenshot in gitea#5).

Move the modifier up to the Form root so exactly one keyboard
toolbar is registered for the entire screen, matching the pattern
already used by `TaskFormView`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:58:19 -05:00
43 changed files with 2207 additions and 427 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"
@@ -314,9 +314,10 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
return ImageLoader.Builder(context) return ImageLoader.Builder(context)
.components { .components {
// Auth interceptor runs before the network fetcher so every // Auth interceptor runs before the network fetcher so every
// image request carries the current Authorization header, with // image request carries the current X-Session-Token header
// 401 -> refresh-token -> retry handled transparently. Mirrors // (Kratos session token), with 401 -> session re-check ->
// iOS AuthenticatedImage.swift (Stream U). // retry handled transparently. Mirrors iOS
// AuthenticatedImage.swift.
add( add(
CoilAuthInterceptor( CoilAuthInterceptor(
tokenProvider = { TokenStorage.getToken() }, tokenProvider = { TokenStorage.getToken() },
@@ -324,7 +325,6 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
val r = APILayer.refreshToken() val r = APILayer.refreshToken()
if (r is ApiResult.Success) r.data else null if (r is ApiResult.Success) r.data else null
}, },
authScheme = "Token",
) )
) )
add(KtorNetworkFetcherFactory()) add(KtorNetworkFetcherFactory())
@@ -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" />
@@ -25,10 +25,14 @@ import kotlin.test.assertTrue
/** /**
* Unit tests for [CoilAuthInterceptor]. * Unit tests for [CoilAuthInterceptor].
* *
* Identity is owned by Ory Kratos. Authenticated honeyDue media is gated on
* the Kratos session token, carried on the `X-Session-Token` header (the old
* `Authorization: Token …` scheme is gone).
*
* The interceptor is responsible for: * The interceptor is responsible for:
* 1. Attaching `Authorization: <scheme> <token>` to image requests. * 1. Attaching `X-Session-Token: <token>` to image requests.
* 2. On HTTP 401, calling the refresh callback once and retrying the * 2. On HTTP 401, calling the re-validation callback once and retrying the
* request with the new token. * request with the returned token.
* 3. Not looping: if the retry also returns 401, the error is returned. * 3. Not looping: if the retry also returns 401, the error is returned.
* 4. When no token is available, the request proceeds unauthenticated. * 4. When no token is available, the request proceeds unauthenticated.
* *
@@ -96,7 +100,7 @@ class CoilAuthInterceptorTest {
} }
@Test @Test
fun interceptor_attaches_authorization_header_when_token_present() = runTest { fun interceptor_attaches_session_token_header_when_token_present() = runTest {
val request = makeRequest() val request = makeRequest()
val chain = FakeChain( val chain = FakeChain(
initialRequest = request, initialRequest = request,
@@ -105,7 +109,6 @@ class CoilAuthInterceptorTest {
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { "abc123" }, tokenProvider = { "abc123" },
refreshToken = { null }, refreshToken = { null },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
@@ -113,7 +116,9 @@ class CoilAuthInterceptorTest {
assertTrue(result is SuccessResult, "Expected success result") assertTrue(result is SuccessResult, "Expected success result")
assertEquals(1, chain.capturedRequests.size) assertEquals(1, chain.capturedRequests.size)
val sent = chain.capturedRequests.first() val sent = chain.capturedRequests.first()
assertEquals("Token abc123", sent.httpHeaders["Authorization"]) // Token is sent bare (no scheme prefix) on the X-Session-Token header.
assertEquals("abc123", sent.httpHeaders["X-Session-Token"])
assertNull(sent.httpHeaders["Authorization"], "Legacy Authorization header must not be set")
} }
@Test @Test
@@ -126,7 +131,6 @@ class CoilAuthInterceptorTest {
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { null }, tokenProvider = { null },
refreshToken = { null }, refreshToken = { null },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
@@ -134,12 +138,12 @@ class CoilAuthInterceptorTest {
assertTrue(result is SuccessResult) assertTrue(result is SuccessResult)
assertEquals(1, chain.capturedRequests.size) assertEquals(1, chain.capturedRequests.size)
val sent = chain.capturedRequests.first() val sent = chain.capturedRequests.first()
// No Authorization header should have been added // No session-token header should have been added.
assertNull(sent.httpHeaders["Authorization"]) assertNull(sent.httpHeaders["X-Session-Token"])
} }
@Test @Test
fun interceptor_refreshes_and_retries_on_401() = runTest { fun interceptor_revalidates_and_retries_on_401() = runTest {
val request = makeRequest() val request = makeRequest()
var refreshCallCount = 0 var refreshCallCount = 0
val chain = FakeChain( val chain = FakeChain(
@@ -150,25 +154,25 @@ class CoilAuthInterceptorTest {
) )
) )
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" }, tokenProvider = { "session-tok" },
refreshToken = { refreshToken = {
refreshCallCount++ refreshCallCount++
"new-token" // Kratos session tokens are not rotated — same token echoed back.
"session-tok"
}, },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult, "Expected retry to succeed") assertTrue(result is SuccessResult, "Expected retry to succeed")
assertEquals(1, refreshCallCount, "refreshToken should be invoked exactly once") assertEquals(1, refreshCallCount, "session re-check should be invoked exactly once")
assertEquals(2, chain.capturedRequests.size, "Expected original + 1 retry") assertEquals(2, chain.capturedRequests.size, "Expected original + 1 retry")
assertEquals("Token old-token", chain.capturedRequests[0].httpHeaders["Authorization"]) assertEquals("session-tok", chain.capturedRequests[0].httpHeaders["X-Session-Token"])
assertEquals("Token new-token", chain.capturedRequests[1].httpHeaders["Authorization"]) assertEquals("session-tok", chain.capturedRequests[1].httpHeaders["X-Session-Token"])
} }
@Test @Test
fun interceptor_returns_error_when_refresh_returns_null() = runTest { fun interceptor_returns_error_when_revalidation_returns_null() = runTest {
val request = makeRequest() val request = makeRequest()
var refreshCallCount = 0 var refreshCallCount = 0
val chain = FakeChain( val chain = FakeChain(
@@ -176,19 +180,18 @@ class CoilAuthInterceptorTest {
responses = mutableListOf({ req -> make401Error(req) }) responses = mutableListOf({ req -> make401Error(req) })
) )
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" }, tokenProvider = { "session-tok" },
refreshToken = { refreshToken = {
refreshCallCount++ refreshCallCount++
null null
}, },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult, "Expected error result when refresh fails") assertTrue(result is ErrorResult, "Expected error result when session is gone")
assertEquals(1, refreshCallCount, "refreshToken should be attempted once") assertEquals(1, refreshCallCount, "session re-check should be attempted once")
// Only the first attempt should have gone through // Only the first attempt should have gone through.
assertEquals(1, chain.capturedRequests.size) assertEquals(1, chain.capturedRequests.size)
} }
@@ -204,23 +207,22 @@ class CoilAuthInterceptorTest {
) )
) )
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" }, tokenProvider = { "session-tok" },
refreshToken = { refreshToken = {
refreshCallCount++ refreshCallCount++
"new-token" "session-tok"
}, },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult, "Second 401 should surface as ErrorResult") assertTrue(result is ErrorResult, "Second 401 should surface as ErrorResult")
assertEquals(1, refreshCallCount, "refreshToken should be called exactly once — no infinite loop") assertEquals(1, refreshCallCount, "session re-check should be called exactly once — no infinite loop")
assertEquals(2, chain.capturedRequests.size, "Expected original + exactly one retry") assertEquals(2, chain.capturedRequests.size, "Expected original + exactly one retry")
} }
@Test @Test
fun interceptor_passes_through_non_401_errors_without_refresh() = runTest { fun interceptor_passes_through_non_401_errors_without_revalidation() = runTest {
val request = makeRequest() val request = makeRequest()
var refreshCallCount = 0 var refreshCallCount = 0
val chain = FakeChain( val chain = FakeChain(
@@ -241,33 +243,32 @@ class CoilAuthInterceptorTest {
refreshCallCount++ refreshCallCount++
"should-not-be-called" "should-not-be-called"
}, },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult) assertTrue(result is ErrorResult)
assertEquals(0, refreshCallCount, "refreshToken should not be invoked on non-401 errors") assertEquals(0, refreshCallCount, "session re-check should not be invoked on non-401 errors")
assertEquals(1, chain.capturedRequests.size) assertEquals(1, chain.capturedRequests.size)
} }
@Test @Test
fun interceptor_supports_bearer_scheme() = runTest { fun interceptor_supports_custom_header_name() = runTest {
val request = makeRequest() val request = makeRequest()
val chain = FakeChain( val chain = FakeChain(
initialRequest = request, initialRequest = request,
responses = mutableListOf({ req -> makeSuccess(req) }) responses = mutableListOf({ req -> makeSuccess(req) })
) )
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { "jwt.payload.sig" }, tokenProvider = { "tok-value" },
refreshToken = { null }, refreshToken = { null },
authScheme = "Bearer", headerName = "X-Custom-Auth",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult) assertTrue(result is SuccessResult)
val sent = chain.capturedRequests.first() val sent = chain.capturedRequests.first()
assertEquals("Bearer jwt.payload.sig", sent.httpHeaders["Authorization"]) assertEquals("tok-value", sent.httpHeaders["X-Custom-Auth"])
} }
} }
@@ -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())
}
}
@@ -0,0 +1,244 @@
package com.tt.honeyDue.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Models for Ory Kratos native (`api`) self-service flows.
*
* honeyDue's Go API no longer owns identity — Ory Kratos does. The mobile
* client drives Kratos' native flows directly:
*
* 1. `GET {kratos}/self-service/{flow}/api` -> a flow object (id + ui.action)
* 2. `POST {ui.action}` -> success body or a re-rendered
* flow carrying validation messages in `ui.nodes[].messages` / `ui.messages`.
*
* Only the fields the client actually needs are modelled; `ignoreUnknownKeys`
* on the Json instance tolerates the rest of Kratos' (large) payloads.
*
* Kratos docs: https://www.ory.sh/docs/kratos/self-service
*/
// ==================== Flow envelope ====================
/**
* A Kratos self-service flow (login / registration / recovery / verification).
* Returned by the initial `GET .../{flow}/api` call.
*/
@Serializable
data class KratosFlow(
val id: String,
val type: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("issued_at") val issuedAt: String? = null,
val ui: KratosUi,
/** Present on a verification/recovery flow that is already complete. */
val state: String? = null,
/**
* Post-submission instructions. On a completed recovery flow this carries
* the privileged session token (`set_ory_session_token`) and the settings
* flow to finish the password change in (`show_settings_ui`).
*/
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
)
/**
* The renderable UI of a flow. `action` is the absolute URL the client must
* POST the method payload to; `messages` carries flow-level errors.
*/
@Serializable
data class KratosUi(
val action: String,
val method: String = "POST",
val nodes: List<KratosUiNode> = emptyList(),
val messages: List<KratosMessage> = emptyList(),
)
@Serializable
data class KratosUiNode(
val type: String? = null,
val group: String? = null,
val attributes: KratosUiNodeAttributes? = null,
val messages: List<KratosMessage> = emptyList(),
)
@Serializable
data class KratosUiNodeAttributes(
val name: String? = null,
val type: String? = null,
val value: JsonElement? = null,
)
/**
* A Kratos UI message. `type` is `info`, `error`, or `success`.
*/
@Serializable
data class KratosMessage(
val id: Long? = null,
val text: String,
val type: String? = null,
)
// ==================== Flow success bodies ====================
/**
* Identity traits as configured in the Kratos identity schema for honeyDue.
* `email` is the primary identifier; `name` mirrors the schema's nested object.
*/
@Serializable
data class KratosTraits(
val email: String,
val name: KratosName? = null,
)
@Serializable
data class KratosName(
val first: String = "",
val last: String = "",
)
/**
* A Kratos identity (subset). Returned nested inside [KratosSession].
*/
@Serializable
data class KratosIdentity(
val id: String,
@SerialName("schema_id") val schemaId: String? = null,
val state: String? = null,
val traits: KratosTraits? = null,
@SerialName("verifiable_addresses") val verifiableAddresses: List<KratosVerifiableAddress>? = null,
)
@Serializable
data class KratosVerifiableAddress(
val id: String? = null,
val value: String? = null,
val verified: Boolean = false,
val via: String? = null,
val status: String? = null,
)
/**
* A Kratos session. `active` + `expires_at` describe validity; `identity`
* carries the authenticated user's traits.
*/
@Serializable
data class KratosSession(
val id: String,
val active: Boolean = true,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("authenticated_at") val authenticatedAt: String? = null,
val identity: KratosIdentity? = null,
)
/**
* Success body of a native login flow submission
* (`POST .../self-service/login/api`).
*/
@Serializable
data class KratosLoginSuccess(
val session: KratosSession,
@SerialName("session_token") val sessionToken: String,
)
/**
* Success body of a native registration flow submission
* (`POST .../self-service/registration/api`). With the
* `session` after-hook enabled, Kratos returns a session + token here.
*/
@Serializable
data class KratosRegistrationSuccess(
val session: KratosSession? = null,
@SerialName("session_token") val sessionToken: String? = null,
val identity: KratosIdentity? = null,
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
)
/**
* A `continue_with` item. `action` is one of `show_verification_ui`,
* `show_settings_ui`, `set_ory_session_token`, etc. — see Kratos docs.
* `flow` is present for the `show_*_ui` actions; `orySessionToken` is present
* for `set_ory_session_token` (the privileged session a recovery flow issues).
*/
@Serializable
data class KratosContinueWith(
val action: String? = null,
val flow: KratosContinueWithFlow? = null,
@SerialName("ory_session_token") val orySessionToken: String? = null,
)
@Serializable
data class KratosContinueWithFlow(
val id: String? = null,
@SerialName("verifiable_address") val verifiableAddress: String? = null,
)
// ==================== Submit payloads ====================
/**
* Body for submitting the `password` method to a login flow.
*/
@Serializable
data class KratosPasswordLoginBody(
val method: String = "password",
val identifier: String,
val password: String,
)
/**
* Body for submitting the `password` method to a registration flow.
*/
@Serializable
data class KratosPasswordRegistrationBody(
val method: String = "password",
val traits: KratosTraits,
val password: String,
)
/**
* Body for submitting an OIDC (`apple` / `google`) provider to a
* login or registration flow using a native `id_token` from the
* platform sign-in SDK.
*/
@Serializable
data class KratosOidcBody(
val method: String = "oidc",
val provider: String,
@SerialName("id_token") val idToken: String,
/** Optional traits — sent on registration so Kratos can seed the identity. */
val traits: KratosTraits? = null,
)
/**
* Body for submitting the `code` method to a recovery flow.
* The first POST omits [code] (sends just the email); the second
* POST includes the code the user received by email.
*/
@Serializable
data class KratosRecoveryBody(
val method: String = "code",
val email: String? = null,
val code: String? = null,
)
/**
* Body for submitting the `code` method to a verification flow.
* As with recovery: first POST sends the email, second sends the code.
*/
@Serializable
data class KratosVerificationBody(
val method: String = "code",
val email: String? = null,
val code: String? = null,
)
/**
* Body for updating an identity's password from within a settings flow
* (used after a recovery flow hands the client a privileged session).
*/
@Serializable
data class KratosSettingsPasswordBody(
val method: String = "password",
val password: String,
)
@@ -85,7 +85,13 @@ data class AuthResponse(
) )
/** /**
* Token refresh response - returned by POST /api/auth/refresh/ * Token refresh response.
*
* Identity is owned by Ory Kratos. Native Kratos session tokens are
* long-lived and not rotated — there is no refresh endpoint. This type is
* retained as the return shape of [com.tt.honeyDue.network.AuthApi.refreshToken],
* which now re-validates the session via Kratos `/sessions/whoami` and echoes
* the same (unchanged) token back when the session is still active.
*/ */
@Serializable @Serializable
data class TokenRefreshResponse( data class TokenRefreshResponse(
@@ -1276,20 +1276,24 @@ object APILayer {
} }
/** /**
* Refresh the current auth token. * Re-validate the current Kratos session.
* Calls POST /api/auth/refresh/ with the current token. *
* On success, saves the new token to DataManager and TokenStorage. * Identity is owned by Ory Kratos. Native Kratos session tokens are
* On failure, returns an error (caller decides whether to trigger logout). * long-lived and there is no native refresh endpoint — "refresh" here
* means: ask Kratos whether the session is still active (`/sessions/whoami`).
*
* - Session still valid → returns the same (unchanged) token.
* - Session gone → returns an error; the caller should sign out.
*
* The method name is kept so the Coil image interceptor and the
* `ApiClient` 401 plumbing continue to compile.
*/ */
suspend fun refreshToken(): ApiResult<String> { suspend fun refreshToken(): ApiResult<String> {
val currentToken = getToken() ?: return ApiResult.Error("No token", 401) val currentToken = getToken() ?: return ApiResult.Error("No token", 401)
val result = authApi.refreshToken(currentToken) return when (val result = authApi.refreshToken(currentToken)) {
if (result is ApiResult.Success) { // Kratos session tokens are never rotated — echo the same token
DataManager.setAuthToken(result.data.token) // back when the session is confirmed still valid.
com.tt.honeyDue.storage.TokenStorage.saveToken(result.data.token) is ApiResult.Success -> ApiResult.Success(currentToken)
}
return when (result) {
is ApiResult.Success -> ApiResult.Success(result.data.token)
is ApiResult.Error -> ApiResult.Error(result.message, result.code) is ApiResult.Error -> ApiResult.Error(result.message, result.code)
else -> ApiResult.Error("Unexpected state") else -> ApiResult.Error("Unexpected state")
} }
@@ -1,8 +1,6 @@
package com.tt.honeyDue.network package com.tt.honeyDue.network
import com.tt.honeyDue.data.DataManager import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.TokenRefreshResponse
import com.tt.honeyDue.storage.TokenStorage
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.plugins.* import io.ktor.client.plugins.*
@@ -34,6 +32,30 @@ expect fun getDeviceLanguage(): String
*/ */
expect fun getDeviceTimezone(): String expect fun getDeviceTimezone(): String
/**
* The HTTP header the honeyDue API expects on authenticated requests.
*
* Identity is owned by Ory Kratos; the honeyDue API now authenticates a
* request by validating the Kratos **session token** carried on this header.
* This replaces the old `Authorization: Token <token>` scheme.
*
* Every honeyDue `*Api.kt` client sends this header via [authHeader]; image
* loading uses it through [CoilAuthInterceptor].
*/
const val SESSION_TOKEN_HEADER: String = "X-Session-Token"
/**
* Set the honeyDue session-token header on a request.
*
* Usage in an `*Api.kt` client:
* ```kotlin
* client.get("$baseUrl/tasks/") { authHeader(token) }
* ```
*/
fun HttpRequestBuilder.authHeader(token: String) {
header(SESSION_TOKEN_HEADER, token)
}
/** /**
* Mutex to prevent multiple concurrent token refresh attempts. * Mutex to prevent multiple concurrent token refresh attempts.
* When one request triggers a 401, only one refresh call is made; * When one request triggers a 401, only one refresh call is made;
@@ -80,37 +102,30 @@ fun HttpClientConfig<*>.installCommonPlugins() {
socketTimeoutMillis = 30_000 // 30 seconds socketTimeoutMillis = 30_000 // 30 seconds
} }
// Task 3: Token refresh on 401 responses // Task 3: Kratos session validation on 401 responses.
//
// The honeyDue API now authenticates via the Kratos session token on the
// X-Session-Token header. A 401 from the API means that token was
// rejected. We confirm with Kratos whether the session is genuinely gone:
// - still valid -> throw TokenExpiredException(refreshed = true) (retry)
// - gone -> clear auth, throw TokenExpiredException(false) (re-login)
HttpResponseValidator { HttpResponseValidator {
validateResponse { response -> validateResponse { response ->
if (response.status.value == 401) { if (response.status.value == 401) {
// Check if this is a token_expired error (not invalid credentials) val currentToken = DataManager.authToken.value
val bodyText = response.bodyAsText() if (currentToken != null) {
val isTokenExpired = bodyText.contains("token_expired") || // Use the mutex so concurrent 401s only trigger one
bodyText.contains("Token has expired") || // whoami check against Kratos.
bodyText.contains("expired") val stillValid = tokenRefreshMutex.withLock {
attemptTokenRefresh(currentToken)
if (isTokenExpired) {
val currentToken = DataManager.authToken.value
if (currentToken != null) {
// Use mutex to prevent concurrent refresh attempts
val refreshed = tokenRefreshMutex.withLock {
// Double-check: another coroutine may have already refreshed
val tokenAfterLock = DataManager.authToken.value
if (tokenAfterLock != currentToken) {
// Token was already refreshed by another coroutine
true
} else {
attemptTokenRefresh(currentToken)
}
}
if (!refreshed) {
// Refresh failed — clear auth and trigger logout
DataManager.clear()
}
// Throw so the caller can retry (or handle the logout)
throw TokenExpiredException(refreshed)
} }
if (!stillValid) {
// Session is gone — clear auth and route to login.
DataManager.clear()
}
// Throw so the caller can retry (still valid) or handle
// the forced logout (session gone).
throw TokenExpiredException(stillValid)
} }
} }
} }
@@ -118,13 +133,25 @@ fun HttpClientConfig<*>.installCommonPlugins() {
} }
/** /**
* Attempt to refresh the auth token by calling POST /api/auth/refresh/. * Re-validate the current Kratos session.
* Returns true if refresh succeeded and the new token was saved. *
* Identity is owned by Ory Kratos. Native Kratos session tokens are
* long-lived and there is **no native refresh endpoint** — when a session
* genuinely expires the user must sign in again. So "refresh" here means:
* ask Kratos `GET /sessions/whoami` whether the session is still active.
*
* - returns `true` → the session is still valid; the original 401 was
* transient (e.g. a brief replication lag) and the caller may retry.
* - returns `false` → the session is gone; the caller should clear auth and
* route the user back to login.
*
* The token itself is never rotated — [DataManager]/[TokenStorage] keep the
* same value either way.
*/ */
private suspend fun attemptTokenRefresh(currentToken: String): Boolean { private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
return try { return try {
// Use a minimal client to avoid recursive interceptor triggers // Use a minimal client to avoid recursive interceptor triggers.
val refreshClient = HttpClient { val whoamiClient = HttpClient {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -137,38 +164,40 @@ private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
socketTimeoutMillis = 15_000 socketTimeoutMillis = 15_000
} }
} }
val baseUrl = ApiConfig.getBaseUrl() val kratosUrl = ApiConfig.getKratosBaseUrl()
val response = refreshClient.post("$baseUrl/auth/refresh/") { val response = whoamiClient.get("$kratosUrl/sessions/whoami") {
header("Authorization", "Token $currentToken") header(SESSION_TOKEN_HEADER, currentToken)
contentType(ContentType.Application.Json) accept(ContentType.Application.Json)
} }
refreshClient.close() whoamiClient.close()
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
val tokenResponse = response.body<TokenRefreshResponse>() // Session still valid — keep the same token.
// Save the new token to both DataManager and persistent storage println("[ApiClient] Kratos session still valid")
DataManager.setAuthToken(tokenResponse.token)
TokenStorage.saveToken(tokenResponse.token)
println("[ApiClient] Token refreshed successfully")
true true
} else { } else {
println("[ApiClient] Token refresh failed: ${response.status.value}") println("[ApiClient] Kratos session invalid: ${response.status.value}")
false false
} }
} catch (e: Exception) { } catch (e: Exception) {
println("[ApiClient] Token refresh error: ${e.message}") println("[ApiClient] Kratos session check error: ${e.message}")
false false
} }
} }
/** /**
* Exception thrown when a 401 response indicates an expired token. * Exception thrown when a 401 response indicates an expired/invalid session.
* [refreshed] indicates whether the token was successfully refreshed. *
* Callers can catch this and retry the request if refreshed is true. * [refreshed] indicates whether the Kratos session was re-validated and is
* still usable. Callers can catch this and retry the request when `refreshed`
* is true; when false the user must re-authenticate.
*
* The name is retained from the pre-Kratos token scheme so existing callers
* ([CoilAuthInterceptor] plumbing, tests) continue to compile.
*/ */
class TokenExpiredException(val refreshed: Boolean) : Exception( class TokenExpiredException(val refreshed: Boolean) : Exception(
if (refreshed) "Token was expired but has been refreshed — retry the request" if (refreshed) "Session was briefly rejected but is still valid — retry the request"
else "Token expired and refresh failed — user must re-authenticate" else "Session expired — user must re-authenticate"
) )
object ApiClient { object ApiClient {
@@ -185,6 +214,13 @@ object ApiClient {
*/ */
fun getMediaBaseUrl(): String = ApiConfig.getMediaBaseUrl() fun getMediaBaseUrl(): String = ApiConfig.getMediaBaseUrl()
/**
* Get the Ory Kratos public API base URL. Identity flows (login,
* registration, recovery, verification, OIDC sign-in) run against this
* host — NOT [getBaseUrl].
*/
fun getKratosBaseUrl(): String = ApiConfig.getKratosBaseUrl()
/** /**
* Print current environment configuration * Print current environment configuration
*/ */
@@ -193,5 +229,6 @@ object ApiClient {
println("Environment: ${ApiConfig.getEnvironmentName()}") println("Environment: ${ApiConfig.getEnvironmentName()}")
println("Base URL: ${getBaseUrl()}") println("Base URL: ${getBaseUrl()}")
println("Media URL: ${getMediaBaseUrl()}") println("Media URL: ${getMediaBaseUrl()}")
println("Kratos URL: ${getKratosBaseUrl()}")
} }
} }
@@ -40,6 +40,28 @@ object ApiConfig {
} }
} }
/**
* Get the Ory Kratos public API base URL.
*
* Identity (login, registration, recovery, verification, OIDC sign-in) is
* owned by Ory Kratos — NOT the honeyDue Go API. The native (`api`)
* self-service flows live under `{kratosBaseUrl}/self-service/...`.
*
* - LOCAL: a Kratos instance running on the dev machine (default public
* port `4433`). The Android emulator reaches the host via `10.0.2.2`,
* the iOS simulator via `127.0.0.1` — both resolved by [getLocalhostAddress].
* - DEV / PROD: the hosted Kratos at `auth.myhoneydue.com`.
*
* No trailing slash — callers append `/self-service/...`.
*/
fun getKratosBaseUrl(): String {
return when (CURRENT_ENV) {
Environment.LOCAL -> "http://${getLocalhostAddress()}:4433"
Environment.DEV -> "https://auth.myhoneydue.com"
Environment.PROD -> "https://auth.myhoneydue.com"
}
}
/** /**
* Get environment name for logging * Get environment name for logging
*/ */
@@ -4,71 +4,533 @@ import com.tt.honeyDue.models.*
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.statement.*
import io.ktor.http.* import io.ktor.http.*
import kotlinx.serialization.json.Json
/**
* Authentication API client.
*
* Identity for honeyDue is owned by **Ory Kratos** — NOT the honeyDue Go API.
* This client drives Kratos' **native (`api`) self-service flows**:
*
* - **Login** — `GET .../self-service/login/api` → `POST ui.action`
* with `{method:"password", identifier, password}`.
* - **Registration** — `GET .../self-service/registration/api` → `POST ui.action`
* with `{method:"password", traits:{email,name{first,last}}, password}`.
* - **Recovery** — `.../self-service/recovery/api` with `method:"code"`.
* - **Verification** — `.../self-service/verification/api` with `method:"code"`.
* - **OIDC** — Apple/Google: the platform SDK obtains a native
* `id_token`, submitted to the login/registration flow with
* `{method:"oidc", provider, id_token}`.
*
* On success Kratos returns a **`session_token`**. That token is what the
* honeyDue API now expects on the `X-Session-Token` header. The session token
* is stored via [com.tt.honeyDue.storage.TokenManager]; see [APILayer].
*
* Endpoints that still live on the honeyDue API — `GET /api/auth/me/`, profile
* update, account deletion — are also driven from here, sending the Kratos
* session token on the `X-Session-Token` header.
*
* The public method signatures are deliberately unchanged from the old
* hand-rolled auth client so [APILayer], [com.tt.honeyDue.viewmodel.AuthViewModel]
* and the UI continue to compile against the same `ApiResult<...>` shapes.
*/
class AuthApi(private val client: HttpClient = ApiClient.httpClient) { class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> { /** honeyDue Go API base — used only for `/auth/me`, profile, delete. */
private val apiBaseUrl = ApiClient.getBaseUrl()
/** Ory Kratos public API base — used for all identity flows. */
private val kratosBaseUrl = ApiConfig.getKratosBaseUrl()
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
}
/**
* Action URL of the recovery flow started by [forgotPassword]. Kratos
* binds the emailed recovery code to that specific flow, so [verifyResetCode]
* must submit the code back to the SAME flow — not a fresh one. Held in
* memory only: a process restart between the two steps simply means the
* user requests a new code (recovery flows are short-lived regardless).
*/
private var pendingRecoveryAction: String? = null
// ==================== Kratos flow plumbing ====================
/**
* Fetch a fresh native self-service flow from Kratos.
*
* @param flow one of `login`, `registration`, `recovery`, `verification`.
* @param refresh when true (login only) forces re-authentication of an
* already-valid session.
*/
private suspend fun initFlow(flow: String, refresh: Boolean = false): ApiResult<KratosFlow> {
return try { return try {
val response = client.post("$baseUrl/auth/register/") { val response = client.get("$kratosBaseUrl/self-service/$flow/api") {
contentType(ContentType.Application.Json) accept(ContentType.Application.Json)
setBody(request) if (refresh) parameter("refresh", "true")
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(json.decodeFromString(KratosFlow.serializer(), response.bodyAsText()))
} else { } else {
// Parse actual error message from backend ApiResult.Error(
val errorMessage = ErrorParser.parseError(response) "Could not start $flow (Kratos ${response.status.value})",
ApiResult.Error(errorMessage, response.status.value) response.status.value,
)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Could not reach the authentication server")
} }
} }
/**
* Submit a method payload to a flow's `ui.action` URL and decode the
* result with [decode]. On a 4xx Kratos re-renders the flow with
* validation messages — those are extracted via [extractKratosError].
*/
private suspend fun <T> submitFlow(
actionUrl: String,
bodyJson: String,
decode: (String) -> T,
): ApiResult<T> {
return try {
val response = client.post(actionUrl) {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
setBody(bodyJson)
}
val text = response.bodyAsText()
if (response.status.isSuccess()) {
ApiResult.Success(decode(text))
} else {
ApiResult.Error(extractKratosError(text, response.status.value), response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Authentication request failed")
}
}
/**
* Pull a human-readable error out of a Kratos error/flow body.
*
* Kratos surfaces validation problems in `ui.messages` and
* `ui.nodes[].messages`; hard errors (expired flow, etc.) come back as
* `{ error: { message, reason } }`.
*/
private fun extractKratosError(body: String, statusCode: Int): String {
// 1. Re-rendered flow with field/flow messages.
runCatching {
val flow = json.decodeFromString(KratosFlow.serializer(), body)
val msgs = buildList {
flow.ui.messages.filter { it.type == "error" || it.type == null }
.forEach { add(it.text) }
flow.ui.nodes.flatMap { it.messages }
.filter { it.type == "error" || it.type == null }
.forEach { add(it.text) }
}.distinct()
if (msgs.isNotEmpty()) return msgs.joinToString(". ")
}
// 2. Generic Kratos error envelope.
runCatching {
val env = json.decodeFromString(KratosErrorEnvelope.serializer(), body)
val msg = env.error?.reason ?: env.error?.message
if (!msg.isNullOrBlank()) return msg
}
return "Authentication failed ($statusCode)"
}
// ==================== Login ====================
/**
* Native password login against Kratos.
*
* [LoginRequest.username] is treated as the Kratos identifier (the user's
* email — honeyDue identities are keyed by email).
*/
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> { suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {
return try { val flow = when (val f = initFlow("login")) {
val response = client.post("$baseUrl/auth/login/") { is ApiResult.Success -> f.data
contentType(ContentType.Application.Json) is ApiResult.Error -> return f
setBody(request) else -> return ApiResult.Error("Could not start login")
} }
val body = json.encodeToString(
if (response.status.isSuccess()) { KratosPasswordLoginBody.serializer(),
ApiResult.Success(response.body()) KratosPasswordLoginBody(identifier = request.username.trim(), password = request.password),
} else { )
// Parse actual error message from backend val success = submitFlow(flow.ui.action, body) {
val errorMessage = ErrorParser.parseError(response) json.decodeFromString(KratosLoginSuccess.serializer(), it)
ApiResult.Error(errorMessage, response.status.value) }
} return when (success) {
} catch (e: Exception) { is ApiResult.Success -> resolveSession(success.data.sessionToken, success.data.session)
ApiResult.Error(e.message ?: "Unknown error occurred") is ApiResult.Error -> success
else -> ApiResult.Error("Login failed")
} }
} }
// ==================== Registration ====================
/**
* Native password registration against Kratos.
*
* Kratos identity traits are `{ email, name: { first, last } }`. The
* legacy [RegisterRequest.username] is preserved for the UI but is not a
* Kratos trait — the email is the identifier.
*/
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
val flow = when (val f = initFlow("registration")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start registration")
}
val traits = KratosTraits(
email = request.email.trim(),
name = KratosName(
first = request.firstName ?: "",
last = request.lastName ?: "",
),
)
val body = json.encodeToString(
KratosPasswordRegistrationBody.serializer(),
KratosPasswordRegistrationBody(traits = traits, password = request.password),
)
val success = submitFlow(flow.ui.action, body) {
json.decodeFromString(KratosRegistrationSuccess.serializer(), it)
}
return when (success) {
is ApiResult.Success -> {
val token = success.data.sessionToken
if (token.isNullOrBlank()) {
// Kratos was configured without the `session` after-hook —
// the identity exists but no session was issued. The caller
// must complete a verification flow then log in.
ApiResult.Error(
"Account created. Please verify your email, then sign in.",
200,
)
} else {
resolveSession(token, success.data.session)
}
}
is ApiResult.Error -> success
else -> ApiResult.Error("Registration failed")
}
}
// ==================== OIDC (Apple / Google) ====================
/**
* Submit a native OIDC `id_token` to Kratos.
*
* The platform SDK (Sign in with Apple / Google Sign-In) obtains the
* `id_token`; Kratos verifies it and either logs the user in or registers
* a new identity. We try the login flow first; if Kratos reports the
* identity does not exist we fall through to the registration flow.
*
* @param provider `"apple"` or `"google"`.
* @param idToken the platform-issued OpenID Connect ID token.
* @param traits optional traits used to seed a new identity on first sign-in.
*/
private suspend fun oidcSignIn(
provider: String,
idToken: String,
traits: KratosTraits?,
): ApiResult<AuthResponse> {
// 1. Attempt the login flow.
val loginFlow = when (val f = initFlow("login")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start sign-in")
}
val loginBody = json.encodeToString(
KratosOidcBody.serializer(),
KratosOidcBody(provider = provider, idToken = idToken),
)
val loginResult = submitFlow(loginFlow.ui.action, loginBody) {
json.decodeFromString(KratosLoginSuccess.serializer(), it)
}
if (loginResult is ApiResult.Success) {
return resolveSession(loginResult.data.sessionToken, loginResult.data.session)
}
// 2. No identity yet — drive the registration flow with the same token.
val regFlow = when (val f = initFlow("registration")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return (loginResult as? ApiResult.Error) ?: f
else -> return ApiResult.Error("Could not start sign-up")
}
val regBody = json.encodeToString(
KratosOidcBody.serializer(),
KratosOidcBody(provider = provider, idToken = idToken, traits = traits),
)
val regResult = submitFlow(regFlow.ui.action, regBody) {
json.decodeFromString(KratosRegistrationSuccess.serializer(), it)
}
return when (regResult) {
is ApiResult.Success -> {
val token = regResult.data.sessionToken
if (token.isNullOrBlank()) {
(loginResult as? ApiResult.Error)
?: ApiResult.Error("Sign-in did not return a session")
} else {
resolveSession(token, regResult.data.session)
}
}
is ApiResult.Error -> regResult
else -> ApiResult.Error("Sign-in failed")
}
}
/**
* Apple Sign In via Kratos OIDC. The Apple `id_token` is obtained natively
* by the platform; [AppleSignInRequest.userId]/email/name seed the
* identity on first sign-in.
*/
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
val traits = request.email?.takeIf { it.isNotBlank() }?.let {
KratosTraits(
email = it,
name = KratosName(
first = request.firstName ?: "",
last = request.lastName ?: "",
),
)
}
return when (val r = oidcSignIn("apple", request.idToken, traits)) {
is ApiResult.Success -> ApiResult.Success(
AppleSignInResponse(token = r.data.token, user = r.data.user, isNewUser = false),
)
is ApiResult.Error -> r
else -> ApiResult.Error("Apple Sign In failed")
}
}
/**
* Google Sign In via Kratos OIDC. The Google `id_token` is obtained
* natively by the platform Google Sign-In SDK.
*/
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
return when (val r = oidcSignIn("google", request.idToken, traits = null)) {
is ApiResult.Success -> ApiResult.Success(
GoogleSignInResponse(token = r.data.token, user = r.data.user, isNewUser = false),
)
is ApiResult.Error -> r
else -> ApiResult.Error("Google Sign In failed")
}
}
// ==================== Logout ====================
/**
* Terminate the Kratos session.
*
* The native logout flow is a single call:
* `DELETE .../self-service/logout/api` with `{ session_token }`.
* A failure here is non-fatal — the caller drops the token locally
* regardless (see [APILayer.logout]).
*/
suspend fun logout(token: String): ApiResult<Unit> { suspend fun logout(token: String): ApiResult<Unit> {
return try { return try {
val response = client.post("$baseUrl/auth/logout/") { client.delete("$kratosBaseUrl/self-service/logout/api") {
header("Authorization", "Token $token") contentType(ContentType.Application.Json)
} setBody(json.encodeToString(LogoutBody.serializer(), LogoutBody(token)))
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Logout failed", response.status.value)
} }
// Treat any outcome as success — the token is being discarded
// locally anyway, and a stale-token DELETE is harmless.
ApiResult.Success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Success(Unit)
} }
} }
// ==================== Recovery (forgot password) ====================
/**
* Start a Kratos recovery flow and submit the user's email so Kratos
* mails them a recovery code.
*
* Mirrors the legacy `forgotPassword` signature. The returned message is
* Kratos' confirmation text.
*/
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<ForgotPasswordResponse> {
val flow = when (val f = initFlow("recovery")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start password recovery")
}
val body = json.encodeToString(
KratosRecoveryBody.serializer(),
KratosRecoveryBody(email = request.email.trim()),
)
// Kratos returns the re-rendered flow (200) carrying an info message
// that the code was sent. A 4xx means the email was malformed.
val result = submitFlow(flow.ui.action, body) {
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
}
return when (result) {
is ApiResult.Success -> {
// Remember this flow's action — verifyResetCode submits the
// emailed code back to the SAME flow Kratos bound it to.
pendingRecoveryAction = flow.ui.action
val info = result.data?.ui?.messages?.firstOrNull()?.text
ApiResult.Success(
ForgotPasswordResponse(
message = info ?: "If that email exists, a recovery code has been sent.",
),
)
}
is ApiResult.Error -> result
else -> ApiResult.Error("Could not send recovery code")
}
}
/**
* Submit the recovery code the user received by email.
*
* The code is submitted back to the SAME recovery flow [forgotPassword]
* started ([pendingRecoveryAction]) — Kratos binds the emailed code to that
* flow. A valid code drives the flow to `passed_challenge`, and Kratos then
* returns, via `continue_with`, the privileged session token plus the
* settings flow to finish the password change in. Both are packed into the
* opaque `resetToken` that [resetPassword] consumes.
*/
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
val action = pendingRecoveryAction
?: return ApiResult.Error("Your recovery session expired. Request a new code.")
val body = json.encodeToString(
KratosRecoveryBody.serializer(),
KratosRecoveryBody(code = request.code.trim()),
)
val result = submitFlow(action, body) {
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
}
return when (result) {
is ApiResult.Success -> {
val flow = result.data
val settingsFlowId = flow?.continueWith
?.firstOrNull { it.action == "show_settings_ui" }?.flow?.id
val sessionToken = flow?.continueWith
?.firstOrNull { it.action == "set_ory_session_token" }?.orySessionToken
if (settingsFlowId != null && sessionToken != null) {
pendingRecoveryAction = null
ApiResult.Success(
VerifyResetCodeResponse(
message = "Code verified.",
// Opaque to the UI: carries the settings flow id and
// the privileged session token resetPassword needs,
// packed as "<settingsFlowId>|<sessionToken>".
resetToken = "$settingsFlowId|$sessionToken",
),
)
} else {
// No continue_with → the code was wrong or the flow expired;
// surface Kratos' own validation message when present.
val msg = flow?.ui?.messages?.firstOrNull { it.type == "error" }?.text
?: flow?.ui?.nodes?.flatMap { it.messages }
?.firstOrNull { it.type == "error" }?.text
ApiResult.Error(msg ?: "Invalid or expired code")
}
}
is ApiResult.Error -> result
else -> ApiResult.Error("Invalid or expired code")
}
}
/**
* Complete a password reset.
*
* [verifyResetCode] packed the settings flow id and the privileged session
* token Kratos issued into [ResetPasswordRequest.resetToken] as
* `"<settingsFlowId>|<sessionToken>"`. This submits the new password to
* that settings flow, authenticating with the privileged session token.
*/
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
val parts = request.resetToken.split("|", limit = 2)
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) {
return ApiResult.Error("This password reset session has expired. Request a new code.")
}
val settingsFlowId = parts[0]
val sessionToken = parts[1]
return try {
val response = client.post("$kratosBaseUrl/self-service/settings") {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
// The privileged session Kratos issued on code verification
// authorizes the settings flow that changes the password.
header("X-Session-Token", sessionToken)
parameter("flow", settingsFlowId)
setBody(
json.encodeToString(
KratosSettingsPasswordBody.serializer(),
KratosSettingsPasswordBody(password = request.newPassword),
),
)
}
val text = response.bodyAsText()
if (response.status.isSuccess()) {
ApiResult.Success(ResetPasswordResponse(message = "Password updated. You can now sign in."))
} else {
ApiResult.Error(extractKratosError(text, response.status.value), response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Could not reset password")
}
}
// ==================== Email verification ====================
/**
* Submit an email-verification code to Kratos' verification flow.
*
* Note: the [token] parameter (a session token) is unused for the Kratos
* verification flow — verification is anonymous and keyed by the code —
* but the parameter is kept so [APILayer]/`AuthViewModel` need no change.
*/
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
val flow = when (val f = initFlow("verification")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start verification")
}
val body = json.encodeToString(
KratosVerificationBody.serializer(),
KratosVerificationBody(code = request.code.trim()),
)
val result = submitFlow(flow.ui.action, body) {
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
}
return when (result) {
is ApiResult.Success -> {
val verified = result.data?.state == "passed_challenge"
ApiResult.Success(
VerifyEmailResponse(
message = if (verified) "Email verified." else "Verification submitted.",
verified = verified,
),
)
}
is ApiResult.Error -> result
else -> ApiResult.Error("Verification failed")
}
}
// ==================== honeyDue API (still session-token-gated) ====================
/**
* Fetch the current honeyDue user from the honeyDue Go API.
*
* Identity lives in Kratos, but the honeyDue API still owns the
* application-level user record (numeric id, profile, verified flag). The
* Kratos session token is sent on the `X-Session-Token` header.
*/
suspend fun getCurrentUser(token: String): ApiResult<User> { suspend fun getCurrentUser(token: String): ApiResult<User> {
return try { return try {
val response = client.get("$baseUrl/auth/me/") { val response = client.get("$apiBaseUrl/auth/me/") {
header("Authorization", "Token $token") header(HEADER_SESSION_TOKEN, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(response.body())
} else { } else {
@@ -79,214 +541,152 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
} }
} }
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> { /**
return try { * Update the honeyDue user profile. Profile data lives on the honeyDue
val response = client.post("$baseUrl/auth/verify-email/") { * API, not Kratos, so this still targets the Go API.
header("Authorization", "Token $token") */
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Verification failed")
}
ApiResult.Error(errorBody["error"] ?: "Verification failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> { suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> {
return try { return try {
val response = client.put("$baseUrl/auth/profile/") { val response = client.put("$apiBaseUrl/auth/profile/") {
header("Authorization", "Token $token") header(HEADER_SESSION_TOKEN, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(response.body())
} else { } else {
val errorBody = try { ApiResult.Error(ErrorParser.parseError(response), response.status.value)
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Profile update failed")
}
ApiResult.Error(errorBody["error"] ?: "Profile update failed", response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Unknown error occurred")
} }
} }
// Password Reset Methods /**
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<ForgotPasswordResponse> { * Delete the honeyDue account. The honeyDue API is responsible for
return try { * tearing down its own user record and asking Kratos to delete the
val response = client.post("$baseUrl/auth/forgot-password/") { * backing identity.
contentType(ContentType.Application.Json) */
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Failed to send reset code")
}
ApiResult.Error(errorBody["error"] ?: "Failed to send reset code", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
return try {
val response = client.post("$baseUrl/auth/verify-reset-code/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Invalid code")
}
ApiResult.Error(errorBody["error"] ?: "Invalid code", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
return try {
val response = client.post("$baseUrl/auth/reset-password/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
// Try to parse Django validation errors (Map<String, List<String>>)
val errorMessage = try {
val validationErrors = response.body<Map<String, List<String>>>()
// Flatten all error messages into a single string
validationErrors.flatMap { (field, errors) ->
errors.map { error ->
if (field == "non_field_errors") error else "$field: $error"
}
}.joinToString(". ")
} catch (e: Exception) {
// Try simple error format {error: "message"}
try {
val simpleError = response.body<Map<String, String>>()
simpleError["error"] ?: "Failed to reset password"
} catch (e2: Exception) {
"Failed to reset password"
}
}
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Apple Sign In
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
return try {
val response = client.post("$baseUrl/auth/apple-sign-in/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Apple Sign In failed")
}
ApiResult.Error(errorBody["error"] ?: "Apple Sign In failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Delete Account
suspend fun deleteAccount(token: String, request: DeleteAccountRequest): ApiResult<Unit> { suspend fun deleteAccount(token: String, request: DeleteAccountRequest): ApiResult<Unit> {
return try { return try {
val response = client.delete("$baseUrl/auth/account/") { val response = client.delete("$apiBaseUrl/auth/account/") {
header("Authorization", "Token $token") header(HEADER_SESSION_TOKEN, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(Unit) ApiResult.Success(Unit)
} else { } else {
val errorMessage = ErrorParser.parseError(response) ApiResult.Error(ErrorParser.parseError(response), response.status.value)
ApiResult.Error(errorMessage, response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Unknown error occurred")
} }
} }
// Token Refresh /**
* Legacy token-refresh shim.
*
* Kratos native session tokens are long-lived and there is no native
* refresh endpoint — when a session expires the user must re-authenticate.
* This method is kept so [ApiClient]'s 401 plumbing and the Coil image
* interceptor still compile; it simply re-validates the current session
* via Kratos `/sessions/whoami` and echoes the same token back if still
* valid, or fails otherwise.
*/
suspend fun refreshToken(token: String): ApiResult<TokenRefreshResponse> { suspend fun refreshToken(token: String): ApiResult<TokenRefreshResponse> {
return try { return try {
val response = client.post("$baseUrl/auth/refresh/") { val response = client.get("$kratosBaseUrl/sessions/whoami") {
header("Authorization", "Token $token") header(HEADER_SESSION_TOKEN, token)
contentType(ContentType.Application.Json) accept(ContentType.Application.Json)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(TokenRefreshResponse(token = token))
} else { } else {
val errorMessage = ErrorParser.parseError(response) ApiResult.Error("Session expired — please sign in again", response.status.value)
ApiResult.Error(errorMessage, response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Could not validate session")
} }
} }
// Google Sign In // ==================== Session → User resolution ====================
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
return try {
val response = client.post("$baseUrl/auth/google-sign-in/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) { /**
ApiResult.Success(response.body()) * Given a freshly issued Kratos `session_token`, fetch the honeyDue
} else { * application user that backs the Kratos identity and assemble an
val errorBody = try { * [AuthResponse].
response.body<Map<String, String>>() *
} catch (e: Exception) { * The honeyDue API maps Kratos identities to its own numeric user records;
mapOf("error" to "Google Sign In failed") * `/auth/me` is the source of truth for `User.id`, profile and the
* `verified` flag. If `/auth/me` is unreachable we fall back to a
* best-effort [User] synthesised from the Kratos identity traits so the
* app can still proceed.
*/
private suspend fun resolveSession(
sessionToken: String,
session: KratosSession?,
): ApiResult<AuthResponse> {
return when (val me = getCurrentUser(sessionToken)) {
is ApiResult.Success -> ApiResult.Success(AuthResponse(token = sessionToken, user = me.data))
is ApiResult.Error -> {
val fallback = session?.identity?.let { userFromKratosIdentity(it) }
if (fallback != null) {
ApiResult.Success(AuthResponse(token = sessionToken, user = fallback))
} else {
me
} }
ApiResult.Error(errorBody["error"] ?: "Google Sign In failed", response.status.value)
} }
} catch (e: Exception) { else -> ApiResult.Error("Could not load profile after sign-in")
ApiResult.Error(e.message ?: "Unknown error occurred")
} }
} }
/**
* Best-effort [User] built from a Kratos identity when the honeyDue
* `/auth/me` lookup is unavailable. `id` is `0` (Kratos ids are UUIDs,
* not the honeyDue numeric id) — callers should re-fetch via
* [getCurrentUser] as soon as the API is reachable.
*/
private fun userFromKratosIdentity(identity: KratosIdentity): User {
val traits = identity.traits
return User(
id = 0,
username = traits?.email ?: "",
email = traits?.email ?: "",
firstName = traits?.name?.first ?: "",
lastName = traits?.name?.last ?: "",
isActive = identity.state == null || identity.state == "active",
dateJoined = "",
authProvider = "kratos",
profile = null,
)
}
companion object {
/**
* The header the honeyDue API now expects on authenticated requests —
* carries the Kratos session token. Replaces `Authorization: Token …`.
*/
const val HEADER_SESSION_TOKEN = "X-Session-Token"
}
} }
/** Body for Kratos' native `DELETE /self-service/logout/api`. */
@kotlinx.serialization.Serializable
private data class LogoutBody(
@kotlinx.serialization.SerialName("session_token") val sessionToken: String,
)
/** Generic Kratos error envelope: `{ "error": { "message", "reason", ... } }`. */
@kotlinx.serialization.Serializable
private data class KratosErrorEnvelope(
val error: KratosErrorDetail? = null,
)
@kotlinx.serialization.Serializable
private data class KratosErrorDetail(
val message: String? = null,
val reason: String? = null,
val status: String? = null,
val code: Int? = null,
)
@@ -7,14 +7,18 @@ import coil3.request.ErrorResult
import coil3.request.ImageResult import coil3.request.ImageResult
/** /**
* Coil3 [Interceptor] that attaches an `Authorization` header to every * Coil3 [Interceptor] that attaches the honeyDue session-token header to every
* outgoing image request and, on an HTTP 401 response, refreshes the token * outgoing image request and, on an HTTP 401 response, re-validates the
* and retries exactly once. * session and retries exactly once.
*
* honeyDue's identity is owned by Ory Kratos. Authenticated honeyDue API
* requests — including authenticated media — carry the Kratos session token
* on the **`X-Session-Token`** header (the old `Authorization: Token …` scheme
* is gone). This interceptor centralises that concern so individual
* composables don't thread the token through themselves.
* *
* Mirrors the behavior of the iOS `AuthenticatedImage` in * Mirrors the behavior of the iOS `AuthenticatedImage` in
* `iosApp/iosApp/Components/AuthenticatedImage.swift`, centralising the * `iosApp/iosApp/Components/AuthenticatedImage.swift`.
* concern so individual composables don't need to thread the token through
* themselves.
* *
* Usage — install on the singleton [coil3.ImageLoader]: * Usage — install on the singleton [coil3.ImageLoader]:
* ```kotlin * ```kotlin
@@ -23,24 +27,28 @@ import coil3.request.ImageResult
* add(CoilAuthInterceptor( * add(CoilAuthInterceptor(
* tokenProvider = { TokenStorage.getToken() }, * tokenProvider = { TokenStorage.getToken() },
* refreshToken = { (APILayer.refreshToken() as? ApiResult.Success)?.data }, * refreshToken = { (APILayer.refreshToken() as? ApiResult.Success)?.data },
* authScheme = "Token",
* )) * ))
* add(KtorNetworkFetcherFactory()) * add(KtorNetworkFetcherFactory())
* } * }
* .build() * .build()
* ``` * ```
* *
* @param tokenProvider Suspending supplier of the current auth token. Returning * @param tokenProvider Suspending supplier of the current session token.
* `null` means "no token available" — the request proceeds unauthenticated. * Returning `null` means "no token available" — the request proceeds
* @param refreshToken Suspending supplier that refreshes the backing session and * unauthenticated so anonymous endpoints still work.
* returns a fresh token, or `null` if refresh failed. * @param refreshToken Suspending supplier that re-validates the session and
* @param authScheme The auth scheme to prefix the token with (default `Token` * returns a still-valid token, or `null` if the session is gone. With
* to match the existing Go backend — use `Bearer` for JWT deployments). * Kratos, session tokens are not rotated — this typically echoes the same
* token back when the session is still active.
* @param headerName The HTTP header carrying the token. Defaults to
* [SESSION_TOKEN_HEADER] (`X-Session-Token`). The token is sent as the bare
* header value — there is no `<scheme> ` prefix under the Kratos
* session-token scheme.
*/ */
class CoilAuthInterceptor( class CoilAuthInterceptor(
private val tokenProvider: suspend () -> String?, private val tokenProvider: suspend () -> String?,
private val refreshToken: suspend () -> String?, private val refreshToken: suspend () -> String?,
private val authScheme: String = "Token", private val headerName: String = SESSION_TOKEN_HEADER,
) : Interceptor { ) : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult { override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
@@ -55,26 +63,26 @@ class CoilAuthInterceptor(
val authed = chain.request.newBuilder() val authed = chain.request.newBuilder()
.httpHeaders( .httpHeaders(
chain.request.httpHeaders.newBuilder() chain.request.httpHeaders.newBuilder()
.set(HEADER_AUTHORIZATION, "$authScheme $token") .set(headerName, token)
.build() .build()
) )
.build() .build()
val result = chain.withRequest(authed).proceed() val result = chain.withRequest(authed).proceed()
// If the server rejected the token, try refreshing once. // If the server rejected the token, re-validate the session once.
if (result.isUnauthorized()) { if (result.isUnauthorized()) {
val newToken = refreshToken() ?: return result val newToken = refreshToken() ?: return result
val retried = authed.newBuilder() val retried = authed.newBuilder()
.httpHeaders( .httpHeaders(
authed.httpHeaders.newBuilder() authed.httpHeaders.newBuilder()
.set(HEADER_AUTHORIZATION, "$authScheme $newToken") .set(headerName, newToken)
.build() .build()
) )
.build() .build()
// Only retry *once* — whatever comes back from this call is final, // Only retry *once* — whatever comes back from this call is final,
// even if it is itself a 401. This guards against an infinite loop // even if it is itself a 401. This guards against an infinite loop
// when refresh succeeds but the backing account is still revoked. // when the session is still revoked.
return chain.withRequest(retried).proceed() return chain.withRequest(retried).proceed()
} }
@@ -88,7 +96,6 @@ class CoilAuthInterceptor(
} }
companion object { companion object {
private const val HEADER_AUTHORIZATION = "Authorization"
private const val HTTP_UNAUTHORIZED = 401 private const val HTTP_UNAUTHORIZED = 401
} }
} }
@@ -18,7 +18,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<List<ContractorSummary>> { ): ApiResult<List<ContractorSummary>> {
return try { return try {
val response = client.get("$baseUrl/contractors/") { val response = client.get("$baseUrl/contractors/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
specialty?.let { parameter("specialty", it) } specialty?.let { parameter("specialty", it) }
isFavorite?.let { parameter("is_favorite", it) } isFavorite?.let { parameter("is_favorite", it) }
isActive?.let { parameter("is_active", it) } isActive?.let { parameter("is_active", it) }
@@ -38,7 +38,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractor(token: String, id: Int): ApiResult<Contractor> { suspend fun getContractor(token: String, id: Int): ApiResult<Contractor> {
return try { return try {
val response = client.get("$baseUrl/contractors/$id/") { val response = client.get("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -54,7 +54,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createContractor(token: String, request: ContractorCreateRequest): ApiResult<Contractor> { suspend fun createContractor(token: String, request: ContractorCreateRequest): ApiResult<Contractor> {
return try { return try {
val response = client.post("$baseUrl/contractors/") { val response = client.post("$baseUrl/contractors/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -77,7 +77,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateContractor(token: String, id: Int, request: ContractorUpdateRequest): ApiResult<Contractor> { suspend fun updateContractor(token: String, id: Int, request: ContractorUpdateRequest): ApiResult<Contractor> {
return try { return try {
val response = client.patch("$baseUrl/contractors/$id/") { val response = client.patch("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -100,7 +100,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteContractor(token: String, id: Int): ApiResult<Unit> { suspend fun deleteContractor(token: String, id: Int): ApiResult<Unit> {
return try { return try {
val response = client.delete("$baseUrl/contractors/$id/") { val response = client.delete("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -116,7 +116,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun toggleFavorite(token: String, id: Int): ApiResult<Contractor> { suspend fun toggleFavorite(token: String, id: Int): ApiResult<Contractor> {
return try { return try {
val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") { val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -132,7 +132,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractorTasks(token: String, id: Int): ApiResult<List<TaskResponse>> { suspend fun getContractorTasks(token: String, id: Int): ApiResult<List<TaskResponse>> {
return try { return try {
val response = client.get("$baseUrl/contractors/$id/tasks/") { val response = client.get("$baseUrl/contractors/$id/tasks/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -148,7 +148,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractorsByResidence(token: String, residenceId: Int): ApiResult<List<ContractorSummary>> { suspend fun getContractorsByResidence(token: String, residenceId: Int): ApiResult<List<ContractorSummary>> {
return try { return try {
val response = client.get("$baseUrl/contractors/by-residence/$residenceId/") { val response = client.get("$baseUrl/contractors/by-residence/$residenceId/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -26,7 +26,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<List<Document>> { ): ApiResult<List<Document>> {
return try { return try {
val response = client.get("$baseUrl/documents/") { val response = client.get("$baseUrl/documents/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
residenceId?.let { parameter("residence", it) } residenceId?.let { parameter("residence", it) }
documentType?.let { parameter("document_type", it) } documentType?.let { parameter("document_type", it) }
isActive?.let { parameter("is_active", it) } isActive?.let { parameter("is_active", it) }
@@ -47,7 +47,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getDocument(token: String, id: Int): ApiResult<Document> { suspend fun getDocument(token: String, id: Int): ApiResult<Document> {
return try { return try {
val response = client.get("$baseUrl/documents/$id/") { val response = client.get("$baseUrl/documents/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -127,7 +127,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
} }
} }
) { ) {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
} else { } else {
// If no file, use JSON // If no file, use JSON
@@ -143,7 +143,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
residenceId = residenceId residenceId = residenceId
) )
client.post("$baseUrl/documents/") { client.post("$baseUrl/documents/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -201,7 +201,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
expiryDate = endDate // Map endDate to expiryDate expiryDate = endDate // Map endDate to expiryDate
) )
val response = client.patch("$baseUrl/documents/$id/") { val response = client.patch("$baseUrl/documents/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -224,7 +224,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteDocument(token: String, id: Int): ApiResult<Unit> { suspend fun deleteDocument(token: String, id: Int): ApiResult<Unit> {
return try { return try {
val response = client.delete("$baseUrl/documents/$id/") { val response = client.delete("$baseUrl/documents/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -240,7 +240,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun downloadDocument(token: String, url: String): ApiResult<ByteArray> { suspend fun downloadDocument(token: String, url: String): ApiResult<ByteArray> {
return try { return try {
val response = client.get(url) { val response = client.get(url) {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -256,7 +256,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun activateDocument(token: String, id: Int): ApiResult<Document> { suspend fun activateDocument(token: String, id: Int): ApiResult<Document> {
return try { return try {
val response = client.post("$baseUrl/documents/$id/activate/") { val response = client.post("$baseUrl/documents/$id/activate/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -274,7 +274,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deactivateDocument(token: String, id: Int): ApiResult<Document> { suspend fun deactivateDocument(token: String, id: Int): ApiResult<Document> {
return try { return try {
val response = client.post("$baseUrl/documents/$id/deactivate/") { val response = client.post("$baseUrl/documents/$id/deactivate/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -308,7 +308,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
caption?.let { append("caption", it) } caption?.let { append("caption", it) }
} }
) { ) {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -329,7 +329,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteDocumentImage(token: String, documentId: Int, imageId: Int): ApiResult<Document> { suspend fun deleteDocumentImage(token: String, documentId: Int, imageId: Int): ApiResult<Document> {
return try { return try {
val response = client.delete("$baseUrl/documents/$documentId/images/$imageId/") { val response = client.delete("$baseUrl/documents/$documentId/images/$imageId/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -36,7 +36,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> { suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> {
return try { return try {
val response = client.get("$baseUrl/residences/types/") { val response = client.get("$baseUrl/residences/types/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -52,7 +52,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskFrequencies(token: String): ApiResult<List<TaskFrequency>> { suspend fun getTaskFrequencies(token: String): ApiResult<List<TaskFrequency>> {
return try { return try {
val response = client.get("$baseUrl/tasks/frequencies/") { val response = client.get("$baseUrl/tasks/frequencies/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -68,7 +68,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskPriorities(token: String): ApiResult<List<TaskPriority>> { suspend fun getTaskPriorities(token: String): ApiResult<List<TaskPriority>> {
return try { return try {
val response = client.get("$baseUrl/tasks/priorities/") { val response = client.get("$baseUrl/tasks/priorities/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -84,7 +84,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskCategories(token: String): ApiResult<List<TaskCategory>> { suspend fun getTaskCategories(token: String): ApiResult<List<TaskCategory>> {
return try { return try {
val response = client.get("$baseUrl/tasks/categories/") { val response = client.get("$baseUrl/tasks/categories/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -100,7 +100,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractorSpecialties(token: String): ApiResult<List<ContractorSpecialty>> { suspend fun getContractorSpecialties(token: String): ApiResult<List<ContractorSpecialty>> {
return try { return try {
val response = client.get("$baseUrl/contractors/specialties/") { val response = client.get("$baseUrl/contractors/specialties/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -117,7 +117,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
return try { return try {
val response = client.get("$baseUrl/static_data/") { val response = client.get("$baseUrl/static_data/") {
// Token is optional - endpoint is public // Token is optional - endpoint is public
token?.let { header("Authorization", "Token $it") } token?.let { header(SESSION_TOKEN_HEADER, it) }
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -145,7 +145,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
return try { return try {
val response: HttpResponse = client.get("$baseUrl/static_data/") { val response: HttpResponse = client.get("$baseUrl/static_data/") {
// Token is optional - endpoint is public // Token is optional - endpoint is public
token?.let { header("Authorization", "Token $it") } token?.let { header(SESSION_TOKEN_HEADER, it) }
// Send If-None-Match header for conditional request // Send If-None-Match header for conditional request
currentETag?.let { header("If-None-Match", it) } currentETag?.let { header("If-None-Match", it) }
} }
@@ -18,7 +18,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<DeviceRegistrationResponse> { ): ApiResult<DeviceRegistrationResponse> {
return try { return try {
val response = client.post("$baseUrl/notifications/devices/register/") { val response = client.post("$baseUrl/notifications/devices/register/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -44,7 +44,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<Unit> { ): ApiResult<Unit> {
return try { return try {
val response = client.delete("$baseUrl/notifications/devices/$deviceId/") { val response = client.delete("$baseUrl/notifications/devices/$deviceId/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -63,7 +63,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> { suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> {
return try { return try {
val response = client.get("$baseUrl/notifications/preferences/") { val response = client.get("$baseUrl/notifications/preferences/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -85,7 +85,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<NotificationPreference> { ): ApiResult<NotificationPreference> {
return try { return try {
val response = client.put("$baseUrl/notifications/preferences/") { val response = client.put("$baseUrl/notifications/preferences/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -103,7 +103,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getNotificationHistory(token: String): ApiResult<List<Notification>> { suspend fun getNotificationHistory(token: String): ApiResult<List<Notification>> {
return try { return try {
val response = client.get("$baseUrl/notifications/") { val response = client.get("$baseUrl/notifications/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -123,7 +123,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<MessageResponse> { ): ApiResult<MessageResponse> {
return try { return try {
val response = client.post("$baseUrl/notifications/$notificationId/read/") { val response = client.post("$baseUrl/notifications/$notificationId/read/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -139,7 +139,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun markAllNotificationsAsRead(token: String): ApiResult<MessageResponse> { suspend fun markAllNotificationsAsRead(token: String): ApiResult<MessageResponse> {
return try { return try {
val response = client.post("$baseUrl/notifications/mark-all-read/") { val response = client.post("$baseUrl/notifications/mark-all-read/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -155,7 +155,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getUnreadCount(token: String): ApiResult<UnreadCountResponse> { suspend fun getUnreadCount(token: String): ApiResult<UnreadCountResponse> {
return try { return try {
val response = client.get("$baseUrl/notifications/unread-count/") { val response = client.get("$baseUrl/notifications/unread-count/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -12,7 +12,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidences(token: String): ApiResult<List<ResidenceResponse>> { suspend fun getResidences(token: String): ApiResult<List<ResidenceResponse>> {
return try { return try {
val response = client.get("$baseUrl/residences/") { val response = client.get("$baseUrl/residences/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -28,7 +28,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidence(token: String, id: Int): ApiResult<ResidenceResponse> { suspend fun getResidence(token: String, id: Int): ApiResult<ResidenceResponse> {
return try { return try {
val response = client.get("$baseUrl/residences/$id/") { val response = client.get("$baseUrl/residences/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -44,7 +44,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createResidence(token: String, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> { suspend fun createResidence(token: String, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> {
return try { return try {
val response = client.post("$baseUrl/residences/") { val response = client.post("$baseUrl/residences/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -62,7 +62,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateResidence(token: String, id: Int, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> { suspend fun updateResidence(token: String, id: Int, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> {
return try { return try {
val response = client.put("$baseUrl/residences/$id/") { val response = client.put("$baseUrl/residences/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -80,7 +80,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteResidence(token: String, id: Int): ApiResult<WithSummaryResponse<String>> { suspend fun deleteResidence(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
return try { return try {
val response = client.delete("$baseUrl/residences/$id/") { val response = client.delete("$baseUrl/residences/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -96,7 +96,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getSummary(token: String): ApiResult<TotalSummary> { suspend fun getSummary(token: String): ApiResult<TotalSummary> {
return try { return try {
val response = client.get("$baseUrl/residences/summary/") { val response = client.get("$baseUrl/residences/summary/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -112,7 +112,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getMyResidences(token: String): ApiResult<MyResidencesResponse> { suspend fun getMyResidences(token: String): ApiResult<MyResidencesResponse> {
return try { return try {
val response = client.get("$baseUrl/residences/my-residences/") { val response = client.get("$baseUrl/residences/my-residences/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -129,7 +129,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun generateSharePackage(token: String, residenceId: Int): ApiResult<SharedResidence> { suspend fun generateSharePackage(token: String, residenceId: Int): ApiResult<SharedResidence> {
return try { return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-share-package/") { val response = client.post("$baseUrl/residences/$residenceId/generate-share-package/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -146,7 +146,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun generateShareCode(token: String, residenceId: Int): ApiResult<GenerateShareCodeResponse> { suspend fun generateShareCode(token: String, residenceId: Int): ApiResult<GenerateShareCodeResponse> {
return try { return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") { val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -163,7 +163,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getShareCode(token: String, residenceId: Int): ApiResult<ShareCodeResponse> { suspend fun getShareCode(token: String, residenceId: Int): ApiResult<ShareCodeResponse> {
return try { return try {
val response = client.get("$baseUrl/residences/$residenceId/share-code/") { val response = client.get("$baseUrl/residences/$residenceId/share-code/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -186,7 +186,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun acceptResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> { suspend fun acceptResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
return try { return try {
val response = client.post("$baseUrl/residences/$residenceId/invite/accept/") { val response = client.post("$baseUrl/residences/$residenceId/invite/accept/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -207,7 +207,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun declineResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> { suspend fun declineResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
return try { return try {
val response = client.post("$baseUrl/residences/$residenceId/invite/decline/") { val response = client.post("$baseUrl/residences/$residenceId/invite/decline/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -224,7 +224,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun joinWithCode(token: String, code: String): ApiResult<JoinResidenceResponse> { suspend fun joinWithCode(token: String, code: String): ApiResult<JoinResidenceResponse> {
return try { return try {
val response = client.post("$baseUrl/residences/join-with-code/") { val response = client.post("$baseUrl/residences/join-with-code/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(JoinResidenceRequest(code)) setBody(JoinResidenceRequest(code))
} }
@@ -244,7 +244,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidenceUsers(token: String, residenceId: Int): ApiResult<ResidenceUsersResponse> { suspend fun getResidenceUsers(token: String, residenceId: Int): ApiResult<ResidenceUsersResponse> {
return try { return try {
val response = client.get("$baseUrl/residences/$residenceId/users/") { val response = client.get("$baseUrl/residences/$residenceId/users/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -261,7 +261,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun removeUser(token: String, residenceId: Int, userId: Int): ApiResult<RemoveUserResponse> { suspend fun removeUser(token: String, residenceId: Int, userId: Int): ApiResult<RemoveUserResponse> {
return try { return try {
val response = client.delete("$baseUrl/residences/$residenceId/users/$userId/") { val response = client.delete("$baseUrl/residences/$residenceId/users/$userId/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -279,7 +279,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun generateTasksReport(token: String, residenceId: Int, email: String? = null): ApiResult<GenerateReportResponse> { suspend fun generateTasksReport(token: String, residenceId: Int, email: String? = null): ApiResult<GenerateReportResponse> {
return try { return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-tasks-report/") { val response = client.post("$baseUrl/residences/$residenceId/generate-tasks-report/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
if (email != null) { if (email != null) {
setBody(mapOf("email" to email)) setBody(mapOf("email" to email))
@@ -12,7 +12,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getSubscriptionStatus(token: String): ApiResult<SubscriptionStatus> { suspend fun getSubscriptionStatus(token: String): ApiResult<SubscriptionStatus> {
return try { return try {
val response = client.get("$baseUrl/subscription/status/") { val response = client.get("$baseUrl/subscription/status/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -29,7 +29,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
return try { return try {
val response = client.get("$baseUrl/subscription/upgrade-triggers/") { val response = client.get("$baseUrl/subscription/upgrade-triggers/") {
// Token is optional - endpoint is public // Token is optional - endpoint is public
token?.let { header("Authorization", "Token $it") } token?.let { header(SESSION_TOKEN_HEADER, it) }
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -45,7 +45,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getFeatureBenefits(token: String): ApiResult<List<FeatureBenefit>> { suspend fun getFeatureBenefits(token: String): ApiResult<List<FeatureBenefit>> {
return try { return try {
val response = client.get("$baseUrl/subscription/features/") { val response = client.get("$baseUrl/subscription/features/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -61,7 +61,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getActivePromotions(token: String): ApiResult<List<Promotion>> { suspend fun getActivePromotions(token: String): ApiResult<List<Promotion>> {
return try { return try {
val response = client.get("$baseUrl/subscription/promotions/") { val response = client.get("$baseUrl/subscription/promotions/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -85,7 +85,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<VerificationResponse> { ): ApiResult<VerificationResponse> {
return try { return try {
val response = client.post("$baseUrl/subscription/purchase/") { val response = client.post("$baseUrl/subscription/purchase/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(mapOf( setBody(mapOf(
"platform" to "ios", "platform" to "ios",
@@ -115,7 +115,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<VerificationResponse> { ): ApiResult<VerificationResponse> {
return try { return try {
val response = client.post("$baseUrl/subscription/purchase/") { val response = client.post("$baseUrl/subscription/purchase/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(mapOf( setBody(mapOf(
"platform" to "android", "platform" to "android",
@@ -154,7 +154,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
} }
val response = client.post("$baseUrl/subscription/restore/") { val response = client.post("$baseUrl/subscription/restore/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(body) setBody(body)
} }
@@ -15,7 +15,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<TaskColumnsResponse> { ): ApiResult<TaskColumnsResponse> {
return try { return try {
val response = client.get("$baseUrl/tasks/") { val response = client.get("$baseUrl/tasks/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
days?.let { parameter("days", it) } days?.let { parameter("days", it) }
} }
@@ -33,7 +33,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTask(token: String, id: Int): ApiResult<TaskResponse> { suspend fun getTask(token: String, id: Int): ApiResult<TaskResponse> {
return try { return try {
val response = client.get("$baseUrl/tasks/$id/") { val response = client.get("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -50,7 +50,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> { suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try { return try {
val response = client.post("$baseUrl/tasks/") { val response = client.post("$baseUrl/tasks/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -75,7 +75,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> { suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
return try { return try {
val response = client.post("$baseUrl/tasks/bulk/") { val response = client.post("$baseUrl/tasks/bulk/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -94,7 +94,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> { suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try { return try {
val response = client.put("$baseUrl/tasks/$id/") { val response = client.put("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -113,7 +113,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteTask(token: String, id: Int): ApiResult<WithSummaryResponse<String>> { suspend fun deleteTask(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
return try { return try {
val response = client.delete("$baseUrl/tasks/$id/") { val response = client.delete("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -134,7 +134,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<TaskColumnsResponse> { ): ApiResult<TaskColumnsResponse> {
return try { return try {
val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") { val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
days?.let { parameter("days", it) } days?.let { parameter("days", it) }
} }
@@ -157,7 +157,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun patchTask(token: String, id: Int, request: TaskPatchRequest): ApiResult<WithSummaryResponse<TaskResponse>> { suspend fun patchTask(token: String, id: Int, request: TaskPatchRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try { return try {
val response = client.patch("$baseUrl/tasks/$id/") { val response = client.patch("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -206,7 +206,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
private suspend fun postTaskAction(token: String, id: Int, action: String): ApiResult<WithSummaryResponse<TaskResponse>> { private suspend fun postTaskAction(token: String, id: Int, action: String): ApiResult<WithSummaryResponse<TaskResponse>> {
return try { return try {
val response = client.post("$baseUrl/tasks/$id/$action/") { val response = client.post("$baseUrl/tasks/$id/$action/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
when (response.status) { when (response.status) {
@@ -233,7 +233,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskCompletions(token: String, taskId: Int): ApiResult<List<TaskCompletionResponse>> { suspend fun getTaskCompletions(token: String, taskId: Int): ApiResult<List<TaskCompletionResponse>> {
return try { return try {
val response = client.get("$baseUrl/tasks/$taskId/completions/") { val response = client.get("$baseUrl/tasks/$taskId/completions/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -13,7 +13,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getCompletions(token: String): ApiResult<List<TaskCompletionResponse>> { suspend fun getCompletions(token: String): ApiResult<List<TaskCompletionResponse>> {
return try { return try {
val response = client.get("$baseUrl/task-completions/") { val response = client.get("$baseUrl/task-completions/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -29,7 +29,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getCompletion(token: String, id: Int): ApiResult<TaskCompletionResponse> { suspend fun getCompletion(token: String, id: Int): ApiResult<TaskCompletionResponse> {
return try { return try {
val response = client.get("$baseUrl/task-completions/$id/") { val response = client.get("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -45,7 +45,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createCompletion(token: String, request: TaskCompletionCreateRequest): ApiResult<WithSummaryResponse<TaskCompletionResponse>> { suspend fun createCompletion(token: String, request: TaskCompletionCreateRequest): ApiResult<WithSummaryResponse<TaskCompletionResponse>> {
return try { return try {
val response = client.post("$baseUrl/task-completions/") { val response = client.post("$baseUrl/task-completions/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -63,7 +63,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateCompletion(token: String, id: Int, request: TaskCompletionCreateRequest): ApiResult<TaskCompletionResponse> { suspend fun updateCompletion(token: String, id: Int, request: TaskCompletionCreateRequest): ApiResult<TaskCompletionResponse> {
return try { return try {
val response = client.put("$baseUrl/task-completions/$id/") { val response = client.put("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -81,7 +81,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteCompletion(token: String, id: Int): ApiResult<WithSummaryResponse<String>> { suspend fun deleteCompletion(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
return try { return try {
val response = client.delete("$baseUrl/task-completions/$id/") { val response = client.delete("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -92,7 +92,7 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<TaskSuggestionsResponse> { suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<TaskSuggestionsResponse> {
return try { return try {
val response = client.get("$baseUrl/tasks/suggestions/") { val response = client.get("$baseUrl/tasks/suggestions/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
parameter("residence_id", residenceId) parameter("residence_id", residenceId)
} }
@@ -36,7 +36,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<PresignUploadResponse> { ): ApiResult<PresignUploadResponse> {
return try { return try {
val response = client.post("$baseUrl/uploads/presign/") { val response = client.post("$baseUrl/uploads/presign/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(PresignUploadRequest(category, contentType, contentLength)) setBody(PresignUploadRequest(category, contentType, contentLength))
} }
@@ -17,6 +17,7 @@ import coil3.request.ImageRequest
import coil3.network.NetworkHeaders import coil3.network.NetworkHeaders
import coil3.network.httpHeaders import coil3.network.httpHeaders
import com.tt.honeyDue.network.ApiClient import com.tt.honeyDue.network.ApiClient
import com.tt.honeyDue.network.SESSION_TOKEN_HEADER
import com.tt.honeyDue.storage.TokenStorage import com.tt.honeyDue.storage.TokenStorage
/** /**
@@ -57,9 +58,11 @@ fun AuthenticatedImage(
.data(fullUrl) .data(fullUrl)
.apply { .apply {
if (token != null) { if (token != null) {
// honeyDue media is gated on the Kratos session token,
// carried on the X-Session-Token header.
httpHeaders( httpHeaders(
NetworkHeaders.Builder() NetworkHeaders.Builder()
.set("Authorization", "Token $token") .set(SESSION_TOKEN_HEADER, token)
.build() .build()
) )
} }
@@ -413,26 +413,30 @@ class HttpClientPluginsTest {
client.close() client.close()
} }
// ==================== Token Refresh / 401 Handling Tests ==================== // ==================== Kratos Session / 401 Handling Tests ====================
@Test @Test
fun testTokenExpiredExceptionIsRefreshed() { fun testTokenExpiredExceptionStillValid() {
// refreshed = true means the Kratos session was re-validated and is
// still usable — the caller may retry the request.
val exception = TokenExpiredException(refreshed = true) val exception = TokenExpiredException(refreshed = true)
assertTrue(exception.refreshed) assertTrue(exception.refreshed)
assertTrue(exception.message!!.contains("refreshed")) assertTrue(exception.message!!.contains("still valid"))
} }
@Test @Test
fun testTokenExpiredExceptionNotRefreshed() { fun testTokenExpiredExceptionSessionGone() {
// refreshed = false means the Kratos session is gone — the user must
// sign in again.
val exception = TokenExpiredException(refreshed = false) val exception = TokenExpiredException(refreshed = false)
assertTrue(!exception.refreshed) assertTrue(!exception.refreshed)
assertTrue(exception.message!!.contains("re-authenticate")) assertTrue(exception.message!!.contains("re-authenticate"))
} }
@Test @Test
fun test401WithNonExpiredTokenDoesNotTriggerRefresh() = runTest { fun test401WithoutValidatorReturnsResponse() = runTest {
// A 401 that does NOT contain "expired" or "token_expired" should NOT // Without the Kratos session validator installed, a 401 simply
// throw TokenExpiredException — it should just return the 401 response. // surfaces as the 401 response — it does not throw.
var requestCount = 0 var requestCount = 0
val client = HttpClient(MockEngine) { val client = HttpClient(MockEngine) {
engine { engine {
+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])
}
}
@@ -153,9 +153,11 @@ private class AuthenticatedImageLoader: ObservableObject {
return return
} }
// Create request with auth header // Create request with the Kratos session-token header.
// Identity is owned by Ory Kratos; the honeyDue API authenticates
// requests via the session token on X-Session-Token.
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") request.setValue(token, forHTTPHeaderField: "X-Session-Token")
request.timeoutInterval = 15 request.timeoutInterval = 15
request.cachePolicy = .returnCacheDataElseLoad request.cachePolicy = .returnCacheDataElseLoad
@@ -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,
@@ -167,9 +167,10 @@ struct DocumentDetailView: View {
return return
} }
// Create authenticated request // Create authenticated request the honeyDue API gates
// media on the Kratos session token (X-Session-Token).
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") request.setValue(token, forHTTPHeaderField: "X-Session-Token")
// Download the file // Download the file
let (tempURL, response) = try await URLSession.shared.download(for: request) let (tempURL, response) = try await URLSession.shared.download(for: request)
@@ -170,7 +170,8 @@ final class PresignedUploader {
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.httpMethod = "POST" req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization") // honeyDue API auth: Kratos session token on X-Session-Token.
req.setValue(authToken, forHTTPHeaderField: "X-Session-Token")
req.httpBody = try JSONEncoder().encode(PresignBody( req.httpBody = try JSONEncoder().encode(PresignBody(
category: category.rawValue, category: category.rawValue,
content_type: contentType, content_type: contentType,
+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 }
}
} }
+6 -2
View File
@@ -120,7 +120,6 @@ struct CompleteTaskView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding(.leading, 12) .padding(.leading, 12)
.keyboardDismissToolbar()
.accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField) .accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField)
} label: { } label: {
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle") Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
@@ -142,7 +141,6 @@ struct CompleteTaskView: View {
TextEditor(text: $notes) TextEditor(text: $notes)
.frame(minHeight: 100) .frame(minHeight: 100)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.keyboardDismissToolbar()
.accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField) .accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField)
} }
} footer: { } footer: {
@@ -289,6 +287,12 @@ struct CompleteTaskView: View {
.background(WarmGradientBackground()) .background(WarmGradientBackground())
.navigationTitle(L10n.Tasks.completeTask) .navigationTitle(L10n.Tasks.completeTask)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
// ONE keyboard "Done" toolbar at the form root per-field
// `.keyboardDismissToolbar()` modifiers each install a
// separate `ToolbarItemGroup(placement: .keyboard)`, and
// SwiftUI stacks them on the responder chain so any focused
// field renders multiple Done buttons side-by-side (issue #5).
.keyboardDismissToolbar()
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button(L10n.Common.cancel) { Button(L10n.Common.cancel) {