add settings screen, clock transparent bg toggle, event tap-to-open, readme

This commit is contained in:
Damocles 2026-04-22 22:26:34 +02:00
parent ca9c45cba1
commit a1a80905a1
8 changed files with 413 additions and 8 deletions

50
README.md Normal file
View file

@ -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.

View file

@ -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)

View file

@ -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<space.darkest.nova.android.data.AgendaDay>())
private var agenda by mutableStateOf(emptyList<AgendaDay>())
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() })
}
}
}
}
}

View file

@ -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) }
}
}

View file

@ -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<AgendaDay>) {
fun AgendaScreen(agenda: List<AgendaDay>, onSettingsClick: () -> Unit = {}) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val context = LocalContext.current
@ -55,6 +57,11 @@ fun AgendaScreen(agenda: List<AgendaDay>) {
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),

View file

@ -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)
}
)
}
}

View file

@ -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,

View file

@ -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" }