Migrate calendar widget to Compose

This commit is contained in:
MM20 2022-02-17 22:52:04 +01:00
parent 386083ede9
commit ca813f3f70
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
7 changed files with 247 additions and 166 deletions

View File

@ -21,9 +21,16 @@ fun ColumnScope.CalendarResults() {
AnimatedVisibility(calendarEvents.isNotEmpty()) { AnimatedVisibility(calendarEvents.isNotEmpty()) {
LauncherCard( LauncherCard(
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth() modifier = Modifier
.padding(bottom = 8.dp)
.fillMaxWidth()
) { ) {
SearchResultList(items = calendarEvents) SearchResultList(
items = calendarEvents,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
)
} }
} }
} }

View File

@ -1,6 +1,5 @@
package de.mm20.launcher2.ui.launcher.search.common.list package de.mm20.launcher2.ui.launcher.search.common.list
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -11,11 +10,12 @@ import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.data.Searchable import de.mm20.launcher2.search.data.Searchable
@Composable @Composable
fun SearchResultList(items: List<Searchable>) { fun SearchResultList(
items: List<Searchable>,
modifier: Modifier = Modifier
) {
Column( Column(
modifier = Modifier modifier = modifier
.fillMaxWidth()
.padding(12.dp)
) { ) {
for (item in items) { for (item in items) {
key(item.key) { key(item.key) {

View File

@ -21,9 +21,16 @@ fun ColumnScope.ContactResults() {
AnimatedVisibility(contacts.isNotEmpty()) { AnimatedVisibility(contacts.isNotEmpty()) {
LauncherCard( LauncherCard(
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth() modifier = Modifier
.padding(bottom = 8.dp)
.fillMaxWidth()
) { ) {
SearchResultList(items = contacts) SearchResultList(
items = contacts,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
)
} }
} }
} }

View File

@ -21,9 +21,16 @@ fun ColumnScope.FileResults() {
AnimatedVisibility(files.isNotEmpty()) { AnimatedVisibility(files.isNotEmpty()) {
LauncherCard( LauncherCard(
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth() modifier = Modifier
.padding(bottom = 8.dp)
.fillMaxWidth()
) { ) {
SearchResultList(items = files) SearchResultList(
items = files,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
)
} }
} }
} }

View File

@ -0,0 +1,182 @@
package de.mm20.launcher2.ui.launcher.widgets.calendar
import android.content.Context
import android.text.format.DateUtils
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.InnerCard
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.launcher.search.common.list.SearchResultList
import de.mm20.launcher2.ui.pluralResource
import java.time.LocalDate
import java.time.ZoneId
@Composable
fun CalendarWidget() {
val viewModel: CalendarWidgetVM = viewModel()
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(null) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.onActive()
}
}
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 4.dp)
) {
IconButton(onClick = { viewModel.previousDay() }) {
Icon(imageVector = Icons.Rounded.ChevronLeft, contentDescription = null)
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
val selectedDate by viewModel.selectedDate.observeAsState(LocalDate.now())
var showDropdown by remember { mutableStateOf(false) }
TextButton(onClick = { showDropdown = true }) {
Text(
text = formatDay(context, selectedDate),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
imageVector = Icons.Rounded.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
}
DropdownMenu(expanded = showDropdown, onDismissRequest = { showDropdown = false }) {
val availableDates = viewModel.availableDates
for (date in availableDates) {
DropdownMenuItem(text = {
Text(formatDay(context, date))
}, onClick = {
viewModel.selectDate(date)
showDropdown = false
})
}
}
}
IconButton(onClick = { viewModel.nextDay() }) {
Icon(imageVector = Icons.Rounded.ChevronRight, contentDescription = null)
}
IconButton(onClick = { viewModel.createEvent(context) }) {
Icon(imageVector = Icons.Rounded.Add, contentDescription = null)
}
IconButton(onClick = { viewModel.openCalendarApp(context) }) {
Icon(imageVector = Icons.Rounded.OpenInNew, contentDescription = null)
}
}
val events by viewModel.calendarEvents.observeAsState(emptyList())
val hasPermission by viewModel.hasPermission.observeAsState()
Column(
modifier = Modifier
.padding(horizontal = 12.dp)
.padding(bottom = 12.dp)
) {
if (hasPermission == false) {
MissingPermissionBanner(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
text = stringResource(R.string.permission_calendar_widget),
onClick = { viewModel.requestCalendarPermission(context as AppCompatActivity) }
)
}
if (events.isEmpty() && hasPermission == true) {
Info(text = stringResource(R.string.calendar_widget_no_events))
}
SearchResultList(
events,
modifier = Modifier
.fillMaxWidth()
)
val runningEvents by viewModel.hiddenPastEvents.observeAsState(0)
if (runningEvents > 0) {
Info(
text = pluralResource(
R.plurals.calendar_widget_running_events,
runningEvents,
runningEvents
),
onClick = {
viewModel.showAllEvents()
}
)
}
val pinnedEvents by viewModel.pinnedCalendarEvents.observeAsState(emptyList())
if (pinnedEvents.size > 0) {
Text(
stringResource(R.string.calendar_widget_pinned_events),
modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp),
style = MaterialTheme.typography.titleMedium
)
SearchResultList(
pinnedEvents,
modifier = Modifier
.fillMaxWidth()
)
}
}
}
}
@Composable
private fun Info(
text: String,
onClick: (() -> Unit)? = null,
) {
InnerCard(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.padding(12.dp)
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
) {
Text(text, style = MaterialTheme.typography.bodySmall)
}
}
}
private fun formatDay(context: Context, day: LocalDate): String {
val today = LocalDate.now()
return when {
today == day -> context.getString(R.string.date_today)
today.plusDays(1) == day -> context.getString(R.string.date_tomorrow)
else -> DateUtils.formatDateTime(
context,
day.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY or DateUtils.FORMAT_ABBREV_WEEKDAY
)
}
}

View File

@ -4,6 +4,7 @@ import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.provider.CalendarContract import android.provider.CalendarContract
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
@ -47,7 +48,7 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
val startDate = val startDate =
Instant.ofEpochMilli(it.startTime).atZone(ZoneId.systemDefault()).toLocalDate() Instant.ofEpochMilli(it.startTime).atZone(ZoneId.systemDefault()).toLocalDate()
val endDate = val endDate =
Instant.ofEpochMilli(it.startTime).atZone(ZoneId.systemDefault()).toLocalDate() Instant.ofEpochMilli(it.endTime).atZone(ZoneId.systemDefault()).toLocalDate()
return@flatMap listOf( return@flatMap listOf(
startDate, startDate,
endDate endDate
@ -121,7 +122,8 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
val totalCount = events.size val totalCount = events.size
events = events.filter { events = events.filter {
it.startTime >= date.atStartOfDay().toEpochSecond(offset) * 1000 it.startTime >= date.atStartOfDay().toEpochSecond(offset) * 1000 ||
it.endTime < date.atStartOfDay().plusDays(1).toEpochSecond(offset) * 1000
} }
val hiddenCount = totalCount - events.size val hiddenCount = totalCount - events.size
@ -138,4 +140,8 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
upcomingEvents = it upcomingEvents = it
} }
} }
fun requestCalendarPermission(context: AppCompatActivity) {
permissionsManager.requestPermission(context, PermissionGroup.Calendar)
}
} }

View File

@ -1,30 +1,18 @@
package de.mm20.launcher2.ui.legacy.widget package de.mm20.launcher2.ui.legacy.widget
import android.animation.LayoutTransition
import android.content.Context import android.content.Context
import android.text.format.DateUtils
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import androidx.compose.foundation.layout.Column
import android.view.Menu import androidx.compose.material3.LocalAbsoluteTonalElevation
import android.view.View import androidx.compose.material3.LocalContentColor
import androidx.activity.viewModels import androidx.compose.material3.MaterialTheme
import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.CompositionLocalProvider
import androidx.appcompat.widget.PopupMenu import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle import androidx.compose.ui.unit.dp
import androidx.lifecycle.repeatOnLifecycle import de.mm20.launcher2.ui.MdcLauncherTheme
import de.mm20.launcher2.ktx.lifecycleOwner
import de.mm20.launcher2.ktx.lifecycleScope
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.search.data.MissingPermission
import de.mm20.launcher2.search.data.Searchable
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.databinding.ViewCalendarWidgetBinding import de.mm20.launcher2.ui.base.ProvideSettings
import de.mm20.launcher2.ui.launcher.widgets.calendar.CalendarWidgetVM import de.mm20.launcher2.ui.launcher.widgets.calendar.CalendarWidget
import de.mm20.launcher2.ui.legacy.data.InformationText
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.ZoneId
class CalendarWidget : LauncherWidget { class CalendarWidget : LauncherWidget {
@ -39,141 +27,25 @@ class CalendarWidget : LauncherWidget {
defStyleRes defStyleRes
) )
private fun formatDay(day: LocalDate): String {
val today = LocalDate.now()
return when {
today == day -> context.getString(R.string.date_today)
today.plusDays(1) == day -> context.getString(R.string.date_tomorrow)
else -> DateUtils.formatDateTime(
context,
day.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY or DateUtils.FORMAT_ABBREV_WEEKDAY
)
}
}
private val binding =
ViewCalendarWidgetBinding.inflate(LayoutInflater.from(context), this, true)
private val viewModel: CalendarWidgetVM by (context as AppCompatActivity).viewModels()
init { init {
clipToPadding = false val composeView = ComposeView(context)
clipChildren = false composeView.setContent {
MdcLauncherTheme {
lifecycleScope.launch { ProvideSettings {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { // TODO: Temporary solution until parent widget card is rewritten in Compose
viewModel.onActive() CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onSurface,
LocalAbsoluteTonalElevation provides 1.dp
) {
Column {
CalendarWidget()
}
}
}
} }
} }
addView(composeView)
binding.calendarNewEvent.setOnClickListener {
viewModel.createEvent(context)
}
binding.calendarDate.setOnClickListener {
val menu = PopupMenu(context, binding.calendarDate)
val availableDates = viewModel.availableDates
for ((i, d) in availableDates.withIndex()) {
menu.menu.add(
Menu.NONE,
i,
Menu.NONE,
formatDay(d)
)
}
menu.setOnMenuItemClickListener {
viewModel.selectDate(availableDates[it.itemId])
true
}
menu.show()
}
binding.calendarOpenApp.setOnClickListener {
viewModel.openCalendarApp(context)
}
binding.calendarDateNext.setOnClickListener {
viewModel.nextDay()
}
binding.calendarDatePrev.setOnClickListener {
viewModel.previousDay()
}
val calendarEvents = viewModel.calendarEvents
val pinnedCalendarEvents = viewModel.pinnedCalendarEvents
val hiddenPastEvents = viewModel.hiddenPastEvents
val selectedDate = viewModel.selectedDate
val hasPermission = viewModel.hasPermission
calendarEvents.observe(context as AppCompatActivity) {
updateEventList(it, hiddenPastEvents.value ?: 0, hasPermission.value == false)
}
hasPermission.observe(context as AppCompatActivity) {
updateEventList(
calendarEvents.value ?: emptyList(),
hiddenPastEvents.value ?: 0,
it == false
)
}
pinnedCalendarEvents.observe(context as AppCompatActivity) {
binding.calendarWidgetPinnedList.submitItems(it)
if (it.isEmpty()) {
binding.calendarWidgetPinnedList.visibility = View.GONE
binding.calendarUpcomingEventsTitle.visibility = View.GONE
} else {
binding.calendarWidgetPinnedList.visibility = View.VISIBLE
binding.calendarUpcomingEventsTitle.visibility = View.VISIBLE
}
}
selectedDate.observe(context as AppCompatActivity) {
binding.calendarDate.text = formatDay(it)
}
binding.calendarWidgetRoot.layoutTransition = LayoutTransition().apply {
enableTransitionType(LayoutTransition.CHANGING)
}
}
private fun updateEventList(
events: List<CalendarEvent>,
hiddenPastDayEvents: Int,
missingPermission: Boolean
) {
val items = events.toMutableList<Searchable>()
if (missingPermission) {
items.add(
MissingPermission(
context.getString(R.string.permission_calendar_widget),
PermissionGroup.Calendar
)
)
}
if (events.isEmpty() && !missingPermission) {
items.add(
InformationText(context.getString(R.string.calendar_widget_no_events))
)
}
if (hiddenPastDayEvents > 0) {
items.add(
InformationText(
resources.getQuantityString(
R.plurals.calendar_widget_running_events,
hiddenPastDayEvents,
hiddenPastDayEvents
)
) {
viewModel.showAllEvents()
})
}
binding.calendarWidgetList.submitItems(items)
} }