Last active
September 6, 2023 10:09
-
-
Save harishrpatel/adcac2edfa930351c7789ede59214a7e to your computer and use it in GitHub Desktop.
Android Glance App Widget Files
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
... | |
<!-- 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> | |
... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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" | |
/> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# This needs to be added to be able to compile. | |
kotlin.mpp.androidSourceSetLayoutVersion=2 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.") | |
} | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="utf-8"?> | |
<resources> | |
<style name="ConsolidatorAppTheme" parent="Theme.Material3.DayNight.NoActionBar"> | |
</style> | |
</resources> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>()) | |
) | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
David
Unfortunately this is part of a larger proprietary code base. So, I cannot
share whole source code. But let me know if you have specific questions.
…On Sat, Aug 19, 2023, 6:43 AM David Horner ***@***.***> wrote:
***@***.**** commented on this gist.
------------------------------
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.
—
Reply to this email directly, view it on GitHub
<https://gist.github.com/harishrpatel/adcac2edfa930351c7789ede59214a7e#gistcomment-4665393>
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ACKXMPQBSRD5642AATSZ4A3XWAY6ZBFKMF2HI4TJMJ2XIZLTSKBKK5TBNR2WLJDHNFZXJJDOMFWWLK3UNBZGKYLEL52HS4DFQKSXMYLMOVS2I5DSOVS2I3TBNVS3W5DIOJSWCZC7OBQXE5DJMNUXAYLOORPWCY3UNF3GS5DZVRZXKYTKMVRXIX3UPFYGLK2HNFZXIQ3PNVWWK3TUUZ2G64DJMNZZDAVEOR4XAZNEM5UXG5FFOZQWY5LFVEYTEMJTGAZTIMJUU52HE2LHM5SXFJTDOJSWC5DF>
.
You are receiving this email because you authored the thread.
Triage notifications on the go with GitHub Mobile for iOS
<https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
or Android
<https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub>
.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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