summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-29 07:09:43 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-29 07:09:43 +0000
commitdacb710fc4ea63baa05cc47016def3849b9db5fa (patch)
treecc86831a3a9c950ec0909975ce77bdb485cf5589
parent3b632feb80a1d6c95836981aad602e2dff4f6b63 (diff)
feat: add standalone task detail page for Android widget deep-links
Adds GET /task?id=xxx&source=xxx route that renders a full mobile-friendly task detail page (session-protected). Widget task rows now open this page when tapped. HandleUpdateTask redirects back to the page after a non-HTMX save. Android: threads serverUrl through composable chain to TaskRow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt32
-rw-r--r--cmd/dashboard/main.go1
-rw-r--r--internal/handlers/handlers.go58
-rw-r--r--web/templates/task-detail-page.html30
4 files changed, 109 insertions, 12 deletions
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
index f8d8ae1..dcb73d5 100644
--- a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
@@ -46,9 +46,10 @@ class DootWidget : GlanceAppWidget() {
val items = parseItems(prefs)
val now = prefs[Keys.NOW]?.let { runCatching { Instant.parse(it) }.getOrNull() }
?: Instant.now()
+ val serverUrl = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: ""
provideContent {
- WidgetRoot(items, now)
+ WidgetRoot(items, now, serverUrl)
}
}
@@ -59,7 +60,7 @@ class DootWidget : GlanceAppWidget() {
}
@Composable
-fun WidgetRoot(items: List<WidgetItem>, now: Instant) {
+fun WidgetRoot(items: List<WidgetItem>, now: Instant, serverUrl: String = "") {
val zone = ZoneId.systemDefault()
val nowZoned: ZonedDateTime = now.atZone(zone)
val allScheduled = items.filter { it.start != null }.sortedBy { Instant.parse(it.start!!) }
@@ -89,7 +90,7 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant) {
}
for (hour in gridStart..gridEnd) {
- HourRow(hour, nowZoned, scheduledEvents, fragments, zone)
+ HourRow(hour, nowZoned, scheduledEvents, fragments, zone, serverUrl)
}
}
}
@@ -100,7 +101,8 @@ fun HourRow(
nowZoned: ZonedDateTime,
scheduled: List<WidgetItem>,
fragments: List<TaskFragment>,
- @Suppress("UNUSED_PARAMETER") zone: ZoneId
+ @Suppress("UNUSED_PARAMETER") zone: ZoneId,
+ serverUrl: String = ""
) {
val hourStart = nowZoned.withHour(hour).withMinute(0).withSecond(0).withNano(0).toInstant()
val hourEnd = hourStart.plus(1, ChronoUnit.HOURS)
@@ -147,7 +149,7 @@ fun HourRow(
}
fragsHere.forEach { frag ->
- TaskFragmentBlock(frag)
+ TaskFragmentBlock(frag, serverUrl)
}
}
}
@@ -183,7 +185,7 @@ fun EventBlock(event: WidgetItem, isPast: Boolean) {
}
@Composable
-fun TaskFragmentBlock(fragment: TaskFragment) {
+fun TaskFragmentBlock(fragment: TaskFragment, serverUrl: String = "") {
Column(
modifier = GlanceModifier
.fillMaxWidth()
@@ -199,17 +201,25 @@ fun TaskFragmentBlock(fragment: TaskFragment) {
modifier = GlanceModifier.padding(start = 8.dp, top = 3.dp, bottom = 1.dp)
)
fragment.slots.forEach { slot ->
- TaskRow(slot.task)
+ TaskRow(slot.task, serverUrl)
}
}
}
@Composable
-fun TaskRow(task: WidgetItem) {
+fun TaskRow(task: WidgetItem, serverUrl: String = "") {
+ val tapAction = if (serverUrl.isNotEmpty()) {
+ val url = "$serverUrl/task?id=${task.id}&source=${task.source}"
+ actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ } else {
+ null
+ }
+ val rowModifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 5.dp)
+ .let { if (tapAction != null) it.clickable(tapAction) else it }
Row(
- modifier = GlanceModifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp, vertical = 5.dp),
+ modifier = rowModifier,
verticalAlignment = Alignment.CenterVertically
) {
if (task.completable) {
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 133b684..92a149b 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -324,6 +324,7 @@ func main() {
// Task detail/edit
r.Get("/tasks/detail", h.HandleGetTaskDetail)
+ r.Get("/task", h.HandleTaskDetailPage)
r.Post("/tasks/update", h.HandleUpdateTask)
// Shopping quick-add
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 19d26be..0903cf3 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -814,6 +814,58 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) {
}{title, id, source, description})
}
+// HandleTaskDetailPage renders a standalone full-page task detail view (used by Android widget deep-links).
+func (h *Handler) HandleTaskDetailPage(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("id")
+ source := r.URL.Query().Get("source")
+
+ if id == "" || source == "" {
+ http.Error(w, "Missing id or source", http.StatusBadRequest)
+ return
+ }
+
+ var title, description string
+ switch source {
+ case "todoist":
+ if tasks, err := h.store.GetTasks(); err == nil {
+ for _, t := range tasks {
+ if t.ID == id {
+ title, description = t.Content, t.Description
+ break
+ }
+ }
+ }
+ case "trello":
+ if boards, err := h.store.GetBoards(); err == nil {
+ for _, b := range boards {
+ for _, c := range b.Cards {
+ if c.ID == id {
+ title = c.Name
+ break
+ }
+ }
+ }
+ }
+ }
+
+ if title == "" {
+ title = "Task"
+ }
+
+ data := struct {
+ Title string
+ ID string
+ Source string
+ Description string
+ CSRFToken string
+ Saved bool
+ }{title, id, source, description, auth.GetCSRFTokenFromContext(r.Context()), r.URL.Query().Get("saved") == "1"}
+
+ if err := h.renderer.Render(w, "task-detail-page.html", data); err != nil {
+ http.Error(w, "Failed to render template", http.StatusInternalServerError)
+ }
+}
+
// HandleUpdateTask updates a task description
func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
@@ -846,7 +898,11 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) {
return
}
- w.WriteHeader(http.StatusOK)
+ if r.Header.Get("HX-Request") != "" {
+ w.WriteHeader(http.StatusOK)
+ } else {
+ http.Redirect(w, r, "/task?id="+id+"&source="+source+"&saved=1", http.StatusSeeOther)
+ }
}
// HandleTabTasks renders the unified Tasks tab (Todoist + Trello cards with due dates + Bugs + Google Tasks)
diff --git a/web/templates/task-detail-page.html b/web/templates/task-detail-page.html
new file mode 100644
index 0000000..9bbf008
--- /dev/null
+++ b/web/templates/task-detail-page.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{.Title}}</title>
+ <link rel="stylesheet" href="/static/css/output.css">
+</head>
+<body class="min-h-screen bg-gray-900 text-slate-200 p-4">
+ <div class="max-w-lg mx-auto pt-6">
+ <a href="/" class="text-sm text-slate-400 hover:text-slate-200 mb-4 inline-block">&larr; Dashboard</a>
+ {{if .Saved}}<p class="text-green-400 text-sm mb-3">Saved.</p>{{end}}
+ <div class="bg-gray-800 rounded-xl p-5 mt-2">
+ <h1 class="font-semibold text-lg mb-4">{{.Title}}</h1>
+ {{if eq .Source "todoist"}}
+ <form method="post" action="/tasks/update">
+ <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
+ <input type="hidden" name="id" value="{{.ID}}">
+ <input type="hidden" name="source" value="{{.Source}}">
+ <label class="block text-sm font-medium text-slate-400 mb-1">Description</label>
+ <textarea name="description" class="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-sm mb-4 h-32 text-slate-200">{{.Description}}</textarea>
+ <button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium">Save</button>
+ </form>
+ {{else}}
+ <p class="text-sm text-slate-400">Source: {{.Source}}</p>
+ {{end}}
+ </div>
+ </div>
+</body>
+</html>