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 {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "space.darkest.nova.android"
|
||||
compileSdk {
|
||||
version = release(36) {
|
||||
minorApiLevel = 1
|
||||
}
|
||||
}
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "space.darkest.nova.android"
|
||||
|
|
@ -22,24 +20,46 @@ android {
|
|||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.appcompat)
|
||||
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)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
}
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
|
|
@ -10,6 +12,27 @@
|
|||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
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">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.NovaShellForAndroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- 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>
|
||||
<resources>
|
||||
<style name="Theme.NovaShellForAndroid" parent="Theme.Material3.DynamicColors.DayNight" />
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
<resources>
|
||||
<string name="app_name">Nova Shell for Android</string>
|
||||
</resources>
|
||||
<string name="app_name">Nova Shell</string>
|
||||
<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">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.NovaShellForAndroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<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. -->
|
||||
<resources>
|
||||
<style name="Theme.NovaShellForAndroid" parent="Theme.Material3.DynamicColors.DayNight">
|
||||
<item name="android:windowTranslucentStatus">false</item>
|
||||
<item name="android:windowTranslucentNavigation">false</item>
|
||||
</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 {
|
||||
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]
|
||||
agp = "9.2.0"
|
||||
kotlin = "2.1.20"
|
||||
coreKtx = "1.18.0"
|
||||
lifecycleRuntime = "2.9.0"
|
||||
activityCompose = "1.10.1"
|
||||
composeBom = "2025.04.01"
|
||||
glance = "1.1.1"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.1.5"
|
||||
espressoCore = "3.5.1"
|
||||
appcompat = "1.6.1"
|
||||
material = "1.13.0"
|
||||
|
||||
[libraries]
|
||||
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" }
|
||||
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-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
|
||||
[plugins]
|
||||
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