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