Skip to content

Instantly share code, notes, and snippets.

@harishrpatel
Last active September 6, 2023 10:09
Show Gist options
  • Save harishrpatel/adcac2edfa930351c7789ede59214a7e to your computer and use it in GitHub Desktop.
Save harishrpatel/adcac2edfa930351c7789ede59214a7e to your computer and use it in GitHub Desktop.
Android Glance App Widget Files
...
<!-- Handles App Widget Lifecycle events as well as resizing the widget to 2 months if enough room exists. -->
<receiver
android:name=".appwidget.receiver.ConsolidatorWidgetReceiver"
android:label="@string/app_name"
android:enabled="@bool/glance_appwidget_available"
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/calendar_widget_info" />
</receiver>
...
<?xml version="1.0" encoding="utf-8"?>
<!-- API 31 (Android 12 and up) -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:minWidth="200dp"
android:minHeight="200dp"
android:resizeMode="horizontal|vertical"
android:previewImage="@drawable/calendar_widget_preview_image"
android:previewLayout="@layout/calendar_widget_small_layout"
android:targetCellWidth="2"
android:targetCellHeight="3"
android:maxResizeWidth="200dp"
android:maxResizeHeight="600dp"
android:widgetCategory="home_screen"
android:updatePeriodMillis="0"
tools:targetApi="s"
/>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@android:color/white"
android:layout_gravity="center_horizontal">
<ImageButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_margin="4dp"
android:src="@drawable/ic_arrow_up_24"
android:background="@android:color/transparent"
android:contentDescription="@string/back"
/>
<ImageView
android:layout_width="150dp"
android:layout_height="150dp"
android:layout_gravity="center_horizontal"
android:src="@drawable/calendar_widget_preview_image"
android:contentDescription="@string/app_name"
/>
<ImageButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_margin="4dp"
android:src="@drawable/ic_arrow_down_24"
android:background="@android:color/transparent"
android:contentDescription="@string/forward"
/>
</LinearLayout>
package com.bottlerocket.android.consolidator.appwidget.ui
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.glance.*
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.layout.*
import com.bottlerocket.android.consolidator.appwidget.ConsolidatorAppWidget
import com.bottlerocket.android.consolidator.appwidget.ui.util.getFirstMonth
import com.bottlerocket.android.consolidator.appwidget.ui.util.setFirstMonth
import com.bottlerocket.common.consolidator.ui.util.calendar.DateArrow
import com.bottlerocket.common.consolidator.ui.util.calendar.KEY_DATE_ARROW
import com.bottlerocket.common.consolidator.util.DebugUtils.calendarWidgetLog
import com.bottlerocket.common.consolidator.viewmodels.DayEvent
import java.time.LocalDate
/**
* Glance based Views of 2 month Calendar app widget.
* [calendarData] is a dynamic list of events to superimpose on to each Day Summary.
*/
@Composable
fun CalendarAppWidgetUI(firstMonthState: LocalDate, numMonthsToShow: Int, calendarEventsMap: Map<LocalDate,List<DayEvent>>) {
ShowMonthsWidget(firstMonthState, numMonthsToShow, calendarEventsMap)
}
@Composable
private fun ShowMonthsWidget(firstMonthState: LocalDate, numMonthsToShow: Int, calendarEventsMap: Map<LocalDate,List<DayEvent>>) {
// Vertically show two months - LazyColumn is needed in case we need to scroll due to lack of space (due to font, widget size).
// NOTE: Going with a Column (no scrolling) because it fixes horizontal alignment issue.
Column(
modifier = GlanceModifier.padding(all = 2.dp).fillMaxWidth().background(Color.White),
horizontalAlignment = Alignment.CenterHorizontally
) {
// UP/Prev button
Image(
provider = ImageProvider(com.bottlerocket.android.consolidator.R.drawable.ic_arrow_up_24),
contentDescription = "Back",
modifier = GlanceModifier.defaultWeight()
.background(Color.Transparent)
.clickable(onClick = actionRunCallback<CalendarActionCallback>(
actionParametersOf(pairs = arrayOf(ActionParameters.Key<DateArrow>(KEY_DATE_ARROW) to DateArrow.Back)))
)
)
val monthEvents = calendarEventsMap.filter { it.key.year == firstMonthState.year && it.key.month == firstMonthState.month }
MonthCalendarWidgetUI(firstMonthState, monthEvents)
if (numMonthsToShow >= 2) {
val secondMonth = firstMonthState.plusMonths(1)
val secondMonthEvents = calendarEventsMap.filter { it.key.year == secondMonth.year && it.key.month == secondMonth.month }
MonthCalendarWidgetUI(secondMonth, secondMonthEvents)
}
// Down/Next button
Image(
provider = ImageProvider(com.bottlerocket.android.consolidator.R.drawable.ic_arrow_down_24),
contentDescription = "Forward",
modifier = GlanceModifier.defaultWeight()
.background(Color.Transparent)
.clickable(onClick = actionRunCallback<CalendarActionCallback>(
actionParametersOf(pairs = arrayOf(ActionParameters.Key<DateArrow>(KEY_DATE_ARROW) to DateArrow.Forward)))
)
)
}
}
/**
* This public accessible class will handle all Calendar Widget events.
*/
class CalendarActionCallback : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val dateArrow = parameters[ActionParameters.Key<DateArrow>(KEY_DATE_ARROW)]
updateAppWidgetState(context, glanceId) {
val currentFirstMonth = it.getFirstMonth()
val updatedFirstMonth = if (dateArrow != null && dateArrow == DateArrow.Forward) {
currentFirstMonth.plusMonths(1)
} else {
currentFirstMonth.minusMonths(1)
}
// Update
calendarWidgetLog("ActionCallback: updated month = $updatedFirstMonth")
it.setFirstMonth(updatedFirstMonth)
}
ConsolidatorAppWidget().update(context, glanceId)
}
}
package com.bottlerocket.common.consolidator.repositories
import com.bottlerocket.common.consolidator.viewmodels.DayEvent
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.time.LocalDate
import java.time.LocalDateTime
import kotlin.random.Random
class CalendarRepository: KoinComponent {
private val debugLogRepo by inject<DebugLogRepository>()
// TODO test data
val events: List<DayEvent>
get() {
return listOf(
DayEvent("New Years Day", LocalDateTime.of(2023, 1, 2, 0, 0), -1, true),
DayEvent("Martin Luther King Jr Day", LocalDateTime.of(2023, 1, 16, 0, 0), -1, true),
DayEvent("Memorial Day", LocalDateTime.of(2023, 5, 29, 0, 0), -1, true),
DayEvent("Juneteenth", LocalDateTime.of(2023, 6, 19, 0, 0), -1, true),
DayEvent("Independence Day", LocalDateTime.of(2023, 7, 4, 0, 0), -1, true),
DayEvent("Labor Day", LocalDateTime.of(2023, 9, 4, 0, 0), -1, true),
DayEvent("Thanksgiving", LocalDateTime.of(2023, 11, 23, 0, 0), -1, true),
DayEvent("Day after Thanksgiving", LocalDateTime.of(2023, 11, 24, 0, 0), -1, true),
DayEvent("Christmas Eve", LocalDateTime.of(2023, 12, 22, 0, 0), -1, true),
DayEvent("Christmas", LocalDateTime.of(2023, 12, 25, 0, 0), -1, true),
DayEvent("New Years Eve", LocalDateTime.of(2023, 12, 29, 0, 0), -1, true),
DayEvent("New Years Day", LocalDateTime.of(2024, 1, 1, 0, 0), -1, true),
// append a random changing event to test dynamic changes
DayEvent("Random", LocalDateTime.of(2023, LocalDate.now().month, Random.nextInt(1, 28), 0, 0), -1, true)
)
}
}
package com.bottlerocket.common.consolidator.ui.util.calendar
import androidx.compose.ui.graphics.Color
import com.bottlerocket.common.consolidator.ui.styles.Cream
import com.bottlerocket.common.consolidator.ui.styles.LightCyan
import com.bottlerocket.common.consolidator.ui.styles.MangoOrange
import com.bottlerocket.common.consolidator.viewmodels.DayEvent
import com.bottlerocket.common.consolidator.viewmodels.DaySummary
import com.bottlerocket.common.consolidator.viewmodels.MonthSummary
import com.bottlerocket.common.consolidator.viewmodels.WeekSummary
import java.time.*
import java.time.temporal.WeekFields
/**
* Year Utils ____________________________________________________________________________________
*/
fun getStartingMonthsList(showOneMonthAtATime: Boolean): List<Int> {
return if (showOneMonthAtATime) { IntRange(1, 12).toList() } else { listOf(1, 4, 7, 10) }
}
/**
* Month Utils ___________________________________________________________________________________
*/
fun isCurrentMonth(date: LocalDate, today: LocalDate): Boolean {
return date.month == today.month && date.year == today.year
}
fun isCurrentMonth(month: Month, year: Year, today: LocalDate): Boolean {
return month == today.month && year.value == today.year
}
fun createMonthSummary(month: Month, year: Year, today: LocalDate, eventsMap: Map<LocalDate,List<DayEvent>>? = null): MonthSummary {
val weekSummaryList = mutableListOf<WeekSummary>()
val isCurrentMonth = isCurrentMonth(month, year, today)
val monthSummary = MonthSummary(month, year, today, month.length(year.isLeap), weekSummaryList, isCurrentMonth)
var startingDayOfNextWeek = 1
var weekSummary: WeekSummary
do {
weekSummary = createWeekSummary(startingDayOfNextWeek, month, year, isCurrentMonth, today, eventsMap)
weekSummaryList.add(weekSummary)
startingDayOfNextWeek = weekSummary.nextDayOfMonth
} while (startingDayOfNextWeek != 0)
return monthSummary
}
/**
* In order to render first week of month, this helps determine how many days we need to skip before 1st of the month starts.
*/
fun getDaysOffsetAtStartOfMonth(dayOfWeek: DayOfWeek?, startingDay: WeekFields): Int {
if (dayOfWeek == null) {
return -1
}
val priorDays = when (dayOfWeek) {
DayOfWeek.SUNDAY -> 0
DayOfWeek.MONDAY -> 1
DayOfWeek.TUESDAY -> 2
DayOfWeek.WEDNESDAY -> 3
DayOfWeek.THURSDAY -> 4
DayOfWeek.FRIDAY -> 5
DayOfWeek.SATURDAY -> 6
}
return if (startingDay == WeekFields.SUNDAY_START) priorDays else priorDays - 1
}
/**
* Week Utils ____________________________________________________________________________________
* [events] will have all events for the month, so need to extract appropriate events for Day/Week Summary objects.
*/
fun createWeekSummary(
firstDayOfWeek: Int,
month: Month,
year: Year,
isCurrentMonth: Boolean,
today: LocalDate,
eventsMap: Map<LocalDate,List<DayEvent>>? = null
): WeekSummary {
val daySummaryOfMonth = mutableListOf<DaySummary>()
val yearMonth = YearMonth.of(year.value, month.value)
var nextDayOfMonth = firstDayOfWeek
val dayOfWeek = getDayOfWeek(year, month, firstDayOfWeek)
val daysOffsetAtStartOfMonth = getDaysOffsetAtStartOfMonth(
dayOfWeek,
WeekFields.SUNDAY_START
) // only US based calendar for now
// fill all seven elements. Use -1 for day if to be left blank (since first and last weeks may be partial)
(1..7).forEach { index ->
// calculate dayOfMonth by taking only days to be displayed in the week (skip initial ones if day 1 is not on Sunday.
// Also, use -1 for extra days at end of a month).
val dayOfMonth = if (daysOffsetAtStartOfMonth >= index || nextDayOfMonth > month.length(year.isLeap)) -1 else nextDayOfMonth++
val isToday = if (dayOfMonth <= yearMonth.lengthOfMonth()) {
isCurrentMonth && today.dayOfMonth == dayOfMonth
} else {
false
}
val dayEvents : List<DayEvent> = if (dayOfMonth != -1) {
eventsMap?.get(LocalDate.of(year.value, month.value, dayOfMonth)) ?: listOf()
} else {
listOf()
}
daySummaryOfMonth.add(DaySummary(getDayOfWeekByDayIndexStartingSunday(index) ?: DayOfWeek.SUNDAY, dayOfMonth, isToday, dayEvents))
}
// help setup next week
nextDayOfMonth = if (nextDayOfMonth <= yearMonth.lengthOfMonth()) nextDayOfMonth else 0 // to render next week
val isOddWeekPeriod = isOddWeekPeriod(LocalDate.of(year.value, month.value, firstDayOfWeek), 2)
return WeekSummary(daySummaryOfMonth, nextDayOfMonth, isOddWeekPeriod)
}
/**
* Will help in highlighting sprints (i.e. [sprintCycleLength] = 2 weeks) for easy viewing.
* So, any date in first two week range will be odd and following two week range as even.
* NOTE: If first week of the year does not start on a Sunday, the first week may have some overlap. Adjust logic if needed in future.
*/
fun isOddWeekPeriod(date: LocalDate, sprintCycleLength: Int): Boolean {
val weekOfYearNumber = getWeekOfYearNumber(date)
val remainder = weekOfYearNumber % (sprintCycleLength * 2)
return remainder == 1 || remainder == 2
}
/**
* NOTE: If week does not start on Sunday, caller would need to make adjustment.
*/
fun getWeekOfYearNumber(date: LocalDate): Int {
return (date.dayOfYear / 7) + 1
}
/**
* Day Utils _____________________________________________________________________________________
*/
/**
* Essentially get day of week for given date, so it helps us rendering calendar.
*/
fun getDayOfWeek(
year: Year,
month: Month,
dayOfMonth: Int
): DayOfWeek? {
val date = LocalDate.of(year.value, month.value, dayOfMonth)
return date.dayOfWeek
}
fun getDayOfWeekByDayIndexStartingSunday(index: Int): DayOfWeek? {
return when (index) {
1 -> DayOfWeek.SUNDAY
2 -> DayOfWeek.MONDAY
3 -> DayOfWeek.TUESDAY
4 -> DayOfWeek.WEDNESDAY
5 -> DayOfWeek.THURSDAY
6 -> DayOfWeek.FRIDAY
7 -> DayOfWeek.SATURDAY
else -> null
}
}
fun isWeekDay(dayOfWeek: DayOfWeek): Boolean {
return !(dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY)
}
fun DaySummary.isHoliday(): Boolean {
return this.events.any { it.isHoliday }
}
/**
* When overlapping color indicators, here is the order of preference.
* 1. Show special days
* 2. Show today
* 3. Weekly (sprint) colors
*/
fun getCalendarDayBackgroundColor(weekSummary: WeekSummary, daySummary: DaySummary): Color {
return when {
daySummary.isHoliday() -> Color.Cyan
daySummary.isToday -> MangoOrange
daySummary.dayOfMonth == -1 -> Color.Transparent // This day is part of prev/next month
isWeekDay(daySummary.dayOfWeek) -> if (weekSummary.isOddWeek) LightCyan else Cream
//index in 2..6 -> LightCyan
else -> Color.Transparent
}
}
enum class DateArrow { Back, Forward }
const val KEY_DATE_ARROW = "DateArrow" // For passing Button Events
package com.bottlerocket.android.consolidator.calendar.workmanager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.bottlerocket.android.consolidator.appwidget.ConsolidatorAppWidget
import com.bottlerocket.android.consolidator.appwidget.ui.util.setCalendarData
import com.bottlerocket.common.consolidator.repositories.CalendarRepository
import com.bottlerocket.common.consolidator.util.DebugUtils.calendarWidgetLog
import com.bottlerocket.common.consolidator.viewmodels.DayEvent
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.time.Duration
/**
* Work Manager task to fetch Calendar entries. App Widget uses this mechanism.
*/
class CalendarWorkManager(private val context: Context, params: WorkerParameters): CoroutineWorker(context, params), KoinComponent {
private val calendarRepo by inject<CalendarRepository>()
override suspend fun doWork(): Result {
val events = calendarRepo.events
val lastEventDayOfMonthStr = events.lastOrNull()?.startDateTime?.dayOfMonth
calendarWidgetLog("${events.size} events loaded. Last day of month: $lastEventDayOfMonthStr")
updateWidgetData(events)
return Result.success()
}
/**
* Refresh widget(s) by populating with latest data.
*/
private suspend fun updateWidgetData(events: List<DayEvent>) {
GlanceAppWidgetManager(context = context).getGlanceIds(ConsolidatorAppWidget::class.java).forEach { glanceId ->
updateAppWidgetState(context, glanceId) { prefs ->
calendarWidgetLog("updateAppWidgetState: glanceId: $glanceId")
// Each app widget instance stores in a different DataStore file.
prefs.setCalendarData(events)
}
// VERY IMPORTANT: Refreshing App Widget with updated events here.
calendarWidgetLog("updateWidgetData: Update UI")
ConsolidatorAppWidget().update(context, glanceId)
}
}
companion object {
private const val CALENDAR_FETCH_WORKER = "calendarFetchWorker"
private const val CALENDAR_WORK_REQ_PERIOD_MINUTES = 15L
private const val CALENDAR_WORK_REQ_INITIAL_DELAY_SECONDS = 20L
fun startCalendarDataFetch(workManager: WorkManager) {
calendarWidgetLog("queueCalendarDataRequest")
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresCharging(false)
.setRequiresBatteryNotLow(true)
.build()
// NOTE: min period is 15 minutes.
val request = PeriodicWorkRequestBuilder<CalendarWorkManager>(
Duration.ofMinutes(
CALENDAR_WORK_REQ_PERIOD_MINUTES
))
.setConstraints(constraints)
.setInitialDelay(Duration.ofSeconds(CALENDAR_WORK_REQ_INITIAL_DELAY_SECONDS))
.build()
// MUST: Specifying "unique" and "KEEP" will ensure there is only one worker in the system.
val operation = workManager.enqueueUniquePeriodicWork(
CALENDAR_FETCH_WORKER,
ExistingPeriodicWorkPolicy.KEEP,
request)
calendarWidgetLog("WorkManager queue status: ${operation.result}")
}
fun cancelCalendarDataFetch(workManager: WorkManager) {
calendarWidgetLog("cancelCalendarDataRequest")
workManager.cancelUniqueWork(CALENDAR_FETCH_WORKER)
}
}
}
package com.bottlerocket.android.consolidator.appwidget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.*
import androidx.glance.appwidget.*
import androidx.glance.state.GlanceStateDefinition
import androidx.glance.state.PreferencesGlanceStateDefinition
import com.bottlerocket.android.consolidator.appwidget.ui.CalendarAppWidgetUI
import com.bottlerocket.android.consolidator.appwidget.ui.util.getCalendarData
import com.bottlerocket.android.consolidator.appwidget.ui.util.getFirstMonth
import com.bottlerocket.android.consolidator.appwidget.ui.util.setFirstMonth
import com.bottlerocket.common.consolidator.util.DebugUtils.calendarWidgetLog
import com.bottlerocket.common.consolidator.util.isDebug
import com.bottlerocket.common.consolidator.viewmodels.createMonthToDayEventsMap
/**
* App Widget Entry Point. Widget dimensions are picked up from here (as far as we understand).
* If there is more room on screen, widget can be dynamically resized to 2 month calendar.
*/
class ConsolidatorAppWidget: GlanceAppWidget() {
private val isDebugOverride = false // Set to true to have only one widget size to reduce logging clutter
/**
* Used to dynamically configure Widget as user is resizing it.
*/
override val sizeMode: SizeMode = SizeMode.Responsive(
if (isDebugOverride && isDebug()) setOf(ONE_MONTH_WIDGET) else setOf(ONE_MONTH_WIDGET, TWO_MONTH_WIDGET)
)
/**
* DataStore implementation is provided by Glance to communicate between app and widget.
*/
override val stateDefinition: GlanceStateDefinition<*>
get() = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val prefs = currentState<Preferences>()
val firstMonthState = prefs.getFirstMonth()
prefs.toMutablePreferences().setFirstMonth(firstMonthState)
val calendarEventsMap = prefs.getCalendarData().createMonthToDayEventsMap()
val size = LocalSize.current
calendarWidgetLog(
"Content: size: $size, calendarEventsMap with last entry ${
calendarEventsMap.entries.lastOrNull()?.value?.get(
0
)?.startDateTime
}"
)
// NOTE: Weird - Placing Two month option first helps render 2 month as well as 1 month calendar widget.
// Otherwise, it only showed 1 month calendar.
when (size) {
TWO_MONTH_WIDGET -> CalendarAppWidgetUI(firstMonthState, 2, calendarEventsMap)
ONE_MONTH_WIDGET -> CalendarAppWidgetUI(firstMonthState, 1, calendarEventsMap)
}
}
}
companion object {
private val ONE_MONTH_WIDGET = DpSize(200.dp, 200.dp)
private val TWO_MONTH_WIDGET = DpSize(200.dp, 425.dp)
}
}
package com.bottlerocket.android.consolidator.appwidget.receiver
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.work.WorkManager
import com.bottlerocket.android.consolidator.appwidget.ConsolidatorAppWidget
import com.bottlerocket.android.consolidator.calendar.workmanager.CalendarWorkManager
import com.bottlerocket.common.consolidator.util.DebugUtils.calendarWidgetLog
/**
* This class handles widget lifecycle events.
*/
class ConsolidatorWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = ConsolidatorAppWidget()
/** TODO: This callback is never called. So, will start WorkManager request from MainActivity.
* It' OK because they still persist when app is wiped away.
*/
override fun onEnabled(context: Context) {
super.onEnabled(context)
calendarWidgetLog("ConsolidatorWidgetReceiver:onEnabled - start work manager to fetch calendar entries.")
CalendarWorkManager.startCalendarDataFetch(WorkManager.getInstance(context))
}
/** TODO: This callback is never called. */
override fun onDisabled(context: Context) {
super.onDisabled(context)
calendarWidgetLog("ConsolidatorWidgetReceiver:onDisabled - stop work manager to fetch calendar entries.")
CalendarWorkManager.cancelCalendarDataFetch(WorkManager.getInstance(context))
}
}
# This needs to be added to be able to compile.
kotlin.mpp.androidSourceSetLayoutVersion=2
private const val APP_WIDGET_GLANCE_VERSION = "1.0.0-beta01"
private const val APP_WIDGET_REMOTE_VIEWS_CORE_VERSION = "1.0.0-beta04"
private const val WORK_MANAGER_KTX_VERSION = "2.8.1"
private const val DATA_STORE_VERSION = "1.1.0-alpha04"
private const val COMPOSE_MATERIAL_VERSION = "1.9.0"
const val KOTLIN_VERSION = "1.8.20"
// App Widget / Glance Package
const val APP_WIDGET_GLANCE = "androidx.glance:glance-appwidget:$APP_WIDGET_GLANCE_VERSION"
const val APP_WIDGET_REMOTE_VIEWS_CORE = "androidx.core:core-remoteviews:$APP_WIDGET_REMOTE_VIEWS_CORE_VERSION"
const val WORK_MANAGER_KTX = "androidx.work:work-runtime-ktx:$WORK_MANAGER_KTX_VERSION"
const val DATA_STORE = "androidx.datastore:datastore-preferences:$DATA_STORE_VERSION"
const val COMPOSE_MATERIAL = "com.google.android.material:material:$COMPOSE_MATERIAL_VERSION"
object AndroidSdkVersions {
const val COMPILE_SDK = 33
const val BUILD_TOOLS = "29.0.3"
const val MIN_SDK = 26
const val TARGET_SDK = 33
}
...
dependencies {
implementation(Libraries.KOTLIN_STDLIB_JDK)
implementation(Libraries.COMPOSE_MATERIAL)
implementation(Dependencies.androidX.appWidgetGlance)
implementation(Dependencies.androidX.appWidgetRemoteViewsCore)
implementation(Dependencies.androidX.workManagerKtx)
implementation(Dependencies.androidX.dataStore)
}
package com.bottlerocket.android.consolidator.ui
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.LaunchedEffect
import androidx.window.layout.WindowInfoTracker
import com.bottlerocket.android.consolidator.ui.util.windowTypeOf
import com.bottlerocket.android.consolidator.ui.viewmodels.MainActivityViewModel
import com.bottlerocket.common.consolidator.ui.NavigationUI
import com.bottlerocket.common.consolidator.ui.util.WindowType
@ExperimentalMaterialApi
class MainActivity : AppCompatActivity() {
private val viewModel by viewModels<MainActivityViewModel>()
private var windowType = WindowType.NonFoldPhone
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// add support for foldable phones
val windowInfoTracker = WindowInfoTracker.getOrCreate(this)
val layoutFlow = windowInfoTracker.windowLayoutInfo(this)
setContent {
LaunchedEffect(layoutFlow) {
layoutFlow.collect {
windowType = windowTypeOf(it.displayFeatures)
}
}
NavigationUI().CreateUI()
}
// Somehow [ConsolidatorWidgetReceiver] callbacks are not called by OS.
// So we will just make WorkManager request here when user starts app.
viewModel.startCalendarDataFetch(this)
// Request user to pin/add widget on home screen (for ease in discoverability of widget).
viewModel.pinWidgetRequest(this)
}
override fun onDestroy() {
super.onDestroy()
viewModel.cancelCalendarDataFetch(this)
}
}
package com.bottlerocket.android.consolidator.ui.viewmodels
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.os.Build
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkManager
import com.bottlerocket.android.consolidator.appwidget.ConsolidatorAppWidget
import com.bottlerocket.android.consolidator.appwidget.receiver.ConsolidatorWidgetReceiver
import com.bottlerocket.android.consolidator.calendar.workmanager.CalendarWorkManager
import com.bottlerocket.common.consolidator.util.DebugUtils.calendarWidgetLog
import kotlinx.coroutines.*
class MainActivityViewModel: ViewModel() {
/**
* This is a one time request, but it will periodically fetch calendar entries (even when app is
* backgrounded) and update app widget.
* IMPORTANT NOTE: [ConsolidatorWidgetReceiver] should initiate the Work Manager, but somehow it is not
* working. So, will leave this call in place otherwise widget will never refresh.
*/
fun startCalendarDataFetch(context: Context) {
CalendarWorkManager.startCalendarDataFetch(WorkManager.getInstance(context))
}
fun cancelCalendarDataFetch(context: Context) {
CalendarWorkManager.cancelCalendarDataFetch(WorkManager.getInstance(context))
}
/**
* TODO Most methods return list of Providers or WidgetIds
* (but have not found a solid way to detect if Widget is added to home screen or not).
* Likely will have to store a DataStore flag to manually set from [WidgetManager.requestPinAppWidget]'s [successCallback].
*/
private suspend fun isWidgetInstalled(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// TODO add DataStore flag detection
return false
} else {
return false
}
}
/**
* TODO We need to add checks 1) to not annoy users with frequent popups, and 2) to prompt only
* if user has not added an App Widget.
*/
fun pinWidgetRequest(context: Context) {
val widgetManager = AppWidgetManager.getInstance(context)
val widgetProvider = ComponentName(context, ConsolidatorWidgetReceiver::class.java)
viewModelScope.launch {
delay(3000L)
val isWidgetInstalled = isWidgetInstalled(context)
// following logic needs to be run on UI thread since it shows modal dialog
withContext(Dispatchers.Main) {
if (isWidgetInstalled) {
calendarWidgetLog("pinWidgetRequest: widget already installed.")
} else {
if (widgetManager.isRequestPinAppWidgetSupported) {
widgetManager.requestPinAppWidget(widgetProvider, null, null)
} else {
calendarWidgetLog("pinWidgetRequest: pinning not supported.")
}
}
}
}
}
}
package com.bottlerocket.android.consolidator.appwidget.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.background
import androidx.glance.layout.*
import androidx.glance.text.Text
import com.bottlerocket.common.consolidator.ui.styles.PeachPuff
import com.bottlerocket.common.consolidator.ui.util.APP_WIDGET_CALENDAR_DAY_SIZE
import com.bottlerocket.common.consolidator.ui.util.calendar.createMonthSummary
import com.bottlerocket.common.consolidator.viewmodels.DayEvent
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.Year
@Composable
fun MonthCalendarWidgetUI(date: LocalDate, monthEventsMap: Map<LocalDate,List<DayEvent>>?) {
val month = date.month
val year = Year.of(date.year)
val today = LocalDate.now()
val monthSummary = createMonthSummary(month, year, today, monthEventsMap)
Column(
modifier = GlanceModifier.background(if (monthSummary.isCurrentMonth) PeachPuff else Color.Transparent)
.padding(2.dp)
)
{
Text(
text = "$month $year",
style = S6_Black,
modifier = GlanceModifier.width(APP_WIDGET_CALENDAR_DAY_SIZE * 7) // horiz alignment issues. So, 24.dp X 7 days = width
)
Spacer(modifier = GlanceModifier.padding(all = 2.dp))
// Days of Week labels
Row {
listOf(
DayOfWeek.SUNDAY,
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY,
DayOfWeek.SATURDAY
).forEach {
Column {
Text(
text = it.name.take(1),
style = S6_Black,
modifier = GlanceModifier.size(APP_WIDGET_CALENDAR_DAY_SIZE)
)
}
}
}
// Show weeks in given month
monthSummary.weekRows.forEach { weekSummary ->
WeekCalendarWidgetUI(weekSummary)
}
// append extra row(s) if < 6 weeks in current month (to avoid shift - rarely there are 6 weeks in a month)
when (monthSummary.weekRows.size) {
4 -> Spacer(modifier = GlanceModifier.size(APP_WIDGET_CALENDAR_DAY_SIZE * 2))
5 -> Spacer(modifier = GlanceModifier.size(APP_WIDGET_CALENDAR_DAY_SIZE))
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="ConsolidatorAppTheme" parent="Theme.Material3.DayNight.NoActionBar">
</style>
</resources>
package com.bottlerocket.android.consolidator.appwidget.ui
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.background
import androidx.glance.layout.*
import androidx.glance.text.Text
import com.bottlerocket.android.consolidator.ui.MainActivity
import com.bottlerocket.common.consolidator.ui.util.APP_WIDGET_CALENDAR_DAY_SIZE
import com.bottlerocket.common.consolidator.ui.util.calendar.getCalendarDayBackgroundColor
import com.bottlerocket.common.consolidator.viewmodels.WeekSummary
/**
* Represents a week Calendar widget showing seven days.
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun WeekCalendarWidgetUI(weekSummary: WeekSummary) {
Row {
weekSummary.daySummaryList.forEach { daySummary ->
Column {
Text(
text = if (daySummary.dayOfMonth > 0) "${daySummary.dayOfMonth}" else "",
style = B6_Black,
modifier = GlanceModifier.size(APP_WIDGET_CALENDAR_DAY_SIZE)
.background(getCalendarDayBackgroundColor(weekSummary, daySummary))
.padding(top = 1.dp, bottom = 1.dp)
.clickable(onClick = actionStartActivity<MainActivity>())
)
}
}
}
}
package com.bottlerocket.android.consolidator.appwidget.ui.util
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.bottlerocket.common.consolidator.viewmodels.DayEvent
import com.bottlerocket.common.consolidator.viewmodels.LocalDateDeserializer
import com.bottlerocket.common.consolidator.viewmodels.LocalDateSerializer
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import java.time.LocalDate
import java.time.LocalDateTime
/**
* DataStore access: First Month.
*/
const val KEY_FIRST_MONTH = "FirstMonth" // For DataStore
fun Preferences.getFirstMonth(): LocalDate {
val currentFirstMonthPrefValue = this[longPreferencesKey(KEY_FIRST_MONTH)]
return currentFirstMonthPrefValue?.let { prefVal ->
LocalDate.ofEpochDay(prefVal)
} ?: LocalDate.now()
}
/**
* Casting [Preferences] as [MutablePreferences] allows correct handling and caller does not have to
* explicitly pass [Preferences.toMutablePreferences].
*/
fun MutablePreferences.setFirstMonth(date: LocalDate) {
this[longPreferencesKey(KEY_FIRST_MONTH)] = date.toEpochDay()
}
/**
* DataStore access: Dynamic Calendar Data.
*/
const val KEY_CALENDAR_DATA = "CalendarData" // For DataStore
fun Preferences.getCalendarData(): List<DayEvent> {
val calendarDataJsonStr = this[stringPreferencesKey(KEY_CALENDAR_DATA)]
val typeToken = object : TypeToken<List<DayEvent>>() {}.type
val gson = GsonBuilder()
.registerTypeAdapter(LocalDateTime::class.java, LocalDateDeserializer())
.create()
return gson.fromJson(calendarDataJsonStr, typeToken) ?: listOf()
}
fun MutablePreferences.setCalendarData(events: List<DayEvent>) {
val typeToken = object: TypeToken<List<DayEvent>>(){}.type
val gson = GsonBuilder()
.registerTypeAdapter(LocalDateTime::class.java, LocalDateSerializer())
.create()
val jsonStr = gson.toJson(events, typeToken).toString()
this[stringPreferencesKey(KEY_CALENDAR_DATA)] = jsonStr
}
@davehorner
Copy link

davehorner commented Aug 19, 2023

The article and code is nice to share. I don't know how many people have time to unpack all of these from a zip and try to actually see if everything is there. Do you have the code in a repo that can be cloned with structure included? Thanks regardless.

missing at least:
import com.bottlerocket.common.consolidator.util.DebugUtils.calendarWidgetLog
import com.bottlerocket.common.consolidator.viewmodels.DayEvent

@harishrpatel
Copy link
Author

harishrpatel commented Aug 19, 2023 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment