From a1ca48e0dc760393a59fe745736b6b0668fade17 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 6 Aug 2026 16:57:44 +0000 Subject: Widget: give Dashboard its own launcher icon, add toolbar nav DashboardActivity is now the app's single home-screen launcher entry (MAIN/LAUNCHER intent-filter), replacing SettingsActivity in that role. SettingsActivity keeps only APPWIDGET_CONFIGURE (still exported=true, since the launcher/home-screen process starts it directly during widget placement) and is now reached via a gear button in Dashboard's toolbar. DashboardActivity itself is rewritten from a bare setContentView(webView) to Compose Scaffold/TopAppBar wrapping the WebView via AndroidView, adding: - a back arrow (shown only when the WebView has history) instead of relying solely on the hardware back key - a settings gear action launching SettingsActivity - the page title tracked from the loaded page Also fixes a race in the original version: server URL was loaded via a lifecycleScope coroutine racing the WebView's own initialization order. Now it's plain Compose state (LaunchedEffect + AndroidView's update callback), so the WebView never loads before the URL is known. No native tab bar: web/templates/index.html's tabs are HTMX partials (hx-get targeting #tab-content), not separate pages, so loading "/" in the WebView already gets full in-app navigation for free. Verified on emulator-5556 (doot_test_api36): pm resolve-activity confirms DashboardActivity is the launcher default; launched it, confirmed no crash and topResumedActivity is Dashboard; configured a dummy server URL via Settings and confirmed the toolbar renders (title, gear icon) and the gear button correctly navigates to SettingsActivity (topResumedActivity becomes SettingsActivity) with no crash. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL --- android/app/src/main/AndroidManifest.xml | 21 +-- .../org/terst/doot/widget/ui/DashboardActivity.kt | 158 +++++++++++++++------ 2 files changed, 129 insertions(+), 50 deletions(-) (limited to 'android/app/src/main') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 148d841..db97b1b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,20 +33,25 @@ android:exported="false" android:windowSoftInputMode="adjustResize|stateVisible" /> - + - - - + android:exported="true" + android:label="Doot"> + + + + diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/DashboardActivity.kt b/android/app/src/main/java/org/terst/doot/widget/ui/DashboardActivity.kt index f5ae744..159b416 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/DashboardActivity.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/DashboardActivity.kt @@ -1,74 +1,148 @@ package org.terst.doot.widget.ui import android.content.Intent -import android.net.Uri import android.os.Bundle import android.view.KeyEvent import android.webkit.CookieManager import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.darkColorScheme +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.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import androidx.lifecycle.lifecycleScope import org.terst.doot.widget.data.Keys import org.terst.doot.widget.data.dataStore /** - * Wraps the existing web dashboard in a plain WebView -- the same session-cookie - * login flow a browser would use (see internal/auth/middleware.go's RequireAuth), - * no separate auth bridge needed since the widget's own bearer token is a - * completely different scheme. First cut per the 2026-08-06 feasibility check: - * one Activity, no deep-linking to specific tabs, no native-rendered chrome -- - * just prove the wrapped experience is worth building further before investing - * in anything past this. + * Wraps the existing web dashboard in a WebView -- the same session-cookie + * login flow a browser would use (see internal/auth/middleware.go's + * RequireAuth), no separate auth bridge needed since the widget's own bearer + * token is a completely different scheme. + * + * No native tab bar: the dashboard's tabs (Tasks/Planning/Meals/...) are + * HTMX partials (hx-get="/tabs/..." targeting #tab-content in + * web/templates/index.html), swapped into ONE page client-side, not separate + * full pages -- navigating a WebView directly to e.g. /tabs/timeline would + * show a bare unstyled fragment. Loading "/" already gets the real + * in-page nav for free; what a bare WebView is actually missing is + * browser-chrome-level navigation (no back arrow, no way back to Settings), + * which is what the top bar below adds. */ class DashboardActivity : ComponentActivity() { - private lateinit var webView: WebView + private var webViewRef: WebView? = null + private var serverHost: String? = null + @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - webView = WebView(this).apply { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.setSupportZoom(false) - webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { - val uri = url.toUri() - val sameHost = uri.host == null || uri.host == serverHost - if (sameHost) return false - // External links (e.g. a calendar event's source URL) open in the - // user's actual browser/app instead of getting stuck in this WebView. - startActivity(Intent(Intent.ACTION_VIEW, uri)) - return true - } - } - } - setContentView(webView) - CookieManager.getInstance().setAcceptCookie(true) - CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true) - lifecycleScope.launch { - val prefs = dataStore.data.first() - val url = prefs[Keys.SERVER_URL]?.trimEnd('/') - if (url.isNullOrBlank()) { - finish() - return@launch + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + var serverUrl by remember { mutableStateOf(null) } + var canGoBack by remember { mutableStateOf(false) } + var pageTitle by remember { mutableStateOf("Doot") } + val context = LocalContext.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(pageTitle) }, + navigationIcon = { + if (canGoBack) { + IconButton(onClick = { webViewRef?.goBack() }) { + // Plain text glyphs, not Compose Material Icons -- + // matches this codebase's existing convention (see + // WidgetRows.kt: every other icon here is a + // hand-authored R.drawable vector, zero Icons.* usage + // anywhere), rather than introducing that dependency + // for two buttons. + Text("←", style = MaterialTheme.typography.titleLarge) + } + } + }, + actions = { + IconButton(onClick = { + context.startActivity(Intent(context, SettingsActivity::class.java)) + }) { + Text("⚙", style = MaterialTheme.typography.titleLarge) + } + } + ) + } + ) { innerPadding -> + AndroidView( + modifier = Modifier.padding(innerPadding), + factory = { ctx -> + WebView(ctx).apply { + webViewRef = this + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.setSupportZoom(false) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + val uri = url.toUri() + val sameHost = uri.host == null || uri.host == serverHost + if (sameHost) return false + // External links (e.g. a calendar event's source URL) + // open in the user's actual browser instead of getting + // stuck in this WebView. + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + return true + } + + override fun onPageFinished(view: WebView, url: String?) { + canGoBack = view.canGoBack() + pageTitle = view.title?.takeIf { it.isNotBlank() } ?: "Doot" + } + } + } + }, + update = { webView -> + val url = serverUrl + if (url != null && webView.url == null) { + webView.loadUrl(url) + } + } + ) + } + + LaunchedEffect(Unit) { + val prefs = dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') + if (url.isNullOrBlank()) { + finish() + } else { + serverHost = url.toUri().host + serverUrl = url + } + } } - serverHost = url.toUri().host - webView.loadUrl(url) } } - private var serverHost: String? = null - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - if (keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack()) { - webView.goBack() + if (keyCode == KeyEvent.KEYCODE_BACK && webViewRef?.canGoBack() == true) { + webViewRef?.goBack() return true } return super.onKeyDown(keyCode, event) -- cgit v1.2.3