From 9fb782e719db4947f12ab162c5ba707ddff033d7 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Tue, 11 Jul 2023 19:23:03 +0100 Subject: [PATCH 1/5] Import datetime module from Syer10/compose-material-dialogs --- common/ui/compose/build.gradle.kts | 2 +- .../common/compose/ui/DateTimeTextFields.kt | 134 ++-- settings.gradle.kts | 1 + .../datetime/build.gradle.kts | 33 + .../datetime/util/AndroidUtils.kt | 52 ++ .../datetime/date/DatePicker.kt | 456 +++++++++++ .../datetime/date/DatePickerColors.kt | 61 ++ .../datetime/date/DatePickerDefaults.kt | 43 + .../datetime/date/DatePickerState.kt | 15 + .../datetime/time/TimePicker.kt | 743 ++++++++++++++++++ .../datetime/time/TimePickerColors.kt | 90 +++ .../datetime/time/TimePickerDefaults.kt | 50 ++ .../datetime/time/TimePickerState.kt | 60 ++ .../datetime/util/Composables.kt | 25 + .../datetime/util/Extensions.kt | 63 ++ .../datetime/util/Utils.kt | 23 + .../datetime/util/WeekFields.kt | 11 + .../datetime/util/IosUIUtils.kt | 51 ++ .../datetime/util/IosUtils.kt | 87 ++ .../datetime/util/IosWeekFields.kt | 17 + .../datetime/util/JvmExtensions.kt | 34 + .../datetime/util/JvmWeekFields.kt | 16 + .../datetime/util/DesktopUtils.kt | 51 ++ 23 files changed, 2038 insertions(+), 80 deletions(-) create mode 100644 thirdparty/compose-material-dialogs/datetime/build.gradle.kts create mode 100644 thirdparty/compose-material-dialogs/datetime/src/androidMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/AndroidUtils.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerColors.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerDefaults.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerState.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePicker.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerColors.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerDefaults.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerState.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Composables.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Extensions.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Utils.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/WeekFields.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosUIUtils.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosUtils.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosWeekFields.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/jvmCommon/kotlin/com/vanpra/composematerialdialogs/datetime/util/JvmExtensions.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/jvmCommon/kotlin/com/vanpra/composematerialdialogs/datetime/util/JvmWeekFields.kt create mode 100644 thirdparty/compose-material-dialogs/datetime/src/jvmMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/DesktopUtils.kt diff --git a/common/ui/compose/build.gradle.kts b/common/ui/compose/build.gradle.kts index 13a1a6619e..0588ba9a3a 100644 --- a/common/ui/compose/build.gradle.kts +++ b/common/ui/compose/build.gradle.kts @@ -32,7 +32,7 @@ kotlin { api(libs.insetsx) implementation(libs.materialdialogs.core) - implementation(libs.materialdialogs.datetime) + implementation(projects.thirdparty.composeMaterialDialogs.datetime) implementation(libs.uuid) diff --git a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt index f10c1ec8dc..200c439c26 100644 --- a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt +++ b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -16,22 +15,22 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp import app.tivi.common.compose.LocalTiviDateFormatter -import app.tivi.common.compose.Material3Dialog +import app.tivi.common.compose.TiviDialog import app.tivi.common.ui.resources.MR -import com.vanpra.composematerialdialogs.datetime.date.DatePickerDefaults -import com.vanpra.composematerialdialogs.datetime.date.datepicker -import com.vanpra.composematerialdialogs.datetime.time.TimePickerDefaults -import com.vanpra.composematerialdialogs.datetime.time.timepicker -import com.vanpra.composematerialdialogs.rememberMaterialDialogState +import com.vanpra.composematerialdialogs.datetime.date.DatePicker +import com.vanpra.composematerialdialogs.datetime.time.TimePicker import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.filterNotNull import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate @@ -55,51 +54,40 @@ fun DateTextField( selectedDate?.let { dateFormatter.formatShortDate(it) } } - val dialogState = rememberMaterialDialogState() + var showDialog by remember { mutableStateOf(false) } + var date by remember { mutableStateOf(selectedDate) } + + LaunchedEffect(Unit) { + snapshotFlow { date } + .filterNotNull() + .collect { onDateSelected(it) } + } ClickableReadOnlyOutlinedTextField( value = formattedDate.orEmpty(), label = { Text(text = stringResource(MR.strings.date_label)) }, - onClick = { dialogState.show() }, + onClick = { showDialog = true }, modifier = Modifier.fillMaxWidth(), ) - var date by remember { mutableStateOf(selectedDate) } - - Material3Dialog( - dialogState = dialogState, - buttons = { - positiveButton( - text = stringResource(MR.strings.button_confirm), - textStyle = MaterialTheme.typography.labelLarge, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.primary, - ), - onClick = { date?.let(onDateSelected) }, + if (showDialog) { + TiviDialog( + onDismissRequest = { showDialog = false }, + ) { + DatePicker( + title = dialogTitle, + initialDate = selectedDate ?: remember { + Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date + }, + allowedDateValidator = { date -> + // Only allow dates in the past + date.toInstant() < Clock.System.now() + }, + onDateChange = { date = it }, ) - }, - ) { - datepicker( - title = dialogTitle, - initialDate = selectedDate ?: remember { - Clock.System.now() - .toLocalDateTime(TimeZone.currentSystemDefault()) - .date - }, - colors = DatePickerDefaults.colors( - headerBackgroundColor = MaterialTheme.colorScheme.primary, - headerTextColor = MaterialTheme.colorScheme.onPrimary, - calendarHeaderTextColor = MaterialTheme.colorScheme.onBackground, - dateActiveBackgroundColor = MaterialTheme.colorScheme.primary, - dateActiveTextColor = MaterialTheme.colorScheme.onPrimary, - dateInactiveTextColor = MaterialTheme.colorScheme.onBackground, - ), - allowedDateValidator = { date -> - // Only allow dates in the past - date.toInstant() < Clock.System.now() - }, - onDateChange = { date = it }, - ) + } } } } @@ -126,49 +114,37 @@ fun TimeTextField( selectedTime?.let { dateFormatter.formatShortTime(it) } } - val dialogState = rememberMaterialDialogState() + var showDialog by remember { mutableStateOf(false) } + var time by remember { mutableStateOf(selectedTime) } + + LaunchedEffect(Unit) { + snapshotFlow { time } + .filterNotNull() + .collect { onTimeSelected(it) } + } ClickableReadOnlyOutlinedTextField( value = formattedTime.orEmpty(), label = { Text(text = stringResource(MR.strings.time_label)) }, - onClick = { dialogState.show() }, + onClick = { showDialog = true }, modifier = Modifier.fillMaxWidth(), ) - var time by remember { mutableStateOf(selectedTime) } - - Material3Dialog( - dialogState = dialogState, - buttons = { - positiveButton( - text = stringResource(MR.strings.button_confirm), - textStyle = MaterialTheme.typography.labelLarge, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.primary, - ), - onClick = { time?.let(onTimeSelected) }, + if (showDialog) { + TiviDialog( + onDismissRequest = { showDialog = false }, + ) { + TimePicker( + title = dialogTitle, + initialTime = selectedTime ?: remember { + Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .time + }, + is24HourClock = is24Hour, + onTimeChange = { time = it }, ) - }, - ) { - timepicker( - title = dialogTitle, - initialTime = selectedTime ?: remember { - Clock.System.now() - .toLocalDateTime(TimeZone.currentSystemDefault()) - .time - }, - colors = TimePickerDefaults.colors( - activeBackgroundColor = MaterialTheme.colorScheme.primary.copy(0.6f), - inactiveBackgroundColor = MaterialTheme.colorScheme.onBackground.copy(0.15f), - inactiveTextColor = MaterialTheme.colorScheme.onBackground, - selectorColor = MaterialTheme.colorScheme.primary, - selectorTextColor = MaterialTheme.colorScheme.onPrimary, - headerTextColor = MaterialTheme.colorScheme.onBackground, - borderColor = MaterialTheme.colorScheme.onBackground, - ), - is24HourClock = is24Hour, - onTimeChange = { time = it }, - ) + } } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 5a5ccbc6cb..774f951769 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -100,4 +100,5 @@ include( ":android-app:common-test", ":desktop-app", ":thirdparty:swipe", + ":thirdparty:compose-material-dialogs:datetime", ) diff --git a/thirdparty/compose-material-dialogs/datetime/build.gradle.kts b/thirdparty/compose-material-dialogs/datetime/build.gradle.kts new file mode 100644 index 0000000000..75c4cc0dfa --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/build.gradle.kts @@ -0,0 +1,33 @@ +// Copyright 2023, Christopher Banes and the Tivi project contributors +// SPDX-License-Identifier: Apache-2.0 + + +plugins { + id("app.tivi.android.library") + id("app.tivi.kotlin.multiplatform") + alias(libs.plugins.composeMultiplatform) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kotlinx.datetime) + implementation(compose.foundation) + implementation(compose.material3) + } + } + + val jvmCommon by creating + val androidMain by getting { + dependsOn(jvmCommon) + } + val jvmMain by getting { + dependsOn(jvmCommon) + } + } +} + +android { + namespace = "com.vanpra.composematerialdialogs.datetime" +} diff --git a/thirdparty/compose-material-dialogs/datetime/src/androidMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/AndroidUtils.kt b/thirdparty/compose-material-dialogs/datetime/src/androidMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/AndroidUtils.kt new file mode 100644 index 0000000000..f4b8883e95 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/androidMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/AndroidUtils.kt @@ -0,0 +1,52 @@ +package com.vanpra.composematerialdialogs.datetime.util + +import android.graphics.Paint +import android.graphics.Rect +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalConfiguration +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sin + +@Composable +internal actual fun isSmallDevice(): Boolean { + return LocalConfiguration.current.screenWidthDp <= 360 +} + +@Composable +internal actual fun isLargeDevice(): Boolean { + return LocalConfiguration.current.screenWidthDp <= 600 +} + +internal actual fun Canvas.drawText( + text: String, + x: Float, + y: Float, + color: Color, + textSize: Float, + angle: Float, + radius: Float, + isCenter: Boolean?, + alpha: Int, +) { + val outerText = Paint() + outerText.color = color.toArgb() + outerText.textSize = textSize + outerText.textAlign = + if (isCenter == true) Paint.Align.CENTER else if (isCenter == false) Paint.Align.LEFT else Paint.Align.RIGHT + outerText.alpha = maxOf(0, minOf(alpha * 255, 255)) + + val r = Rect() + outerText.getTextBounds(text, 0, text.length, r) + + nativeCanvas.drawText( + text, + x + (radius * cos(angle)), + y + (radius * sin(angle)) + (abs(r.height())) / 2, + outerText + ) +} \ No newline at end of file diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt new file mode 100644 index 0000000000..c8ce7dfe90 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt @@ -0,0 +1,456 @@ +package com.vanpra.composematerialdialogs.datetime.date + +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.paddingFromBaseline +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.vanpra.composematerialdialogs.datetime.util.WeekFields +import com.vanpra.composematerialdialogs.datetime.util.getFullLocalName +import com.vanpra.composematerialdialogs.datetime.util.getNarrowDisplayName +import com.vanpra.composematerialdialogs.datetime.util.getShortLocalName +import com.vanpra.composematerialdialogs.datetime.util.isLeapYear +import com.vanpra.composematerialdialogs.datetime.util.isSmallDevice +import com.vanpra.composematerialdialogs.datetime.util.plusDays +import com.vanpra.composematerialdialogs.datetime.util.testLength +import com.vanpra.composematerialdialogs.datetime.util.withDayOfMonth +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.toLocalDateTime + +/** + * @brief A date picker body layout + * + * @param initialDate time to be shown to the user when the dialog is first shown. + * Defaults to the current date if this is not set + * @param yearRange the range of years the user should be allowed to pick from + * @param onDateChange callback with a LocalDateTime object when the user completes their input + * @param allowedDateValidator when this returns true the date will be selectable otherwise it won't be + */ +@Composable +fun DatePicker( + initialDate: LocalDate = remember { + Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date + }, + title: String = "", + colors: DatePickerColors = DatePickerDefaults.colors(), + yearRange: IntRange = IntRange(1900, 2100), + allowedDateValidator: (LocalDate) -> Boolean = { true }, + locale: Locale = Locale.current, + onDateChange: (LocalDate) -> Unit = {}, +) { + val datePickerState = remember { + DatePickerState(initialDate, colors, yearRange) + } + + DatePickerImpl( + title = title, + state = datePickerState, + allowedDateValidator = allowedDateValidator, + locale = locale, + ) + + LaunchedEffect(datePickerState) { + snapshotFlow { datePickerState.selected } + .collect { onDateChange(it) } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun DatePickerImpl( + title: String, + state: DatePickerState, + allowedDateValidator: (LocalDate) -> Boolean, + locale: Locale, +) { + val pagerState = rememberPagerState( + initialPage = (state.selected.year - state.yearRange.first) * 12 + state.selected.monthNumber - 1, + ) + val pageCount = (state.yearRange.last - state.yearRange.first + 1) * 12 + + Column(Modifier.fillMaxWidth()) { + CalendarHeader(title, state, locale) + HorizontalPager( + pageCount = pageCount, + state = pagerState, + verticalAlignment = Alignment.Top, + modifier = Modifier.height(336.dp), + ) { page -> + val viewDate = remember { + LocalDate( + state.yearRange.first + page / 12, + page % 12 + 1, + 1, + ) + } + + Column { + CalendarViewHeader(viewDate, state, pagerState, locale, pageCount) + Box { + androidx.compose.animation.AnimatedVisibility( + state.yearPickerShowing, + modifier = Modifier + .zIndex(0.7f) + .clipToBounds(), + enter = slideInVertically(initialOffsetY = { -it }), + exit = slideOutVertically(targetOffsetY = { -it }), + ) { + YearPicker(viewDate, state, pagerState) + } + + CalendarView(viewDate, state, locale, allowedDateValidator) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun YearPicker( + viewDate: LocalDate, + state: DatePickerState, + pagerState: PagerState, +) { + val gridState = rememberLazyGridState(viewDate.year - state.yearRange.first) + val coroutineScope = rememberCoroutineScope() + + Surface { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + state = gridState, + ) { + itemsIndexed(state.yearRange.toList()) { _, item -> + val selected = remember { item == viewDate.year } + YearPickerItem(year = item, selected = selected, colors = state.colors) { + if (!selected) { + coroutineScope.launch { + pagerState.scrollToPage( + pagerState.currentPage + (item - viewDate.year) * 12, + ) + } + } + state.yearPickerShowing = false + } + } + } + } +} + +@Composable +private fun YearPickerItem( + year: Int, + selected: Boolean, + colors: DatePickerColors, + onClick: () -> Unit, +) { + Box(Modifier.size(88.dp, 52.dp), contentAlignment = Alignment.Center) { + Box( + Modifier + .size(72.dp, 36.dp) + .clip(RoundedCornerShape(16.dp)) + .background(colors.dateBackgroundColor(selected).value) + .clickable( + onClick = onClick, + interactionSource = MutableInteractionSource(), + indication = null, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = year.toString(), + style = MaterialTheme.typography.labelLarge, + color = colors.dateTextColor(selected, true).value, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CalendarViewHeader( + viewDate: LocalDate, + state: DatePickerState, + pagerState: PagerState, + locale: Locale, + pageCount: Int, +) { + val coroutineScope = rememberCoroutineScope() + val month = remember { viewDate.month.getFullLocalName(locale) } + + Box( + Modifier + .padding(top = 16.dp, bottom = 16.dp, start = 24.dp, end = 24.dp) + .height(24.dp) + .fillMaxWidth(), + ) { + Row( + Modifier + .fillMaxHeight() + .align(Alignment.CenterStart) + .clickable(onClick = { state.yearPickerShowing = !state.yearPickerShowing }), + ) { + Text( + text = "$month ${viewDate.year}", + modifier = Modifier + .paddingFromBaseline(top = 16.dp) + .wrapContentSize(Alignment.Center), + style = MaterialTheme.typography.titleSmall, + color = state.colors.calendarHeaderTextColor, + ) + + Spacer(Modifier.width(4.dp)) + Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = "Year Selector", + tint = state.colors.calendarHeaderTextColor, + modifier = Modifier.rotate(if (state.yearPickerShowing) 180F else 0F), + ) + } + } + + Row( + Modifier + .fillMaxHeight() + .align(Alignment.CenterEnd), + ) { + Icon( + Icons.Default.KeyboardArrowLeft, + contentDescription = "Previous Month", + modifier = Modifier + .testTag("dialog_date_prev_month") + .size(24.dp) + .clickable( + onClick = { + coroutineScope.launch { + if (pagerState.currentPage - 1 >= 0) { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + } + }, + ), + tint = state.colors.calendarHeaderTextColor, + ) + + Spacer(modifier = Modifier.width(24.dp)) + + Icon( + Icons.Default.KeyboardArrowRight, + contentDescription = "Next Month", + modifier = Modifier + .testTag("dialog_date_next_month") + .size(24.dp) + .clickable( + onClick = { + coroutineScope.launch { + if (pagerState.currentPage + 1 < pageCount) + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + }, + ), + tint = state.colors.calendarHeaderTextColor, + ) + } + } +} + +@Composable +private fun CalendarView( + viewDate: LocalDate, + state: DatePickerState, + locale: Locale, + allowedDateValidator: (LocalDate) -> Boolean, +) { + Column( + Modifier + .padding(start = 12.dp, end = 12.dp) + .testTag("dialog_date_calendar"), + ) { + DayOfWeekHeader(state, locale) + val calendarDatesData = remember { getDates(viewDate, locale) } + val datesList = remember { IntRange(1, calendarDatesData.second).toList() } + val possibleSelected = remember(state.selected) { + viewDate.year == state.selected.year && viewDate.month == state.selected.month + } + + LazyVerticalGrid(columns = GridCells.Fixed(7), modifier = Modifier.height(240.dp)) { + for (x in 0 until calendarDatesData.first) { + item { Box(Modifier.size(40.dp)) } + } + + items(datesList) { + val selected = remember(state.selected) { + possibleSelected && it == state.selected.dayOfMonth + } + val date = viewDate.withDayOfMonth(it) + val enabled = allowedDateValidator(date) + DateSelectionBox(it, selected, state.colors, enabled) { + state.selected = date + } + } + } + } +} + +@Composable +private fun DateSelectionBox( + date: Int, + selected: Boolean, + colors: DatePickerColors, + enabled: Boolean, + onClick: () -> Unit, +) { + Box( + Modifier + .testTag("dialog_date_selection_$date") + .size(40.dp) + .clickable(enabled = enabled, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = date.toString(), + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(colors.dateBackgroundColor(selected).value) + .wrapContentSize(Alignment.Center), + style = MaterialTheme.typography.labelMedium, + color = colors.dateTextColor(selected, enabled).value, + ) + } +} + +@Composable +private fun DayOfWeekHeader(state: DatePickerState, locale: Locale) { + val dayHeaders = WeekFields.of(locale).firstDayOfWeek.let { firstDayOfWeek -> + (0L until 7L).map { + firstDayOfWeek.plusDays(it).getNarrowDisplayName(locale) + } + } + + Row( + modifier = Modifier + .height(40.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + LazyVerticalGrid(columns = GridCells.Fixed(7)) { + dayHeaders.forEach { + item { + Box(Modifier.size(40.dp)) { + Text( + it, + modifier = Modifier + .alpha(0.8f) + .fillMaxSize() + .wrapContentSize(Alignment.Center), + style = MaterialTheme.typography.labelLarge, + color = state.colors.calendarHeaderTextColor, + ) + } + } + } + } + } +} + +@Composable +private fun CalendarHeader(title: String, state: DatePickerState, locale: Locale) { + val month = remember(state.selected) { state.selected.month.getShortLocalName(locale) } + val day = remember(state.selected) { state.selected.dayOfWeek.getShortLocalName(locale) } + + Box( + Modifier + .background(state.colors.headerBackgroundColor) + .fillMaxWidth(), + ) { + Column(Modifier.padding(start = 24.dp, end = 24.dp)) { + Text( + text = title, + modifier = Modifier.paddingFromBaseline(top = if (isSmallDevice()) 24.dp else 32.dp), + color = state.colors.headerTextColor, + style = MaterialTheme.typography.labelMedium, + ) + + Box( + Modifier + .fillMaxWidth() + .paddingFromBaseline(top = if (isSmallDevice()) 0.dp else 64.dp), + ) { + Text( + text = "$day, $month ${state.selected.dayOfMonth}", + modifier = Modifier.align(Alignment.CenterStart), + color = state.colors.headerTextColor, + style = MaterialTheme.typography.headlineMedium, + ) + } + + Spacer(Modifier.height(if (isSmallDevice()) 8.dp else 16.dp)) + } + } +} + +private fun getDates(date: LocalDate, locale: Locale): Pair { + val numDays = date.month.testLength(date.year, date.isLeapYear) + + val firstDayOfWeek = WeekFields.of(locale).firstDayOfWeek.isoDayNumber + val firstDay = date.withDayOfMonth(1).dayOfWeek.isoDayNumber - firstDayOfWeek % 7 + + return Pair(firstDay, numDays) +} diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerColors.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerColors.kt new file mode 100644 index 0000000000..34be7dbf31 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerColors.kt @@ -0,0 +1,61 @@ +package com.vanpra.composematerialdialogs.datetime.date + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.graphics.Color + +/** + * Represents the colors used by a [timepicker] and its parts in different states + * + * See [DatePickerDefaults.colors] for the default implementation + */ +interface DatePickerColors { + val headerBackgroundColor: Color + val headerTextColor: Color + val calendarHeaderTextColor: Color + + /** + * Gets the background color dependant on if the item is active or not + * + * @param active true if the component/item is selected and false otherwise + * @return background color as a State + */ + @Composable + fun dateBackgroundColor(active: Boolean): State + + /** + * Gets the text color dependant on if the item is active or not + * + * @param active true if the component/item is selected and false otherwise + * @return text color as a State + */ + @Composable + fun dateTextColor(active: Boolean, enabled: Boolean): State +} + +internal class DefaultDatePickerColors( + override val headerBackgroundColor: Color, + override val headerTextColor: Color, + override val calendarHeaderTextColor: Color, + private val dateActiveBackgroundColor: Color, + private val dateInactiveBackgroundColor: Color, + private val dateActiveTextColor: Color, + private val dateInactiveTextColor: Color +) : DatePickerColors { + @Composable + override fun dateBackgroundColor(active: Boolean): State { + return rememberUpdatedState(if (active) dateActiveBackgroundColor else dateInactiveBackgroundColor) + } + + @Composable + override fun dateTextColor(active: Boolean, enabled: Boolean): State { + return rememberUpdatedState( + when { + active -> dateActiveTextColor + enabled -> dateInactiveTextColor + else -> dateInactiveTextColor.copy(alpha = 0.4f) + } + ) + } +} diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerDefaults.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerDefaults.kt new file mode 100644 index 0000000000..6c6e0c823c --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerDefaults.kt @@ -0,0 +1,43 @@ +package com.vanpra.composematerialdialogs.datetime.date + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * Object to hold default values used by [datepicker] + */ +object DatePickerDefaults { + /** + * Initialises a [DatePickerColors] object which represents the different colors used by + * the [datepicker] composable + * @param headerBackgroundColor background color of header + * @param headerTextColor color of text on the header + * @param calendarHeaderTextColor color of text on the calendar header (year selector + * and days of week) + * @param dateActiveBackgroundColor background color of date when selected + * @param dateActiveTextColor color of date text when selected + * @param dateInactiveBackgroundColor background color of date when not selected + * @param dateInactiveTextColor color of date text when not selected + */ + @Composable + fun colors( + headerBackgroundColor: Color = MaterialTheme.colorScheme.primary, + headerTextColor: Color = MaterialTheme.colorScheme.onPrimary, + calendarHeaderTextColor: Color = MaterialTheme.colorScheme.onBackground, + dateActiveBackgroundColor: Color = MaterialTheme.colorScheme.primary, + dateInactiveBackgroundColor: Color = Color.Transparent, + dateActiveTextColor: Color = MaterialTheme.colorScheme.onPrimary, + dateInactiveTextColor: Color = MaterialTheme.colorScheme.onBackground + ): DatePickerColors { + return DefaultDatePickerColors( + headerBackgroundColor = headerBackgroundColor, + headerTextColor = headerTextColor, + calendarHeaderTextColor = calendarHeaderTextColor, + dateActiveBackgroundColor = dateActiveBackgroundColor, + dateInactiveBackgroundColor = dateInactiveBackgroundColor, + dateActiveTextColor = dateActiveTextColor, + dateInactiveTextColor = dateInactiveTextColor, + ) + } +} diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerState.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerState.kt new file mode 100644 index 0000000000..30a1319bb8 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePickerState.kt @@ -0,0 +1,15 @@ +package com.vanpra.composematerialdialogs.datetime.date + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.datetime.LocalDate + +internal class DatePickerState( + initialDate: LocalDate, + val colors: DatePickerColors, + val yearRange: IntRange, +) { + var selected by mutableStateOf(initialDate) + var yearPickerShowing by mutableStateOf(false) +} diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePicker.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePicker.kt new file mode 100644 index 0000000000..aa53fd9ade --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePicker.kt @@ -0,0 +1,743 @@ +package com.vanpra.composematerialdialogs.datetime.time + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +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.paddingFromBaseline +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vanpra.composematerialdialogs.datetime.util.Max +import com.vanpra.composematerialdialogs.datetime.util.Min +import com.vanpra.composematerialdialogs.datetime.util.drawText +import com.vanpra.composematerialdialogs.datetime.util.getOffset +import com.vanpra.composematerialdialogs.datetime.util.isAM +import com.vanpra.composematerialdialogs.datetime.util.isSmallDevice +import com.vanpra.composematerialdialogs.datetime.util.noSeconds +import com.vanpra.composematerialdialogs.datetime.util.simpleHour +import com.vanpra.composematerialdialogs.datetime.util.toAM +import com.vanpra.composematerialdialogs.datetime.util.toPM +import com.vanpra.composematerialdialogs.datetime.util.withHour +import com.vanpra.composematerialdialogs.datetime.util.withMinute +import kotlin.math.PI +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +/* Offset of the clock line and selected circle */ +private data class SelectedOffset( + val lineOffset: Offset = Offset.Zero, + val selectedOffset: Offset = Offset.Zero, + val selectedRadius: Float = 0.0f, +) + +/** + * @brief A time picker dialog + * + * @param initialTime The time to be shown to the user when the dialog is first shown. + * Defaults to the current time if this is not set + * @param colors see [TimePickerColors] + * @param timeRange any time outside this range will be disabled + * @param is24HourClock uses the 24 hour clock face when true + * @param onTimeChange callback with a LocalTime object when the user completes their input + */ +@Composable +fun TimePicker( + initialTime: LocalTime = remember { + Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .time + .noSeconds() + }, + title: String = "SELECT TIME", + colors: TimePickerColors = TimePickerDefaults.colors(), + timeRange: ClosedRange = LocalTime.Min..LocalTime.Max, + is24HourClock: Boolean = false, + onTimeChange: (LocalTime) -> Unit = {}, +) { + val timePickerState = remember { + TimePickerState( + selectedTime = initialTime.coerceIn(timeRange), + colors = colors, + timeRange = timeRange, + is24Hour = is24HourClock, + ) + } + + LaunchedEffect(timePickerState) { + snapshotFlow { timePickerState.selectedTime } + .collect { onTimeChange(it) } + } + + TimePickerImpl(title = title, state = timePickerState) +} + +@Composable +internal fun TimePickerExpandedImpl( + modifier: Modifier = Modifier, + title: String, + state: TimePickerState, +) { + Column(modifier.padding(start = 24.dp, end = 24.dp)) { + Box(Modifier.align(Alignment.Start)) { + TimePickerTitle(Modifier.height(36.dp), title, state) + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Column( + Modifier + .padding(top = 72.dp, bottom = 50.dp) + .width(216.dp), + ) { + TimeLayout(state = state) + Spacer(modifier = Modifier.height(12.dp)) + HorizontalPeriodPicker(state = state) + } + + /* This isn't an exact match to the material spec as there is a contradiction it. + Dialogs should be limited to the size of 560 dp but given sizes for extended + time picker go over this limit */ + Spacer(modifier = Modifier.width(40.dp)) + Crossfade(state.currentScreen) { + when (it) { + ClockScreen.Hour -> if (state.is24Hour) { + ExtendedClockHourLayout(state = state) + } else { + ClockHourLayout(state = state) + } + + ClockScreen.Minute -> ClockMinuteLayout(state = state) + } + } + } + } +} + +@Composable +internal fun TimePickerImpl( + modifier: Modifier = Modifier, + title: String, + state: TimePickerState, +) { + Column( + modifier.padding(start = 24.dp, end = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (title != "") { + Box(Modifier.align(Alignment.Start)) { + TimePickerTitle(Modifier.height(52.dp), title, state) + } + } + + TimeLayout(state = state) + + Spacer(modifier = Modifier.height(if (isSmallDevice()) 24.dp else 36.dp)) + Crossfade(state.currentScreen) { + when (it) { + ClockScreen.Hour -> if (state.is24Hour) { + ExtendedClockHourLayout(state = state) + } else { + ClockHourLayout(state = state) + } + + ClockScreen.Minute -> ClockMinuteLayout(state = state) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun ExtendedClockHourLayout(state: TimePickerState) { + fun adjustAnchor(anchor: Int): Int = when (anchor) { + 0 -> 12 + 12 -> 0 + else -> anchor + } + + val isEnabled: (Int) -> Boolean = remember(state.timeRange) { + { index -> adjustAnchor(index) in state.hourRange() } + } + + ClockLayout( + anchorPoints = 12, + innerAnchorPoints = 12, + label = { index -> + /* Swapping 12 and 00 as this is the standard layout */ + when (index) { + 0 -> "12" + 12 -> "00" + else -> index.toString() + } + }, + onAnchorChange = { anchor -> + /* Swapping 12 and 00 as this is the standard layout */ + state.selectedTime = + state.selectedTime.withHour(adjustAnchor(anchor)).coerceIn(state.timeRange) + }, + startAnchor = adjustAnchor(state.selectedTime.hour), + onLift = { state.currentScreen = ClockScreen.Minute }, + colors = state.colors, + isAnchorEnabled = isEnabled, + ) +} + +@Composable +private fun ClockHourLayout(state: TimePickerState) { + fun adjustedHour(hour: Int): Int { + return if (state.selectedTime.isAM || hour == 12) hour else hour + 12 + } + + val isEnabled: (Int) -> Boolean = remember(state.timeRange, state.selectedTime) { + { index -> adjustedHour(index) in state.hourRange() } + } + + ClockLayout( + anchorPoints = 12, + label = { index -> if (index == 0) "12" else index.toString() }, + onAnchorChange = { hours -> + val adjustedHour = when (hours) { + 12 -> if (state.selectedTime.isAM) 0 else 12 + else -> if (state.selectedTime.isAM) hours else hours + 12 + } + state.selectedTime = state.selectedTime.withHour(adjustedHour).coerceIn(state.timeRange) + }, + startAnchor = state.selectedTime.simpleHour % 12, + onLift = { state.currentScreen = ClockScreen.Minute }, + colors = state.colors, + isAnchorEnabled = isEnabled, + ) +} + +@Composable +private fun ClockMinuteLayout(state: TimePickerState) { + val isEnabled: (Int) -> Boolean = + remember(state.timeRange, state.selectedTime, state.selectedTime.isAM) { + { index -> + index in state.minuteRange(state.selectedTime.isAM, state.selectedTime.hour) + } + } + ClockLayout( + anchorPoints = 60, + label = { index -> index.toString().padStart(2, '0') }, + onAnchorChange = { mins -> state.selectedTime = state.selectedTime.withMinute(mins) }, + startAnchor = state.selectedTime.minute, + isNamedAnchor = { anchor -> anchor % 5 == 0 }, + colors = state.colors, + isAnchorEnabled = isEnabled, + ) +} + +@Composable +internal fun TimePickerTitle(modifier: Modifier, text: String, state: TimePickerState) { + Box(modifier) { + Text( + text = text, + modifier = Modifier.paddingFromBaseline(top = 28.dp), + color = state.colors.headerTextColor(), + ) + } +} + +@Composable +internal fun ClockLabel( + text: String, + backgroundColor: Color, + textColor: Color, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .width(if (isSmallDevice()) 80.dp else 96.dp) + .fillMaxHeight(), + shape = MaterialTheme.shapes.medium, + color = backgroundColor, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = MaterialTheme.typography.displayMedium, + color = textColor, + ) + } + } +} + +@Composable +internal fun TimeLayout(modifier: Modifier = Modifier, state: TimePickerState) { + val clockHour: String = remember( + state.is24Hour, + state.selectedTime, + state.selectedTime.hour, + ) { + if (state.is24Hour) { + state.selectedTime.hour.toString().padStart(2, '0') + } else { + state.selectedTime.simpleHour.toString() + } + } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = modifier + .height(80.dp) + .fillMaxWidth(), + ) { + ClockLabel( + text = clockHour, + backgroundColor = state.colors.backgroundColor(state.currentScreen.isHour()).value, + textColor = state.colors.textColor(state.currentScreen.isHour()).value, + onClick = { state.currentScreen = ClockScreen.Hour }, + ) + + Box( + Modifier + .width(24.dp) + .fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + Text( + text = ":", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.displayMedium, + ) + } + + ClockLabel( + text = state.selectedTime.minute.toString().padStart(2, '0'), + backgroundColor = state.colors.backgroundColor(state.currentScreen.isMinute()).value, + textColor = state.colors.textColor(state.currentScreen.isMinute()).value, + onClick = { state.currentScreen = ClockScreen.Minute }, + ) + + if (!state.is24Hour) { + VerticalPeriodPicker(state = state) + } + } +} + +@Composable +private fun VerticalPeriodPicker(state: TimePickerState) { + val topPeriodShape = MaterialTheme.shapes.medium.copy( + bottomStart = CornerSize(0.dp), + bottomEnd = CornerSize(0.dp), + ) + val bottomPeriodShape = + MaterialTheme.shapes.medium.copy(topStart = CornerSize(0.dp), topEnd = CornerSize(0.dp)) + val isAMEnabled = remember(state.timeRange) { state.timeRange.start.hour <= 12 } + val isPMEnabled = remember(state.timeRange) { state.timeRange.endInclusive.hour >= 0 } + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + Modifier + .fillMaxHeight() + .width(52.dp) + .border(state.colors.border, MaterialTheme.shapes.medium), + ) { + Box( + modifier = Modifier + .size(height = 40.dp, width = 52.dp) + .clip(topPeriodShape) + .background(state.colors.periodBackgroundColor(state.selectedTime.isAM).value) + .then( + if (isAMEnabled) Modifier.clickable { + state.selectedTime = state.selectedTime + .toAM() + .coerceIn(state.timeRange) + } else Modifier, + ), + contentAlignment = Alignment.Center, + ) { + Text( + "AM", + style = MaterialTheme.typography.labelMedium, + color = state.colors + .textColor(state.selectedTime.isAM).value + .copy(alpha = if (isAMEnabled) 1f else 0.6f), + ) + } + + Spacer( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(state.colors.border.brush), + ) + + Box( + modifier = Modifier + .size(height = 40.dp, width = 52.dp) + .clip(bottomPeriodShape) + .background(state.colors.periodBackgroundColor(!state.selectedTime.isAM).value) + .then( + if (isPMEnabled) Modifier.clickable { + state.selectedTime = state.selectedTime + .toPM() + .coerceIn(state.timeRange) + } else Modifier, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "PM", + style = MaterialTheme.typography.labelMedium, + color = state.colors.textColor(!state.selectedTime.isAM).value.copy( + alpha = if (isPMEnabled) 1f else 0.6f, + ), + ) + } + } +} + +@Composable +private fun HorizontalPeriodPicker(state: TimePickerState) { + val leftPeriodShape = MaterialTheme.shapes.medium.copy( + bottomEnd = CornerSize(0.dp), + topEnd = CornerSize(0.dp), + ) + val rightPeriodShape = MaterialTheme.shapes.medium.copy( + topStart = CornerSize(0.dp), + bottomStart = CornerSize(0.dp), + ) + val isAMEnabled = remember(state.timeRange) { state.timeRange.start.hour <= 12 } + val isPMEnabled = remember(state.timeRange) { state.timeRange.endInclusive.hour >= 0 } + + Spacer(modifier = Modifier.width(12.dp)) + + Row( + Modifier + .fillMaxWidth() + .height(height = 40.dp) + .border(state.colors.border, MaterialTheme.shapes.medium), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(0.5f) + .clip(leftPeriodShape) + .background(state.colors.periodBackgroundColor(state.selectedTime.isAM).value) + .then( + if (isAMEnabled) Modifier.clickable { + state.selectedTime = state.selectedTime + .toAM() + .coerceIn(state.timeRange) + } else Modifier, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "AM", + style = MaterialTheme.typography.labelMedium, + color = state.colors + .textColor(state.selectedTime.isAM).value + .copy(alpha = if (isAMEnabled) 1f else 0.6f), + ) + } + + Spacer( + Modifier + .fillMaxHeight() + .width(1.dp) + .background(state.colors.border.brush), + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(rightPeriodShape) + .background(state.colors.periodBackgroundColor(!state.selectedTime.isAM).value) + .then( + if (isPMEnabled) Modifier.clickable { + state.selectedTime = state.selectedTime + .toPM() + .coerceIn(state.timeRange) + } else Modifier, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "PM", + style = MaterialTheme.typography.labelMedium, + color = state.colors.textColor(!state.selectedTime.isAM).value.copy( + alpha = if (isPMEnabled) 1f else 0.6f, + ), + ) + } + } +} + +@Composable +private fun ClockLayout( + isNamedAnchor: (Int) -> Boolean = { true }, + anchorPoints: Int, + innerAnchorPoints: Int = 0, + label: (Int) -> String, + startAnchor: Int, + colors: TimePickerColors, + isAnchorEnabled: (Int) -> Boolean, + onAnchorChange: (Int) -> Unit = {}, + onLift: () -> Unit = {}, +) { + BoxWithConstraints { + val faceDiameter = min(maxHeight.value, maxWidth.value).coerceAtMost(256f).dp + val faceDiameterPx = with(LocalDensity.current) { faceDiameter.toPx() } + + val faceRadiusPx = faceDiameterPx / 2f + + val outerRadiusPx = faceRadiusPx * 0.8f + val innerRadiusPx = remember(outerRadiusPx) { outerRadiusPx * 0.6f } + + val textSizePx = with(LocalDensity.current) { 18.sp.toPx() } + val innerTextSizePx = remember(textSizePx) { textSizePx * 0.8f } + + val selectedRadius = remember(outerRadiusPx) { outerRadiusPx * 0.2f } + val selectedInnerDotRadius = remember(selectedRadius) { selectedRadius * 0.2f } + val innerSelectedRadius = remember(innerRadiusPx) { innerRadiusPx * 0.3f } + + val centerCircleRadius = remember(selectedRadius) { selectedRadius * 0.4f } + val selectedLineWidth = remember(centerCircleRadius) { centerCircleRadius * 0.5f } + + val center = remember { Offset(faceRadiusPx, faceRadiusPx) } + + val namedAnchor = remember(isNamedAnchor) { mutableStateOf(isNamedAnchor(startAnchor)) } + val selectedAnchor = remember { mutableStateOf(startAnchor) } + + val anchors = remember(anchorPoints, innerAnchorPoints) { + val anchors = mutableListOf() + for (x in 0 until anchorPoints) { + val angle = (2 * PI / anchorPoints) * (x - 15) + val selectedOuterOffset = outerRadiusPx.getOffset(angle) + val lineOuterOffset = (outerRadiusPx - selectedRadius).getOffset(angle) + + anchors.add( + SelectedOffset( + lineOuterOffset, + selectedOuterOffset, + selectedRadius, + ), + ) + } + for (x in 0 until innerAnchorPoints) { + val angle = (2 * PI / innerAnchorPoints) * (x - 15) + val selectedOuterOffset = innerRadiusPx.getOffset(angle) + val lineOuterOffset = (innerRadiusPx - innerSelectedRadius).getOffset(angle) + + anchors.add( + SelectedOffset( + lineOuterOffset, + selectedOuterOffset, + innerSelectedRadius, + ), + ) + } + anchors + } + + val anchoredOffset = remember(anchors, startAnchor) { mutableStateOf(anchors[startAnchor]) } + + val updateAnchor: (Offset) -> Boolean = remember(anchors, isAnchorEnabled) { + { newOffset -> + val absDiff = anchors.map { + val diff = it.selectedOffset - newOffset + center + diff.x.pow(2) + diff.y.pow(2) + } + + val minAnchor = absDiff.withIndex().minByOrNull { (_, f) -> f }!!.index + if (isAnchorEnabled(minAnchor)) { + if (anchoredOffset.value.selectedOffset != anchors[minAnchor].selectedOffset) { + onAnchorChange(minAnchor) + + anchoredOffset.value = anchors[minAnchor] + namedAnchor.value = isNamedAnchor(minAnchor) + selectedAnchor.value = minAnchor + } + true + } else { + false + } + } + } + + val dragSuccess = remember { mutableStateOf(false) } + + val dragObserver: suspend PointerInputScope.() -> Unit = { + detectDragGestures( + onDragStart = { dragSuccess.value = true }, + onDragCancel = { dragSuccess.value = false }, + onDragEnd = { if (dragSuccess.value) onLift() }, + ) { change, _ -> + dragSuccess.value = updateAnchor(change.position) + if (change.positionChange() != Offset.Zero) change.consume() + } + } + + val tapObserver: suspend PointerInputScope.() -> Unit = { + detectTapGestures( + onPress = { + val anchorsChanged = updateAnchor(it) + val success = tryAwaitRelease() + + if ((success || !dragSuccess.value) && anchorsChanged) { + onLift() + } + }, + ) + } + + val inactiveTextColor = colors.textColor(false).value + val clockBackgroundColor = colors.backgroundColor(false).value + val selectorColor = remember { colors.selectorColor() } + val selectorTextColor = remember { colors.selectorTextColor() } + + val enabledAlpha = 1f + val disabledAlpha = 0.6f + + Canvas( + modifier = Modifier + .size(faceDiameter) + .pointerInput(null, dragObserver) + .pointerInput(null, tapObserver), + ) { + drawCircle(clockBackgroundColor, radius = faceRadiusPx, center = center) + drawCircle(selectorColor, radius = centerCircleRadius, center = center) + drawLine( + color = selectorColor, + start = center, + end = center + anchoredOffset.value.lineOffset, + strokeWidth = selectedLineWidth, + alpha = 0.8f, + ) + + drawCircle( + selectorColor, + center = center + anchoredOffset.value.selectedOffset, + radius = anchoredOffset.value.selectedRadius, + alpha = 0.7f, + ) + + if (!namedAnchor.value) { + drawCircle( + Color.White, + center = center + anchoredOffset.value.selectedOffset, + radius = selectedInnerDotRadius, + alpha = 0.8f, + ) + } + + drawIntoCanvas { canvas -> + fun drawAnchorText( + anchor: Int, + textSize: Float, + radius: Float, + angle: Double, + alpha: Int = 255, + ) { + val textOuter = label(anchor) + val textColor = if (selectedAnchor.value == anchor) { + selectorTextColor + } else { + inactiveTextColor + } + + val contentAlpha = if (isAnchorEnabled(anchor)) enabledAlpha else disabledAlpha + + drawText( + textSize, + textOuter, + center, + angle.toFloat(), + canvas, + radius, + alpha = (255f * contentAlpha).roundToInt().coerceAtMost(alpha), + color = textColor, + ) + } + + for (x in 0 until 12) { + val angle = (2 * PI / 12) * (x - 15) + drawAnchorText(x * anchorPoints / 12, textSizePx, outerRadiusPx, angle) + + if (innerAnchorPoints > 0) { + drawAnchorText( + x * innerAnchorPoints / 12 + anchorPoints, + innerTextSizePx, + innerRadiusPx, + angle, + alpha = (255 * 0.8f).toInt(), + ) + } + } + } + } + } +} + +private fun drawText( + textSize: Float, + text: String, + center: Offset, + angle: Float, + canvas: Canvas, + radius: Float, + alpha: Int, + color: Color = Color.White, +) { + canvas.drawText( + text, + center.x, + center.y, + angle = angle, + radius = radius, + color = color, + textSize = textSize, + isCenter = true, + alpha = alpha, + ) +} diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerColors.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerColors.kt new file mode 100644 index 0000000000..24f919b057 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerColors.kt @@ -0,0 +1,90 @@ +package com.vanpra.composematerialdialogs.datetime.time + +import androidx.compose.foundation.BorderStroke +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Represents the colors used by a [timepicker] and its parts in different states + * + * See [TimePickerDefaults.colors] for the default implementation + */ +interface TimePickerColors { + val border: BorderStroke + + /** + * Gets the background color dependant on if the item is active or not + * + * @param active true if the component/item is selected and false otherwise + * @return background color as a State + */ + @Composable + fun backgroundColor(active: Boolean): State + + /** + * Gets the text color dependant on if the item is active or not + * + * @param active true if the component/item is selected and false otherwise + * @return text color as a State + */ + @Composable + fun textColor(active: Boolean): State + + /** + * Get the color of clock hand and color of text in clock hand + */ + fun selectorColor(): Color + fun selectorTextColor(): Color + + /** + * Get color of title text + */ + fun headerTextColor(): Color + + @Composable + fun periodBackgroundColor(active: Boolean): State +} + +internal class DefaultTimePickerColors( + private val activeBackgroundColor: Color, + private val inactiveBackgroundColor: Color, + private val activeTextColor: Color, + private val inactiveTextColor: Color, + private val inactivePeriodBackground: Color, + private val selectorColor: Color, + private val selectorTextColor: Color, + private val headerTextColor: Color, + borderColor: Color +) : TimePickerColors { + override val border = BorderStroke(1.dp, borderColor) + + @Composable + override fun backgroundColor(active: Boolean): State { + return rememberUpdatedState(if (active) activeBackgroundColor else inactiveBackgroundColor) + } + + @Composable + override fun textColor(active: Boolean): State { + return rememberUpdatedState(if (active) activeTextColor else inactiveTextColor) + } + + override fun selectorColor(): Color { + return selectorColor + } + + override fun selectorTextColor(): Color { + return selectorTextColor + } + + override fun headerTextColor(): Color { + return headerTextColor + } + + @Composable + override fun periodBackgroundColor(active: Boolean): State { + return rememberUpdatedState(if (active) activeBackgroundColor else inactivePeriodBackground) + } +} diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerDefaults.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerDefaults.kt new file mode 100644 index 0000000000..aeae02ae9c --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerDefaults.kt @@ -0,0 +1,50 @@ +package com.vanpra.composematerialdialogs.datetime.time + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * Object to hold default values used by [timepicker] + */ +object TimePickerDefaults { + /** + * Initialises a [TimePickerColors] object which represents the different colors used by + * the [timepicker] composable + * + * @param activeBackgroundColor background color of selected time unit or period (AM/PM) + * @param inactiveBackgroundColor background color of inactive items in the dialog including + * the clock face + * @param activeTextColor color of text on the activeBackgroundColor + * @param inactiveTextColor color of text on the inactiveBackgroundColor + * @param inactivePeriodBackground background color of the inactive period (AM/PM) selector + * @param selectorColor color of clock hand/selector + * @param selectorTextColor color of text on selectedColor + * @param headerTextColor Get color of title text + * @param borderColor border color of the period (AM/PM) selector + */ + @Composable + fun colors( + activeBackgroundColor: Color = MaterialTheme.colorScheme.primary.copy(0.3f), + inactiveBackgroundColor: Color = MaterialTheme.colorScheme.onBackground.copy(0.3f), + activeTextColor: Color = MaterialTheme.colorScheme.onPrimary, + inactiveTextColor: Color = MaterialTheme.colorScheme.onBackground, + inactivePeriodBackground: Color = Color.Transparent, + selectorColor: Color = MaterialTheme.colorScheme.primary, + selectorTextColor: Color = MaterialTheme.colorScheme.onPrimary, + headerTextColor: Color = MaterialTheme.colorScheme.onBackground, + borderColor: Color = MaterialTheme.colorScheme.onBackground, + ): TimePickerColors { + return DefaultTimePickerColors( + activeBackgroundColor = activeBackgroundColor, + inactiveBackgroundColor = inactiveBackgroundColor, + activeTextColor = activeTextColor, + inactiveTextColor = inactiveTextColor, + inactivePeriodBackground = inactivePeriodBackground, + selectorColor = selectorColor, + selectorTextColor = selectorTextColor, + headerTextColor = headerTextColor, + borderColor = borderColor + ) + } +} diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerState.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerState.kt new file mode 100644 index 0000000000..c8dd569e32 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePickerState.kt @@ -0,0 +1,60 @@ +package com.vanpra.composematerialdialogs.datetime.time + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.vanpra.composematerialdialogs.datetime.util.isAM +import kotlinx.datetime.LocalTime + +internal enum class ClockScreen { + Hour, + Minute; + + fun isHour() = this == Hour + fun isMinute() = this == Minute +} + +internal class TimePickerState( + val colors: TimePickerColors, + selectedTime: LocalTime, + currentScreen: ClockScreen = ClockScreen.Hour, + clockInput: Boolean = true, + timeRange: ClosedRange, + is24Hour: Boolean, +) { + var selectedTime by mutableStateOf(selectedTime) + var timeRange by mutableStateOf(timeRange) + var is24Hour by mutableStateOf(is24Hour) + var currentScreen by mutableStateOf(currentScreen) + var clockInput by mutableStateOf(clockInput) + + private fun minimumMinute(isAM: Boolean, hour: Int): Int { + return when { + isAM == timeRange.start.isAM -> + if (timeRange.start.hour == hour) { + timeRange.start.minute + } else { + 0 + } + isAM -> 61 + else -> 0 + } + } + + private fun maximumMinute(isAM: Boolean, hour: Int): Int { + return when { + isAM == timeRange.endInclusive.isAM -> + if (timeRange.endInclusive.hour == hour) { + timeRange.endInclusive.minute + } else { + 60 + } + isAM -> 60 + else -> 0 + } + } + + fun hourRange() = timeRange.start.hour..timeRange.endInclusive.hour + + fun minuteRange(isAM: Boolean, hour: Int) = minimumMinute(isAM, hour)..maximumMinute(isAM, hour) +} diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Composables.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Composables.kt new file mode 100644 index 0000000000..f59b40bfe8 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Composables.kt @@ -0,0 +1,25 @@ +package com.vanpra.composematerialdialogs.datetime.util + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +@Composable +internal fun DialogTitle(text: String, modifier: Modifier = Modifier) { + Text( + text, + modifier = modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + color = MaterialTheme.colorScheme.onBackground, + fontSize = 20.sp, + style = TextStyle(fontWeight = FontWeight.W600) + ) +} diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Extensions.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Extensions.kt new file mode 100644 index 0000000000..24395ddad2 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Extensions.kt @@ -0,0 +1,63 @@ +package com.vanpra.composematerialdialogs.datetime.util + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.text.intl.Locale +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlin.math.cos +import kotlin.math.sin + +internal fun Float.getOffset(angle: Double): Offset = + Offset((this * cos(angle)).toFloat(), (this * sin(angle)).toFloat()) + +/*internal val LocalDate.yearMonth: YearMonth + get() = YearMonth.of(this.year, this.month)*/ + +internal expect val LocalDate.isLeapYear: Boolean + +internal val LocalTime.isAM: Boolean + get() = this.hour in 0..11 + +internal val LocalTime.simpleHour: Int + get() { + val tempHour = this.hour % 12 + return if (tempHour == 0) 12 else tempHour + } + +internal expect fun Month.getShortLocalName(locale: Locale): String + +internal expect fun Month.getFullLocalName(locale: Locale): String + +internal expect fun DayOfWeek.getShortLocalName(locale: Locale): String + +internal expect fun Month.testLength(year: Int, isLeapYear: Boolean): Int + +internal fun LocalTime.toAM(): LocalTime = if (this.isAM) this else this.minusHours(12) +internal fun LocalTime.toPM(): LocalTime = if (!this.isAM) this else this.plusHours(12) + +internal expect fun LocalTime.minusHours(hoursToSubtract: Long): LocalTime +internal expect fun LocalTime.plusHours(hoursToAdd: Long): LocalTime + +internal fun LocalTime.noSeconds(): LocalTime = LocalTime(this.hour, this.minute, 0, 0) + +internal fun LocalTime.withHour(hour: Int): LocalTime = LocalTime(hour, this.minute, this.second, this.nanosecond) + +internal fun LocalTime.withMinute(minute: Int): LocalTime = LocalTime(this.hour, minute, this.second, this.nanosecond) + +internal fun LocalTime.withSecond(second: Int): LocalTime = LocalTime(this.hour, this.minute, second, this.nanosecond) + +internal fun LocalTime.withNanosecond(nanosecond: Int): LocalTime = LocalTime(this.hour, this.minute, this.second, nanosecond) + +internal fun LocalDate.withDayOfMonth(dayOfMonth: Int) = LocalDate(this.year, this.month, dayOfMonth) + +private val minTime = LocalTime(0, 0, 0, 0) +internal val LocalTime.Companion.Min: LocalTime get() = minTime + +private val maxTime = LocalTime(23, 59, 59, 999_999_999) +internal val LocalTime.Companion.Max: LocalTime get() = maxTime + +internal expect fun DayOfWeek.plusDays(days: Long): DayOfWeek + +internal expect fun DayOfWeek.getNarrowDisplayName(locale: Locale): String diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Utils.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Utils.kt new file mode 100644 index 0000000000..4a704b7e35 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/Utils.kt @@ -0,0 +1,23 @@ +package com.vanpra.composematerialdialogs.datetime.util + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color + +@Composable +internal expect fun isSmallDevice(): Boolean + +@Composable +internal expect fun isLargeDevice(): Boolean + +internal expect fun Canvas.drawText( + text: String, + x: Float, + y: Float, + color: Color, + textSize: Float, + angle: Float, + radius: Float, + isCenter: Boolean?, + alpha: Int, +) \ No newline at end of file diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/WeekFields.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/WeekFields.kt new file mode 100644 index 0000000000..37b57ffd77 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/WeekFields.kt @@ -0,0 +1,11 @@ +package com.vanpra.composematerialdialogs.datetime.util + +import androidx.compose.ui.text.intl.Locale +import kotlinx.datetime.DayOfWeek + +internal expect class WeekFields { + val firstDayOfWeek: DayOfWeek + companion object { + fun of(locale: Locale): WeekFields + } +} \ No newline at end of file diff --git a/thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosUIUtils.kt b/thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosUIUtils.kt new file mode 100644 index 0000000000..0a9c0419fa --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosUIUtils.kt @@ -0,0 +1,51 @@ +package com.vanpra.composematerialdialogs.datetime.util + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sin +import org.jetbrains.skia.Font +import org.jetbrains.skia.Paint +import org.jetbrains.skia.TextBlobBuilder + +@Composable +internal actual fun isSmallDevice(): Boolean { + return true +} + +@Composable +internal actual fun isLargeDevice(): Boolean { + return false +} + +// todo This function needs to be corrected +internal actual fun Canvas.drawText( + text: String, + x: Float, + y: Float, + color: Color, + textSize: Float, + angle: Float, + radius: Float, + isCenter: Boolean?, + alpha: Int, +) { + val font = Font().apply { + size = textSize + } + + nativeCanvas.drawTextBlob( + blob = TextBlobBuilder().apply { + appendRun(font = font, text = text, x = 0f, y = 0f) + }.build()!!, + x = x + (radius * cos(angle)), + y = y + (radius * sin(angle)) + (abs(font.metrics.height)) / 2, + paint = Paint().apply { + this.color = color.toArgb() + } + ) +} diff --git a/thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosUtils.kt b/thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosUtils.kt new file mode 100644 index 0000000000..40e1c8aa59 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosUtils.kt @@ -0,0 +1,87 @@ +package com.vanpra.composematerialdialogs.datetime.util + +import androidx.compose.ui.text.intl.Locale +import kotlinx.cinterop.convert +import kotlinx.cinterop.useContents +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.number +import kotlinx.datetime.toKotlinInstant +import kotlinx.datetime.toLocalDateTime +import platform.Foundation.NSCalendar +import platform.Foundation.NSCalendarUnitDay +import platform.Foundation.NSCalendarUnitMonth +import platform.Foundation.NSDateComponents +import platform.Foundation.calendarIdentifier +import platform.Foundation.NSLocale as PlatformLocale + +fun LocalTime.toNSDateComponents(): NSDateComponents { + val components = NSDateComponents() + components.hour = hour.convert() + components.minute = minute.convert() + components.second = second.convert() + components.nanosecond = nanosecond.convert() + return components +} + +internal fun getCalendar(locale: Locale): NSCalendar { + val platformLocale = locale.toPlatform() + return NSCalendar(platformLocale.calendarIdentifier).apply { + this.locale = platformLocale + } +} + +internal actual val LocalDate.isLeapYear: Boolean + get() = if (year % 400 == 0) { + true + } else if (year % 100 == 0) { + false + } else { + year % 4 == 0 + } + +private fun NSDateComponents.toKotlinInstant() = NSCalendar.currentCalendar.dateFromComponents(this)!!.toKotlinInstant() + +internal actual fun LocalTime.minusHours(hoursToSubtract: Long): LocalTime = toNSDateComponents().apply { + hour -= hoursToSubtract +}.toKotlinInstant().toLocalDateTime(TimeZone.UTC).time +internal actual fun LocalTime.plusHours(hoursToAdd: Long): LocalTime = toNSDateComponents().apply { + hour += hoursToAdd +}.toKotlinInstant().toLocalDateTime(TimeZone.UTC).time + +internal fun Locale.toPlatform() = PlatformLocale(language) + +internal actual fun Month.getShortLocalName(locale: Locale): String = getCalendar(locale).shortStandaloneMonthSymbols() + .getOrNull(this.ordinal) + .toString() + +internal actual fun Month.getFullLocalName(locale: Locale) = + getCalendar(locale).standaloneMonthSymbols() + .getOrNull(this.ordinal) + .toString() + +internal actual fun DayOfWeek.getShortLocalName(locale: Locale) = getCalendar(locale).shortStandaloneWeekdaySymbols() + .getOrNull(ordinal) + .toString() + +internal actual fun Month.testLength(year: Int, isLeapYear: Boolean): Int { + val cal = NSCalendar.currentCalendar() + val dateComponents = NSDateComponents().apply { + this.year = year.convert() + month = number.convert() + } + val date = cal.dateFromComponents(dateComponents)!! + val range = cal.rangeOfUnit(NSCalendarUnitDay, NSCalendarUnitMonth, date) + return range.useContents { length }.convert() +} + +internal actual fun DayOfWeek.plusDays(days: Long): DayOfWeek { + return DayOfWeek.values()[(ordinal + days % 7).toInt()] +} + +internal actual fun DayOfWeek.getNarrowDisplayName(locale: Locale): String = getCalendar(locale).veryShortWeekdaySymbols() + .getOrNull(ordinal) + .toString() diff --git a/thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosWeekFields.kt b/thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosWeekFields.kt new file mode 100644 index 0000000000..18137ec85a --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/iosMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/IosWeekFields.kt @@ -0,0 +1,17 @@ +package com.vanpra.composematerialdialogs.datetime.util + +import androidx.compose.ui.text.intl.Locale +import kotlinx.cinterop.convert +import kotlinx.datetime.DayOfWeek +import platform.Foundation.NSCalendar + +internal actual class WeekFields(private val calendar: NSCalendar) { + actual val firstDayOfWeek: DayOfWeek + get() = DayOfWeek(calendar.firstWeekday.convert()) + + actual companion object { + actual fun of(locale: Locale): WeekFields { + return WeekFields(getCalendar(locale)) + } + } +} \ No newline at end of file diff --git a/thirdparty/compose-material-dialogs/datetime/src/jvmCommon/kotlin/com/vanpra/composematerialdialogs/datetime/util/JvmExtensions.kt b/thirdparty/compose-material-dialogs/datetime/src/jvmCommon/kotlin/com/vanpra/composematerialdialogs/datetime/util/JvmExtensions.kt new file mode 100644 index 0000000000..22a135a6cd --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/jvmCommon/kotlin/com/vanpra/composematerialdialogs/datetime/util/JvmExtensions.kt @@ -0,0 +1,34 @@ +package com.vanpra.composematerialdialogs.datetime.util + +import androidx.compose.ui.text.intl.Locale +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.Month +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toJavaLocalTime +import kotlinx.datetime.toKotlinLocalTime +import java.time.format.TextStyle + +internal actual val LocalDate.isLeapYear: Boolean + get() = toJavaLocalDate().isLeapYear + +internal actual fun LocalTime.minusHours(hoursToSubtract: Long): LocalTime = toJavaLocalTime().minusHours(hoursToSubtract).toKotlinLocalTime() +internal actual fun LocalTime.plusHours(hoursToAdd: Long): LocalTime = toJavaLocalTime().plusHours(hoursToAdd).toKotlinLocalTime() + +internal fun Locale.toPlatform() = java.util.Locale.forLanguageTag(toLanguageTag()) + +internal actual fun Month.getShortLocalName(locale: Locale): String = + this.getDisplayName(TextStyle.SHORT_STANDALONE, locale.toPlatform()) + +internal actual fun Month.getFullLocalName(locale: Locale) = + this.getDisplayName(TextStyle.FULL_STANDALONE, locale.toPlatform()) + +internal actual fun DayOfWeek.getShortLocalName(locale: Locale) = + this.getDisplayName(TextStyle.SHORT_STANDALONE, locale.toPlatform()) + +internal actual fun Month.testLength(year: Int, isLeapYear: Boolean): Int = this.length(isLeapYear) + +internal actual fun DayOfWeek.plusDays(days: Long): DayOfWeek = plus(days) + +internal actual fun DayOfWeek.getNarrowDisplayName(locale: Locale): String = getDisplayName(TextStyle.NARROW, locale.toPlatform()) diff --git a/thirdparty/compose-material-dialogs/datetime/src/jvmCommon/kotlin/com/vanpra/composematerialdialogs/datetime/util/JvmWeekFields.kt b/thirdparty/compose-material-dialogs/datetime/src/jvmCommon/kotlin/com/vanpra/composematerialdialogs/datetime/util/JvmWeekFields.kt new file mode 100644 index 0000000000..39eb65a724 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/jvmCommon/kotlin/com/vanpra/composematerialdialogs/datetime/util/JvmWeekFields.kt @@ -0,0 +1,16 @@ +package com.vanpra.composematerialdialogs.datetime.util + +import androidx.compose.ui.text.intl.Locale +import kotlinx.datetime.DayOfWeek +import java.time.temporal.WeekFields as jWeekFields + +internal actual class WeekFields(private val weekFields: jWeekFields) { + actual val firstDayOfWeek: DayOfWeek + get() = weekFields.firstDayOfWeek + + actual companion object { + actual fun of(locale: Locale): WeekFields { + return WeekFields(jWeekFields.of(locale.toPlatform())) + } + } +} \ No newline at end of file diff --git a/thirdparty/compose-material-dialogs/datetime/src/jvmMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/DesktopUtils.kt b/thirdparty/compose-material-dialogs/datetime/src/jvmMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/DesktopUtils.kt new file mode 100644 index 0000000000..aa7fedef77 --- /dev/null +++ b/thirdparty/compose-material-dialogs/datetime/src/jvmMain/kotlin/com/vanpra/composematerialdialogs/datetime/util/DesktopUtils.kt @@ -0,0 +1,51 @@ +package com.vanpra.composematerialdialogs.datetime.util + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import org.jetbrains.skia.Font +import org.jetbrains.skia.Paint +import org.jetbrains.skia.TextBlobBuilder +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sin + +@Composable +internal actual fun isSmallDevice(): Boolean { + return false +} + +@Composable +internal actual fun isLargeDevice(): Boolean { + return true +} + +// todo This function needs to be corrected +internal actual fun Canvas.drawText( + text: String, + x: Float, + y: Float, + color: Color, + textSize: Float, + angle: Float, + radius: Float, + isCenter: Boolean?, + alpha: Int, +) { + val outerText = Paint() + outerText.color = color.toArgb() + + val font = Font() + + nativeCanvas.drawTextBlob( + blob = TextBlobBuilder().apply { + appendRun(font = font, text = text, x = 0f, y = 0f) + }.build()!!, + x = x + (radius * cos(angle)), + y = y + (radius * sin(angle)) + (abs(font.metrics.height)) / 2, + paint = Paint() + ) + +} \ No newline at end of file From df2bb6ded1750038b32a0ebce6e25f3793042b91 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Tue, 11 Jul 2023 21:32:53 +0100 Subject: [PATCH 2/5] Animate dialog entrance on iOS --- .../kotlin/app/tivi/common/compose/Dialog.kt | 33 +++++++++++--- .../kotlin/app/tivi/common/compose/Dialog.kt | 44 ++++++++++++++++--- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/Dialog.kt b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/Dialog.kt index 1abcb19eb6..af23fe8800 100644 --- a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/Dialog.kt +++ b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/Dialog.kt @@ -4,14 +4,19 @@ package app.tivi.common.compose import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -36,11 +41,29 @@ fun TiviDialog( content: @Composable () -> Unit, ) { Dialog(onDismissRequest, properties) { - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), - ) { - content() + BoxWithConstraints { + val windowSizeClass = LocalWindowSizeClass.current + val width = constraints.maxWidth + + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + modifier = Modifier + .widthIn( + min = 280.dp, + max = when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> { + with(LocalDensity.current) { + (width * 0.9f).toDp() + } + } + WindowWidthSizeClass.Medium -> 440.dp + else -> 560.dp + }, + ), + ) { + content() + } } } } diff --git a/common/ui/compose/src/iosMain/kotlin/app/tivi/common/compose/Dialog.kt b/common/ui/compose/src/iosMain/kotlin/app/tivi/common/compose/Dialog.kt index 7a3b1b5826..332eedf028 100644 --- a/common/ui/compose/src/iosMain/kotlin/app/tivi/common/compose/Dialog.kt +++ b/common/ui/compose/src/iosMain/kotlin/app/tivi/common/compose/Dialog.kt @@ -3,12 +3,22 @@ package app.tivi.common.compose +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.DrawerDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.KeyEventType @@ -49,20 +59,42 @@ actual fun Dialog( }, ) { Box( - modifier = Modifier.fillMaxSize() - .background(DrawerDefaults.scrimColor), contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), ) { - if (properties.dismissOnClickOutside) { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + visible = true + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + ) { Box( modifier = Modifier .fillMaxSize() - .pointerInput(onDismissRequest) { - detectTapGestures(onTap = { onDismissRequest() }) + .background(DrawerDefaults.scrimColor) + .let { m -> + if (properties.dismissOnClickOutside) { + m.pointerInput(onDismissRequest) { + detectTapGestures(onTap = { onDismissRequest() }) + } + } else { + m + } }, ) } - content() + + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { it / 6 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 6 }) + fadeOut(), + ) { + content() + } } } } From f1106c387bf3b1c23edbf54e646ce24476a2704e Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Tue, 11 Jul 2023 21:49:23 +0100 Subject: [PATCH 3/5] Use unbounded Ripple on date picker --- .../datetime/date/DatePicker.kt | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt index c8ce7dfe90..fd67b4286b 100644 --- a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -207,7 +208,7 @@ private fun YearPickerItem( .clickable( onClick = onClick, interactionSource = MutableInteractionSource(), - indication = null, + indication = rememberRipple(bounded = false), ), contentAlignment = Alignment.Center, ) { @@ -270,20 +271,21 @@ private fun CalendarViewHeader( .align(Alignment.CenterEnd), ) { Icon( - Icons.Default.KeyboardArrowLeft, + imageVector = Icons.Default.KeyboardArrowLeft, contentDescription = "Previous Month", modifier = Modifier .testTag("dialog_date_prev_month") .size(24.dp) .clickable( - onClick = { - coroutineScope.launch { - if (pagerState.currentPage - 1 >= 0) { - pagerState.animateScrollToPage(pagerState.currentPage - 1) - } + interactionSource = MutableInteractionSource(), + indication = rememberRipple(bounded = false), + ) { + coroutineScope.launch { + if (pagerState.currentPage - 1 >= 0) { + pagerState.animateScrollToPage(pagerState.currentPage - 1) } - }, - ), + } + }, tint = state.colors.calendarHeaderTextColor, ) @@ -302,6 +304,8 @@ private fun CalendarViewHeader( pagerState.animateScrollToPage(pagerState.currentPage + 1) } }, + interactionSource = MutableInteractionSource(), + indication = rememberRipple(bounded = false), ), tint = state.colors.calendarHeaderTextColor, ) @@ -339,7 +343,12 @@ private fun CalendarView( } val date = viewDate.withDayOfMonth(it) val enabled = allowedDateValidator(date) - DateSelectionBox(it, selected, state.colors, enabled) { + DateSelectionBox( + date = it, + selected = selected, + colors = state.colors, + enabled = enabled, + ) { state.selected = date } } @@ -359,7 +368,12 @@ private fun DateSelectionBox( Modifier .testTag("dialog_date_selection_$date") .size(40.dp) - .clickable(enabled = enabled, onClick = onClick), + .clickable( + enabled = enabled, + onClick = onClick, + interactionSource = MutableInteractionSource(), + indication = rememberRipple(bounded = false), + ), contentAlignment = Alignment.Center, ) { Text( From 4bb865b4aefb77acf94f9dc992f1b3d975a14121 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Tue, 11 Jul 2023 22:38:06 +0100 Subject: [PATCH 4/5] Import M3 AlertDialog impl --- .../kotlin/app/tivi/overlays/DialogOverlay.kt | 24 +- .../kotlin/app/tivi/common/compose/Dialog.kt | 79 ---- .../app/tivi/common/compose/ui/AlertDialog.kt | 354 ++++++++++++++++++ .../common/compose/ui/DateTimeTextFields.kt | 89 +++-- .../tivi/common/compose/ui/TiviAlertDialog.kt | 73 ---- .../app/tivi/episodedetails/EpisodeDetails.kt | 32 +- 6 files changed, 435 insertions(+), 216 deletions(-) create mode 100644 common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/AlertDialog.kt delete mode 100644 common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/TiviAlertDialog.kt diff --git a/common/ui/circuit-overlay/src/commonMain/kotlin/app/tivi/overlays/DialogOverlay.kt b/common/ui/circuit-overlay/src/commonMain/kotlin/app/tivi/overlays/DialogOverlay.kt index 82a94b0c27..bc18df06ea 100644 --- a/common/ui/circuit-overlay/src/commonMain/kotlin/app/tivi/overlays/DialogOverlay.kt +++ b/common/ui/circuit-overlay/src/commonMain/kotlin/app/tivi/overlays/DialogOverlay.kt @@ -3,9 +3,12 @@ package app.tivi.overlays +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import app.tivi.common.compose.TiviDialog import app.tivi.common.compose.rememberCoroutineScope +import app.tivi.common.compose.ui.AlertDialog +import app.tivi.common.compose.ui.AlertDialogDefaults import com.slack.circuit.foundation.CircuitContent import com.slack.circuit.overlay.Overlay import com.slack.circuit.overlay.OverlayHost @@ -18,17 +21,24 @@ class DialogOverlay( private val onDismiss: () -> Result, private val content: @Composable (Model, OverlayNavigator) -> Unit, ) : Overlay { + @OptIn(ExperimentalMaterial3Api::class) @Composable override fun Content(navigator: OverlayNavigator) { val coroutineScope = rememberCoroutineScope() - TiviDialog( + AlertDialog( onDismissRequest = { navigator.finish(onDismiss()) }, ) { - // Delay setting the result until we've finished dismissing - content(model) { result -> - // This is the OverlayNavigator.finish() callback - coroutineScope.launch { - navigator.finish(result) + Surface( + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + // Delay setting the result until we've finished dismissing + content(model) { result -> + // This is the OverlayNavigator.finish() callback + coroutineScope.launch { + navigator.finish(result) + } } } } diff --git a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/Dialog.kt b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/Dialog.kt index af23fe8800..3268a13292 100644 --- a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/Dialog.kt +++ b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/Dialog.kt @@ -3,29 +3,11 @@ package app.tivi.common.compose -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import com.vanpra.composematerialdialogs.MaterialDialog -import com.vanpra.composematerialdialogs.MaterialDialogButtons -import com.vanpra.composematerialdialogs.MaterialDialogProperties -import com.vanpra.composematerialdialogs.MaterialDialogScope -import com.vanpra.composematerialdialogs.MaterialDialogState -import com.vanpra.composematerialdialogs.rememberMaterialDialogState @Composable expect fun Dialog( @@ -34,67 +16,6 @@ expect fun Dialog( content: @Composable () -> Unit, ) -@Composable -fun TiviDialog( - onDismissRequest: () -> Unit, - properties: DialogProperties = DialogProperties(), - content: @Composable () -> Unit, -) { - Dialog(onDismissRequest, properties) { - BoxWithConstraints { - val windowSizeClass = LocalWindowSizeClass.current - val width = constraints.maxWidth - - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), - modifier = Modifier - .widthIn( - min = 280.dp, - max = when (windowSizeClass.widthSizeClass) { - WindowWidthSizeClass.Compact -> { - with(LocalDensity.current) { - (width * 0.9f).toDp() - } - } - WindowWidthSizeClass.Medium -> 440.dp - else -> 560.dp - }, - ), - ) { - content() - } - } - } -} - -@Composable -fun Material3Dialog( - dialogState: MaterialDialogState = rememberMaterialDialogState(), - properties: MaterialDialogProperties = MaterialDialogProperties(), - backgroundColor: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), - shape: Shape = MaterialTheme.shapes.large, - border: BorderStroke? = null, - elevation: Dp = 0.dp, - autoDismiss: Boolean = true, - onCloseRequest: (MaterialDialogState) -> Unit = { it.hide() }, - buttons: @Composable MaterialDialogButtons.() -> Unit = {}, - content: @Composable MaterialDialogScope.() -> Unit, -) { - MaterialDialog( - dialogState = dialogState, - properties = properties, - backgroundColor = backgroundColor, - shape = shape, - border = border, - elevation = elevation, - autoDismiss = autoDismiss, - onCloseRequest = onCloseRequest, - buttons = buttons, - content = content, - ) -} - @Immutable data class DialogProperties( val dismissOnBackPress: Boolean = true, diff --git a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/AlertDialog.kt b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/AlertDialog.kt new file mode 100644 index 0000000000..df46cf04e0 --- /dev/null +++ b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/AlertDialog.kt @@ -0,0 +1,354 @@ +// Copyright 2021, Google LLC, Christopher Banes and the Tivi project contributors +// SPDX-License-Identifier: Apache-2.0 + +package app.tivi.common.compose.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.tivi.common.compose.Dialog +import app.tivi.common.compose.DialogProperties +import app.tivi.common.compose.LocalWindowSizeClass +import kotlin.math.max + +/** + * Copy of Material3's AlertDialog + */ +@ExperimentalMaterial3Api +@Composable +fun AlertDialog( + onDismissRequest: () -> Unit, + confirmButton: @Composable () -> Unit, + modifier: Modifier = Modifier, + dismissButton: @Composable (() -> Unit)? = null, + icon: @Composable (() -> Unit)? = null, + title: @Composable (() -> Unit)? = null, + text: @Composable (() -> Unit)? = null, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + properties: DialogProperties = DialogProperties(), +) = AlertDialog(onDismissRequest = onDismissRequest, modifier = modifier, properties = properties) { + AlertDialogContent( + buttons = { + AlertDialogFlowRow( + mainAxisSpacing = ButtonsMainAxisSpacing, + crossAxisSpacing = ButtonsCrossAxisSpacing, + ) { + dismissButton?.invoke() + confirmButton() + } + }, + icon = icon, + title = title, + text = text, + shape = shape, + containerColor = containerColor, + tonalElevation = tonalElevation, + // Note that a button content color is provided here from the dialog's token, but in + // most cases, TextButtons should be used for dismiss and confirm buttons. + // TextButtons will not consume this provided content color value, and will used their + // own defined or default colors. + buttonContentColor = MaterialTheme.colorScheme.primary, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + ) +} + +/** + * Basic alert dialog dialog. + * + * Dialogs provide important prompts in a user flow. They can require an action, communicate + * information, or help users accomplish a task. + * + * ![Basic dialog image](https://developer.android.com/images/reference/androidx/compose/material3/basic-dialog.png) + * + * This basic alert dialog expects an arbitrary content that is defined by the caller. Note that + * your content will need to define its own styling. + * + * By default, the displayed dialog has the minimum height and width that the Material Design spec + * defines. If required, these constraints can be overwritten by providing a `width` or `height` + * [Modifier]s. + * + * @sample androidx.compose.material3.samples.AlertDialogWithCustomContentSample + * + * @param onDismissRequest called when the user tries to dismiss the Dialog by clicking outside + * or pressing the back button. This is not called when the dismiss button is clicked. + * @param modifier the [Modifier] to be applied to this dialog's content. + * @param properties typically platform specific properties to further configure the dialog. + * @param content the content of the dialog + */ +@ExperimentalMaterial3Api +@Composable +fun AlertDialog( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + properties: DialogProperties = DialogProperties(), + content: @Composable () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + BoxWithConstraints(modifier) { + val windowSizeClass = LocalWindowSizeClass.current + val width = constraints.maxWidth + Box( + modifier = Modifier + .widthIn( + min = DialogMinWidth, + max = when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> { + with(LocalDensity.current) { + (width * 0.9f).toDp() + } + } + + WindowWidthSizeClass.Medium -> 440.dp + else -> DialogMaxWidth + }, + ), + propagateMinConstraints = true, + ) { + content() + } + } + } +} + +/** + * Contains default values used for [AlertDialog] + */ +object AlertDialogDefaults { + /** The default shape for alert dialogs */ + val shape: Shape @Composable get() = MaterialTheme.shapes.extraLarge + + /** The default container color for alert dialogs */ + val containerColor: Color @Composable get() = MaterialTheme.colorScheme.surface + + /** The default icon color for alert dialogs */ + val iconContentColor: Color @Composable get() = MaterialTheme.colorScheme.secondary + + /** The default title color for alert dialogs */ + val titleContentColor: Color @Composable get() = MaterialTheme.colorScheme.onSurface + + /** The default text color for alert dialogs */ + val textContentColor: Color @Composable get() = MaterialTheme.colorScheme.onSurfaceVariant + + /** The default tonal elevation for alert dialogs */ + val TonalElevation: Dp = 6.dp +} + +private val ButtonsMainAxisSpacing = 8.dp +private val ButtonsCrossAxisSpacing = 12.dp + +@Composable +internal fun AlertDialogContent( + buttons: @Composable () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)?, + title: (@Composable () -> Unit)?, + text: @Composable (() -> Unit)?, + shape: Shape, + containerColor: Color, + tonalElevation: Dp, + buttonContentColor: Color, + iconContentColor: Color, + titleContentColor: Color, + textContentColor: Color, +) { + Surface( + modifier = modifier, + shape = shape, + color = containerColor, + tonalElevation = tonalElevation, + ) { + Column( + modifier = Modifier.padding(DialogPadding), + ) { + icon?.let { + CompositionLocalProvider(LocalContentColor provides iconContentColor) { + Box( + Modifier + .padding(IconPadding) + .align(Alignment.CenterHorizontally), + ) { + icon() + } + } + } + title?.let { + CompositionLocalProvider(LocalContentColor provides titleContentColor) { + val textStyle = MaterialTheme.typography.titleLarge + ProvideTextStyle(textStyle) { + Box( + // Align the title to the center when an icon is present. + Modifier + .padding(TitlePadding) + .align( + if (icon == null) { + Alignment.Start + } else { + Alignment.CenterHorizontally + }, + ), + ) { + title() + } + } + } + } + text?.let { + CompositionLocalProvider(LocalContentColor provides textContentColor) { + val textStyle = + MaterialTheme.typography.bodyMedium + ProvideTextStyle(textStyle) { + Box( + Modifier + .weight(weight = 1f, fill = false) + .padding(TextPadding) + .align(Alignment.Start), + ) { + text() + } + } + } + } + Box(modifier = Modifier.align(Alignment.End)) { + CompositionLocalProvider(LocalContentColor provides buttonContentColor) { + val textStyle = + MaterialTheme.typography.labelLarge + ProvideTextStyle(value = textStyle, content = buttons) + } + } + } + } +} + +/** + * Simple clone of FlowRow that arranges its children in a horizontal flow with limited + * customization. + */ +@Composable +internal fun AlertDialogFlowRow( + mainAxisSpacing: Dp, + crossAxisSpacing: Dp, + content: @Composable () -> Unit, +) { + Layout(content) { measurables, constraints -> + val sequences = mutableListOf>() + val crossAxisSizes = mutableListOf() + val crossAxisPositions = mutableListOf() + + var mainAxisSpace = 0 + var crossAxisSpace = 0 + + val currentSequence = mutableListOf() + var currentMainAxisSize = 0 + var currentCrossAxisSize = 0 + + // Return whether the placeable can be added to the current sequence. + fun canAddToCurrentSequence(placeable: Placeable) = + currentSequence.isEmpty() || currentMainAxisSize + mainAxisSpacing.roundToPx() + + placeable.width <= constraints.maxWidth + + // Store current sequence information and start a new sequence. + fun startNewSequence() { + if (sequences.isNotEmpty()) { + crossAxisSpace += crossAxisSpacing.roundToPx() + } + // Ensures that confirming actions appear above dismissive actions. + sequences.add(0, currentSequence.toList()) + crossAxisSizes += currentCrossAxisSize + crossAxisPositions += crossAxisSpace + + crossAxisSpace += currentCrossAxisSize + mainAxisSpace = max(mainAxisSpace, currentMainAxisSize) + + currentSequence.clear() + currentMainAxisSize = 0 + currentCrossAxisSize = 0 + } + + for (measurable in measurables) { + // Ask the child for its preferred size. + val placeable = measurable.measure(constraints) + + // Start a new sequence if there is not enough space. + if (!canAddToCurrentSequence(placeable)) startNewSequence() + + // Add the child to the current sequence. + if (currentSequence.isNotEmpty()) { + currentMainAxisSize += mainAxisSpacing.roundToPx() + } + currentSequence.add(placeable) + currentMainAxisSize += placeable.width + currentCrossAxisSize = max(currentCrossAxisSize, placeable.height) + } + + if (currentSequence.isNotEmpty()) startNewSequence() + + val mainAxisLayoutSize = max(mainAxisSpace, constraints.minWidth) + val crossAxisLayoutSize = max(crossAxisSpace, constraints.minHeight) + + val layoutWidth = mainAxisLayoutSize + val layoutHeight = crossAxisLayoutSize + + layout(layoutWidth, layoutHeight) { + sequences.forEachIndexed { i, placeables -> + val childrenMainAxisSizes = IntArray(placeables.size) { j -> + placeables[j].width + + if (j < placeables.lastIndex) mainAxisSpacing.roundToPx() else 0 + } + val arrangement = Arrangement.End + val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 } + with(arrangement) { + arrange( + mainAxisLayoutSize, childrenMainAxisSizes, + layoutDirection, mainAxisPositions, + ) + } + placeables.forEachIndexed { j, placeable -> + placeable.place( + x = mainAxisPositions[j], + y = crossAxisPositions[i], + ) + } + } + } + } +} + +internal val DialogMinWidth = 280.dp +internal val DialogMaxWidth = 560.dp + +// Paddings for each of the dialog's parts. +private val DialogPadding = PaddingValues(all = 24.dp) +private val IconPadding = PaddingValues(bottom = 16.dp) +private val TitlePadding = PaddingValues(bottom = 16.dp) +private val TextPadding = PaddingValues(bottom = 24.dp) diff --git a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt index 200c439c26..2c73dadc31 100644 --- a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt +++ b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -15,22 +16,19 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp import app.tivi.common.compose.LocalTiviDateFormatter -import app.tivi.common.compose.TiviDialog import app.tivi.common.ui.resources.MR import com.vanpra.composematerialdialogs.datetime.date.DatePicker import com.vanpra.composematerialdialogs.datetime.time.TimePicker import dev.icerock.moko.resources.compose.stringResource -import kotlinx.coroutines.flow.filterNotNull import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate @@ -56,12 +54,7 @@ fun DateTextField( var showDialog by remember { mutableStateOf(false) } var date by remember { mutableStateOf(selectedDate) } - - LaunchedEffect(Unit) { - snapshotFlow { date } - .filterNotNull() - .collect { onDateSelected(it) } - } + val lastOnDateSelected by rememberUpdatedState(onDateSelected) ClickableReadOnlyOutlinedTextField( value = formattedDate.orEmpty(), @@ -71,30 +64,33 @@ fun DateTextField( ) if (showDialog) { - TiviDialog( + AlertDialog( onDismissRequest = { showDialog = false }, - ) { - DatePicker( - title = dialogTitle, - initialDate = selectedDate ?: remember { - Clock.System.now() - .toLocalDateTime(TimeZone.currentSystemDefault()) - .date - }, - allowedDateValidator = { date -> - // Only allow dates in the past - date.toInstant() < Clock.System.now() - }, - onDateChange = { date = it }, - ) - } + confirmButton = { + Button(onClick = { date?.let(lastOnDateSelected) }) { + Text(text = stringResource(MR.strings.button_confirm)) + } + }, + text = { + DatePicker( + title = dialogTitle, + initialDate = selectedDate ?: remember { + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + }, + allowedDateValidator = { date -> + // Only allow dates in the past + date.toInstant() < Clock.System.now() + }, + onDateChange = { date = it }, + ) + } + ) } } } private fun LocalDate.toInstant(): Instant { - return LocalDateTime(this, midday) - .toInstant(TimeZone.currentSystemDefault()) + return LocalDateTime(this, midday).toInstant(TimeZone.currentSystemDefault()) } private val midday: LocalTime by lazy { LocalTime(12, 0, 0, 0) } @@ -116,12 +112,7 @@ fun TimeTextField( var showDialog by remember { mutableStateOf(false) } var time by remember { mutableStateOf(selectedTime) } - - LaunchedEffect(Unit) { - snapshotFlow { time } - .filterNotNull() - .collect { onTimeSelected(it) } - } + val lastOnTimeSelected by rememberUpdatedState(onTimeSelected) ClickableReadOnlyOutlinedTextField( value = formattedTime.orEmpty(), @@ -131,20 +122,24 @@ fun TimeTextField( ) if (showDialog) { - TiviDialog( + AlertDialog( onDismissRequest = { showDialog = false }, - ) { - TimePicker( - title = dialogTitle, - initialTime = selectedTime ?: remember { - Clock.System.now() - .toLocalDateTime(TimeZone.currentSystemDefault()) - .time - }, - is24HourClock = is24Hour, - onTimeChange = { time = it }, - ) - } + confirmButton = { + Button(onClick = { time?.let(lastOnTimeSelected) }) { + Text(text = stringResource(MR.strings.button_confirm)) + } + }, + text = { + TimePicker( + title = dialogTitle, + initialTime = selectedTime ?: remember { + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).time + }, + is24HourClock = is24Hour, + onTimeChange = { time = it }, + ) + }, + ) } } } diff --git a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/TiviAlertDialog.kt b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/TiviAlertDialog.kt deleted file mode 100644 index 831b01baf9..0000000000 --- a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/TiviAlertDialog.kt +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright 2021, Google LLC, Christopher Banes and the Tivi project contributors -// SPDX-License-Identifier: Apache-2.0 - -package app.tivi.common.compose.ui - -import androidx.compose.material.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import app.tivi.common.compose.Material3Dialog -import com.vanpra.composematerialdialogs.message -import com.vanpra.composematerialdialogs.rememberMaterialDialogState -import com.vanpra.composematerialdialogs.title - -@Composable -fun TiviAlertDialog( - title: String, - message: String, - confirmText: String, - onConfirm: () -> Unit, - dismissText: String, - onDismissRequest: () -> Unit, -) { - val dialogState = rememberMaterialDialogState() - - LaunchedEffect(dialogState) { - dialogState.show() - } - - val lastOnDismissRequest by rememberUpdatedState(onDismissRequest) - val onDismiss = { - dialogState.hide() - lastOnDismissRequest() - } - - Material3Dialog( - dialogState = dialogState, - onCloseRequest = { onDismiss() }, - buttons = { - negativeButton( - text = dismissText, - textStyle = MaterialTheme.typography.bodyMedium, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.primary, - ), - onClick = onDismiss, - ) - - positiveButton( - text = confirmText, - textStyle = MaterialTheme.typography.labelLarge, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.primary, - ), - onClick = onConfirm, - ) - }, - ) { - title( - text = title, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall, - ) - - message( - text = message, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - } -} diff --git a/ui/episode/details/src/commonMain/kotlin/app/tivi/episodedetails/EpisodeDetails.kt b/ui/episode/details/src/commonMain/kotlin/app/tivi/episodedetails/EpisodeDetails.kt index 73465a166b..12618d4994 100644 --- a/ui/episode/details/src/commonMain/kotlin/app/tivi/episodedetails/EpisodeDetails.kt +++ b/ui/episode/details/src/commonMain/kotlin/app/tivi/episodedetails/EpisodeDetails.kt @@ -67,11 +67,11 @@ import app.tivi.common.compose.Layout import app.tivi.common.compose.LocalTiviDateFormatter import app.tivi.common.compose.rememberCoroutineScope import app.tivi.common.compose.theme.TiviTheme +import app.tivi.common.compose.ui.AlertDialog import app.tivi.common.compose.ui.AutoSizedCircularProgressIndicator import app.tivi.common.compose.ui.Backdrop import app.tivi.common.compose.ui.ExpandingText import app.tivi.common.compose.ui.ScrimmedIconButton -import app.tivi.common.compose.ui.TiviAlertDialog import app.tivi.common.compose.ui.none import app.tivi.common.ui.resources.MR import app.tivi.data.imagemodels.asImageModel @@ -240,7 +240,7 @@ internal fun EpisodeDetails( onRemoveAllWatches() openDialog = false }, - onDismiss = { openDialog = false }, + onDismissRequest = { openDialog = false }, ) } } @@ -495,18 +495,30 @@ private fun AddWatchButton( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RemoveAllWatchesDialog( onConfirm: () -> Unit, - onDismiss: () -> Unit, + onDismissRequest: () -> Unit, ) { - TiviAlertDialog( - title = stringResource(MR.strings.episode_remove_watches_dialog_title), - message = stringResource(MR.strings.episode_remove_watches_dialog_message), - confirmText = stringResource(MR.strings.episode_remove_watches_dialog_confirm), - onConfirm = { onConfirm() }, - dismissText = stringResource(MR.strings.dialog_dismiss), - onDismissRequest = { onDismiss() }, + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(stringResource(MR.strings.episode_remove_watches_dialog_title)) + }, + text = { + Text(stringResource(MR.strings.episode_remove_watches_dialog_message)) + }, + dismissButton = { + Button(onClick = onDismissRequest) { + Text(stringResource(MR.strings.dialog_dismiss)) + } + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(MR.strings.episode_remove_watches_dialog_confirm)) + } + }, ) } From bac9e80fcf0b3544c8e56603834c2959bbbb1f26 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Tue, 11 Jul 2023 22:45:57 +0100 Subject: [PATCH 5/5] Dismiss time/date dialogs on confirm --- .../app/tivi/common/compose/ui/AlertDialog.kt | 6 ++- .../common/compose/ui/DateTimeTextFields.kt | 16 +++++-- .../datetime/date/DatePicker.kt | 7 ++- .../datetime/time/TimePicker.kt | 44 +------------------ 4 files changed, 23 insertions(+), 50 deletions(-) diff --git a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/AlertDialog.kt b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/AlertDialog.kt index df46cf04e0..9054591e2d 100644 --- a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/AlertDialog.kt +++ b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/AlertDialog.kt @@ -329,8 +329,10 @@ internal fun AlertDialogFlowRow( val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 } with(arrangement) { arrange( - mainAxisLayoutSize, childrenMainAxisSizes, - layoutDirection, mainAxisPositions, + mainAxisLayoutSize, + childrenMainAxisSizes, + layoutDirection, + mainAxisPositions, ) } placeables.forEachIndexed { j, placeable -> diff --git a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt index 2c73dadc31..7851f2b59e 100644 --- a/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt +++ b/common/ui/compose/src/commonMain/kotlin/app/tivi/common/compose/ui/DateTimeTextFields.kt @@ -67,7 +67,12 @@ fun DateTextField( AlertDialog( onDismissRequest = { showDialog = false }, confirmButton = { - Button(onClick = { date?.let(lastOnDateSelected) }) { + Button( + onClick = { + date?.let(lastOnDateSelected) + showDialog = false + }, + ) { Text(text = stringResource(MR.strings.button_confirm)) } }, @@ -83,7 +88,7 @@ fun DateTextField( }, onDateChange = { date = it }, ) - } + }, ) } } @@ -125,7 +130,12 @@ fun TimeTextField( AlertDialog( onDismissRequest = { showDialog = false }, confirmButton = { - Button(onClick = { time?.let(lastOnTimeSelected) }) { + Button( + onClick = { + time?.let(lastOnTimeSelected) + showDialog = false + }, + ) { Text(text = stringResource(MR.strings.button_confirm)) } }, diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt index fd67b4286b..8e73262e2e 100644 --- a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/date/DatePicker.kt @@ -383,7 +383,7 @@ private fun DateSelectionBox( .clip(CircleShape) .background(colors.dateBackgroundColor(selected).value) .wrapContentSize(Alignment.Center), - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelLarge, color = colors.dateTextColor(selected, enabled).value, ) } @@ -431,7 +431,10 @@ private fun CalendarHeader(title: String, state: DatePickerState, locale: Locale Box( Modifier - .background(state.colors.headerBackgroundColor) + .background( + color = state.colors.headerBackgroundColor, + shape = MaterialTheme.shapes.medium, + ) .fillMaxWidth(), ) { Column(Modifier.padding(start = 24.dp, end = 24.dp)) { diff --git a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePicker.kt b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePicker.kt index aa53fd9ade..871dc52229 100644 --- a/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePicker.kt +++ b/thirdparty/compose-material-dialogs/datetime/src/commonMain/kotlin/com/vanpra/composematerialdialogs/datetime/time/TimePicker.kt @@ -17,7 +17,6 @@ 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.paddingFromBaseline import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -112,47 +111,6 @@ fun TimePicker( TimePickerImpl(title = title, state = timePickerState) } -@Composable -internal fun TimePickerExpandedImpl( - modifier: Modifier = Modifier, - title: String, - state: TimePickerState, -) { - Column(modifier.padding(start = 24.dp, end = 24.dp)) { - Box(Modifier.align(Alignment.Start)) { - TimePickerTitle(Modifier.height(36.dp), title, state) - } - - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Column( - Modifier - .padding(top = 72.dp, bottom = 50.dp) - .width(216.dp), - ) { - TimeLayout(state = state) - Spacer(modifier = Modifier.height(12.dp)) - HorizontalPeriodPicker(state = state) - } - - /* This isn't an exact match to the material spec as there is a contradiction it. - Dialogs should be limited to the size of 560 dp but given sizes for extended - time picker go over this limit */ - Spacer(modifier = Modifier.width(40.dp)) - Crossfade(state.currentScreen) { - when (it) { - ClockScreen.Hour -> if (state.is24Hour) { - ExtendedClockHourLayout(state = state) - } else { - ClockHourLayout(state = state) - } - - ClockScreen.Minute -> ClockMinuteLayout(state = state) - } - } - } - } -} - @Composable internal fun TimePickerImpl( modifier: Modifier = Modifier, @@ -160,7 +118,7 @@ internal fun TimePickerImpl( state: TimePickerState, ) { Column( - modifier.padding(start = 24.dp, end = 24.dp), + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { if (title != "") {