Files
calendula/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt
Jean-Luc Makiola b537e143f8 feat(nav): default view setting + widget-aware back stack
Replace CalendarHost's single global view slot with a top-level view
back stack rooted at a user-configurable default view. A lateral move
(pill/drawer/widget) replaces the non-home top; a date tap drills the
day view on top; a base-level BackHandler pops one level until only the
home view remains, then the system exits.

Widgets now carry their source view (EXTRA_SOURCE_VIEW) so a launch
roots the stack in that widget's view: backing out of a day/event opened
from the agenda widget returns to Agenda, and from the month widget to
Month, instead of always landing on Week. Reminder taps keep the
separate detail-key channel and leave the base view untouched.

Add a Default view setting (Settings -> Appearance) backing the stack's
home view; defaults to Week so existing users see no change.

Resolves the two Codeberg reports from @devinside:
- #1 [FR] Option to set default view
- #2 [Bug] Widget UX improvement

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:07:00 +02:00

257 lines
12 KiB
Kotlin

package de.jeanlucmakiola.calendula
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import kotlinx.datetime.LocalDate
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
// The occurrence a reminder notification was tapped for (eventId, begin,
// end — the detail screen's key shape). singleTop + onNewIntent route a
// tap into the running activity; CalendarHost consumes and clears it.
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
// A navigation a home-screen widget asked for (open a date / start a
// create). Consumed once by CalendarHost, same pattern as the detail key.
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
// by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null)
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the calendar on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
private var pendingCrashReport by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// If the app keeps crashing as it starts, the main UI can't be trusted
// to come up — route to the standalone report screen instead of
// re-entering the crashing graph.
if (CrashReporter.isCrashLoop(this)) {
startActivity(
Intent(this, CrashReportActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
)
finish()
return
}
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once.
val settingsViewModel: SettingsViewModel = hiltViewModel()
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
CalendulaTheme(
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
)
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this@MainActivity, report)
CrashReporter.clearReport(this@MainActivity)
pendingCrashReport = null
},
onDismiss = {
// Keep the report (Settings can still reach it); just
// stop it popping on every launch.
CrashReporter.dismissPrompt(this@MainActivity)
pendingCrashReport = null
},
)
}
}
}
}
override fun onResume() {
super.onResume()
// Reaching a running UI means startup succeeded; reset the loop trail.
CrashReporter.markHealthy(this)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
}
/**
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
* app's own `calendula://` deep-links never match.
*/
private fun Intent.importUriOrNull(): Uri? {
val uri = when (action) {
Intent.ACTION_VIEW -> data
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
else -> null
} ?: return null
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
val source = sourceViewOrNull()
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
return when {
// Launcher long-press "New event" shortcut. Static shortcut intents
// can't carry typed extras, so the action alone signals create-on-today.
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
getBooleanExtra(EXTRA_CREATE, false) ->
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
// A widget event tap carries both the occurrence and its source view;
// reminders (no source) fall through to [detailKeyOrNull] instead.
source != null && eventId != -1L -> WidgetNavRequest.OpenEvent(
eventId = eventId,
beginMillis = getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
endMillis = getLongExtra(EXTRA_END_MILLIS, 0L),
source = source,
)
source != null && getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!, source)
else -> null
}
}
/** The view the launching widget represents, if any (see [EXTRA_SOURCE_VIEW]). */
private fun Intent.sourceViewOrNull(): CalendarView? =
getStringExtra(EXTRA_SOURCE_VIEW)
?.let { name -> CalendarView.entries.firstOrNull { it.name == name } }
private fun Intent.detailKeyOrNull(): LongArray? {
// A widget event tap (source present) is routed through [navRequestOrNull]
// so it can also set the base view; only sourceless reminder taps land here.
if (sourceViewOrNull() != null) return null
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
if (eventId == -1L) return null
return longArrayOf(
eventId,
getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
getLongExtra(EXTRA_END_MILLIS, 0L),
)
}
companion object {
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
// The [CalendarView] (by name) of the widget a launch came from. Roots the
// in-app back stack in that view; absent for non-widget launches (reminders).
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
// Fired by the launcher long-press "New event" shortcut (res/xml/
// shortcuts.xml hardcodes this string — keep the two in sync).
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
/**
* Intent opening the detail screen of one occurrence (reminder
* notifications). The synthetic data URI keys the intent so
* PendingIntents for different occurrences never collapse into one.
*/
fun eventDetailIntent(
context: Context,
eventId: Long,
beginMillis: Long,
endMillis: Long,
): Intent = Intent(context, MainActivity::class.java).apply {
data = "calendula://event/$eventId/$beginMillis".toUri()
putExtra(EXTRA_EVENT_ID, eventId)
putExtra(EXTRA_BEGIN_MILLIS, beginMillis)
putExtra(EXTRA_END_MILLIS, endMillis)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Open the day view anchored on [date], drilled in over the launching
* widget's [source] view (home-screen widgets). Backing out of the day
* returns to [source], then to the default home view.
*/
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://date/$date".toUri()
putExtra(EXTRA_DATE_ISO, date.toString())
putExtra(EXTRA_SOURCE_VIEW, source.name)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Open one occurrence's detail from a widget event tap, rooting the back
* stack in the widget's [source] view. Same occurrence-key shape as
* [eventDetailIntent]; the [source] extra is what distinguishes a widget
* tap (sets the base view) from a reminder tap (leaves it untouched).
*/
fun openEventIntent(
context: Context,
eventId: Long,
beginMillis: Long,
endMillis: Long,
source: CalendarView,
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
putExtra(EXTRA_SOURCE_VIEW, source.name)
}
/** Open the create-event form prefilled for [date] (home-screen widgets). */
fun openCreateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://create/$date".toUri()
putExtra(EXTRA_CREATE, true)
putExtra(EXTRA_DATE_ISO, date.toString())
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}