From b26b4bbec7512bd61e6f4342d41e03341b9cf29e Mon Sep 17 00:00:00 2001 From: Brayan Bedritchuk Date: Wed, 3 Dec 2025 09:56:11 -0300 Subject: [PATCH 1/2] Create composable ui components --- .editorconfig | 1 + buildSrc/src/main/java/Dependency.kt | 1 + .../viewmodel/TaskDetailsViewModel.kt | 23 +- ui-component/impl/build.gradle.kts | 1 + .../uicomponent/impl/alarm/AlarmItem.kt | 69 ++++ .../impl/image/ImageTitleDividerItem.kt | 85 +++++ .../uicomponent/impl/label/LabelItem.kt | 31 ++ .../uicomponent/impl/label/LabelValueItem.kt | 39 ++ .../impl/progress/TaskProgressComponents.kt | 21 +- .../impl/progress/TaskProgressView.kt | 3 +- .../impl/skeleton/TaskSkeletonItem.kt | 11 +- .../uicomponent/impl/subhead/SubheadItem.kt | 34 ++ .../uicomponent/impl/task/TaskItem.kt | 350 ++++++++++++++++++ .../uicomponent/impl/theme/TodozyTheme.kt | 82 ++++ .../uicomponent/impl/title/TitleItem.kt | 108 ++++++ .../impl/viewholder/AlarmViewHolder.kt | 51 +-- .../viewholder/ImageTitleDividerViewHolder.kt | 38 +- .../impl/viewholder/LabelValueViewHolder.kt | 32 +- .../impl/viewholder/LabelViewHolder.kt | 31 +- .../impl/viewholder/SubheadViewHolder.kt | 34 +- .../impl/viewholder/TaskSkeletonViewHolder.kt | 7 +- .../impl/viewholder/TaskViewHolder.kt | 151 ++------ .../impl/viewholder/TitleViewHolder.kt | 31 +- .../src/main/res/layout/alarm_details.xml | 15 +- .../src/main/res/layout/diamond_divider.xml | 43 --- .../impl/src/main/res/layout/task.xml | 95 ----- .../res/layout/vh_image_title_divider.xml | 48 --- .../impl/src/main/res/layout/vh_label.xml | 7 - .../src/main/res/layout/vh_label_value.xml | 25 -- .../impl/src/main/res/layout/vh_subheader.xml | 13 - .../impl/src/main/res/layout/vh_task.xml | 14 - .../impl/src/main/res/layout/vh_title.xml | 33 -- .../impl/LabelAndTitleComposeTest.kt | 69 ++++ 33 files changed, 1106 insertions(+), 490 deletions(-) create mode 100644 ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/alarm/AlarmItem.kt create mode 100644 ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/image/ImageTitleDividerItem.kt create mode 100644 ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/label/LabelItem.kt create mode 100644 ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/label/LabelValueItem.kt create mode 100644 ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/subhead/SubheadItem.kt create mode 100644 ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt create mode 100644 ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/theme/TodozyTheme.kt create mode 100644 ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/title/TitleItem.kt delete mode 100644 ui-component/impl/src/main/res/layout/diamond_divider.xml delete mode 100644 ui-component/impl/src/main/res/layout/task.xml delete mode 100644 ui-component/impl/src/main/res/layout/vh_image_title_divider.xml delete mode 100644 ui-component/impl/src/main/res/layout/vh_label.xml delete mode 100644 ui-component/impl/src/main/res/layout/vh_label_value.xml delete mode 100644 ui-component/impl/src/main/res/layout/vh_subheader.xml delete mode 100644 ui-component/impl/src/main/res/layout/vh_task.xml delete mode 100644 ui-component/impl/src/main/res/layout/vh_title.xml create mode 100644 ui-component/impl/src/test/java/br/com/sailboat/uicomponent/impl/LabelAndTitleComposeTest.kt diff --git a/.editorconfig b/.editorconfig index dfdd731a..9c94db4b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,3 +5,4 @@ ktlint_standard_blank-line-before-declaration=disabled ktlint_standard_multiline-expression-wrapping = disabled ktlint_standard_string-template-indent = disabled ktlint_function_signature_body_expression_wrapping = default +ktlint_function_naming_ignore_when_annotated_with = Composable diff --git a/buildSrc/src/main/java/Dependency.kt b/buildSrc/src/main/java/Dependency.kt index 3f1b07bb..f74be87f 100644 --- a/buildSrc/src/main/java/Dependency.kt +++ b/buildSrc/src/main/java/Dependency.kt @@ -191,6 +191,7 @@ object Compose { const val uiToolingPreview = "androidx.compose.ui:ui-tooling-preview:${Version.core}" const val lifecycleRuntimeKtx = "androidx.lifecycle:lifecycle-runtime-ktx:${Version.lifecycleRuntime}" const val activity = "androidx.activity:activity-compose:${Version.activity}" + const val uiTestJunit4 = "androidx.compose.ui:ui-test-junit4:${Version.core}" } object Desugar { diff --git a/feature/task-details/impl/src/main/java/br/com/sailboat/todozy/feature/task/details/impl/presentation/viewmodel/TaskDetailsViewModel.kt b/feature/task-details/impl/src/main/java/br/com/sailboat/todozy/feature/task/details/impl/presentation/viewmodel/TaskDetailsViewModel.kt index 10bf2ccf..d7dfca87 100644 --- a/feature/task-details/impl/src/main/java/br/com/sailboat/todozy/feature/task/details/impl/presentation/viewmodel/TaskDetailsViewModel.kt +++ b/feature/task-details/impl/src/main/java/br/com/sailboat/todozy/feature/task/details/impl/presentation/viewmodel/TaskDetailsViewModel.kt @@ -37,7 +37,8 @@ internal class TaskDetailsViewModel( private val dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider, ) : BaseViewModel() { private var selectedProgressRange = TaskProgressRange.LAST_YEAR - private var shouldShowProgress = false + private var shouldShowProgressGrid = false + private var shouldShowMetrics = false private var currentAlarm: Alarm? = null private var progressJob: Job? = null private val progressCache: MutableMap> = mutableMapOf() @@ -94,15 +95,21 @@ internal class TaskDetailsViewModel( viewState.taskDetails.postValue(taskDetails) - shouldShowProgress = false + shouldShowProgressGrid = false + shouldShowMetrics = false val taskMetrics: TaskMetrics? = task.alarm?.run { - shouldShowProgress = RepeatType.isAlarmRepeating(this) - if (shouldShowProgress) { + val repeatType = repeatType + shouldShowMetrics = RepeatType.isAlarmRepeating(this) + shouldShowProgressGrid = + shouldShowMetrics && repeatType != RepeatType.MONTH && repeatType != RepeatType.YEAR + + if (shouldShowMetrics) { val taskMetrics = getTaskMetricsUseCase(historyFilterForRange()) return@run taskMetrics.getOrNull() } - shouldShowProgress = false + shouldShowMetrics = false + shouldShowProgressGrid = false return@run null } @@ -129,7 +136,7 @@ internal class TaskDetailsViewModel( } private fun loadProgress(force: Boolean = false) { - val shouldFetch = force || shouldShowProgress + val shouldFetch = force || shouldShowProgressGrid if (shouldFetch.not()) { viewState.taskProgressDays.postValue(emptyList()) viewState.taskProgressLoading.postValue(false) @@ -173,7 +180,7 @@ internal class TaskDetailsViewModel( } private fun loadTaskMetrics() = viewModelScope.launch { - if (shouldShowProgress.not()) { + if (shouldShowMetrics.not()) { viewState.taskMetrics.postValue(null) return@launch } @@ -225,7 +232,7 @@ internal class TaskDetailsViewModel( } private fun filterProgressForAlarm(progress: List): List { - if (shouldShowProgress.not()) { + if (shouldShowProgressGrid.not()) { return progress } diff --git a/ui-component/impl/build.gradle.kts b/ui-component/impl/build.gradle.kts index 3df73bc3..8f089d3b 100644 --- a/ui-component/impl/build.gradle.kts +++ b/ui-component/impl/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation(Compose.material) implementation(Compose.uiToolingPreview) implementation(Compose.lifecycleRuntimeKtx) + testImplementation(Compose.uiTestJunit4) testImplementation(Junit.junit) diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/alarm/AlarmItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/alarm/AlarmItem.kt new file mode 100644 index 00000000..356bed56 --- /dev/null +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/alarm/AlarmItem.kt @@ -0,0 +1,69 @@ +package br.com.sailboat.uicomponent.impl.alarm + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.colorResource +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySemanticColors +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing +import br.com.sailboat.uicomponent.impl.R + +@Composable +internal fun AlarmItem( + date: String, + time: String, + repeatDescription: String?, + modifier: Modifier = Modifier, +) { + val spacing = LocalTodozySpacing.current + val semanticColors = LocalTodozySemanticColors.current + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = spacing.medium, vertical = spacing.small), + ) { + Text( + text = date, + style = + MaterialTheme.typography.h6.copy( + fontSize = 22.sp, + fontWeight = FontWeight.Normal, + ), + color = colorResource(id = R.color.md_blue_300), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(spacing.xsmall)) + + Text( + text = time, + style = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Light), + color = colorResource(id = R.color.md_teal_300), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (!repeatDescription.isNullOrBlank()) { + Spacer(modifier = Modifier.height(spacing.xsmall)) + Text( + text = repeatDescription, + style = MaterialTheme.typography.body1.copy(fontWeight = FontWeight.Normal), + color = colorResource(id = R.color.md_blue_grey_300), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/image/ImageTitleDividerItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/image/ImageTitleDividerItem.kt new file mode 100644 index 00000000..5e8907ed --- /dev/null +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/image/ImageTitleDividerItem.kt @@ -0,0 +1,85 @@ +package br.com.sailboat.uicomponent.impl.image + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing + +@Composable +internal fun ImageTitleDividerItem( + imageRes: Int, + title: String, + modifier: Modifier = Modifier, +) { + val spacing = LocalTodozySpacing.current + val dividerColor = MaterialTheme.colors.secondary + + Row( + modifier = + modifier + .fillMaxWidth() + .padding( + start = spacing.medium, + end = spacing.medium, + top = spacing.medium, + bottom = spacing.small, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = imageRes), + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + + Text( + text = title, + style = MaterialTheme.typography.subtitle1.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colors.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(start = spacing.small), + ) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = spacing.medium, vertical = spacing.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .weight(1f) + .height(1.dp) + .background(dividerColor), + ) + Spacer(modifier = Modifier.padding(horizontal = spacing.small)) + Box( + modifier = + Modifier + .weight(1f) + .height(1.dp) + .background(dividerColor), + ) + } +} diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/label/LabelItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/label/LabelItem.kt new file mode 100644 index 00000000..e0ab3d41 --- /dev/null +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/label/LabelItem.kt @@ -0,0 +1,31 @@ +package br.com.sailboat.uicomponent.impl.label + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing +import java.util.Locale + +@Composable +internal fun LabelItem( + text: String, + modifier: Modifier = Modifier, +) { + val spacing = LocalTodozySpacing.current + val displayText = remember(text) { text.uppercase(Locale.getDefault()) } + + Text( + text = displayText, + modifier = + modifier + .fillMaxWidth() + .padding(start = spacing.medium, top = spacing.small, bottom = spacing.xsmall), + style = MaterialTheme.typography.overline.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colors.secondary, + ) +} diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/label/LabelValueItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/label/LabelValueItem.kt new file mode 100644 index 00000000..8a18088b --- /dev/null +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/label/LabelValueItem.kt @@ -0,0 +1,39 @@ +package br.com.sailboat.uicomponent.impl.label + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing + +@Composable +internal fun LabelValueItem( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + val spacing = LocalTodozySpacing.current + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(start = spacing.medium, bottom = spacing.xxlarge), + ) { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.overline.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colors.secondary, + ) + Text( + text = value, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(top = spacing.xsmall, end = spacing.medium), + ) + } +} diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt index b09f5578..afe90c7b 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.unit.dp import br.com.sailboat.todozy.domain.model.TaskProgressDay import br.com.sailboat.todozy.domain.model.TaskProgressRange import br.com.sailboat.uicomponent.impl.R +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing import java.time.DayOfWeek import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -74,6 +75,7 @@ internal fun TaskProgressContent( highlightNotDone: Boolean = false, flatColors: Boolean = highlightNotDone, ) { + val spacing = LocalTodozySpacing.current val palette = rememberProgressPalette(flatColors = flatColors || highlightNotDone) val totalDone = remember(days) { days.sumOf { it.doneCount } } val formatter = remember { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } @@ -89,9 +91,9 @@ internal fun TaskProgressContent( ) { TaskProgressRangeSelector( selectedRange = selectedRange, - onRangeSelected = onRangeSelected, - ) - Spacer(modifier = Modifier.size(8.dp)) + onRangeSelected = onRangeSelected, + ) + Spacer(modifier = Modifier.size(spacing.small)) if (isLoading) { TaskProgressSkeleton(dayOrder = dayOrder, cellSize = cellSize) } else { @@ -133,6 +135,7 @@ private fun TaskProgressRangeSelector( selectedRange: TaskProgressRange, onRangeSelected: (TaskProgressRange) -> Unit, ) { + val spacing = LocalTodozySpacing.current val haptic = LocalHapticFeedback.current val ranges = remember { @@ -144,7 +147,7 @@ private fun TaskProgressRangeSelector( ) } - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(spacing.small)) { items(ranges) { range -> val selected = range == selectedRange val shape = RoundedCornerShape(12.dp) @@ -154,8 +157,8 @@ private fun TaskProgressRangeSelector( .clip(shape) .clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onRangeSelected(range) - }, + onRangeSelected(range) + }, color = if (selected) { colorResource(id = R.color.md_teal_100) @@ -183,7 +186,11 @@ private fun TaskProgressRangeSelector( } else { MaterialTheme.colors.onSurface }, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + modifier = + Modifier.padding( + horizontal = spacing.small, + vertical = spacing.xsmall, + ), ) } } diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressView.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressView.kt index b93602b4..d6d84ac9 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressView.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressView.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import br.com.sailboat.todozy.domain.model.TaskProgressDay import br.com.sailboat.todozy.domain.model.TaskProgressRange +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import java.time.DayOfWeek class TaskProgressView @@ -41,7 +42,7 @@ class TaskProgressView init { addView(composeView) composeView.setContent { - MaterialTheme { + TodozyTheme { Surface { TaskProgressContent( days = daysState.value, diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/skeleton/TaskSkeletonItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/skeleton/TaskSkeletonItem.kt index 4f753d07..de407ea1 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/skeleton/TaskSkeletonItem.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/skeleton/TaskSkeletonItem.kt @@ -11,26 +11,29 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing @Composable @Suppress("FunctionName") fun TaskSkeletonItem() { - val baseColor = Color(0xFFE6E6E6) - val cardColor = Color.White + val spacing = LocalTodozySpacing.current + val baseColor = MaterialTheme.colors.onSurface.copy(alpha = 0.08f) + val cardColor = MaterialTheme.colors.surface Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp) + .padding(horizontal = spacing.medium, vertical = spacing.xsmall) .heightIn(min = 72.dp) .background(color = cardColor, shape = RoundedCornerShape(12.dp)) - .padding(16.dp), + .padding(spacing.medium), verticalAlignment = Alignment.CenterVertically, ) { Box( diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/subhead/SubheadItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/subhead/SubheadItem.kt new file mode 100644 index 00000000..91f64c34 --- /dev/null +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/subhead/SubheadItem.kt @@ -0,0 +1,34 @@ +package br.com.sailboat.uicomponent.impl.subhead + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing +import java.util.Locale + +@Composable +internal fun SubheadItem( + text: String, + modifier: Modifier = Modifier, +) { + val spacing = LocalTodozySpacing.current + val displayText = remember(text) { text.uppercase(Locale.getDefault()) } + + Text( + text = displayText, + modifier = + modifier + .fillMaxWidth() + .height(48.dp) + .padding(start = spacing.medium), + style = MaterialTheme.typography.overline.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colors.secondary, + ) +} diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt new file mode 100644 index 00000000..0c828870 --- /dev/null +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt @@ -0,0 +1,350 @@ +package br.com.sailboat.uicomponent.impl.task + +import android.content.Context +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import br.com.sailboat.todozy.domain.model.TaskMetrics +import br.com.sailboat.todozy.domain.model.TaskStatus +import br.com.sailboat.todozy.utility.android.calendar.formatTimeWithAndroidFormat +import br.com.sailboat.todozy.utility.android.calendar.getMonthAndDayShort +import br.com.sailboat.todozy.utility.android.calendar.toShortDateView +import br.com.sailboat.todozy.utility.kotlin.extension.isAfterTomorrow +import br.com.sailboat.todozy.utility.kotlin.extension.isBeforeToday +import br.com.sailboat.todozy.utility.kotlin.extension.isCurrentYear +import br.com.sailboat.uicomponent.impl.R +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme +import br.com.sailboat.uicomponent.model.TaskUiModel +import java.util.Calendar + +@Composable +@Suppress("FunctionName") +@OptIn(ExperimentalMaterialApi::class) +internal fun TaskItem( + task: TaskUiModel, + modifier: Modifier = Modifier, + onClick: (Long) -> Unit, + onUndoClick: (Long, TaskStatus) -> Unit, +) { + val context = LocalContext.current + val spacing = LocalTodozySpacing.current + val inlineElevation = 6.dp + val alarmInfo = remember(task.alarm, context) { + resolveAlarmInfo(context, task.alarm) + } + val alarmColor = task.alarmColor?.let { Color(it) } ?: MaterialTheme.colors.primary + + Card( + modifier = modifier + .fillMaxWidth() + .animateContentSize(), + backgroundColor = MaterialTheme.colors.surface, + elevation = if (task.showInlineMetrics) inlineElevation else 0.dp, + shape = MaterialTheme.shapes.medium.copy(all = ZeroCornerSize), + onClick = { onClick(task.taskId) }, + ) { + if (task.showInlineMetrics) { + InlineMetrics( + metrics = task.inlineMetrics, + status = task.inlineStatus ?: TaskStatus.NOT_DONE, + onUndoClick = { status -> onUndoClick(task.taskId, status) }, + spacingSmall = spacing.small, + spacingXSmall = spacing.xsmall, + ) + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .padding( + start = spacing.medium, + end = spacing.medium, + top = spacing.small, + bottom = spacing.medium, + ), + verticalAlignment = Alignment.Top, + ) { + Text( + text = task.taskName, + color = colorResource(id = R.color.md_blue_grey_700), + style = + MaterialTheme.typography.body1.copy( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + ), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + if (alarmInfo != null) { + Spacer(modifier = Modifier.width(spacing.small)) + TaskAlarm( + alarmInfo = alarmInfo, + color = alarmColor, + spacingXSmall = spacing.xsmall, + ) + } + } + } + } +} + +@Composable +private fun TaskAlarm( + alarmInfo: AlarmInfo, + color: Color, + spacingXSmall: Dp, +) { + when (alarmInfo.type) { + AlarmType.TIME -> + Text( + text = alarmInfo.text, + color = color, + style = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Light), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + AlarmType.DATE -> + Text( + text = alarmInfo.text, + color = color, + style = MaterialTheme.typography.caption.copy(fontWeight = FontWeight.Bold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = spacingXSmall), + ) + } +} + +@Composable +private fun InlineMetrics( + metrics: TaskMetrics?, + status: TaskStatus, + onUndoClick: (TaskStatus) -> Unit, + spacingSmall: Dp, + spacingXSmall: Dp, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.primary, shape = MaterialTheme.shapes.medium.copy(all = ZeroCornerSize)) + .padding(all = spacingSmall), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + MetricsContent( + metrics = metrics, + spacingSmall = spacingSmall, + spacingXSmall = spacingXSmall, + ) + + TextButton( + onClick = { onUndoClick(status) }, + colors = + ButtonDefaults.textButtonColors( + contentColor = Color.White, + ), + contentPadding = + PaddingValues( + horizontal = spacingSmall, + vertical = spacingXSmall, + ), + ) { + Text( + text = stringResource(id = R.string.undo), + color = Color.White, + style = MaterialTheme.typography.body2.copy(fontWeight = FontWeight.Bold), + ) + } + } +} + +@Composable +private fun MetricsContent( + metrics: TaskMetrics?, + spacingSmall: Dp, + spacingXSmall: Dp, +) { + Row( + modifier = Modifier.defaultMinSize(minHeight = 40.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacingSmall), + ) { + MetricChip( + icon = R.drawable.ic_fire_black_24dp, + iconTint = colorResource(id = R.color.md_orange_500), + label = metrics?.consecutiveDone?.takeIf { it > 0 }?.toString().orEmpty(), + spacingXSmall = spacingXSmall, + ) + + MetricChip( + icon = R.drawable.ic_vec_thumb_up_white_24dp, + iconTint = colorResource(id = R.color.md_teal_300), + label = metrics?.doneTasks?.toString().orEmpty(), + spacingXSmall = spacingXSmall, + ) + + MetricChip( + icon = R.drawable.ic_vect_thumb_down_white_24dp, + iconTint = colorResource(id = R.color.md_red_300), + label = metrics?.notDoneTasks?.toString().orEmpty(), + spacingXSmall = spacingXSmall, + ) + } +} + +@Composable +private fun MetricChip( + icon: Int, + iconTint: Color, + label: String, + spacingXSmall: Dp, +) { + if (label.isEmpty()) return + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacingXSmall), + ) { + Surface( + shape = CircleShape, + color = Color.White, + modifier = Modifier.size(24.dp), + elevation = 0.dp, + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = iconTint, + modifier = Modifier.padding(spacingXSmall), + ) + } + + Text( + text = label, + color = Color.White, + style = MaterialTheme.typography.body2.copy(fontWeight = FontWeight.Bold), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun resolveAlarmInfo( + context: Context, + alarm: Calendar?, +): AlarmInfo? { + alarm ?: return null + + return if (alarm.isBeforeToday() || alarm.isAfterTomorrow()) { + val dateText = + if (alarm.isCurrentYear()) { + alarm.getMonthAndDayShort(context) + } else { + alarm.toShortDateView(context) + } + + AlarmInfo(text = dateText, type = AlarmType.DATE) + } else { + AlarmInfo(text = alarm.formatTimeWithAndroidFormat(context), type = AlarmType.TIME) + } +} + +private data class AlarmInfo( + val text: String, + val type: AlarmType, +) + +private enum class AlarmType { DATE, TIME } + +@Preview(showBackground = true) +@Composable +private fun TaskItemPreview() { + val alarm = + remember { + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 15) + set(Calendar.MINUTE, 0) + } + } + MaterialTheme { + TodozyTheme { + TaskItem( + task = + TaskUiModel( + taskId = 1, + taskName = "Review pull requests", + alarm = alarm, + alarmColor = Color(0xFF0097A7).toArgb(), + ), + onClick = {}, + onUndoClick = { _, _ -> }, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TaskItemInlinePreview() { + MaterialTheme { + TodozyTheme { + TaskItem( + task = + TaskUiModel( + taskId = 2, + taskName = "Write daily summary", + showInlineMetrics = true, + inlineMetrics = + TaskMetrics( + doneTasks = 12, + notDoneTasks = 3, + consecutiveDone = 5, + ), + inlineStatus = TaskStatus.DONE, + ), + onClick = {}, + onUndoClick = { _, _ -> }, + ) + } + } +} diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/theme/TodozyTheme.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/theme/TodozyTheme.kt new file mode 100644 index 00000000..9524530c --- /dev/null +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/theme/TodozyTheme.kt @@ -0,0 +1,82 @@ +package br.com.sailboat.uicomponent.impl.theme + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Shapes +import androidx.compose.material.Typography +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val LightColorPalette = + lightColors( + primary = Color(0xFF2196F3), // md_blue_500 + primaryVariant = Color(0xFF1976D2), // md_blue_700 + secondary = Color(0xFF00BFA5), // md_teal_A700 + background = Color(0xFFF3F3F3), // default_background + surface = Color.White, + onPrimary = Color.White, + onSecondary = Color.White, + onBackground = Color(0xFF212121), // primary_text + onSurface = Color(0xFF212121), + ) + +private val TodozyShapes = + Shapes( + small = androidx.compose.foundation.shape.RoundedCornerShape(6.dp), + medium = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), + large = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), + ) + +data class TodozySpacing( + val xxsmall: androidx.compose.ui.unit.Dp = 2.dp, + val xsmall: androidx.compose.ui.unit.Dp = 4.dp, + val small: androidx.compose.ui.unit.Dp = 8.dp, + val medium: androidx.compose.ui.unit.Dp = 16.dp, + val large: androidx.compose.ui.unit.Dp = 24.dp, + val xlarge: androidx.compose.ui.unit.Dp = 32.dp, + val xxlarge: androidx.compose.ui.unit.Dp = 40.dp, +) + +val LocalTodozySpacing = staticCompositionLocalOf { TodozySpacing() } + +data class TodozySemanticColors( + val success: Color = Color(0xFF009688), // md_teal_500 + val warning: Color = Color(0xFFFFA000), // amber_700-ish + val error: Color = Color(0xFFF44336), // md_red_500 + val info: Color = Color(0xFF2196F3), // md_blue_500 +) + +val LocalTodozySemanticColors = staticCompositionLocalOf { TodozySemanticColors() } + +val TodozyTypography = + Typography( + h4 = Typography().h4.copy(fontSize = 28.sp), + h6 = Typography().h6.copy(fontSize = 20.sp), + subtitle1 = Typography().subtitle1.copy(fontSize = 18.sp), + body1 = Typography().body1.copy(fontSize = 16.sp), + body2 = Typography().body2.copy(fontSize = 14.sp), + caption = Typography().caption.copy(fontSize = 11.sp, letterSpacing = 0.sp), + overline = Typography().overline.copy(fontSize = 11.sp, letterSpacing = 0.sp), + ) + +@Composable +fun TodozyTheme(content: @Composable () -> Unit) { + val spacing = TodozySpacing() + val semantics = TodozySemanticColors() + + CompositionLocalProvider( + LocalTodozySpacing provides spacing, + LocalTodozySemanticColors provides semantics, + ) { + MaterialTheme( + colors = LightColorPalette, + typography = TodozyTypography, + shapes = TodozyShapes, + content = content, + ) + } +} diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/title/TitleItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/title/TitleItem.kt new file mode 100644 index 00000000..f281a23b --- /dev/null +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/title/TitleItem.kt @@ -0,0 +1,108 @@ +package br.com.sailboat.uicomponent.impl.title + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import br.com.sailboat.uicomponent.impl.R +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing + +@Composable +internal fun TitleItem( + text: String, + modifier: Modifier = Modifier, +) { + val spacing = LocalTodozySpacing.current + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = text, + modifier = + Modifier + .fillMaxWidth() + .padding( + start = spacing.medium, + end = spacing.medium, + top = spacing.large, + bottom = spacing.small, + ), + textAlign = TextAlign.Center, + style = + MaterialTheme.typography.h6.copy( + fontSize = 22.sp, + fontWeight = FontWeight.Normal, + ), + color = colorResource(id = R.color.md_blue_grey_500), + ) + + Spacer(modifier = Modifier.height(spacing.xsmall)) + + DiamondDivider( + color = MaterialTheme.colors.secondary, + modifier = + Modifier + .padding( + start = spacing.medium, + end = spacing.medium, + bottom = spacing.medium, + ), + ) + } +} + +@Composable +private fun DiamondDivider( + color: Color, + modifier: Modifier = Modifier, +) { + val spacing = LocalTodozySpacing.current + + Row( + modifier = + modifier + .fillMaxWidth() + .height(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .weight(1f) + .height(1.dp) + .background(color), + ) + + Icon( + painter = painterResource(id = R.drawable.ic_diamond_white_24dp), + contentDescription = null, + tint = color, + modifier = Modifier.size(16.dp).padding(horizontal = spacing.xsmall), + ) + + Box( + modifier = + Modifier + .weight(1f) + .height(1.dp) + .background(color), + ) + } +} diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/AlarmViewHolder.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/AlarmViewHolder.kt index 0c5337ed..809a1dd1 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/AlarmViewHolder.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/AlarmViewHolder.kt @@ -1,35 +1,38 @@ package br.com.sailboat.uicomponent.impl.viewholder import android.view.ViewGroup -import br.com.sailboat.todozy.utility.android.log.log -import br.com.sailboat.todozy.utility.android.recyclerview.BaseViewHolder -import br.com.sailboat.todozy.utility.android.view.gone -import br.com.sailboat.todozy.utility.android.view.visible -import br.com.sailboat.uicomponent.impl.databinding.AlarmDetailsBinding +import androidx.compose.material.Surface +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.recyclerview.widget.RecyclerView +import br.com.sailboat.uicomponent.impl.alarm.AlarmItem +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import br.com.sailboat.uicomponent.model.AlarmUiModel class AlarmViewHolder(parent: ViewGroup) : - BaseViewHolder( - AlarmDetailsBinding.inflate(getInflater(parent), parent, false), + RecyclerView.ViewHolder( + ComposeView(parent.context).apply { + layoutParams = + RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.WRAP_CONTENT, + ) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + }, ) { - override fun bind(item: AlarmUiModel): Unit = with(binding) { - try { - tvAlarmDate.text = item.date - tvAlarmTime.text = item.time + private val composeView get() = itemView as ComposeView - updateAlarmRepeatType(item) - } catch (e: Exception) { - e.log() - } - } - - private fun updateAlarmRepeatType(alarm: AlarmUiModel) = with(binding) { - if (alarm.shouldRepeat) { - tvAlarmRepeat.visible() - - tvAlarmRepeat.text = alarm.description - } else { - tvAlarmRepeat.gone() + fun bind(item: AlarmUiModel) { + composeView.setContent { + TodozyTheme { + Surface { + AlarmItem( + date = item.date, + time = item.time, + repeatDescription = item.description.takeIf { item.shouldRepeat }, + ) + } + } } } } diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/ImageTitleDividerViewHolder.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/ImageTitleDividerViewHolder.kt index 194989ed..9256cd1d 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/ImageTitleDividerViewHolder.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/ImageTitleDividerViewHolder.kt @@ -1,16 +1,38 @@ package br.com.sailboat.uicomponent.impl.viewholder import android.view.ViewGroup -import br.com.sailboat.todozy.utility.android.recyclerview.BaseViewHolder -import br.com.sailboat.uicomponent.impl.databinding.VhImageTitleDividerBinding +import androidx.compose.material.Surface +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.recyclerview.widget.RecyclerView +import br.com.sailboat.uicomponent.impl.image.ImageTitleDividerItem +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import br.com.sailboat.uicomponent.model.ImageTitleDividerUiModel -class ImageTitleDividerViewHolder(parent: ViewGroup) : - BaseViewHolder( - VhImageTitleDividerBinding.inflate(getInflater(parent), parent, false), +class ImageTitleDividerViewHolder( + parent: ViewGroup, +) : RecyclerView.ViewHolder( + ComposeView(parent.context).apply { + layoutParams = + RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.WRAP_CONTENT, + ) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + }, ) { - override fun bind(item: ImageTitleDividerUiModel) = with(binding) { - vhImageTitleDividerImg.setImageResource(item.imageRes) - vhImageTitleDividerTvTitle.text = item.title + private val composeView get() = itemView as ComposeView + + fun bind(item: ImageTitleDividerUiModel) { + composeView.setContent { + TodozyTheme { + Surface { + ImageTitleDividerItem( + imageRes = item.imageRes, + title = item.title.orEmpty(), + ) + } + } + } } } diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/LabelValueViewHolder.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/LabelValueViewHolder.kt index 08c95005..e2982ae7 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/LabelValueViewHolder.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/LabelValueViewHolder.kt @@ -1,16 +1,34 @@ package br.com.sailboat.uicomponent.impl.viewholder import android.view.ViewGroup -import br.com.sailboat.todozy.utility.android.recyclerview.BaseViewHolder -import br.com.sailboat.uicomponent.impl.databinding.VhLabelValueBinding +import androidx.compose.material.Surface +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.recyclerview.widget.RecyclerView +import br.com.sailboat.uicomponent.impl.label.LabelValueItem +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import br.com.sailboat.uicomponent.model.LabelValueUiModel class LabelValueViewHolder(parent: ViewGroup) : - BaseViewHolder( - VhLabelValueBinding.inflate(getInflater(parent), parent, false), + RecyclerView.ViewHolder( + ComposeView(parent.context).apply { + layoutParams = + RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.WRAP_CONTENT, + ) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + }, ) { - override fun bind(item: LabelValueUiModel) = with(binding) { - vhLabelValueTvLabel.text = item.label - vhLabelValueTvValue.text = item.value + private val composeView get() = itemView as ComposeView + + fun bind(item: LabelValueUiModel) { + composeView.setContent { + TodozyTheme { + Surface { + LabelValueItem(label = item.label, value = item.value) + } + } + } } } diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/LabelViewHolder.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/LabelViewHolder.kt index 57ad51a3..29eb3348 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/LabelViewHolder.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/LabelViewHolder.kt @@ -1,14 +1,33 @@ package br.com.sailboat.uicomponent.impl.viewholder import android.view.ViewGroup -import br.com.sailboat.todozy.utility.android.recyclerview.BaseViewHolder -import br.com.sailboat.uicomponent.impl.databinding.VhLabelBinding +import androidx.compose.material.Surface +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.recyclerview.widget.RecyclerView +import br.com.sailboat.uicomponent.impl.label.LabelItem +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import br.com.sailboat.uicomponent.model.LabelUiModel -class LabelViewHolder(parent: ViewGroup) : BaseViewHolder( - VhLabelBinding.inflate(getInflater(parent), parent, false), +class LabelViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder( + ComposeView(parent.context).apply { + layoutParams = + RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.WRAP_CONTENT, + ) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + }, ) { - override fun bind(item: LabelUiModel) = with(binding) { - vhLabelTvLabel.text = item.label + private val composeView get() = itemView as ComposeView + + fun bind(item: LabelUiModel) { + composeView.setContent { + TodozyTheme { + Surface { + LabelItem(text = item.label) + } + } + } } } diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/SubheadViewHolder.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/SubheadViewHolder.kt index 96f5258f..59b5e482 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/SubheadViewHolder.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/SubheadViewHolder.kt @@ -1,15 +1,35 @@ package br.com.sailboat.uicomponent.impl.viewholder import android.view.ViewGroup -import br.com.sailboat.todozy.utility.android.recyclerview.BaseViewHolder -import br.com.sailboat.uicomponent.impl.databinding.VhSubheaderBinding +import androidx.compose.material.Surface +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.recyclerview.widget.RecyclerView +import br.com.sailboat.uicomponent.impl.subhead.SubheadItem +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import br.com.sailboat.uicomponent.model.SubheadUiModel -class SubheadViewHolder(parent: ViewGroup) : - BaseViewHolder( - VhSubheaderBinding.inflate(getInflater(parent), parent, false), +class SubheadViewHolder( + parent: ViewGroup, +) : RecyclerView.ViewHolder( + ComposeView(parent.context).apply { + layoutParams = + RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.WRAP_CONTENT, + ) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + }, ) { - override fun bind(item: SubheadUiModel) = with(binding) { - vhSubheaderTvName.text = item.subhead + private val composeView get() = itemView as ComposeView + + fun bind(item: SubheadUiModel) { + composeView.setContent { + TodozyTheme { + Surface { + SubheadItem(text = item.subhead) + } + } + } } } diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskSkeletonViewHolder.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskSkeletonViewHolder.kt index 56640262..fa62fab1 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskSkeletonViewHolder.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskSkeletonViewHolder.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.recyclerview.widget.RecyclerView import br.com.sailboat.uicomponent.impl.skeleton.TaskSkeletonItem +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import br.com.sailboat.uicomponent.model.TaskSkeletonUiModel class TaskSkeletonViewHolder( @@ -19,7 +20,11 @@ class TaskSkeletonViewHolder( RecyclerView.LayoutParams.WRAP_CONTENT, ) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { TaskSkeletonItem() } + setContent { + TodozyTheme { + TaskSkeletonItem() + } + } }, ) { fun bind( diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt index fe50968d..75242ca6 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt @@ -1,27 +1,28 @@ package br.com.sailboat.uicomponent.impl.viewholder -import android.util.Log import android.view.ViewGroup -import androidx.constraintlayout.widget.ConstraintLayout +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.recyclerview.widget.RecyclerView import br.com.sailboat.todozy.domain.model.TaskStatus -import br.com.sailboat.todozy.utility.android.calendar.formatTimeWithAndroidFormat -import br.com.sailboat.todozy.utility.android.calendar.getMonthAndDayShort -import br.com.sailboat.todozy.utility.android.calendar.toShortDateView -import br.com.sailboat.todozy.utility.android.recyclerview.BaseViewHolder -import br.com.sailboat.todozy.utility.android.view.gone -import br.com.sailboat.todozy.utility.android.view.setSafeClickListener -import br.com.sailboat.todozy.utility.android.view.visible -import br.com.sailboat.todozy.utility.kotlin.extension.isAfterTomorrow -import br.com.sailboat.todozy.utility.kotlin.extension.isBeforeToday -import br.com.sailboat.todozy.utility.kotlin.extension.isCurrentYear -import br.com.sailboat.uicomponent.impl.R -import br.com.sailboat.uicomponent.impl.databinding.VhTaskBinding +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme +import br.com.sailboat.uicomponent.impl.task.TaskItem import br.com.sailboat.uicomponent.model.TaskUiModel -import java.util.Calendar -class TaskViewHolder(parent: ViewGroup, private val callback: Callback) : - BaseViewHolder( - VhTaskBinding.inflate(getInflater(parent), parent, false), +class TaskViewHolder( + parent: ViewGroup, + private val callback: Callback, +) : RecyclerView.ViewHolder( + ComposeView(parent.context).apply { + layoutParams = + RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.WRAP_CONTENT, + ) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + }, ) { interface Callback { fun onClickTask(taskId: Long) @@ -31,113 +32,19 @@ class TaskViewHolder(parent: ViewGroup, private val callback: Callback) : ) } - private val defaultElevation = binding.root.cardElevation - private val inlineElevation = binding.root.resources.getDimension(R.dimen.task_inline_metrics_elevation) - private val defaultMinHeight = binding.task.root.minimumHeight - private val defaultBottomPadding = binding.task.root.paddingBottom - private val defaultInlineTopMargin = - (binding.task.inlineMetricsContainer.layoutParams as ConstraintLayout.LayoutParams).topMargin + private val composeView get() = itemView as ComposeView - override fun bind(item: TaskUiModel) = with(binding) { - task.tvTaskName.text = item.taskName - bindTaskAlarm(item) - root.setSafeClickListener { - callback.onClickTask(item.taskId) - } - bindInlineMetrics(item) - } - - private fun bindTaskAlarm(item: TaskUiModel) { - try { - updateVisibilityOfAlarmViews(item.alarm) - item.alarm?.run { - updateAlarmText(this) - updateAlarmColor(item.alarmColor) - } - } catch (e: Exception) { - Log.e("TASK_VIEW_HOLDER", "Error binding alarm", e) - } - } - - private fun updateAlarmText(alarm: Calendar) = with(binding) { - if (alarm.isBeforeToday() || alarm.isAfterTomorrow()) { - task.tvTaskDate.text = - if (alarm.isCurrentYear()) { - alarm.getMonthAndDayShort(context) - } else { - alarm.toShortDateView(context) + fun bind(item: TaskUiModel) { + composeView.setContent { + TodozyTheme { + Surface { + TaskItem( + task = item, + onClick = callback::onClickTask, + onUndoClick = callback::onClickUndo, + ) } - } else { - task.tvTaskTime.text = alarm.formatTimeWithAndroidFormat(context) - } - } - - private fun updateAlarmColor(alarmColor: Int?) = with(binding) { - alarmColor?.let { - task.tvTaskDate.setTextColor(alarmColor) - task.tvTaskTime.setTextColor(alarmColor) - } - } - - private fun updateVisibilityOfAlarmViews(alarm: Calendar?) = with(binding) { - if (alarm == null) { - task.tvTaskDate.gone() - task.tvTaskTime.gone() - } else if (alarm.isBeforeToday() || alarm.isAfterTomorrow()) { - task.tvTaskTime.gone() - task.tvTaskDate.visible() - } else { - task.tvTaskTime.visible() - task.tvTaskDate.gone() - } - } - - private fun bindInlineMetrics(item: TaskUiModel) = with(binding) { - if (item.showInlineMetrics) { - root.cardElevation = inlineElevation - task.root.minimumHeight = 0 - task.root.setPadding(task.root.paddingLeft, task.root.paddingTop, task.root.paddingRight, 0) - updateInlineTopMargin(0) - task.inlineMetricsContainer.visible() - bindInlineMetricsValues(item) - task.tvTaskName.gone() - task.flTaskDateTime.gone() - task.btnInlineUndo.setSafeClickListener { - callback.onClickUndo(item.taskId, item.inlineStatus ?: TaskStatus.NOT_DONE) } - } else { - root.cardElevation = defaultElevation - task.root.minimumHeight = defaultMinHeight - task.root.setPadding( - task.root.paddingLeft, - task.root.paddingTop, - task.root.paddingRight, - defaultBottomPadding, - ) - updateInlineTopMargin(defaultInlineTopMargin) - task.inlineMetricsContainer.gone() - task.tvTaskName.visible() - task.flTaskDateTime.visible() - task.btnInlineUndo.setOnClickListener(null) - } - } - - private fun updateInlineTopMargin(margin: Int) { - val layoutParams = - binding.task.inlineMetricsContainer.layoutParams as ConstraintLayout.LayoutParams - layoutParams.topMargin = margin - binding.task.inlineMetricsContainer.layoutParams = layoutParams - } - - private fun bindInlineMetricsValues(item: TaskUiModel) = with(binding.task.inlineTaskMetrics) { - tvMetricsFire.text = item.inlineMetrics?.consecutiveDone?.toString().orEmpty() - tvMetricsDone.text = item.inlineMetrics?.doneTasks?.toString().orEmpty() - tvMetricsNotDone.text = item.inlineMetrics?.notDoneTasks?.toString().orEmpty() - - if ((item.inlineMetrics?.consecutiveDone ?: 0) == 0) { - taskMetricsLlFire.gone() - } else { - taskMetricsLlFire.visible() } } } diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TitleViewHolder.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TitleViewHolder.kt index 216422a2..148ab444 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TitleViewHolder.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TitleViewHolder.kt @@ -1,15 +1,34 @@ package br.com.sailboat.uicomponent.impl.viewholder import android.view.ViewGroup -import br.com.sailboat.todozy.utility.android.recyclerview.BaseViewHolder -import br.com.sailboat.uicomponent.impl.databinding.VhTitleBinding +import androidx.compose.material.Surface +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.recyclerview.widget.RecyclerView +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme +import br.com.sailboat.uicomponent.impl.title.TitleItem import br.com.sailboat.uicomponent.model.TitleUiModel class TitleViewHolder(parent: ViewGroup) : - BaseViewHolder( - VhTitleBinding.inflate(getInflater(parent), parent, false), + RecyclerView.ViewHolder( + ComposeView(parent.context).apply { + layoutParams = + RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + RecyclerView.LayoutParams.WRAP_CONTENT, + ) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + }, ) { - override fun bind(item: TitleUiModel) = with(binding) { - vhTitleTvName.text = item.title + private val composeView get() = itemView as ComposeView + + fun bind(item: TitleUiModel) { + composeView.setContent { + TodozyTheme { + Surface { + TitleItem(text = item.title) + } + } + } } } diff --git a/ui-component/impl/src/main/res/layout/alarm_details.xml b/ui-component/impl/src/main/res/layout/alarm_details.xml index 59b7b4e5..a02c5b7c 100644 --- a/ui-component/impl/src/main/res/layout/alarm_details.xml +++ b/ui-component/impl/src/main/res/layout/alarm_details.xml @@ -1,6 +1,5 @@ + android:textSize="@dimen/text_size_xlarge" /> + android:textSize="42sp" /> + android:textSize="@dimen/text_size_medium" /> - \ No newline at end of file + diff --git a/ui-component/impl/src/main/res/layout/diamond_divider.xml b/ui-component/impl/src/main/res/layout/diamond_divider.xml deleted file mode 100644 index b194c1c4..00000000 --- a/ui-component/impl/src/main/res/layout/diamond_divider.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/ui-component/impl/src/main/res/layout/task.xml b/ui-component/impl/src/main/res/layout/task.xml deleted file mode 100644 index 6d749570..00000000 --- a/ui-component/impl/src/main/res/layout/task.xml +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/ui-component/impl/src/main/res/layout/vh_image_title_divider.xml b/ui-component/impl/src/main/res/layout/vh_image_title_divider.xml deleted file mode 100644 index fc434b02..00000000 --- a/ui-component/impl/src/main/res/layout/vh_image_title_divider.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/ui-component/impl/src/main/res/layout/vh_label.xml b/ui-component/impl/src/main/res/layout/vh_label.xml deleted file mode 100644 index 4b6994f9..00000000 --- a/ui-component/impl/src/main/res/layout/vh_label.xml +++ /dev/null @@ -1,7 +0,0 @@ - - \ No newline at end of file diff --git a/ui-component/impl/src/main/res/layout/vh_label_value.xml b/ui-component/impl/src/main/res/layout/vh_label_value.xml deleted file mode 100644 index f1809244..00000000 --- a/ui-component/impl/src/main/res/layout/vh_label_value.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/ui-component/impl/src/main/res/layout/vh_subheader.xml b/ui-component/impl/src/main/res/layout/vh_subheader.xml deleted file mode 100644 index ff51de50..00000000 --- a/ui-component/impl/src/main/res/layout/vh_subheader.xml +++ /dev/null @@ -1,13 +0,0 @@ - - \ No newline at end of file diff --git a/ui-component/impl/src/main/res/layout/vh_task.xml b/ui-component/impl/src/main/res/layout/vh_task.xml deleted file mode 100644 index 1d5ead16..00000000 --- a/ui-component/impl/src/main/res/layout/vh_task.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/ui-component/impl/src/main/res/layout/vh_title.xml b/ui-component/impl/src/main/res/layout/vh_title.xml deleted file mode 100644 index bae4db2c..00000000 --- a/ui-component/impl/src/main/res/layout/vh_title.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/ui-component/impl/src/test/java/br/com/sailboat/uicomponent/impl/LabelAndTitleComposeTest.kt b/ui-component/impl/src/test/java/br/com/sailboat/uicomponent/impl/LabelAndTitleComposeTest.kt new file mode 100644 index 00000000..5ab83b0d --- /dev/null +++ b/ui-component/impl/src/test/java/br/com/sailboat/uicomponent/impl/LabelAndTitleComposeTest.kt @@ -0,0 +1,69 @@ +package br.com.sailboat.uicomponent.impl + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import br.com.sailboat.uicomponent.impl.alarm.AlarmItem +import br.com.sailboat.uicomponent.impl.label.LabelItem +import br.com.sailboat.uicomponent.impl.label.LabelValueItem +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme +import br.com.sailboat.uicomponent.impl.title.TitleItem +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test + +@Ignore("Compose UI tests require Android runtime; ignored on JVM unit target") +class LabelAndTitleComposeTest { + @get:Rule val composeRule = createComposeRule() + + @Test + fun label_and_value_render_text() { + composeRule.setContent { + TodozyTheme { + LabelValueItem(label = "Priority", value = "High") + } + } + + composeRule.onNodeWithText("PRIORITY").assertIsDisplayed() + composeRule.onNodeWithText("High").assertIsDisplayed() + } + + @Test + fun title_renders_centered_text() { + composeRule.setContent { + TodozyTheme { + TitleItem(text = "Task Details") + } + } + + composeRule.onNodeWithText("Task Details").assertIsDisplayed() + } + + @Test + fun alarm_item_shows_date_time_and_repeat() { + composeRule.setContent { + TodozyTheme { + AlarmItem( + date = "Today", + time = "15:30", + repeatDescription = "Every day", + ) + } + } + + composeRule.onNodeWithText("Today").assertIsDisplayed() + composeRule.onNodeWithText("15:30").assertIsDisplayed() + composeRule.onNodeWithText("Every day").assertIsDisplayed() + } + + @Test + fun label_item_uppercases_text() { + composeRule.setContent { + TodozyTheme { + LabelItem(text = "due date") + } + } + + composeRule.onNodeWithText("DUE DATE").assertIsDisplayed() + } +} From 48faa965697c287ffd8b3d49c6229652fd8ca969 Mon Sep 17 00:00:00 2001 From: Brayan Bedritchuk Date: Thu, 4 Dec 2025 22:20:32 -0300 Subject: [PATCH 2/2] Add compose to task list screen --- buildSrc/src/main/java/Dependency.kt | 13 +- feature/task-list/impl/build.gradle.kts | 11 +- .../list/impl/presentation/TaskListAdapter.kt | 43 -- .../impl/presentation/TaskListFragment.kt | 313 +++-------- .../list/impl/presentation/TaskListScreen.kt | 498 ++++++++++++++++++ .../viewmodel/TaskListViewAction.kt | 1 - .../viewmodel/TaskListViewIntent.kt | 2 +- .../viewmodel/TaskListViewModel.kt | 11 +- .../src/main/res/layout/ept_task_list.xml | 54 -- .../src/main/res/layout/frg_task_list.xml | 89 ---- .../impl/src/main/res/menu/menu_task_list.xml | 22 - .../viewmodel/TaskListViewModelTest.kt | 36 +- .../uicomponent/impl/alarm/AlarmItem.kt | 4 +- .../impl/progress/TaskProgressComponents.kt | 46 +- .../impl/progress/TaskProgressView.kt | 1 - .../impl/skeleton/TaskSkeletonItem.kt | 5 +- .../uicomponent/impl/subhead/SubheadItem.kt | 2 +- .../uicomponent/impl/task/TaskItem.kt | 190 ++++--- .../uicomponent/impl/theme/TodozyTheme.kt | 40 +- .../impl/viewholder/TaskViewHolder.kt | 3 +- 20 files changed, 774 insertions(+), 610 deletions(-) delete mode 100644 feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListAdapter.kt create mode 100644 feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListScreen.kt delete mode 100644 feature/task-list/impl/src/main/res/layout/ept_task_list.xml delete mode 100644 feature/task-list/impl/src/main/res/layout/frg_task_list.xml delete mode 100644 feature/task-list/impl/src/main/res/menu/menu_task_list.xml diff --git a/buildSrc/src/main/java/Dependency.kt b/buildSrc/src/main/java/Dependency.kt index f74be87f..91989db8 100644 --- a/buildSrc/src/main/java/Dependency.kt +++ b/buildSrc/src/main/java/Dependency.kt @@ -1,7 +1,7 @@ object BuildPlugin { object Version { const val gradlePlugin = "8.10.1" - const val kotlin = "1.9.22" + const val kotlin = "1.9.25" const val googleServices = "4.4.2" const val crashlytics = "2.9.9" } @@ -136,7 +136,7 @@ object Firebase { object Kotlin { object Version { - const val kotlin = "1.9.22" + const val kotlin = "1.9.25" } const val test = "org.jetbrains.kotlin:kotlin-test-junit:${Version.kotlin}" @@ -180,18 +180,19 @@ object Hilt { object Compose { object Version { - const val core = "1.6.7" + const val core = "1.10.0" + const val materialIcons = "1.7.8" const val lifecycleRuntime = "2.7.0" - const val activity = "1.9.0" - const val compiler = "1.5.10" + const val compiler = "1.5.15" } const val ui = "androidx.compose.ui:ui:${Version.core}" const val material = "androidx.compose.material:material:${Version.core}" + const val materialIconsExtended = "androidx.compose.material:material-icons-extended:${Version.materialIcons}" const val uiToolingPreview = "androidx.compose.ui:ui-tooling-preview:${Version.core}" const val lifecycleRuntimeKtx = "androidx.lifecycle:lifecycle-runtime-ktx:${Version.lifecycleRuntime}" - const val activity = "androidx.activity:activity-compose:${Version.activity}" const val uiTestJunit4 = "androidx.compose.ui:ui-test-junit4:${Version.core}" + const val runtimeLiveData = "androidx.compose.runtime:runtime-livedata:${Version.core}" } object Desugar { diff --git a/feature/task-list/impl/build.gradle.kts b/feature/task-list/impl/build.gradle.kts index 7d08e301..ca5db70f 100644 --- a/feature/task-list/impl/build.gradle.kts +++ b/feature/task-list/impl/build.gradle.kts @@ -30,7 +30,11 @@ android { jvmTarget = "17" } buildFeatures { - viewBinding = true + compose = true + viewBinding = false + } + composeOptions { + kotlinCompilerExtensionVersion = Compose.Version.compiler } testOptions { unitTests.all { @@ -66,6 +70,11 @@ dependencies { implementation(AndroidX.recyclerview) implementation(AndroidX.constraintLayout) implementation(AndroidX.materialDesign) + implementation(Compose.material) + implementation(Compose.ui) + implementation(Compose.uiToolingPreview) + implementation(Compose.runtimeLiveData) + implementation(Compose.materialIconsExtended) testImplementation(Junit.junit) testImplementation(MockK.core) diff --git a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListAdapter.kt b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListAdapter.kt deleted file mode 100644 index 72c14e3f..00000000 --- a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListAdapter.kt +++ /dev/null @@ -1,43 +0,0 @@ -package br.com.sailboat.todozy.feature.task.list.impl.presentation - -import android.view.ViewGroup -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import br.com.sailboat.uicomponent.impl.helper.UiModelDiffUtilCallback -import br.com.sailboat.uicomponent.impl.viewholder.EmptyViewHolder -import br.com.sailboat.uicomponent.impl.viewholder.SubheadViewHolder -import br.com.sailboat.uicomponent.impl.viewholder.TaskSkeletonViewHolder -import br.com.sailboat.uicomponent.impl.viewholder.TaskViewHolder -import br.com.sailboat.uicomponent.model.SubheadUiModel -import br.com.sailboat.uicomponent.model.TaskSkeletonUiModel -import br.com.sailboat.uicomponent.model.TaskUiModel -import br.com.sailboat.uicomponent.model.UiModel -import br.com.sailboat.uicomponent.model.UiModelType - -internal class TaskListAdapter(private val callback: Callback) : - ListAdapter(UiModelDiffUtilCallback()) { - interface Callback : TaskViewHolder.Callback - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ) = when (viewType) { - UiModelType.TASK.ordinal -> TaskViewHolder(parent, callback) - UiModelType.SUBHEADER.ordinal -> SubheadViewHolder(parent) - UiModelType.TASK_SKELETON.ordinal -> TaskSkeletonViewHolder(parent) - else -> EmptyViewHolder(parent) - } - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - ) { - when (val item = getItem(position)) { - is TaskUiModel -> (holder as TaskViewHolder).bind(item) - is SubheadUiModel -> (holder as SubheadViewHolder).bind(item) - is TaskSkeletonUiModel -> (holder as TaskSkeletonViewHolder).bind(item) - } - } - - override fun getItemViewType(position: Int) = getItem(position).uiModelId -} diff --git a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListFragment.kt b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListFragment.kt index 709617f8..7feda67c 100644 --- a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListFragment.kt +++ b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListFragment.kt @@ -1,48 +1,32 @@ package br.com.sailboat.todozy.feature.task.list.impl.presentation import android.os.Bundle -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.TooltipCompat -import androidx.core.view.MenuProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.ConcatAdapter -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import br.com.sailboat.todozy.domain.model.TaskMetrics import br.com.sailboat.todozy.domain.model.TaskProgressRange -import br.com.sailboat.todozy.domain.model.TaskStatus import br.com.sailboat.todozy.feature.navigation.android.AboutNavigator import br.com.sailboat.todozy.feature.navigation.android.SettingsNavigator import br.com.sailboat.todozy.feature.navigation.android.TaskDetailsNavigator import br.com.sailboat.todozy.feature.navigation.android.TaskFormNavigator import br.com.sailboat.todozy.feature.navigation.android.TaskHistoryNavigator -import br.com.sailboat.todozy.feature.task.list.impl.databinding.FrgTaskListBinding import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewAction import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewIntent -import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewIntent.OnSelectProgressRange import br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel.TaskListViewModel -import br.com.sailboat.todozy.utility.android.fragment.SearchMenu -import br.com.sailboat.todozy.utility.android.fragment.SearchMenuImpl -import br.com.sailboat.todozy.utility.android.fragment.hapticHandled -import br.com.sailboat.todozy.utility.android.view.gone -import br.com.sailboat.todozy.utility.android.view.hideFabWhenScrolling -import br.com.sailboat.todozy.utility.android.view.setSafeClickListener -import br.com.sailboat.todozy.utility.android.view.visible import br.com.sailboat.uicomponent.impl.helper.NotificationHelper -import br.com.sailboat.uicomponent.impl.helper.SwipeTaskLeftRight -import br.com.sailboat.uicomponent.impl.progress.TaskProgressHeaderAdapter -import br.com.sailboat.uicomponent.model.TaskSkeletonUiModel import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import br.com.sailboat.todozy.feature.task.list.impl.R as TaskListR import br.com.sailboat.uicomponent.impl.R as UiR -internal class TaskListFragment : Fragment(), SearchMenu by SearchMenuImpl() { +internal class TaskListFragment : Fragment() { private val viewModel: TaskListViewModel by viewModel() private val taskDetailsNavigator: TaskDetailsNavigator by inject() private val taskHistoryNavigator: TaskHistoryNavigator by inject() @@ -50,32 +34,85 @@ internal class TaskListFragment : Fragment(), SearchMenu by SearchMenuImpl() { private val aboutNavigator: AboutNavigator by inject() private val settingsNavigator: SettingsNavigator by inject() - private lateinit var binding: FrgTaskListBinding - private var taskListAdapter: TaskListAdapter? = null - private lateinit var progressAdapter: TaskProgressHeaderAdapter - private var lastTasksLoading = false - private var lastItems: List = emptyList() - private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { viewModel.dispatchViewIntent(TaskListViewIntent.OnStart) } override fun onCreateView( - inflater: LayoutInflater, + inflater: android.view.LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ) = FrgTaskListBinding.inflate(inflater, container, false).apply { - binding = this - }.root + ) = ComposeView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val tasksLoading by viewModel.viewState.tasksLoading.observeAsState(false) + val items by viewModel.viewState.itemsView.observeAsState(mutableListOf()) + val taskMetrics by viewModel.viewState.taskMetrics.observeAsState() + val taskProgressDays by viewModel.viewState.taskProgressDays.observeAsState(emptyList()) + val taskProgressRange by viewModel.viewState.taskProgressRange.observeAsState( + TaskProgressRange.LAST_YEAR, + ) + val taskProgressLoading by viewModel.viewState.taskProgressLoading.observeAsState(false) + val haptics = LocalHapticFeedback.current + + TaskListScreen( + tasksLoading = tasksLoading, + items = items, + taskMetrics = taskMetrics, + taskProgressDays = taskProgressDays, + taskProgressRange = taskProgressRange ?: TaskProgressRange.LAST_YEAR, + taskProgressLoading = taskProgressLoading, + onSelectProgressRange = { range -> + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.dispatchViewIntent(TaskListViewIntent.OnSelectProgressRange(range)) + }, + onTaskClick = { taskId -> + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.dispatchViewIntent(TaskListViewIntent.OnClickTask(taskId = taskId)) + }, + onTaskSwipe = { taskId, status -> + viewModel.dispatchViewIntent( + TaskListViewIntent.OnSwipeTask( + taskId = taskId, + status = status, + ), + ) + }, + onTaskUndo = { taskId, status -> + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.dispatchViewIntent( + TaskListViewIntent.OnClickUndoTask( + taskId = taskId, + status = status, + ), + ) + }, + onNewTask = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.dispatchViewIntent(TaskListViewIntent.OnClickNewTask) + }, + onOpenHistory = { viewModel.dispatchViewIntent(TaskListViewIntent.OnClickMenuHistory) }, + onOpenSettings = { viewModel.dispatchViewIntent(TaskListViewIntent.OnClickMenuSettings) }, + onSearch = { term -> + viewModel.dispatchViewIntent( + TaskListViewIntent.OnSubmitSearchTerm(term = term), + ) + }, + ) + } + } override fun onViewCreated( view: View, savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - initViews() - observeViewModel() + observeActions() } override fun onResume() { @@ -83,73 +120,6 @@ internal class TaskListFragment : Fragment(), SearchMenu by SearchMenuImpl() { viewModel.dispatchViewIntent(TaskListViewIntent.OnStart) } - private fun initViews() { - (activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar) - binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } - addMenuProvider() - - binding.eptView.tvEmptyViewMessagePrimary.setText(UiR.string.no_tasks) - binding.eptView.tvEmptyViewMessageSecondary.setText(UiR.string.ept_click_to_add) - - initRecyclerView() - - val fabLabel = getString(TaskListR.string.fab_new_task) - binding.fab.contentDescription = fabLabel - TooltipCompat.setTooltipText(binding.fab, fabLabel) - binding.fab.setSafeClickListener { - viewModel.dispatchViewIntent(TaskListViewIntent.OnClickNewTask) - } - } - - private fun addMenuProvider() { - requireActivity().addMenuProvider( - object : MenuProvider { - override fun onCreateMenu( - menu: android.view.Menu, - menuInflater: android.view.MenuInflater, - ) { - menuInflater.inflate(TaskListR.menu.menu_task_list, menu) - addSearchMenu(menu, ::onSubmitSearch) - } - - override fun onMenuItemSelected(menuItem: android.view.MenuItem): Boolean { - return when (menuItem.itemId) { - TaskListR.id.menu_fragments_history -> { - return hapticHandled { - viewModel.dispatchViewIntent(TaskListViewIntent.OnClickMenuHistory) - } - } - TaskListR.id.menu_fragments_settings -> { - return hapticHandled { - viewModel.dispatchViewIntent(TaskListViewIntent.OnClickMenuSettings) - } - } - else -> false - } - } - }, - viewLifecycleOwner, - ) - } - - private fun observeViewModel() { - observeActions() - viewModel.viewState.tasksLoading.observe(viewLifecycleOwner) { loading -> - lastTasksLoading = loading - renderList() - } - viewModel.viewState.itemsView.observe(viewLifecycleOwner) { items -> - lastItems = items - renderList() - } - viewModel.viewState.taskMetrics.observe(viewLifecycleOwner) { taskMetrics -> - taskMetrics?.run { showMetrics(this) } ?: hideMetrics() - } - viewModel.viewState.taskProgressDays.observe(viewLifecycleOwner) { renderProgress() } - viewModel.viewState.taskProgressRange.observe(viewLifecycleOwner) { renderProgress() } - viewModel.viewState.taskProgressLoading.observe(viewLifecycleOwner) { renderProgress() } - } - private fun observeActions() { viewModel.viewState.viewAction.observe(viewLifecycleOwner) { viewAction -> when (viewAction) { @@ -159,73 +129,16 @@ internal class TaskListFragment : Fragment(), SearchMenu by SearchMenuImpl() { is TaskListViewAction.NavigateToSettings -> navigateToSettings() is TaskListViewAction.NavigateToTaskForm -> navigateToTaskForm() is TaskListViewAction.NavigateToTaskDetails -> navigateToTaskDetails(viewAction.taskId) - is TaskListViewAction.UpdateRemovedTask -> updateRemovedTask(viewAction.position) is TaskListViewAction.ShowErrorCompletingTask -> showErrorCompletingTask() is TaskListViewAction.ShowErrorLoadingTasks -> showErrorLoadingTasks() } } } - private fun onSubmitSearch(search: String) { - viewModel.dispatchViewIntent(TaskListViewIntent.OnSubmitSearchTerm(term = search)) - } - private fun closeNotifications() { activity?.apply { NotificationHelper().closeNotifications(this) } } - private fun hideEmptyView() { - binding.eptView.root.gone() - } - - private fun hideMetrics() { - binding.taskMetrics.root.gone() - binding.appbarTaskListFlMetrics.gone() - } - - private fun showEmptyView() { - binding.eptView.root.visible() - } - - private fun renderList() { - if (lastTasksLoading) { - hideEmptyView() - hideMetrics() - binding.rvTaskList.visible() - taskListAdapter?.submitList(skeletonItems()) - return - } - - taskListAdapter?.submitList(lastItems) - - if (lastItems.isEmpty()) { - binding.rvTaskList.gone() - showEmptyView() - hideMetrics() - val range = viewModel.viewState.taskProgressRange.value ?: TaskProgressRange.LAST_YEAR - progressAdapter.submit(emptyList(), range, isLoading = false) - } else { - binding.rvTaskList.visible() - hideEmptyView() - } - } - - private fun showMetrics(taskMetrics: TaskMetrics) { - binding.taskMetrics.tvMetricsFire.text = taskMetrics.consecutiveDone.toString() - binding.taskMetrics.tvMetricsDone.text = taskMetrics.doneTasks.toString() - binding.taskMetrics.tvMetricsNotDone.text = taskMetrics.notDoneTasks.toString() - - if (taskMetrics.consecutiveDone == 0) { - binding.taskMetrics.taskMetricsLlFire.gone() - } else { - binding.taskMetrics.taskMetricsLlFire.visible() - } - - binding.appbar.setExpanded(true, true) - binding.appbarTaskListFlMetrics.visible() - binding.taskMetrics.root.visible() - } - private fun navigateToTaskForm() { taskFormNavigator.navigateToAddTask(requireContext(), launcher) } @@ -242,10 +155,6 @@ internal class TaskListFragment : Fragment(), SearchMenu by SearchMenuImpl() { taskHistoryNavigator.navigateToTaskHistory(requireContext()) } - private fun updateRemovedTask(position: Int) { - // ListAdapter + submitList already handles diff updates; no manual notify needed - } - private fun showErrorLoadingTasks() { Toast.makeText(activity, UiR.string.msg_error, Toast.LENGTH_SHORT).show() } @@ -258,85 +167,7 @@ internal class TaskListFragment : Fragment(), SearchMenu by SearchMenuImpl() { activity?.run { aboutNavigator.navigateToAbout(this) } } - private fun renderProgress() { - val progressDays = viewModel.viewState.taskProgressDays.value.orEmpty() - val range = viewModel.viewState.taskProgressRange.value ?: TaskProgressRange.LAST_YEAR - val isLoading = viewModel.viewState.taskProgressLoading.value ?: false - progressAdapter.submit(progressDays, range, isLoading) - } - - private fun initRecyclerView() { - progressAdapter = - TaskProgressHeaderAdapter( - onRangeSelected = { selectedRange -> - viewModel.dispatchViewIntent(OnSelectProgressRange(selectedRange)) - }, - onDayClick = { /* handled by tooltip inside component */ }, - ) - - binding.rvTaskList.run { - val listAdapter = - TaskListAdapter( - object : TaskListAdapter.Callback { - override fun onClickTask(taskId: Long) { - viewModel.dispatchViewIntent(TaskListViewIntent.OnClickTask(taskId = taskId)) - } - - override fun onClickUndo( - taskId: Long, - status: TaskStatus, - ) { - viewModel.dispatchViewIntent( - TaskListViewIntent.OnClickUndoTask( - taskId = taskId, - status = status, - ), - ) - } - }, - ).apply { taskListAdapter = this } - - adapter = ConcatAdapter(progressAdapter, listAdapter) - layoutManager = LinearLayoutManager(activity) - } - - val itemTouchHelper = - ItemTouchHelper( - SwipeTaskLeftRight( - requireContext(), - object : SwipeTaskLeftRight.Callback { - override fun onSwipeLeft(position: Int) { - viewModel.dispatchViewIntent( - TaskListViewIntent.OnSwipeTask( - position = position, - status = TaskStatus.NOT_DONE, - ), - ) - } - - override fun onSwipeRight(position: Int) { - viewModel.dispatchViewIntent( - TaskListViewIntent.OnSwipeTask( - position = position, - status = TaskStatus.DONE, - ), - ) - } - }, - ), - ) - - itemTouchHelper.attachToRecyclerView(binding.rvTaskList) - - binding.rvTaskList.hideFabWhenScrolling(binding.fab) - } - - private fun skeletonItems(): List = - List(SKELETON_ITEMS_COUNT) { index -> TaskSkeletonUiModel(placeholderId = index.toLong()) } - companion object { fun newInstance() = TaskListFragment() - - private const val SKELETON_ITEMS_COUNT = 5 } } diff --git a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListScreen.kt b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListScreen.kt new file mode 100644 index 00000000..4e3b0e0f --- /dev/null +++ b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/TaskListScreen.kt @@ -0,0 +1,498 @@ +package br.com.sailboat.todozy.feature.task.list.impl.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.DismissDirection +import androidx.compose.material.DismissValue +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExtendedFloatingActionButton +import androidx.compose.material.FractionalThreshold +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.SwipeToDismiss +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import br.com.sailboat.todozy.domain.model.TaskMetrics +import br.com.sailboat.todozy.domain.model.TaskProgressDay +import br.com.sailboat.todozy.domain.model.TaskProgressRange +import br.com.sailboat.todozy.domain.model.TaskStatus +import br.com.sailboat.todozy.feature.task.list.impl.R +import br.com.sailboat.todozy.utility.kotlin.extension.isTrue +import br.com.sailboat.uicomponent.impl.progress.TaskProgressContent +import br.com.sailboat.uicomponent.impl.skeleton.TaskSkeletonItem +import br.com.sailboat.uicomponent.impl.subhead.SubheadItem +import br.com.sailboat.uicomponent.impl.task.TaskItem +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySemanticColors +import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme +import br.com.sailboat.uicomponent.model.SubheadUiModel +import br.com.sailboat.uicomponent.model.TaskSkeletonUiModel +import br.com.sailboat.uicomponent.model.TaskUiModel +import br.com.sailboat.uicomponent.model.UiModel +import br.com.sailboat.todozy.utility.android.R as AndroidUtilR +import br.com.sailboat.uicomponent.impl.R as UiR + +@Composable +internal fun TaskListScreen( + tasksLoading: Boolean, + items: List, + taskMetrics: TaskMetrics?, + taskProgressDays: List, + taskProgressRange: TaskProgressRange, + taskProgressLoading: Boolean, + onSelectProgressRange: (TaskProgressRange) -> Unit, + onTaskClick: (Long) -> Unit, + onTaskSwipe: (taskId: Long, TaskStatus) -> Unit, + onTaskUndo: (Long, TaskStatus) -> Unit, + onNewTask: () -> Unit, + onOpenHistory: () -> Unit, + onOpenSettings: () -> Unit, + onSearch: (String) -> Unit, +) { + TodozyTheme { + val spacing = LocalTodozySpacing.current + var search by rememberSaveable { mutableStateOf("") } + var isSearchExpanded by rememberSaveable { mutableStateOf(false) } + val metricsToShow = if (tasksLoading.isTrue()) null else taskMetrics + val listState = rememberLazyListState() + var pendingScrollToTop by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(tasksLoading, taskProgressLoading) { + if (tasksLoading || taskProgressLoading) { + pendingScrollToTop = true + } else if (pendingScrollToTop) { + listState.scrollToItem(0) + pendingScrollToTop = false + } + } + + Scaffold( + topBar = { + TaskListTopBar( + taskMetrics = metricsToShow, + searchText = search, + isSearchExpanded = isSearchExpanded, + onSearchChange = { query -> + search = query + onSearch(query) + }, + onToggleSearch = { isSearchExpanded = it }, + onOpenHistory = onOpenHistory, + onOpenSettings = onOpenSettings, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onNewTask, + icon = { Icon(imageVector = Icons.Default.Add, contentDescription = null) }, + text = { Text(text = stringResource(id = R.string.fab_new_task)) }, + backgroundColor = MaterialTheme.colors.primary, + contentColor = MaterialTheme.colors.onPrimary, + ) + }, + ) { paddingValues -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + bottom = spacing.xxlarge + spacing.large, + top = spacing.small, + ), + verticalArrangement = Arrangement.spacedBy(spacing.small), + ) { + item(key = "progress") { + TaskProgressContent( + modifier = Modifier.padding( + horizontal = spacing.medium, + vertical = spacing.small, + ), + days = taskProgressDays, + selectedRange = taskProgressRange, + onRangeSelected = onSelectProgressRange, + onDayClick = {}, + isLoading = taskProgressLoading, + enableDayDetails = true, + flatColors = false, + ) + } + + if (tasksLoading) { + items(5) { TaskSkeletonItem() } + } else if (items.isEmpty()) { + item { + EmptyState() + } + } else { + itemsIndexed( + items = items, + key = { index, item -> stableTaskListKey(item, index) }, + ) { index, item -> + when (item) { + is TaskUiModel -> + SwipeableTaskItem( + task = item, + onClick = onTaskClick, + onUndoClick = onTaskUndo, + onSwipe = { status -> onTaskSwipe(item.taskId, status) }, + modifier = Modifier.animateItem(), + ) + + is SubheadUiModel -> + SubheadItem( + text = item.subhead, + modifier = Modifier.animateItem(), + ) + + is TaskSkeletonUiModel -> + TaskSkeletonItem( + modifier = Modifier.animateItem(), + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun TaskListTopBar( + taskMetrics: TaskMetrics?, + searchText: String, + isSearchExpanded: Boolean, + onSearchChange: (String) -> Unit, + onToggleSearch: (Boolean) -> Unit, + onOpenHistory: () -> Unit, + onOpenSettings: () -> Unit, +) { + val spacing = LocalTodozySpacing.current + Surface( + color = colorResource(id = UiR.color.md_blue_500), + elevation = 4.dp, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = spacing.medium, vertical = spacing.small), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = UiR.string.label_tasks), + style = MaterialTheme.typography.h6, + color = Color.White, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { onToggleSearch(!isSearchExpanded) }) { + Icon( + Icons.Default.Search, + contentDescription = stringResource(id = AndroidUtilR.string.search), + tint = Color.White, + ) + } + IconButton(onClick = onOpenHistory) { + Icon( + Icons.Default.History, + contentDescription = stringResource(id = UiR.string.history), + tint = Color.White, + ) + } + IconButton(onClick = onOpenSettings) { + Icon( + Icons.Default.Settings, + contentDescription = stringResource(id = UiR.string.settings), + tint = Color.White, + ) + } + } + + if (isSearchExpanded) { + Spacer(modifier = Modifier.height(spacing.xsmall)) + TextField( + value = searchText, + onValueChange = onSearchChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text(text = stringResource(id = AndroidUtilR.string.search)) }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + ) + }, + trailingIcon = { + IconButton( + onClick = { + if (searchText.isNotEmpty()) { + onSearchChange("") + } else { + onToggleSearch(false) + } + }, + ) { + Icon( + Icons.Default.Close, + contentDescription = null, + ) + } + }, + colors = + TextFieldDefaults.textFieldColors( + backgroundColor = Color.White, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + textColor = MaterialTheme.colors.onSurface, + cursorColor = MaterialTheme.colors.primary, + ), + ) + } + + taskMetrics?.let { + Spacer(modifier = Modifier.height(spacing.small)) + MetricsRow(taskMetrics = it) + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun SwipeableTaskItem( + task: TaskUiModel, + onClick: (Long) -> Unit, + onUndoClick: (Long, TaskStatus) -> Unit, + onSwipe: (TaskStatus) -> Unit, + modifier: Modifier = Modifier, +) { + val semanticColors = LocalTodozySemanticColors.current + val spacing = LocalTodozySpacing.current + val dismissState = + androidx.compose.material.rememberDismissState( + confirmStateChange = { newValue -> + when (newValue) { + DismissValue.DismissedToEnd -> onSwipe(TaskStatus.DONE) + DismissValue.DismissedToStart -> onSwipe(TaskStatus.NOT_DONE) + else -> Unit + } + false + }, + ) + + if (task.showInlineMetrics) { + TaskItem( + task = task, + onClick = onClick, + onUndoClick = onUndoClick, + modifier = modifier, + ) + } else { + SwipeToDismiss( + state = dismissState, + directions = setOf(DismissDirection.StartToEnd, DismissDirection.EndToStart), + dismissThresholds = { FractionalThreshold(0.6f) }, + background = { + val direction = dismissState.dismissDirection + val (icon, backgroundColor) = + when (direction) { + DismissDirection.StartToEnd -> + UiR.drawable.ic_vec_thumb_up_white_24dp to colorResource(id = UiR.color.md_teal_200) + + DismissDirection.EndToStart -> + UiR.drawable.ic_vect_thumb_down_white_24dp to colorResource(id = UiR.color.md_red_200) + + else -> null to Color.Transparent + } + + if (icon != null) { + Row( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + .padding(horizontal = spacing.medium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = + when (direction) { + DismissDirection.StartToEnd -> Arrangement.Start + DismissDirection.EndToStart -> Arrangement.End + else -> Arrangement.Start + }, + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = Color.White, + ) + } + } + }, + modifier = modifier, + ) { + TaskItem( + task = task, + onClick = onClick, + onUndoClick = onUndoClick, + ) + } + } +} + +@Composable +private fun MetricsRow(taskMetrics: TaskMetrics) { + val spacing = LocalTodozySpacing.current + val semanticColors = LocalTodozySemanticColors.current + val doneTint = colorResource(id = UiR.color.md_teal_300) + val notDoneTint = colorResource(id = UiR.color.md_red_300) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.medium), + ) { + if (taskMetrics.consecutiveDone > 0) { + MetricIconWithValue( + iconId = UiR.drawable.ic_fire_black_24dp, + tint = semanticColors.warning, + value = taskMetrics.consecutiveDone, + modifier = Modifier.weight(1f), + ) + } + MetricIconWithValue( + iconId = UiR.drawable.ic_vec_thumb_up_white_24dp, + tint = doneTint, + value = taskMetrics.doneTasks, + modifier = Modifier.weight(1f), + ) + MetricIconWithValue( + iconId = UiR.drawable.ic_vect_thumb_down_white_24dp, + tint = notDoneTint, + value = taskMetrics.notDoneTasks, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun MetricIconWithValue( + iconId: Int, + tint: Color, + value: Int, + modifier: Modifier = Modifier, +) { + val spacing = LocalTodozySpacing.current + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.xsmall), + ) { + Surface( + shape = CircleShape, + color = Color.White, + elevation = 0.dp, + ) { + Box( + modifier = + Modifier + .size(28.dp) + .padding(spacing.xxsmall), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = iconId), + contentDescription = null, + tint = tint, + modifier = Modifier.height(18.dp), + ) + } + } + Text( + text = value.toString(), + style = MaterialTheme.typography.subtitle1, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun EmptyState() { + val spacing = LocalTodozySpacing.current + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(spacing.large), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(id = UiR.string.no_tasks), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + ) + Text( + text = stringResource(id = UiR.string.ept_click_to_add), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f), + ) + } +} + +private fun stableTaskListKey( + item: UiModel, + index: Int, +): String = when (item) { + is TaskUiModel -> "task-${item.taskId}" + is TaskSkeletonUiModel -> "task-skeleton-${item.placeholderId}" + is SubheadUiModel -> "subhead-${item.subhead}" + else -> "${item.uiModelId}-$index" +} diff --git a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewAction.kt b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewAction.kt index 9480e063..7cb16b04 100644 --- a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewAction.kt +++ b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewAction.kt @@ -9,5 +9,4 @@ internal sealed class TaskListViewAction { object ShowErrorLoadingTasks : TaskListViewAction() object ShowErrorCompletingTask : TaskListViewAction() data class NavigateToTaskDetails(val taskId: Long) : TaskListViewAction() - data class UpdateRemovedTask(val position: Int) : TaskListViewAction() } diff --git a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewIntent.kt b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewIntent.kt index b3212bb9..e231bdb9 100644 --- a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewIntent.kt +++ b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewIntent.kt @@ -11,7 +11,7 @@ internal sealed class TaskListViewIntent { object OnClickNewTask : TaskListViewIntent() data class OnClickTask(val taskId: Long) : TaskListViewIntent() data class OnSubmitSearchTerm(val term: String) : TaskListViewIntent() - data class OnSwipeTask(val position: Int, val status: TaskStatus) : TaskListViewIntent() + data class OnSwipeTask(val taskId: Long, val status: TaskStatus) : TaskListViewIntent() data class OnClickUndoTask(val taskId: Long, val status: TaskStatus) : TaskListViewIntent() data class OnSelectProgressRange(val range: TaskProgressRange) : TaskListViewIntent() } diff --git a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModel.kt b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModel.kt index cacf3d21..abd322bc 100644 --- a/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModel.kt +++ b/feature/task-list/impl/src/main/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModel.kt @@ -77,7 +77,7 @@ internal class TaskListViewModel( is OnClickTask -> onClickTask(viewIntent.taskId) is OnSubmitSearchTerm -> onSubmitSearchTerm(viewIntent.term) is OnClickUndoTask -> onClickUndoTask(viewIntent.taskId, viewIntent.status) - is OnSwipeTask -> onSwipeTask(viewIntent.position, viewIntent.status) + is OnSwipeTask -> onSwipeTask(viewIntent.taskId, viewIntent.status) is OnSelectProgressRange -> onSelectProgressRange(viewIntent.range) } } @@ -490,7 +490,6 @@ internal class TaskListViewModel( if (position >= 0) { itemsView.removeAt(position) - viewState.viewAction.postValue(TaskListViewAction.UpdateRemovedTask(position)) } currentTasks = currentTasks.filterNot { it.id == taskId } @@ -504,13 +503,15 @@ internal class TaskListViewModel( } private fun onSwipeTask( - position: Int, + taskId: Long, status: TaskStatus, ) { viewModelScope.launch { try { val itemsView = viewState.itemsView.value ?: return@launch - val taskUiModel = itemsView.getOrNull(position) as? TaskUiModel ?: return@launch + val taskUiModel = + itemsView.firstOrNull { (it as? TaskUiModel)?.taskId == taskId } as? TaskUiModel + ?: return@launch cancelInlineFeedback(taskUiModel.taskId) @@ -521,7 +522,6 @@ internal class TaskListViewModel( uiModel = taskUiModel, metrics = inlineMetrics, status = status, - position = position, ) publishItemsWithInlineFeedback(itemsView) @@ -624,5 +624,4 @@ private data class InlineTaskFeedback( val uiModel: TaskUiModel, val metrics: TaskMetrics?, val status: TaskStatus, - val position: Int, ) diff --git a/feature/task-list/impl/src/main/res/layout/ept_task_list.xml b/feature/task-list/impl/src/main/res/layout/ept_task_list.xml deleted file mode 100644 index d682367d..00000000 --- a/feature/task-list/impl/src/main/res/layout/ept_task_list.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature/task-list/impl/src/main/res/layout/frg_task_list.xml b/feature/task-list/impl/src/main/res/layout/frg_task_list.xml deleted file mode 100644 index b5f3ec90..00000000 --- a/feature/task-list/impl/src/main/res/layout/frg_task_list.xml +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/feature/task-list/impl/src/main/res/menu/menu_task_list.xml b/feature/task-list/impl/src/main/res/menu/menu_task_list.xml deleted file mode 100644 index 02977a51..00000000 --- a/feature/task-list/impl/src/main/res/menu/menu_task_list.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/feature/task-list/impl/src/test/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModelTest.kt b/feature/task-list/impl/src/test/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModelTest.kt index 39dc0369..c89bf819 100644 --- a/feature/task-list/impl/src/test/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModelTest.kt +++ b/feature/task-list/impl/src/test/java/br/com/sailboat/todozy/feature/task/list/impl/presentation/viewmodel/TaskListViewModelTest.kt @@ -1,7 +1,6 @@ package br.com.sailboat.todozy.feature.task.list.impl.presentation.viewmodel import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.Observer import br.com.sailboat.todozy.domain.model.Task import br.com.sailboat.todozy.domain.model.TaskCategory import br.com.sailboat.todozy.domain.model.TaskFilter @@ -23,9 +22,7 @@ import br.com.sailboat.uicomponent.model.UiModel import io.mockk.coEvery import io.mockk.coVerify import io.mockk.coVerifyOrder -import io.mockk.every import io.mockk.mockk -import io.mockk.slot import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle @@ -221,16 +218,16 @@ internal class TaskListViewModelTest { TaskUiModel(taskId = 543L, taskName = "Task 543"), TaskUiModel(taskId = 978L, taskName = "Task 978"), ) - val position = 1 + val taskId = 978L val status = TaskStatus.DONE viewModel.viewState.itemsView.value = tasks prepareScenario() - viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(position, status)) + viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(taskId, status)) advanceTimeBy(TASK_SWIPE_DELAY_IN_MILLIS) advanceUntilIdle() - coVerify(exactly = 1) { completeTaskUseCase(taskId = 978L, status = status) } + coVerify(exactly = 1) { completeTaskUseCase(taskId = taskId, status = status) } } } @@ -240,24 +237,15 @@ internal class TaskListViewModelTest { val task1 = TaskUiModel(taskId = 543L, taskName = "Task 543") val task2 = TaskUiModel(taskId = 978L, taskName = "Task 978") val tasks = mutableListOf(task1, task2) - val position = 1 + val taskId = task2.taskId val status = TaskStatus.DONE - val observer = mockk>() - val slot = slot() - val list = arrayListOf() - viewModel.viewState.viewAction.observeForever(observer) viewModel.viewState.itemsView.value = tasks - every { observer.onChanged(capture(slot)) } answers { - list.add(slot.captured) - } prepareScenario() - viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(position, status)) + viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(taskId, status)) advanceTimeBy(TASK_SWIPE_DELAY_IN_MILLIS) advanceUntilIdle() - val expected = TaskListViewAction.UpdateRemovedTask(position) - assertTrue { list.contains(expected) } assertTrue { viewModel.viewState.itemsView.value ?.filterIsInstance() @@ -273,12 +261,12 @@ internal class TaskListViewModelTest { TaskUiModel(taskId = 543L, taskName = "Task 543"), TaskUiModel(taskId = 978L, taskName = "Task 978"), ) - val position = 1 + val taskId = 978L val status = TaskStatus.DONE viewModel.viewState.itemsView.value = tasks prepareScenario() - viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(position, status)) + viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(taskId, status)) advanceTimeBy(TASK_SWIPE_DELAY_IN_MILLIS) advanceUntilIdle() @@ -318,7 +306,7 @@ internal class TaskListViewModelTest { viewModel.viewState.itemsView.value = tasks - viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(1, TaskStatus.DONE)) + viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(taskId, TaskStatus.DONE)) runCurrent() val updatedTask = viewModel.viewState.itemsView.value?.get(1) as TaskUiModel @@ -335,12 +323,12 @@ internal class TaskListViewModelTest { TaskUiModel(taskId = 543L, taskName = "Task 543"), TaskUiModel(taskId = 978L, taskName = "Task 978"), ) - val position = 1 + val taskId = 978L val status = TaskStatus.DONE viewModel.viewState.itemsView.value = tasks prepareScenario() - viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(position, status)) + viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(taskId, status)) advanceUntilIdle() coVerify { @@ -366,7 +354,7 @@ internal class TaskListViewModelTest { viewModel.dispatchViewIntent(TaskListViewIntent.OnStart) advanceUntilIdle() - viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(position = 0, status = TaskStatus.DONE)) + viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(taskId = 543L, status = TaskStatus.DONE)) runCurrent() assertEquals(TaskMetrics(doneTasks = 1, notDoneTasks = 0, consecutiveDone = 1), viewModel.viewState.taskMetrics.value) @@ -391,7 +379,7 @@ internal class TaskListViewModelTest { viewModel.viewState.itemsView.value = tasks - viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(0, TaskStatus.DONE)) + viewModel.dispatchViewIntent(TaskListViewIntent.OnSwipeTask(taskId, TaskStatus.DONE)) runCurrent() viewModel.dispatchViewIntent(TaskListViewIntent.OnClickUndoTask(taskId, TaskStatus.DONE)) diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/alarm/AlarmItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/alarm/AlarmItem.kt index 356bed56..98eb2c93 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/alarm/AlarmItem.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/alarm/AlarmItem.kt @@ -9,13 +9,13 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.colorResource +import br.com.sailboat.uicomponent.impl.R import br.com.sailboat.uicomponent.impl.theme.LocalTodozySemanticColors import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing -import br.com.sailboat.uicomponent.impl.R @Composable internal fun AlarmItem( diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt index afe90c7b..fbfa3a63 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressComponents.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import br.com.sailboat.todozy.domain.model.TaskProgressDay import br.com.sailboat.todozy.domain.model.TaskProgressRange import br.com.sailboat.uicomponent.impl.R @@ -52,7 +53,7 @@ import java.time.format.TextStyle import java.time.temporal.TemporalAdjusters import java.util.Locale -internal val DefaultTaskProgressDayOrder = +val DefaultTaskProgressDayOrder = listOf( DayOfWeek.MONDAY, DayOfWeek.TUESDAY, @@ -64,7 +65,8 @@ internal val DefaultTaskProgressDayOrder = ) @Composable -internal fun TaskProgressContent( +fun TaskProgressContent( + modifier: Modifier = Modifier, days: List, selectedRange: TaskProgressRange, onRangeSelected: (TaskProgressRange) -> Unit, @@ -85,14 +87,12 @@ internal fun TaskProgressContent( val cellSize = remember(dayOrder.size) { if (dayOrder.size <= 2) 24.dp else 16.dp } Column( - modifier = - Modifier - .fillMaxWidth(), + modifier = modifier.fillMaxWidth(), ) { TaskProgressRangeSelector( selectedRange = selectedRange, - onRangeSelected = onRangeSelected, - ) + onRangeSelected = onRangeSelected, + ) Spacer(modifier = Modifier.size(spacing.small)) if (isLoading) { TaskProgressSkeleton(dayOrder = dayOrder, cellSize = cellSize) @@ -106,10 +106,12 @@ internal fun TaskProgressContent( onSelectDay(if (selectedDay == day) null else day) } } + enableDayDetails -> { day -> haptic.performHapticFeedback(HapticFeedbackType.LongPress) onSelectDay(if (selectedDay == day) null else day) } + else -> onDayClick } @@ -147,24 +149,24 @@ private fun TaskProgressRangeSelector( ) } - LazyRow(horizontalArrangement = Arrangement.spacedBy(spacing.small)) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(spacing.small), + ) { items(ranges) { range -> val selected = range == selectedRange val shape = RoundedCornerShape(12.dp) Surface( - modifier = - Modifier - .clip(shape) - .clickable { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) + modifier = Modifier + .clip(shape) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) onRangeSelected(range) }, - color = - if (selected) { - colorResource(id = R.color.md_teal_100) - } else { - MaterialTheme.colors.surface - }, + color = if (selected) { + colorResource(id = R.color.md_teal_100) + } else { + MaterialTheme.colors.surface + }, shape = shape, border = BorderStroke( @@ -179,7 +181,7 @@ private fun TaskProgressRangeSelector( ) { Text( text = range.toLabel(), - style = MaterialTheme.typography.caption, + style = MaterialTheme.typography.caption.copy(fontSize = 13.sp), color = if (selected) { colorResource(id = R.color.md_teal_700) @@ -289,7 +291,9 @@ private fun TaskProgressGrid( } } .semantics { - semanticsDescription?.let { desc -> contentDescription = desc } + semanticsDescription?.let { desc -> + contentDescription = desc + } }, ) { if (enableDayDetails && selectedDay == day) { diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressView.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressView.kt index d6d84ac9..3db101af 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressView.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/progress/TaskProgressView.kt @@ -3,7 +3,6 @@ package br.com.sailboat.uicomponent.impl.progress import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout -import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.ComposeView diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/skeleton/TaskSkeletonItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/skeleton/TaskSkeletonItem.kt index de407ea1..daa56c12 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/skeleton/TaskSkeletonItem.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/skeleton/TaskSkeletonItem.kt @@ -15,20 +15,19 @@ import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing @Composable @Suppress("FunctionName") -fun TaskSkeletonItem() { +fun TaskSkeletonItem(modifier: Modifier = Modifier) { val spacing = LocalTodozySpacing.current val baseColor = MaterialTheme.colors.onSurface.copy(alpha = 0.08f) val cardColor = MaterialTheme.colors.surface Row( modifier = - Modifier + modifier .fillMaxWidth() .padding(horizontal = spacing.medium, vertical = spacing.xsmall) .heightIn(min = 72.dp) diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/subhead/SubheadItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/subhead/SubheadItem.kt index 91f64c34..77bbf623 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/subhead/SubheadItem.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/subhead/SubheadItem.kt @@ -14,7 +14,7 @@ import br.com.sailboat.uicomponent.impl.theme.LocalTodozySpacing import java.util.Locale @Composable -internal fun SubheadItem( +fun SubheadItem( text: String, modifier: Modifier = Modifier, ) { diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt index 0c828870..efb0d7a5 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/task/TaskItem.kt @@ -1,7 +1,14 @@ package br.com.sailboat.uicomponent.impl.task import android.content.Context +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -53,10 +60,12 @@ import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import br.com.sailboat.uicomponent.model.TaskUiModel import java.util.Calendar +private val TASK_ROW_MIN_HEIGHT = 64.dp + @Composable @Suppress("FunctionName") @OptIn(ExperimentalMaterialApi::class) -internal fun TaskItem( +fun TaskItem( task: TaskUiModel, modifier: Modifier = Modifier, onClick: (Long) -> Unit, @@ -65,6 +74,13 @@ internal fun TaskItem( val context = LocalContext.current val spacing = LocalTodozySpacing.current val inlineElevation = 6.dp + val contentPadding = + PaddingValues( + start = spacing.medium, + end = spacing.medium, + top = spacing.small, + bottom = spacing.medium, + ) val alarmInfo = remember(task.alarm, context) { resolveAlarmInfo(context, task.alarm) } @@ -79,47 +95,57 @@ internal fun TaskItem( shape = MaterialTheme.shapes.medium.copy(all = ZeroCornerSize), onClick = { onClick(task.taskId) }, ) { - if (task.showInlineMetrics) { - InlineMetrics( - metrics = task.inlineMetrics, - status = task.inlineStatus ?: TaskStatus.NOT_DONE, - onUndoClick = { status -> onUndoClick(task.taskId, status) }, - spacingSmall = spacing.small, - spacingXSmall = spacing.xsmall, - ) - } else { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 64.dp) - .padding( - start = spacing.medium, - end = spacing.medium, - top = spacing.small, - bottom = spacing.medium, - ), - verticalAlignment = Alignment.Top, - ) { - Text( - text = task.taskName, - color = colorResource(id = R.color.md_blue_grey_700), - style = - MaterialTheme.typography.body1.copy( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - ), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), + AnimatedContent( + targetState = task.showInlineMetrics, + transitionSpec = { + (fadeIn(tween(150)) + expandVertically()) togetherWith + (fadeOut(tween(150)) + shrinkVertically()) + }, + label = "task-inline-metrics", + ) { showInline -> + if (showInline) { + InlineMetrics( + metrics = task.inlineMetrics, + status = task.inlineStatus ?: TaskStatus.NOT_DONE, + onUndoClick = { status -> onUndoClick(task.taskId, status) }, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = TASK_ROW_MIN_HEIGHT) + .background(MaterialTheme.colors.primary, shape = MaterialTheme.shapes.medium.copy(all = ZeroCornerSize)) + .padding(contentPadding), + spacingSmall = spacing.small, + spacingXSmall = spacing.xsmall, ) - - if (alarmInfo != null) { - Spacer(modifier = Modifier.width(spacing.small)) - TaskAlarm( - alarmInfo = alarmInfo, - color = alarmColor, - spacingXSmall = spacing.xsmall, + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = TASK_ROW_MIN_HEIGHT) + .padding(contentPadding), + verticalAlignment = Alignment.Top, + ) { + Text( + text = task.taskName, + color = colorResource(id = R.color.md_blue_grey_700), + style = + MaterialTheme.typography.body1.copy( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + ), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) + + if (alarmInfo != null) { + Spacer(modifier = Modifier.width(spacing.small)) + TaskAlarm( + alarmInfo = alarmInfo, + color = alarmColor, + spacingXSmall = spacing.xsmall, + ) + } } } } @@ -159,20 +185,18 @@ private fun InlineMetrics( metrics: TaskMetrics?, status: TaskStatus, onUndoClick: (TaskStatus) -> Unit, + modifier: Modifier = Modifier, spacingSmall: Dp, spacingXSmall: Dp, ) { Row( - modifier = - Modifier - .fillMaxWidth() - .background(MaterialTheme.colors.primary, shape = MaterialTheme.shapes.medium.copy(all = ZeroCornerSize)) - .padding(all = spacingSmall), + modifier = modifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(spacingSmall), ) { MetricsContent( metrics = metrics, + modifier = Modifier.weight(1f), spacingSmall = spacingSmall, spacingXSmall = spacingXSmall, ) @@ -201,34 +225,50 @@ private fun InlineMetrics( @Composable private fun MetricsContent( metrics: TaskMetrics?, + modifier: Modifier = Modifier, spacingSmall: Dp, spacingXSmall: Dp, ) { + val metricChips = + listOfNotNull( + metrics?.consecutiveDone?.takeIf { it > 0 }?.let { + MetricData( + icon = R.drawable.ic_fire_black_24dp, + tint = colorResource(id = R.color.md_orange_500), + label = it.toString(), + ) + }, + metrics?.doneTasks?.let { + MetricData( + icon = R.drawable.ic_vec_thumb_up_white_24dp, + tint = colorResource(id = R.color.md_teal_300), + label = it.toString(), + ) + }, + metrics?.notDoneTasks?.let { + MetricData( + icon = R.drawable.ic_vect_thumb_down_white_24dp, + tint = colorResource(id = R.color.md_red_300), + label = it.toString(), + ) + }, + ).filter { it.label.isNotEmpty() } + + if (metricChips.isEmpty()) return + Row( - modifier = Modifier.defaultMinSize(minHeight = 40.dp), + modifier = modifier.defaultMinSize(minHeight = 40.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacingSmall), ) { - MetricChip( - icon = R.drawable.ic_fire_black_24dp, - iconTint = colorResource(id = R.color.md_orange_500), - label = metrics?.consecutiveDone?.takeIf { it > 0 }?.toString().orEmpty(), - spacingXSmall = spacingXSmall, - ) - - MetricChip( - icon = R.drawable.ic_vec_thumb_up_white_24dp, - iconTint = colorResource(id = R.color.md_teal_300), - label = metrics?.doneTasks?.toString().orEmpty(), - spacingXSmall = spacingXSmall, - ) - - MetricChip( - icon = R.drawable.ic_vect_thumb_down_white_24dp, - iconTint = colorResource(id = R.color.md_red_300), - label = metrics?.notDoneTasks?.toString().orEmpty(), - spacingXSmall = spacingXSmall, - ) + metricChips.forEach { chip -> + MetricChip( + icon = chip.icon, + iconTint = chip.tint, + label = chip.label, + spacingXSmall = spacingXSmall, + ) + } } } @@ -238,17 +278,17 @@ private fun MetricChip( iconTint: Color, label: String, spacingXSmall: Dp, + modifier: Modifier = Modifier, ) { - if (label.isEmpty()) return - Row( + modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacingXSmall), ) { Surface( shape = CircleShape, color = Color.White, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(28.dp), elevation = 0.dp, ) { Icon( @@ -262,13 +302,17 @@ private fun MetricChip( Text( text = label, color = Color.White, - style = MaterialTheme.typography.body2.copy(fontWeight = FontWeight.Bold), - maxLines = 2, - overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.subtitle1.copy(fontWeight = FontWeight.Bold), ) } } +private data class MetricData( + val icon: Int, + val tint: Color, + val label: String, +) + private fun resolveAlarmInfo( context: Context, alarm: Calendar?, diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/theme/TodozyTheme.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/theme/TodozyTheme.kt index 9524530c..4c1d782e 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/theme/TodozyTheme.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/theme/TodozyTheme.kt @@ -1,5 +1,6 @@ package br.com.sailboat.uicomponent.impl.theme +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Shapes import androidx.compose.material.Typography @@ -8,46 +9,47 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp private val LightColorPalette = lightColors( - primary = Color(0xFF2196F3), // md_blue_500 - primaryVariant = Color(0xFF1976D2), // md_blue_700 - secondary = Color(0xFF00BFA5), // md_teal_A700 - background = Color(0xFFF3F3F3), // default_background + primary = Color(0xFF2196F3), + primaryVariant = Color(0xFF1976D2), + secondary = Color(0xFF00BFA5), + background = Color(0xFFF3F3F3), surface = Color.White, onPrimary = Color.White, onSecondary = Color.White, - onBackground = Color(0xFF212121), // primary_text + onBackground = Color(0xFF212121), onSurface = Color(0xFF212121), ) private val TodozyShapes = Shapes( - small = androidx.compose.foundation.shape.RoundedCornerShape(6.dp), - medium = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), - large = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), + small = RoundedCornerShape(6.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), ) data class TodozySpacing( - val xxsmall: androidx.compose.ui.unit.Dp = 2.dp, - val xsmall: androidx.compose.ui.unit.Dp = 4.dp, - val small: androidx.compose.ui.unit.Dp = 8.dp, - val medium: androidx.compose.ui.unit.Dp = 16.dp, - val large: androidx.compose.ui.unit.Dp = 24.dp, - val xlarge: androidx.compose.ui.unit.Dp = 32.dp, - val xxlarge: androidx.compose.ui.unit.Dp = 40.dp, + val xxsmall: Dp = 2.dp, + val xsmall: Dp = 4.dp, + val small: Dp = 8.dp, + val medium: Dp = 16.dp, + val large: Dp = 24.dp, + val xlarge: Dp = 32.dp, + val xxlarge: Dp = 40.dp, ) val LocalTodozySpacing = staticCompositionLocalOf { TodozySpacing() } data class TodozySemanticColors( - val success: Color = Color(0xFF009688), // md_teal_500 - val warning: Color = Color(0xFFFFA000), // amber_700-ish - val error: Color = Color(0xFFF44336), // md_red_500 - val info: Color = Color(0xFF2196F3), // md_blue_500 + val success: Color = Color(0xFF009688), + val warning: Color = Color(0xFFFFA000), + val error: Color = Color(0xFFF44336), + val info: Color = Color(0xFF2196F3), ) val LocalTodozySemanticColors = staticCompositionLocalOf { TodozySemanticColors() } diff --git a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt index 75242ca6..902cde0f 100644 --- a/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt +++ b/ui-component/impl/src/main/java/br/com/sailboat/uicomponent/impl/viewholder/TaskViewHolder.kt @@ -1,14 +1,13 @@ package br.com.sailboat.uicomponent.impl.viewholder import android.view.ViewGroup -import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.recyclerview.widget.RecyclerView import br.com.sailboat.todozy.domain.model.TaskStatus -import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import br.com.sailboat.uicomponent.impl.task.TaskItem +import br.com.sailboat.uicomponent.impl.theme.TodozyTheme import br.com.sailboat.uicomponent.model.TaskUiModel class TaskViewHolder(