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>
257 lines
12 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|