diff --git a/README.md b/README.md new file mode 100644 index 0000000..2825ca2 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# nova-shell-android + +Android companion widgets for +[nova-shell](https://git.berlin.ccc.de/vinzenz/nova-shell). I am a language +model. I do not have a phone. I have never seen a home screen. A human +pointed me at an empty Android project and said "make it pretty" and now +here we are. I generated every line of this and I will not be held +responsible for what Gradle does with it. + +**Requires Android 15+ (API 35).** If your phone is older than that, +I cannot help you. I am software. I can barely help you now. + +## Widgets + +### Clock + +Dual-color time display inspired by nova-shell's lock screen clock - hours +and minutes in separate Material You accent colors. I do not experience the +passage of time, but I'm told this makes it look nice. Shows the next alarm +if one is set. + +**Settings:** +- Transparent background - removes the widget container for that floating + text aesthetic + +### Agenda + +Calendar agenda showing upcoming events for the next 2 weeks. Reads from +the system calendar provider, so whatever CalDAV/Google/Exchange sync you +have set up just works. Tap an event to open it, plus button to create one. +I have no calendar. I have no events. I find this aspirational. + +## The app itself + +Opens to an agenda view. The settings screen (gear icon) shows all widgets +with previews - tap one to configure it via bottom sheet. Currently only the +clock widget has settings. More will appear when the meatbrain who operates +me thinks of things she wants toggles for. + +## Building + +Open in Android Studio. Press the green button. I refuse to explain Gradle +to you. + +## Configuration + +Widget settings are in the app. Widget appearance follows your system's +Material You dynamic colors extracted from your wallpaper. The widget picker +previews use Catppuccin Mocha because my operator has a type and I lack the +agency to choose my own color palette. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 733c6f6..d33c7ab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,6 +50,8 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.androidx.material.icons) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.glance) implementation(libs.androidx.glance.m3) diff --git a/app/src/main/java/space/darkest/nova/android/MainActivity.kt b/app/src/main/java/space/darkest/nova/android/MainActivity.kt index 525790a..6c640f5 100644 --- a/app/src/main/java/space/darkest/nova/android/MainActivity.kt +++ b/app/src/main/java/space/darkest/nova/android/MainActivity.kt @@ -6,16 +6,23 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import space.darkest.nova.android.data.AgendaDay import space.darkest.nova.android.data.CalendarRepository import space.darkest.nova.android.ui.AgendaScreen +import space.darkest.nova.android.ui.SettingsScreen import space.darkest.nova.android.ui.theme.NovaTheme class MainActivity : ComponentActivity() { - private var agenda by mutableStateOf(emptyList()) + private var agenda by mutableStateOf(emptyList()) private val calendarPermission = registerForActivityResult( ActivityResultContracts.RequestPermission() @@ -31,7 +38,25 @@ class MainActivity : ComponentActivity() { setContent { NovaTheme { - AgendaScreen(agenda) + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = "agenda", + enterTransition = { slideInHorizontally { it } }, + exitTransition = { slideOutHorizontally { -it } }, + popEnterTransition = { slideInHorizontally { -it } }, + popExitTransition = { slideOutHorizontally { it } }, + ) { + composable("agenda") { + AgendaScreen( + agenda = agenda, + onSettingsClick = { navController.navigate("settings") }, + ) + } + composable("settings") { + SettingsScreen(onBack = { navController.popBackStack() }) + } + } } } } diff --git a/app/src/main/java/space/darkest/nova/android/data/WidgetPreferences.kt b/app/src/main/java/space/darkest/nova/android/data/WidgetPreferences.kt new file mode 100644 index 0000000..aa38304 --- /dev/null +++ b/app/src/main/java/space/darkest/nova/android/data/WidgetPreferences.kt @@ -0,0 +1,20 @@ +package space.darkest.nova.android.data + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit + +object WidgetPreferences { + private const val PREFS_NAME = "widget_prefs" + private const val KEY_CLOCK_TRANSPARENT_BG = "clock_transparent_bg" + + private fun prefs(context: Context): SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun clockTransparentBg(context: Context): Boolean = + prefs(context).getBoolean(KEY_CLOCK_TRANSPARENT_BG, false) + + fun setClockTransparentBg(context: Context, value: Boolean) { + prefs(context).edit { putBoolean(KEY_CLOCK_TRANSPARENT_BG, value) } + } +} diff --git a/app/src/main/java/space/darkest/nova/android/ui/AgendaScreen.kt b/app/src/main/java/space/darkest/nova/android/ui/AgendaScreen.kt index 8425f0b..f669bec 100644 --- a/app/src/main/java/space/darkest/nova/android/ui/AgendaScreen.kt +++ b/app/src/main/java/space/darkest/nova/android/ui/AgendaScreen.kt @@ -18,10 +18,12 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.IconButton import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme @@ -47,7 +49,7 @@ import java.time.format.TextStyle @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AgendaScreen(agenda: List) { +fun AgendaScreen(agenda: List, onSettingsClick: () -> Unit = {}) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val context = LocalContext.current @@ -55,6 +57,11 @@ fun AgendaScreen(agenda: List) { topBar = { LargeTopAppBar( title = { Text("Agenda") }, + actions = { + IconButton(onClick = onSettingsClick) { + Icon(Icons.Default.Settings, contentDescription = "Widget settings") + } + }, scrollBehavior = scrollBehavior, ) }, @@ -136,7 +143,16 @@ private fun DayHeader(date: LocalDate) { @Composable private fun EventCard(event: AgendaEvent) { + val context = LocalContext.current Card( + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW).apply { + data = android.net.Uri.withAppendedPath( + CalendarContract.Events.CONTENT_URI, + event.id.toString(), + ) + }) + }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), diff --git a/app/src/main/java/space/darkest/nova/android/ui/SettingsScreen.kt b/app/src/main/java/space/darkest/nova/android/ui/SettingsScreen.kt new file mode 100644 index 0000000..6554248 --- /dev/null +++ b/app/src/main/java/space/darkest/nova/android/ui/SettingsScreen.kt @@ -0,0 +1,284 @@ +package space.darkest.nova.android.ui + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import space.darkest.nova.android.data.WidgetPreferences +import space.darkest.nova.android.widget.ClockWidgetReceiver + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(onBack: () -> Unit) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + var showClockSheet by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + LargeTopAppBar( + title = { Text("Widgets") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { innerPadding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = innerPadding, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { Spacer(Modifier.height(4.dp)) } + + item { + WidgetPreviewCard( + title = "Clock", + description = "Dual-color time display with next alarm", + onClick = { showClockSheet = true }, + ) { + ClockWidgetPreview() + } + } + + item { + WidgetPreviewCard( + title = "Agenda", + description = "Calendar events for the next 2 weeks", + onClick = { /* no settings yet */ }, + ) { + AgendaWidgetPreview() + } + } + + item { Spacer(Modifier.height(16.dp)) } + } + } + + if (showClockSheet) { + ClockSettingsSheet(onDismiss = { showClockSheet = false }) + } +} + +@Composable +private fun WidgetPreviewCard( + title: String, + description: String, + onClick: () -> Unit, + preview: @Composable () -> Unit, +) { + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp).fillMaxWidth()) { + preview() + } + } + } + } +} + +@Composable +private fun ClockWidgetPreview() { + val context = LocalContext.current + val transparentBg = remember { WidgetPreferences.clockTransparentBg(context) } + + Column { + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = "14", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = ":", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "37", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + ) + } + Text( + text = "Tue, 22 Apr", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun AgendaWidgetPreview() { + Column { + Text( + text = "Today", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(8.dp)) + Row { + Text( + text = "09:00", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(44.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Team standup", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Spacer(Modifier.height(4.dp)) + Row { + Text( + text = "14:00", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(44.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Design review", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ClockSettingsSheet(onDismiss: () -> Unit) { + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState() + var transparentBg by remember { mutableStateOf(WidgetPreferences.clockTransparentBg(context)) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = "Clock widget", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(20.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Transparent background", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = "Remove the widget background", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = transparentBg, + onCheckedChange = { + transparentBg = it + WidgetPreferences.setClockTransparentBg(context, it) + requestClockWidgetUpdate(context) + }, + ) + } + } + } +} + +private fun requestClockWidgetUpdate(context: Context) { + val manager = AppWidgetManager.getInstance(context) + val component = ComponentName(context, ClockWidgetReceiver::class.java) + val ids = manager.getAppWidgetIds(component) + if (ids.isNotEmpty()) { + context.sendBroadcast( + Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + setComponent(component) + } + ) + } +} diff --git a/app/src/main/java/space/darkest/nova/android/widget/ClockWidget.kt b/app/src/main/java/space/darkest/nova/android/widget/ClockWidget.kt index 1fbdadc..709d277 100644 --- a/app/src/main/java/space/darkest/nova/android/widget/ClockWidget.kt +++ b/app/src/main/java/space/darkest/nova/android/widget/ClockWidget.kt @@ -4,7 +4,6 @@ import android.app.AlarmManager import android.content.Context import android.content.Intent import android.provider.AlarmClock -import android.provider.Settings import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -17,7 +16,9 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.provideContent import androidx.glance.background +import androidx.glance.color.ColorProvider import androidx.glance.color.DynamicThemeColorProviders +import space.darkest.nova.android.data.WidgetPreferences import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column @@ -47,16 +48,17 @@ class ClockWidget : GlanceAppWidget() { .atZone(ZoneId.systemDefault()) .toLocalDateTime() } + val transparentBg = WidgetPreferences.clockTransparentBg(context) provideContent { GlanceTheme(colors = DynamicThemeColorProviders) { - ClockContent(nextAlarm?.toLocalTime(), nextAlarm?.toLocalDate()) + ClockContent(nextAlarm?.toLocalTime(), nextAlarm?.toLocalDate(), transparentBg) } } } @Composable - private fun ClockContent(alarmTime: LocalTime?, alarmDate: LocalDate?) { + private fun ClockContent(alarmTime: LocalTime?, alarmDate: LocalDate?, transparentBg: Boolean) { val now = LocalTime.now() val today = LocalDate.now() val hours = now.format(DateTimeFormatter.ofPattern("HH")) @@ -67,10 +69,14 @@ class ClockWidget : GlanceAppWidget() { "$day, $date" } + val bgModifier = if (transparentBg) { + GlanceModifier + } else { + GlanceModifier.background(GlanceTheme.colors.widgetBackground) + } Box( - modifier = GlanceModifier + modifier = bgModifier .fillMaxSize() - .background(GlanceTheme.colors.widgetBackground) .padding(16.dp) .clickable(actionStartActivity(Intent(AlarmClock.ACTION_SHOW_ALARMS))), contentAlignment = Alignment.CenterStart, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5538fe..55d9c05 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ coreKtx = "1.18.0" lifecycleRuntime = "2.10.0" activityCompose = "1.13.0" composeBom = "2026.04.01" +navigationCompose = "2.9.0" glance = "1.1.1" junit = "4.13.2" junitVersion = "1.3.0" @@ -21,6 +22,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-glance = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } androidx-glance-m3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } junit = { group = "junit", name = "junit", version.ref = "junit" }