diff --git a/android/modules/module_common/src/main/java/github/tornaco/android/thanos/module/compose/common/ResExtensions.kt b/android/modules/module_common/src/main/java/github/tornaco/android/thanos/module/compose/common/ResExtensions.kt new file mode 100644 index 000000000..eafd8517d --- /dev/null +++ b/android/modules/module_common/src/main/java/github/tornaco/android/thanos/module/compose/common/ResExtensions.kt @@ -0,0 +1,19 @@ +package github.tornaco.android.thanos.module.compose.common + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp + +@Composable +fun Int.toDp(): Dp { + return with(LocalDensity.current) { + this@toDp.toDp() + } +} + +@Composable +fun Dp.toPx(): Float { + return with(LocalDensity.current) { + this@toPx.toPx() + } +} diff --git a/android/modules/module_common/src/main/java/github/tornaco/android/thanos/module/compose/common/widget/Cards.kt b/android/modules/module_common/src/main/java/github/tornaco/android/thanos/module/compose/common/widget/Cards.kt new file mode 100644 index 000000000..7d970def6 --- /dev/null +++ b/android/modules/module_common/src/main/java/github/tornaco/android/thanos/module/compose/common/widget/Cards.kt @@ -0,0 +1,38 @@ +/* + * (C) Copyright 2022 Thanox + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package github.tornaco.android.thanos.module.compose.common.widget + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import github.tornaco.android.thanos.module.compose.common.theme.ColorDefaults + +@Composable +fun CardContainer(content: @Composable () -> Unit) { + androidx.compose.material.Card( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + backgroundColor = ColorDefaults.backgroundSurfaceColor(), + shape = RoundedCornerShape(12.dp), + elevation = 0.dp + ) { + content() + } +} \ No newline at end of file diff --git a/android/modules/module_common/src/main/java/github/tornaco/android/thanos/module/compose/common/widget/Dialog.kt b/android/modules/module_common/src/main/java/github/tornaco/android/thanos/module/compose/common/widget/Dialog.kt index 803219148..e3a599df8 100644 --- a/android/modules/module_common/src/main/java/github/tornaco/android/thanos/module/compose/common/widget/Dialog.kt +++ b/android/modules/module_common/src/main/java/github/tornaco/android/thanos/module/compose/common/widget/Dialog.kt @@ -17,15 +17,19 @@ package github.tornaco.android.thanos.module.compose.common.widget -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.* import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @OptIn(ExperimentalComposeUiApi::class) @@ -66,4 +70,43 @@ fun ThanoxAlertDialog( usePlatformDefaultWidth = false ) ) +} + + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ThanoxDialog( + onDismissRequest: () -> Unit, + properties: DialogProperties = DialogProperties(), + title: @Composable () -> Unit = {}, + buttons: @Composable RowScope.() -> Unit = {}, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest, + properties = DialogProperties( + dismissOnBackPress = properties.dismissOnBackPress, + dismissOnClickOutside = properties.dismissOnClickOutside, + securePolicy = properties.securePolicy, + usePlatformDefaultWidth = false + )) { + Surface(modifier = Modifier.fillMaxWidth(fraction = 0.82f), + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation) { + + Column(modifier = Modifier + .fillMaxWidth() + .padding(16.dp)) { + title() + Spacer(modifier = Modifier.size(16.dp)) + content() + Row(modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically) { + buttons() + } + } + + } + } } \ No newline at end of file diff --git a/android/modules/module_profile/src/main/AndroidManifest.xml b/android/modules/module_profile/src/main/AndroidManifest.xml index b51b95f61..6e489041c 100644 --- a/android/modules/module_profile/src/main/AndroidManifest.xml +++ b/android/modules/module_profile/src/main/AndroidManifest.xml @@ -37,9 +37,6 @@ - diff --git a/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/AlarmSelectDialog.kt b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/AlarmSelectDialog.kt new file mode 100644 index 000000000..d6ca58ef1 --- /dev/null +++ b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/AlarmSelectDialog.kt @@ -0,0 +1,208 @@ +package github.tornaco.thanos.android.module.profile.engine + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight.Companion.W600 +import androidx.compose.ui.unit.dp +import github.tornaco.android.thanos.core.alarm.Alarm +import github.tornaco.android.thanos.core.alarm.Repeat +import github.tornaco.android.thanos.core.alarm.TimeOfADay +import github.tornaco.android.thanos.core.alarm.WeekDay +import github.tornaco.android.thanos.module.compose.common.theme.ColorDefaults +import github.tornaco.android.thanos.module.compose.common.widget.StandardSpacer +import github.tornaco.android.thanos.module.compose.common.widget.ThanoxDialog +import github.tornaco.thanos.android.module.profile.R +import github.tornaco.thanos.android.module.profile.engine.timepicker.BottomTimePicker +import java.time.LocalTime + +class AlarmSelectorState( + initialTime: LocalTime, + initialRepeat: Array, + val selected: (Alarm) -> Unit +) { + private var _isShow by mutableStateOf(false) + val isShow get() = _isShow + + var time: LocalTime = initialTime + + var repeat = mutableStateOf(initialRepeat.toList()) + + var tag by mutableStateOf("") + + fun addRepeat(weekDay: WeekDay) { + repeat.value = repeat.value.toMutableList().apply { + add(weekDay) + } + } + + fun removeRepeat(weekDay: WeekDay) { + repeat.value = repeat.value.toMutableList().apply { + remove(weekDay) + } + } + + fun show() { + _isShow = true + } + + fun dismiss() { + _isShow = false + } +} + +@Composable +fun rememberAlarmSelectorState( + initialTime: LocalTime = LocalTime.now(), + initialRepeat: Array = emptyArray(), + selected: (Alarm) -> Unit +): AlarmSelectorState { + return remember { + AlarmSelectorState(initialTime, initialRepeat, selected) + } +} + +@Composable +fun AlarmSelector(state: AlarmSelectorState) { + if (state.isShow) { + ThanoxDialog(onDismissRequest = { state.dismiss() }, title = { + DialogTitle(text = stringResource(id = R.string.module_profile_date_time_alarm)) + }, buttons = { + TextButton(onClick = { + state.selected( + Alarm( + label = state.tag, + triggerAt = TimeOfADay( + hour = state.time.hour, + minutes = state.time.minute, + seconds = state.time.second + ), + repeat = Repeat(state.repeat.value) + ) + ) + state.dismiss() + }) { + Text(text = stringResource(id = android.R.string.ok)) + } + }, content = { + AlarmContent(state) + }) + } +} + +@Composable +private fun AlarmContent(state: AlarmSelectorState) { + Column( + verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally + ) { + Tag(state = state) + + BottomTimePicker(is24TimeFormat = true, currentTime = state.time) { + state.time = it + } + RepeatSelector(state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Tag(state: AlarmSelectorState) { + Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Start) { + TextField( + label = { + Text(text = "Tag") + }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Tag, + contentDescription = "tag" + ) + }, + value = state.tag, onValueChange = { + state.tag = it + }) + Text( + text = stringResource( + id = R.string.module_profile_date_time_tag, + "\ncondition: \"timeTick && tag == \"${state.tag}\"\"" + ), style = MaterialTheme.typography.labelSmall + ) + } + StandardSpacer() +} + +@Composable +private fun RepeatSelector(state: AlarmSelectorState) { + Column( + modifier = Modifier.padding(top = 24.dp, bottom = 32.dp), + horizontalAlignment = Alignment.Start + ) { + Text( + text = "Repeat", + style = MaterialTheme.typography.bodySmall + ) + StandardSpacer() + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + arrayOf( + WeekDay.SUNDAY, + WeekDay.MONDAY, + WeekDay.TUESDAY, + WeekDay.WEDNESDAY, + WeekDay.THURSDAY, + WeekDay.FRIDAY, + WeekDay.SATURDAY + ).forEach { weekDay -> + val isSelected = state.repeat.value.contains(weekDay) + Column( + modifier = Modifier + .width(36.dp) + .height(36.dp) + .padding(2.dp) + .clip(CircleShape) + .background(color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else ColorDefaults.backgroundSurfaceColor()) + .clickable { + if (isSelected) { + state.removeRepeat(weekDay) + } else { + state.addRepeat(weekDay) + } + } + .padding(4.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = weekDay.name.first().toString().uppercase(), + style = MaterialTheme.typography.bodySmall.copy(fontWeight = W600) + ) + } + } + } + } +} + + +@Composable +fun DialogTitle(text: String) { + Text( + modifier = Modifier.padding(8.dp), + text = text, + style = MaterialTheme.typography.titleLarge + ) +} \ No newline at end of file diff --git a/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/DateTimeEngineScreen.kt b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/DateTimeEngineScreen.kt index 1477eca6a..abb4469db 100644 --- a/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/DateTimeEngineScreen.kt +++ b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/DateTimeEngineScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Timer import androidx.compose.material3.* @@ -45,6 +44,9 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import dev.enro.core.compose.registerForNavigationResult import github.tornaco.android.thanos.core.alarm.AlarmRecord +import github.tornaco.android.thanos.core.alarm.TimeOfADay +import github.tornaco.android.thanos.core.alarm.WeekDay +import github.tornaco.android.thanos.core.util.DateUtils import github.tornaco.android.thanos.module.compose.common.theme.TypographyDefaults import github.tornaco.android.thanos.module.compose.common.widget.* import github.tornaco.android.thanos.module.compose.common.widget.Switch @@ -59,10 +61,10 @@ private const val ID_REGULAR_INTERVAL = "ri" sealed class BottomNavItem(var title: String, var icon: Int, var screenRoute: String) { object TimeOfADay : - BottomNavItem("TimeOfADay", R.drawable.ic_account_circle_fill, "TimeOfADay") + BottomNavItem("TimeOfADay", R.drawable.ic_remix_time_fill, "TimeOfADay") object RegularInterval : - BottomNavItem("RegularInterval", R.drawable.ic_alipay_fill, "RegularInterval") + BottomNavItem("RegularInterval", R.drawable.ic_remix_24_hours_fill, "RegularInterval") } @Composable @@ -83,34 +85,9 @@ fun Activity.DateTimeEngineScreen() { ) } - val newAlarmHandle = registerForNavigationResult { alarm -> - viewModel.addAlarm(alarm.alarm) - } - - val typeSelectDialogState = - rememberSingleChoiceDialogState(title = stringResource(id = R.string.module_profile_rule_new), - items = listOf( - SingleChoiceItem( - id = ID_TIME_OF_A_DAY, - icon = Icons.Filled.Schedule, - label = stringResource(id = R.string.module_profile_date_time_alarm) - ), - SingleChoiceItem( - id = ID_REGULAR_INTERVAL, - icon = Icons.Filled.Timer, - label = stringResource(id = R.string.module_profile_date_time_regular_interval) - ), - ), - onItemClick = { - when (it) { - ID_REGULAR_INTERVAL -> { - newRegularIntervalHandle.open(NewRegularInterval) - } - ID_TIME_OF_A_DAY -> { - newAlarmHandle.open(NewAlarm) - } - } - }) + val alarmDialogState = rememberAlarmSelectorState(selected = { + viewModel.addAlarm(it) + }) val navController = rememberNavController() @@ -137,16 +114,28 @@ fun Activity.DateTimeEngineScreen() { contentDescription = stringResource(id = R.string.module_profile_rule_new) ) }) { - typeSelectDialogState.show() + + when (navController.currentBackStackEntry?.destination?.route) { + BottomNavItem.TimeOfADay.screenRoute -> { + alarmDialogState.show() + } + BottomNavItem.RegularInterval.screenRoute -> { + newRegularIntervalHandle.open(NewRegularInterval) + } + else -> {} + } } }) { contentPadding -> - Column(modifier = Modifier - .fillMaxSize() - .padding(contentPadding)) { - SingleChoiceDialog(state = typeSelectDialogState) + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + ) { NavigationGraph(navController = navController, viewModel = viewModel, state = state) } + + AlarmSelector(state = alarmDialogState) } } @@ -164,8 +153,10 @@ private fun NavigationContent( items.forEach { item -> NavigationBarItem( icon = { - Icon(painterResource(id = item.icon), - contentDescription = item.title) + Icon( + painterResource(id = item.icon), + contentDescription = item.title + ) }, label = { Text(text = item.title, fontSize = 9.sp) @@ -196,18 +187,18 @@ fun NavigationGraph( ) { NavHost(navController, startDestination = BottomNavItem.TimeOfADay.screenRoute) { composable(BottomNavItem.TimeOfADay.screenRoute) { - WorkList(state = state.workStates) { - viewModel.deleteWorkById(it) - } - } - - composable(BottomNavItem.RegularInterval.screenRoute) { AlarmList(state = state.alarms, delete = { viewModel.deleteAlarm(it) }, onCheckChange = { record, checked -> viewModel.setAlarmEnabled(record.alarm, checked) }) } + + composable(BottomNavItem.RegularInterval.screenRoute) { + WorkList(state = state.workStates) { + viewModel.deleteWorkById(it) + } + } } } @@ -219,84 +210,7 @@ private fun WorkList( ) { LazyColumn(modifier = Modifier.fillMaxSize()) { items(state) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .heightIn(min = 64.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column( - modifier = Modifier - ) { - val typeText = if (it.type == Type.Periodic) { - stringResource(id = R.string.module_profile_date_time_regular_interval) - } else { - "Unknown" - } - val valueText = if (it.type == Type.Periodic) { - it.value.toDuration(DurationUnit.MILLISECONDS).toString() - } else { - "" - } - - Text( - text = typeText, style = MaterialTheme.typography.titleMedium, - maxLines = 1 - ) - TinySpacer() - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Filled.Tag, - contentDescription = "tag" - ) - TinySpacer() - Text( - text = it.tag.substring(0, min(18, it.tag.length)), - style = MaterialTheme.typography.labelMedium, - maxLines = 1 - ) - } - - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Filled.Timer, - contentDescription = "interval" - ) - TinySpacer() - Text( - text = valueText, style = MaterialTheme.typography.labelMedium, - maxLines = 1 - ) - } - } - - IconButton(onClick = { - delete(it.id) - }) { - Icon( - painter = painterResource(id = R.drawable.module_profile_ic_delete_bin_fill), - contentDescription = "Remove" - ) - } - } - - } - } -} - - -@Composable -private fun AlarmList( - state: List, - delete: (alarm: AlarmRecord) -> Unit, - onCheckChange: (alarm: AlarmRecord, checked: Boolean) -> Unit, -) { - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(state) { record -> - Column(modifier = Modifier - .fillMaxWidth()) { + CardContainer { Row( modifier = Modifier .fillMaxWidth() @@ -306,10 +220,18 @@ private fun AlarmList( horizontalArrangement = Arrangement.SpaceBetween ) { Column( - modifier = Modifier.weight(1f, fill = false) + modifier = Modifier ) { - val typeText = stringResource(id = R.string.module_profile_date_time_alarm) - val valueText = record.alarm.triggerAt.toString() + val typeText = if (it.type == Type.Periodic) { + stringResource(id = R.string.module_profile_date_time_regular_interval) + } else { + "Unknown" + } + val valueText = if (it.type == Type.Periodic) { + it.value.toDuration(DurationUnit.MILLISECONDS).toString() + } else { + "" + } Text( text = typeText, style = MaterialTheme.typography.titleMedium, @@ -323,13 +245,14 @@ private fun AlarmList( ) TinySpacer() Text( - text = record.alarm.label.substring(0, - min(18, record.alarm.label.length)), + text = it.tag.substring(0, min(18, it.tag.length)), style = MaterialTheme.typography.labelMedium, maxLines = 1 ) } + StandardSpacer() + Row(verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.Filled.Timer, @@ -343,24 +266,9 @@ private fun AlarmList( } } - Switch( - modifier = Modifier, - checked = record.isEnabled, - onCheckedChange = { checked -> - onCheckChange(record, checked) - }) - } - - Box(modifier = Modifier - .fillMaxWidth() - .padding(top = 6.dp)) { - IconButton( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 16.dp), - onClick = { - delete(record) - }) { + IconButton(onClick = { + delete(it.id) + }) { Icon( painter = painterResource(id = R.drawable.module_profile_ic_delete_bin_fill), contentDescription = "Remove" @@ -368,6 +276,143 @@ private fun AlarmList( } } } + + } + } +} + + +@Composable +private fun AlarmList( + state: List, + delete: (alarm: AlarmRecord) -> Unit, + onCheckChange: (alarm: AlarmRecord, checked: Boolean) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state) { record -> + CardContainer { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .heightIn(min = 64.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier.weight(1f, fill = false) + ) { + val typeText = + stringResource(id = R.string.module_profile_date_time_alarm) + + val repeatText = if (record.alarm.repeat.isNo) { + "No repeat" + } else if (record.alarm.repeat.isEveryDay) { + "Repeat every day" + } else { + "Repeat at ${ + record.alarm.repeat.days.joinToString( + " " + ) { + getLongLabelForWeekDay(weekDay = it) + } + }" + } + + val valueText = + "${record.alarm.triggerAt.toDisplayTime()}\n${repeatText}" + + Text( + text = typeText, style = MaterialTheme.typography.titleMedium, + maxLines = 1 + ) + TinySpacer() + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Tag, + contentDescription = "tag" + ) + TinySpacer() + Text( + text = record.alarm.label.substring( + 0, + min(18, record.alarm.label.length) + ), + style = MaterialTheme.typography.labelMedium, + maxLines = 1 + ) + } + StandardSpacer() + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Timer, + contentDescription = "Time" + ) + TinySpacer() + Text( + text = valueText, style = MaterialTheme.typography.labelMedium, + ) + } + } + + Switch( + modifier = Modifier, + checked = record.isEnabled, + onCheckedChange = { checked -> + onCheckChange(record, checked) + }) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) + ) { + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp), + onClick = { + delete(record) + }) { + Icon( + painter = painterResource(id = R.drawable.module_profile_ic_delete_bin_fill), + contentDescription = "Remove" + ) + } + } + } + } + } + } +} + +private fun TimeOfADay.toDisplayTime(): String { + val date = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, hour) + set(Calendar.MINUTE, minutes) + set(Calendar.SECOND, 0) + }.time + return DateUtils.formatShortForMessageTime(date.time) +} + + +private fun getLongLabelForWeekDay(weekDay: WeekDay): String { + return when (weekDay) { + WeekDay.MONDAY -> "Mon" + WeekDay.TUESDAY -> "Tue" + WeekDay.WEDNESDAY -> "Wed" + WeekDay.THURSDAY -> "Thu" + WeekDay.FRIDAY -> "Fri" + WeekDay.SATURDAY -> "Sat" + WeekDay.SUNDAY -> "Sun" + else -> { + "N/A" } } } \ No newline at end of file diff --git a/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/NewAlarmActivity.kt b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/NewAlarmActivity.kt deleted file mode 100644 index 624f060ec..000000000 --- a/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/NewAlarmActivity.kt +++ /dev/null @@ -1,246 +0,0 @@ -/* - * (C) Copyright 2022 Thanox - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -@file:OptIn(ExperimentalMaterial3Api::class) - -package github.tornaco.thanos.android.module.profile.engine - -import android.os.Parcelable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Remove -import androidx.compose.material.icons.filled.Tag -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import dagger.hilt.android.AndroidEntryPoint -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationKey -import dev.enro.core.close -import dev.enro.core.compose.navigationHandle -import dev.enro.core.result.closeWithResult -import github.tornaco.android.thanos.core.alarm.Alarm -import github.tornaco.android.thanos.core.alarm.Repeat -import github.tornaco.android.thanos.core.alarm.TimeOfADay -import github.tornaco.android.thanos.module.compose.common.ComposeThemeActivity -import github.tornaco.android.thanos.module.compose.common.theme.TypographyDefaults -import github.tornaco.android.thanos.module.compose.common.widget.* -import github.tornaco.thanos.android.module.profile.R -import kotlinx.parcelize.Parcelize -import kotlin.math.min - -@Parcelize -object NewAlarm : NavigationKey.WithResult - -@Parcelize -data class NewAlarmResult( - val alarm: Alarm, -) : Parcelable - -@AndroidEntryPoint -@NavigationDestination(NewAlarm::class) -class NewAlarmActivity : ComposeThemeActivity() { - override fun isF(): Boolean { - return true - } - - override fun isADVF(): Boolean { - return true - } - - @Composable - override fun Content() { - NewAlarmContent() - } - - - @Composable - private fun NewAlarmContent() { - val state = AlarmState() - val navHandle = navigationHandle() - - ThanoxSmallAppBarScaffold(title = { - Text( - text = stringResource(id = R.string.module_profile_date_time_alarm), - style = TypographyDefaults.appBarTitleTextStyle() - ) - }, - onBackPressed = { navHandle.close() }, - actions = { - }, floatingActionButton = { - ExtendableFloatingActionButton( - extended = true, - text = { Text(text = stringResource(id = R.string.module_profile_rule_edit_action_save)) }, - icon = { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = stringResource(id = R.string.module_profile_rule_edit_action_save) - ) - }) { - val alarm = Alarm( - label = state.tag, - triggerAt = TimeOfADay( - state.h.value, - state.m.value, - state.s.value - ), - repeat = Repeat() - ) - navHandle.closeWithResult( - NewAlarmResult( - alarm = alarm - ) - ) - } - }) { contentPadding -> - Column( - modifier = Modifier - .padding(contentPadding) - .padding(16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.Start - ) { - Hour(state) - Minute(state) - Second(state) - Text( - text = stringResource( - id = R.string.module_profile_date_time_min_interval - ), style = MaterialTheme.typography.labelSmall - ) - - LargeSpacer() - OutlinedTextField( - label = { - Text(text = "Tag") - }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Tag, - contentDescription = "tag" - ) - }, - value = state.tag, - maxLines = 1, - onValueChange = { - state.tag = it.substring(0, min(16, it.length)).replace("-", "_") - .trim() - }) - - TinySpacer() - Text( - text = stringResource( - id = R.string.module_profile_date_time_tag, - "\ncondition: \"timeTick && tag == \"${state.tag}\"\"" - ), style = MaterialTheme.typography.labelSmall - ) - } - } - } - - - @Composable - private fun Hour(durationState: AlarmState) { - Field( - durationState.h, - "Hour", - 0, - 24 - ) - } - - @Composable - private fun Minute(durationState: AlarmState) { - Field( - durationState.m, - "Minute", - 15, - 59 - ) - } - - @Composable - private fun Second(durationState: AlarmState) { - Field( - durationState.s, - "Second", - 0, - 59 - ) - } - - @Composable - private fun Field(fieldValue: MutableState, label: String, min: Int = 0, max: Int) { - var value by fieldValue - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Text(text = label) - LargeSpacer() - - // - - IconButton(onClick = { - if (value > min) { - value -= 1 - } else { - value = max - } - }) { - Icon( - imageVector = Icons.Filled.Remove, - contentDescription = "-" - ) - } - - StandardSpacer() - Text(text = "$value") - StandardSpacer() - - // + - IconButton(onClick = { - if (value < max) { - value += 1 - } else { - value = min - } - }) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "+" - ) - } - } - } - - private class AlarmState { - var h = mutableStateOf(0) - var m = mutableStateOf(0) - var s = mutableStateOf(0) - - var tag by mutableStateOf("") - } - -} diff --git a/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/BottomTimePicker.kt b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/BottomTimePicker.kt new file mode 100644 index 000000000..62d4c87de --- /dev/null +++ b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/BottomTimePicker.kt @@ -0,0 +1,55 @@ +package github.tornaco.thanos.android.module.profile.engine.timepicker + + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import github.tornaco.android.thanos.module.compose.common.theme.ColorDefaults +import java.time.LocalTime + + +@Composable +fun BottomTimePicker( + currentTime: LocalTime? = null, + is24TimeFormat: Boolean, + onTimeChanged: (LocalTime) -> Unit +) { + + var time by remember { mutableStateOf(currentTime ?: LocalTime.now()) } + + PickerContainer( + modifier = Modifier.padding(18.dp), + backgroundColor = ColorDefaults.backgroundSurfaceColor(), + cornerRadius = 16.dp, + fadingEdgeLength = 60.dp + ) { + TimePicker( + modifier = Modifier.height(130.dp), + itemHeight = 40.dp, + is24TimeFormat = is24TimeFormat, + itemStyles = ItemStyles( + defaultTextStyle = TextStyle( + MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Medium + ), + selectedTextStyle = TextStyle( + MaterialTheme.colorScheme.primary, + fontSize = 20.sp, + fontWeight = FontWeight.Black + ) + ), + onTimeChanged = { + time = it + onTimeChanged(it) + }, + currentTime = time + ) + } +} \ No newline at end of file diff --git a/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/PickerContainer.kt b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/PickerContainer.kt new file mode 100644 index 000000000..831b8a4dd --- /dev/null +++ b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/PickerContainer.kt @@ -0,0 +1,65 @@ +package github.tornaco.thanos.android.module.profile.engine.timepicker + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import github.tornaco.android.thanos.module.compose.common.toPx + + +// https://github.com/KirillVolkov/Compose-TimePicker +@Composable +fun PickerContainer( + modifier: Modifier = Modifier, + backgroundColor: Color = Color.Transparent, + cornerRadius: Dp = 0.dp, + fadingEdgeLength: Dp = 0.dp, + content: @Composable () -> Unit +) { + + val corners = cornerRadius.toPx() + var height by remember { mutableStateOf(0) } + + Box( + modifier = modifier + .background(color = backgroundColor, shape = RoundedCornerShape(corners)) + .onGloballyPositioned { + if (height == 0) { + height = it.size.height + } + } + .drawWithContent { + drawContent() + if (fadingEdgeLength > 0.dp) { + drawRoundRect( + cornerRadius = CornerRadius(corners, corners), + brush = Brush.verticalGradient( + listOf(backgroundColor, Color.Transparent), + startY = 0f, + endY = fadingEdgeLength.toPx() + ) + ) + drawRoundRect( + cornerRadius = CornerRadius(corners, corners), + brush = Brush.verticalGradient( + listOf(Color.Transparent, backgroundColor), + startY = height - fadingEdgeLength.toPx(), + endY = height.toFloat() + ) + ) + } + }, + contentAlignment = Alignment.Center + ) { + content() + } +} diff --git a/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/TimePicker.kt b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/TimePicker.kt new file mode 100644 index 000000000..bbbd9e02b --- /dev/null +++ b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/TimePicker.kt @@ -0,0 +1,227 @@ +package github.tornaco.thanos.android.module.profile.engine.timepicker + +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import java.time.LocalTime + +@Composable +fun TimePicker( + modifier: Modifier = Modifier, + is24TimeFormat: Boolean, + itemHeight: Dp = 32.dp, + divider: NumberPickerDivider = NumberPickerDivider(), + itemStyles: ItemStyles = ItemStyles(), + currentTime: LocalTime, + onTimeChanged: (LocalTime) -> Unit +) { + + val pickerTime by remember { + mutableStateOf( + parseTime( + currentTime, + is24TimeFormat = is24TimeFormat + ) + ) + } + + val hours = (if (is24TimeFormat) (0..23) else (1..12)).toList() + val minutes = (0..59).toList() + + Row(horizontalArrangement = Arrangement.Center, modifier = modifier.fillMaxWidth(1f)) { + WheelPicker( + itemHeight = itemHeight, + divider = divider, + itemStyles = itemStyles, + items = hours, + selectedItem = pickerTime.hours, + itemToString = { if (is24TimeFormat) String.format("%02d", it) else it.toString() }, + modifier = Modifier + .fillMaxHeight(1f) + .fillMaxWidth(.3f), + onItemChanged = { + pickerTime.hours = it + onTimeChanged(pickerTime.toLocalTime()) + } + ) + WheelPicker( + items = minutes, + selectedItem = pickerTime.minutes, + itemHeight = itemHeight, + divider = divider, + itemStyles = itemStyles, + itemToString = { String.format("%02d", it) }, + modifier = Modifier + .fillMaxHeight(1f) + .fillMaxWidth(.3f), + onItemChanged = { + pickerTime.minutes = it + onTimeChanged(pickerTime.toLocalTime()) + } + + ) + if (is24TimeFormat.not()) { + AmPmPicker( + itemHeight = itemHeight, + divider = divider, + itemStyles = itemStyles, + modifier = Modifier + .fillMaxHeight(1f) + .fillMaxWidth(.3f), + selectedItem = pickerTime.timesOfDay!!, + onItemChanged = { + pickerTime.timesOfDay = it + onTimeChanged(pickerTime.toLocalTime()) + } + ) + } + } +} + +@Composable +fun AmPmPicker( + modifier: Modifier = Modifier, + selectedItem: TimesOfDay, + itemHeight: Dp = 32.dp, + divider: NumberPickerDivider = NumberPickerDivider(showed = true, Color.Black), + itemStyles: ItemStyles = ItemStyles(), + onItemChanged: (TimesOfDay) -> Unit = {} +) { + val scope = rememberCoroutineScope() + var currItem by remember { mutableStateOf(selectedItem) } + var listHeightInPixels by remember { mutableStateOf(0) } + var itemHeightInPixels by remember { mutableStateOf(0) } + + val items = TimesOfDay.values() + + val listState = + rememberLazyListState(initialFirstVisibleItemIndex = items.indexOf(selectedItem)) + + if (listState.isScrollInProgress.not() && itemHeightInPixels > 0 && listHeightInPixels > itemHeightInPixels) { + if (listState.firstVisibleItemScrollOffset != 0) { + LaunchedEffect(key1 = listState) { + scope.launch { + listState.animateScrollBy( + (if (listState.firstVisibleItemScrollOffset <= itemHeightInPixels / 2) + -listState.firstVisibleItemScrollOffset - itemHeightInPixels + else listState.firstVisibleItemScrollOffset + itemHeightInPixels).toFloat() + ) + } + } + } else { + if (items[listState.firstVisibleItemIndex] != currItem) { + currItem = items[listState.firstVisibleItemIndex] + onItemChanged(currItem) + } + } + } + + Box(modifier = modifier, contentAlignment = Alignment.Center) { + if (items.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(top = itemHeight, bottom = itemHeight), + state = listState, + modifier = Modifier + .fillMaxWidth(1f) + .height(itemHeight * (items.size + 1)) + .onGloballyPositioned { + if (listHeightInPixels < itemHeightInPixels) { + listHeightInPixels = it.size.height + + scope.launch { + listState.scrollToItem(items.indexOf(selectedItem)) + } + } + } + ) { + items(items.size) { i -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth(1f) + .height(itemHeight) + .onGloballyPositioned { + if (itemHeightInPixels == 0) { + itemHeightInPixels = it.size.height + } + } + ) { + Text( + text = items[i].string, + style = if (i == listState.firstVisibleItemIndex) itemStyles.selectedTextStyle else itemStyles.defaultTextStyle + ) + } + } + } + } + if (divider.showed) { + Divider( + color = divider.color, + modifier = Modifier + .fillMaxWidth(1f) + .offset(y = -itemHeight / 2) + .offset(x = -divider.indent), + thickness = divider.thickness, + startIndent = divider.indent * 2 + ) + Divider( + color = divider.color, modifier = Modifier + .fillMaxWidth(1f) + .offset(y = itemHeight / 2) + .offset(x = -divider.indent), + thickness = divider.thickness, + startIndent = divider.indent * 2 + ) + } + } +} + +enum class TimesOfDay(val string: String) { + AM("AM"), PM("PM") +} + + +fun parseTime(time: LocalTime, is24TimeFormat: Boolean): PickerTime { + return PickerTime( + hours = if (is24TimeFormat.not() && time.hour > 12) time.hour - 12 else time.hour, + minutes = time.minute, + if (is24TimeFormat) null else if (time.hour > 12) TimesOfDay.PM else TimesOfDay.AM + ) +} + +fun PickerTime.toLocalTime(): LocalTime { + return LocalTime.of( + when (timesOfDay) { + TimesOfDay.AM -> hours % 12 + TimesOfDay.PM -> hours % 12 + 12 + else -> hours + }, + minutes + ) +} + +class PickerTime( + var hours: Int, + var minutes: Int, + var timesOfDay: TimesOfDay? = null +) { + override fun toString(): String { + return "${String.format("%02d", hours)}:${ + String.format( + "%02d", + minutes + ) + } ${timesOfDay?.string ?: ""}" + } +} diff --git a/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/WheelPicker.kt b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/WheelPicker.kt new file mode 100644 index 000000000..c35f2e760 --- /dev/null +++ b/android/modules/module_profile/src/main/java/github/tornaco/thanos/android/module/profile/engine/timepicker/WheelPicker.kt @@ -0,0 +1,133 @@ +package github.tornaco.thanos.android.module.profile.engine.timepicker + +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import github.tornaco.android.thanos.module.compose.common.toDp +import kotlinx.coroutines.launch +import kotlin.math.abs + +@Composable +internal fun WheelPicker( + modifier: Modifier = Modifier, + items: List = emptyList(), + selectedItem: T? = null, + itemHeight: Dp = 32.dp, + divider: NumberPickerDivider = NumberPickerDivider(), + itemStyles: ItemStyles = ItemStyles(), + onItemChanged: (T) -> Unit = {}, + itemToString: (T) -> String = { it.toString() } +) { + val scope = rememberCoroutineScope() + var currItem by remember { mutableStateOf(selectedItem) } + var listHeightInPixels by remember { mutableStateOf(0) } + var itemHeightInPixels by remember { mutableStateOf(0) } + + val listState = rememberSaveable(saver = LazyListState.Saver) { + if (items.isNotEmpty()) { + val centerList = Int.MAX_VALUE / 2 - (Int.MAX_VALUE / 2) % items.size + val selectedIndex = selectedItem?.let { items.indexOf(selectedItem) } ?: 0 + LazyListState( + firstVisibleItemIndex = centerList + selectedIndex + ) + } else { + LazyListState() + } + } + + if (listState.isScrollInProgress.not() && itemHeightInPixels > 0) { + val needScrollTop = -listState.firstVisibleItemScrollOffset + itemHeightInPixels / 2 + + if (abs(listState.firstVisibleItemScrollOffset - itemHeightInPixels / 2) > 1) { + + LaunchedEffect(key1 = listState) { + scope.launch { + listState.animateScrollBy(needScrollTop.toFloat()) + if (items[listState.firstVisibleItemIndex % items.size] != currItem) { + currItem = items[listState.firstVisibleItemIndex % items.size] + onItemChanged(currItem!!) + } + } + } + } + } + + Box(modifier = modifier, contentAlignment = Alignment.Center) { + if (items.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(top = (listHeightInPixels / 2).toDp()), + state = listState, + modifier = Modifier + .fillMaxWidth(1f) + .onGloballyPositioned { + if (listHeightInPixels < itemHeightInPixels) { + listHeightInPixels = it.size.height + } + } + ) { + items(Int.MAX_VALUE) { i -> + val index = i % items.size + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth(1f) + .height(itemHeight) + .onGloballyPositioned { + if (itemHeightInPixels == 0) { + itemHeightInPixels = it.size.height + } + } + ) { + Text( + text = itemToString(items[index]), + style = if (i == listState.firstVisibleItemIndex) itemStyles.selectedTextStyle else itemStyles.defaultTextStyle + ) + } + } + } + } + if (divider.showed) { + Divider( + color = divider.color, + modifier = Modifier + .fillMaxWidth(1f) + .offset(y = -itemHeight / 2) + .offset(x = -divider.indent), + thickness = divider.thickness, + startIndent = divider.indent * 2 + ) + Divider( + color = divider.color, modifier = Modifier + .fillMaxWidth(1f) + .offset(y = itemHeight / 2) + .offset(x = -divider.indent), + thickness = divider.thickness, + startIndent = divider.indent * 2 + ) + } + } +} + +class NumberPickerDivider( + val showed: Boolean = false, + val color: Color = Color.Transparent, + val thickness: Dp = 1.dp, + val indent: Dp = 0.dp +) + +class ItemStyles( + val defaultTextStyle: TextStyle = TextStyle.Default, + val selectedTextStyle: TextStyle = TextStyle.Default +) diff --git a/android/modules/module_profile/src/main/res/values-zh-rCN/strings.xml b/android/modules/module_profile/src/main/res/values-zh-rCN/strings.xml index 39c94713d..a748901c6 100644 --- a/android/modules/module_profile/src/main/res/values-zh-rCN/strings.xml +++ b/android/modules/module_profile/src/main/res/values-zh-rCN/strings.xml @@ -24,7 +24,7 @@ 安排一些重复或定时任务 固定时间间隔 一天中的时间 - Tag用于情景模式的条件判断。使用举例:%s + Tag用于情景模式的条件判断。\n使用举例:%s 当前支持的最短时间间隔为15分钟 保存 diff --git a/android/modules/module_profile/src/main/res/values-zh-rTW/strings.xml b/android/modules/module_profile/src/main/res/values-zh-rTW/strings.xml index 3d7231326..1471df2fe 100644 --- a/android/modules/module_profile/src/main/res/values-zh-rTW/strings.xml +++ b/android/modules/module_profile/src/main/res/values-zh-rTW/strings.xml @@ -24,7 +24,7 @@ 安排一些重複或定時任務 固定時間間隔 一天中的時間 - Tag用於情景模式的條件判斷。使用舉例:%s + Tag用於情景模式的條件判斷。\n使用舉例:%s 當前支持的最短時間間隔為15分鐘 保存 diff --git a/android/modules/module_profile/src/main/res/values/strings.xml b/android/modules/module_profile/src/main/res/values/strings.xml index bf74cb1fb..3d492419d 100644 --- a/android/modules/module_profile/src/main/res/values/strings.xml +++ b/android/modules/module_profile/src/main/res/values/strings.xml @@ -24,7 +24,7 @@ Schedule some repeating or timed tasks Regular interval Time of a day - Tag is for condition check. Example of use: %s + Tag is for condition check. \nExample of use: %s Min interval is 15m Save