add material you calendar agenda widget with compose + glance
This commit is contained in:
parent
db4c6a7f63
commit
d54a7ef5eb
14 changed files with 733 additions and 54 deletions
|
|
@ -1,14 +1,12 @@
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.compose.compiler)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "space.darkest.nova.android"
|
namespace = "space.darkest.nova.android"
|
||||||
compileSdk {
|
compileSdk = 36
|
||||||
version = release(36) {
|
|
||||||
minorApiLevel = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "space.darkest.nova.android"
|
applicationId = "space.darkest.nova.android"
|
||||||
|
|
@ -22,24 +20,46 @@ android {
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.appcompat)
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.material)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.material.icons)
|
||||||
|
|
||||||
|
implementation(libs.androidx.glance)
|
||||||
|
implementation(libs.androidx.glance.m3)
|
||||||
|
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
}
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
|
@ -10,6 +12,27 @@
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.NovaShellForAndroid" />
|
android:theme="@style/Theme.NovaShellForAndroid">
|
||||||
|
|
||||||
</manifest>
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.NovaShellForAndroid">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.AgendaWidgetReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/agenda_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
|
||||||
47
app/src/main/java/space/darkest/nova/android/MainActivity.kt
Normal file
47
app/src/main/java/space/darkest/nova/android/MainActivity.kt
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package space.darkest.nova.android
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import space.darkest.nova.android.data.CalendarRepository
|
||||||
|
import space.darkest.nova.android.ui.AgendaScreen
|
||||||
|
import space.darkest.nova.android.ui.theme.NovaTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private var agenda by mutableStateOf(emptyList<space.darkest.nova.android.data.AgendaDay>())
|
||||||
|
|
||||||
|
private val calendarPermission = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
if (granted) refreshAgenda()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
calendarPermission.launch(Manifest.permission.READ_CALENDAR)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
NovaTheme {
|
||||||
|
AgendaScreen(agenda)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
refreshAgenda()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshAgenda() {
|
||||||
|
agenda = CalendarRepository.getAgenda(this, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
package space.darkest.nova.android.data
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
data class AgendaEvent(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val startTime: LocalDateTime,
|
||||||
|
val endTime: LocalDateTime,
|
||||||
|
val allDay: Boolean,
|
||||||
|
val calendarColor: Int,
|
||||||
|
val location: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AgendaDay(
|
||||||
|
val date: LocalDate,
|
||||||
|
val events: List<AgendaEvent>,
|
||||||
|
)
|
||||||
|
|
||||||
|
object CalendarRepository {
|
||||||
|
|
||||||
|
fun getAgenda(context: Context, days: Int = 14): List<AgendaDay> {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.READ_CALENDAR)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) return emptyList()
|
||||||
|
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val now = Instant.now()
|
||||||
|
val start = now.toEpochMilli()
|
||||||
|
val end = now.plus(days.toLong(), ChronoUnit.DAYS).toEpochMilli()
|
||||||
|
|
||||||
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().let {
|
||||||
|
ContentUris.appendId(it, start)
|
||||||
|
ContentUris.appendId(it, end)
|
||||||
|
it.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val projection = arrayOf(
|
||||||
|
CalendarContract.Instances.EVENT_ID,
|
||||||
|
CalendarContract.Instances.TITLE,
|
||||||
|
CalendarContract.Instances.BEGIN,
|
||||||
|
CalendarContract.Instances.END,
|
||||||
|
CalendarContract.Instances.ALL_DAY,
|
||||||
|
CalendarContract.Instances.CALENDAR_COLOR,
|
||||||
|
CalendarContract.Instances.EVENT_LOCATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
val events = mutableListOf<AgendaEvent>()
|
||||||
|
|
||||||
|
context.contentResolver.query(
|
||||||
|
uri, projection, null, null, "${CalendarContract.Instances.BEGIN} ASC"
|
||||||
|
)?.use { cursor ->
|
||||||
|
val idIdx = cursor.getColumnIndex(CalendarContract.Instances.EVENT_ID)
|
||||||
|
val titleIdx = cursor.getColumnIndex(CalendarContract.Instances.TITLE)
|
||||||
|
val beginIdx = cursor.getColumnIndex(CalendarContract.Instances.BEGIN)
|
||||||
|
val endIdx = cursor.getColumnIndex(CalendarContract.Instances.END)
|
||||||
|
val allDayIdx = cursor.getColumnIndex(CalendarContract.Instances.ALL_DAY)
|
||||||
|
val colorIdx = cursor.getColumnIndex(CalendarContract.Instances.CALENDAR_COLOR)
|
||||||
|
val locIdx = cursor.getColumnIndex(CalendarContract.Instances.EVENT_LOCATION)
|
||||||
|
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
events += AgendaEvent(
|
||||||
|
id = cursor.getLong(idIdx),
|
||||||
|
title = cursor.getString(titleIdx) ?: "(No title)",
|
||||||
|
startTime = Instant.ofEpochMilli(cursor.getLong(beginIdx))
|
||||||
|
.atZone(zone).toLocalDateTime(),
|
||||||
|
endTime = Instant.ofEpochMilli(cursor.getLong(endIdx))
|
||||||
|
.atZone(zone).toLocalDateTime(),
|
||||||
|
allDay = cursor.getInt(allDayIdx) == 1,
|
||||||
|
calendarColor = cursor.getInt(colorIdx),
|
||||||
|
location = cursor.getString(locIdx)?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
.groupBy { it.startTime.toLocalDate() }
|
||||||
|
.map { (date, dayEvents) -> AgendaDay(date, dayEvents) }
|
||||||
|
.sortedBy { it.date }
|
||||||
|
}
|
||||||
|
}
|
||||||
199
app/src/main/java/space/darkest/nova/android/ui/AgendaScreen.kt
Normal file
199
app/src/main/java/space/darkest/nova/android/ui/AgendaScreen.kt
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
package space.darkest.nova.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
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.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.LargeTopAppBar
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import space.darkest.nova.android.data.AgendaDay
|
||||||
|
import space.darkest.nova.android.data.AgendaEvent
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AgendaScreen(agenda: List<AgendaDay>) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = { Text("Agenda") },
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
) { innerPadding ->
|
||||||
|
if (agenda.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "No upcoming events",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Your next 2 weeks are clear",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = innerPadding,
|
||||||
|
) {
|
||||||
|
for (day in agenda) {
|
||||||
|
item(key = "header-${day.date}") {
|
||||||
|
DayHeader(day.date)
|
||||||
|
}
|
||||||
|
items(day.events, key = { "${it.id}-${it.startTime}" }) { event ->
|
||||||
|
EventCard(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(16.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayHeader(date: LocalDate) {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val label = when (date) {
|
||||||
|
today -> "Today"
|
||||||
|
today.plusDays(1) -> "Tomorrow"
|
||||||
|
else -> {
|
||||||
|
val dayName = date.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.getDefault())
|
||||||
|
val monthDay = date.format(DateTimeFormatter.ofPattern("MMM d"))
|
||||||
|
"$dayName, $monthDay"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.padding(start = 72.dp, top = 20.dp, bottom = 8.dp, end = 16.dp),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventCard(event: AgendaEvent) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
// time column
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.width(52.dp),
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
if (event.allDay) {
|
||||||
|
Text(
|
||||||
|
text = "All day",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = event.startTime.format(timeFormatter),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = event.endTime.format(timeFormatter),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
|
// color bar
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(4.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(Color(event.calendarColor)),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
|
// details
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = event.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 2,
|
||||||
|
)
|
||||||
|
if (event.location != null) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = event.location,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package space.darkest.nova.android.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NovaTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val colorScheme = if (darkTheme) {
|
||||||
|
dynamicDarkColorScheme(context)
|
||||||
|
} else {
|
||||||
|
dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
package space.darkest.nova.android.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.action.actionStartActivity
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import androidx.glance.appwidget.action.actionStartActivity as actionStartActivityIntent
|
||||||
|
import androidx.glance.appwidget.components.Scaffold
|
||||||
|
import androidx.glance.appwidget.components.TitleBar
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
|
import androidx.glance.appwidget.lazy.LazyColumn
|
||||||
|
import androidx.glance.appwidget.lazy.items
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.background
|
||||||
|
import androidx.glance.color.DynamicThemeColorProviders
|
||||||
|
import androidx.glance.layout.Alignment
|
||||||
|
import androidx.glance.layout.Box
|
||||||
|
import androidx.glance.layout.Column
|
||||||
|
import androidx.glance.layout.Row
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.layout.size
|
||||||
|
import androidx.glance.layout.width
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import space.darkest.nova.android.MainActivity
|
||||||
|
import space.darkest.nova.android.data.AgendaDay
|
||||||
|
import space.darkest.nova.android.data.AgendaEvent
|
||||||
|
import space.darkest.nova.android.data.CalendarRepository
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.TextStyle as DateTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val agenda = CalendarRepository.getAgenda(context, days = 14)
|
||||||
|
|
||||||
|
provideContent {
|
||||||
|
GlanceTheme(colors = DynamicThemeColorProviders) {
|
||||||
|
AgendaContent(agenda)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaContent(agenda: List<AgendaDay>) {
|
||||||
|
Scaffold(
|
||||||
|
titleBar = TitleBar(
|
||||||
|
startIcon = null,
|
||||||
|
title = "Agenda",
|
||||||
|
textColor = GlanceTheme.colors.onSurface,
|
||||||
|
),
|
||||||
|
backgroundColor = GlanceTheme.colors.widgetBackground,
|
||||||
|
modifier = GlanceModifier.clickable(actionStartActivity<MainActivity>()),
|
||||||
|
) {
|
||||||
|
if (agenda.isEmpty()) {
|
||||||
|
EmptyState()
|
||||||
|
} else {
|
||||||
|
AgendaList(agenda)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyState() {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "No upcoming events",
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Your next 2 weeks are clear",
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaList(agenda: List<AgendaDay>) {
|
||||||
|
val items = buildList {
|
||||||
|
for (day in agenda) {
|
||||||
|
add(AgendaListItem.Header(day.date))
|
||||||
|
for (event in day.events) {
|
||||||
|
add(AgendaListItem.Event(event))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
|
items(items, itemId = { item ->
|
||||||
|
when (item) {
|
||||||
|
is AgendaListItem.Header -> item.date.toEpochDay()
|
||||||
|
is AgendaListItem.Event -> item.event.id * 31 + item.event.startTime.hashCode()
|
||||||
|
}
|
||||||
|
}) { item ->
|
||||||
|
when (item) {
|
||||||
|
is AgendaListItem.Header -> DayHeader(item.date)
|
||||||
|
is AgendaListItem.Event -> EventCard(item.event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayHeader(date: LocalDate) {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val label = when (date) {
|
||||||
|
today -> "Today"
|
||||||
|
today.plusDays(1) -> "Tomorrow"
|
||||||
|
else -> {
|
||||||
|
val dayName = date.dayOfWeek.getDisplayName(DateTextStyle.FULL, Locale.getDefault())
|
||||||
|
val monthDay = date.format(DateTimeFormatter.ofPattern("MMM d"))
|
||||||
|
"$dayName, $monthDay"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = GlanceModifier.fillMaxWidth().padding(
|
||||||
|
start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp
|
||||||
|
),
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.primary,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventCard(event: AgendaEvent) {
|
||||||
|
val calendarIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
data = CalendarContract.Events.CONTENT_URI.buildUpon()
|
||||||
|
.appendPath(event.id.toString()).build()
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable(actionStartActivityIntent(calendarIntent)),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
// time column
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier.width(52.dp),
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
) {
|
||||||
|
if (event.allDay) {
|
||||||
|
Text(
|
||||||
|
text = "All day",
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = event.startTime.format(timeFormatter),
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurface,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = event.endTime.format(timeFormatter),
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(GlanceModifier.width(8.dp))
|
||||||
|
|
||||||
|
// color indicator
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.size(width = 3.dp, height = 36.dp)
|
||||||
|
.cornerRadius(2.dp)
|
||||||
|
.background(GlanceTheme.colors.primary),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
Spacer(GlanceModifier.width(8.dp))
|
||||||
|
|
||||||
|
// event details
|
||||||
|
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||||
|
Text(
|
||||||
|
text = event.title,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurface,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
maxLines = 2,
|
||||||
|
)
|
||||||
|
if (event.location != null) {
|
||||||
|
Text(
|
||||||
|
text = event.location,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AgendaListItem {
|
||||||
|
data class Header(val date: LocalDate) : AgendaListItem()
|
||||||
|
data class Event(val event: AgendaEvent) : AgendaListItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
||||||
|
}
|
||||||
12
app/src/main/res/layout/glance_default_loading_layout.xml
Normal file
12
app/src/main/res/layout/glance_default_loading_layout.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:text="@string/widget_loading"
|
||||||
|
android:textAppearance="@android:style/TextAppearance.Material.Body1" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
@ -1,16 +1,3 @@
|
||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources>
|
||||||
<!-- Base application theme. -->
|
<style name="Theme.NovaShellForAndroid" parent="Theme.Material3.DynamicColors.DayNight" />
|
||||||
<style name="Theme.NovaShellForAndroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
</resources>
|
||||||
<!-- Primary brand color. -->
|
|
||||||
<item name="colorPrimary">@color/purple_200</item>
|
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
|
||||||
<item name="colorOnPrimary">@color/black</item>
|
|
||||||
<!-- Secondary brand color. -->
|
|
||||||
<item name="colorSecondary">@color/teal_200</item>
|
|
||||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
|
||||||
<item name="colorOnSecondary">@color/black</item>
|
|
||||||
<!-- Status bar color. -->
|
|
||||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Nova Shell for Android</string>
|
<string name="app_name">Nova Shell</string>
|
||||||
</resources>
|
<string name="widget_description">Calendar agenda showing upcoming events</string>
|
||||||
|
<string name="widget_loading">Loading…</string>
|
||||||
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,6 @@
|
||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources>
|
||||||
<!-- Base application theme. -->
|
<style name="Theme.NovaShellForAndroid" parent="Theme.Material3.DynamicColors.DayNight">
|
||||||
<style name="Theme.NovaShellForAndroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
<item name="android:windowTranslucentStatus">false</item>
|
||||||
<!-- Primary brand color. -->
|
<item name="android:windowTranslucentNavigation">false</item>
|
||||||
<item name="colorPrimary">@color/purple_500</item>
|
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
|
||||||
<item name="colorOnPrimary">@color/white</item>
|
|
||||||
<!-- Secondary brand color. -->
|
|
||||||
<item name="colorSecondary">@color/teal_200</item>
|
|
||||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
|
||||||
<item name="colorOnSecondary">@color/black</item>
|
|
||||||
<!-- Status bar color. -->
|
|
||||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
15
app/src/main/res/xml/agenda_widget_info.xml
Normal file
15
app/src/main/res/xml/agenda_widget_info.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:description="@string/widget_description"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
|
android:minWidth="180dp"
|
||||||
|
android:minHeight="180dp"
|
||||||
|
android:minResizeWidth="110dp"
|
||||||
|
android:minResizeHeight="110dp"
|
||||||
|
android:maxResizeWidth="530dp"
|
||||||
|
android:maxResizeHeight="530dp"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:targetCellWidth="3"
|
||||||
|
android:targetCellHeight="3"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:widgetCategory="home_screen" />
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
}
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,33 @@
|
||||||
[versions]
|
[versions]
|
||||||
agp = "9.2.0"
|
agp = "9.2.0"
|
||||||
|
kotlin = "2.1.20"
|
||||||
coreKtx = "1.18.0"
|
coreKtx = "1.18.0"
|
||||||
|
lifecycleRuntime = "2.9.0"
|
||||||
|
activityCompose = "1.10.1"
|
||||||
|
composeBom = "2025.04.01"
|
||||||
|
glance = "1.1.1"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.1.5"
|
junitVersion = "1.1.5"
|
||||||
espressoCore = "3.5.1"
|
espressoCore = "3.5.1"
|
||||||
appcompat = "1.6.1"
|
|
||||||
material = "1.13.0"
|
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
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-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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue