Files
agendula/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt
T
Jean-Luc Makiolaandmakiolaj dd08c035df
Renovate / renovate (push) Successful in 54s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 21m44s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m32s
Release 1.1.0 (#51)
Agendula 1.1.0. Merging this triggers the release pipeline (F-Droid, Codeberg, Play) and mints the v1.1.0 tag.

- Tags, synced as CATEGORIES (#35)
- Saved filters, also usable in the Tasks widget (#47)
- Offline flavor without sync or network, published to Codeberg only (#39)
- New Today, Up next, Lists and Week widgets; widgets can show several lists
- Synced lists can be renamed and deleted
- All-day tasks no longer land a day off west of UTC in smart lists and filters

versionName 1.1.0 / versionCode 1010099. CHANGELOG has the 1.1.0 section, and What's New is written for en-US, en-GB, de-DE, pt-BR, plus new fr-FR and ar.

After the tag lands, fdroiddata!49998 needs a 1.1.0 build entry with gradle: full.

Closes #35, closes #39, closes #47

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/51
2026-10-10 15:06:07 +02:00

242 lines
12 KiB
Kotlin

package de.jeanlucmakiola.agendula
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.format.DateFormat
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.di.LaunchHook
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
* (replaceable) functional scaffold over the real data layer. Notification taps
* arrive as a [NavRequest] (see [navRequestOf]).
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the app 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)
// A notification tap's destination, handed to the nav host and cleared once taken.
private var navRequest by mutableStateOf<NavRequest?>(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))
finish()
return
}
enableEdgeToEdge()
AppShortcuts.publish(this)
// Only on a fresh launch: after a rotation or process restore the intent
// is the same old one and the back stack already holds its destination.
val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
if (savedInstanceState == null && !fromHistory) navRequest = navRequestOf(intent)
// Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
// Each hook on its own, so one waiting on the network holds up no other.
if (savedInstanceState == null) {
launchHooks.forEach { hook -> lifecycleScope.launch { runCatching { hook.onLaunch(intent) } } }
}
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (ui.settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
val context = LocalContext.current
val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek()
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalFirstDayOfWeek provides firstDayOfWeek,
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
navRequest = navRequest,
onNavRequestConsumed = { navRequest = null },
)
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this@MainActivity, report)
CrashReporter.clearReport(this@MainActivity)
pendingCrashReport = null
},
onDismiss = {
CrashReporter.dismissPrompt(this@MainActivity)
pendingCrashReport = null
},
)
}
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
navRequestOf(intent)?.let { navRequest = it }
}
override fun onResume() {
super.onResume()
// A successful start breaks any loop; reset the timing trail so a later
// ordinary crash isn't mistaken for a loop.
CrashReporter.markHealthy(this)
}
companion object {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
/** The account a sign-in notification is about, on the intent it opens. */
const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
private const val ACTION_OPEN_FILTER = "de.jeanlucmakiola.agendula.action.OPEN_FILTER"
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
private const val SHARED_TITLE_LIMIT = 500
private const val NO_OCCURRENCE = -1L
/**
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
* picks the occurrence of a recurring task.
*/
fun taskIntent(context: Context, taskId: Long, occurrenceStart: Long? = null): Intent =
Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TASK_ID, taskId)
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/** Opens Settings → Accounts, where the sync notice's detail lives. */
fun openIntent(context: Context): Intent =
Intent(context, MainActivity::class.java)
.putExtra(EXTRA_OPEN_ACCOUNTS, true)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/**
* The launcher shortcut's, Quick Settings tile's and a widget's "New
* task". [listId] preset the list when it names exactly one; the data
* URI keeps each widget's PendingIntent apart from the others.
*/
fun newTaskIntent(context: Context, listId: Long? = null): Intent =
Intent(ACTION_NEW_TASK, listId?.let { "agendula://newtask/$it".toUri() }, context, MainActivity::class.java)
.putExtra(EXTRA_PRESET_LIST_ID, listId ?: -1L)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
fun todayIntent(context: Context): Intent =
Intent(ACTION_TODAY, null, context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** Opens one smart list; the data URI keeps each widget's PendingIntent apart. */
fun smartListIntent(context: Context, list: SmartList): Intent =
Intent(ACTION_OPEN_SMART, "agendula://smart/${list.name}".toUri(), context, MainActivity::class.java)
.putExtra(EXTRA_SMART_LIST, list.name)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** Opens one real list. */
fun listIntent(context: Context, listId: Long): Intent =
Intent(ACTION_OPEN_LIST, "agendula://list/$listId".toUri(), context, MainActivity::class.java)
.putExtra(EXTRA_LIST_ID, listId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** Opens one saved filter. */
fun filterIntent(context: Context, filterId: Long): Intent =
Intent(ACTION_OPEN_FILTER, "agendula://filter/$filterId".toUri(), context, MainActivity::class.java)
.putExtra(EXTRA_FILTER_ID, filterId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
internal fun navRequestOf(intent: Intent?): NavRequest? {
if (intent == null) return null
if (BuildConfig.SYNC_ENABLED) {
intent.getLongExtra(EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenAccount(it) }
if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
}
when (intent.action) {
ACTION_NEW_TASK -> return NavRequest.NewTask(
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
)
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
?.let { return NavRequest.OpenSmart(it) }
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenList(it) }
ACTION_OPEN_FILTER -> intent.getLongExtra(EXTRA_FILTER_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenFilter(it) }
}
importIntentUri(intent)?.let { return NavRequest.Import(it) }
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
?: intent.getStringExtra(Intent.EXTRA_TEXT)
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT))
}
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
.takeIf { it != NO_OCCURRENCE }
return NavRequest.OpenTask(taskId, occurrence)
}
}
}