Add event add and edit

This commit is contained in:
Andres Gomez, Thomas (ITDV RL) 2025-09-03 18:50:40 +02:00
parent 2c5d9b6df1
commit 62c3639a9e
44 changed files with 1897 additions and 473 deletions

View file

@ -14,6 +14,9 @@
<SelectionState runConfigName="text2Pills_3()">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="EventEditPreview">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

1
.idea/misc.xml generated
View file

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceStreaming">
<option name="deviceSelectionList">

View file

@ -51,7 +51,7 @@ android {
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("pixelized")
defaultConfig {
versionCode = 1 // getGitBuildNumber()
versionCode = getGitBuildNumber()
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@ -65,6 +65,7 @@ android {
}
kotlinOptions {
jvmTarget = "11"
freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode")
}
buildFeatures {
compose = true
@ -116,7 +117,7 @@ private fun getGitBuildNumber(
standardOutput = stdout
}
stdout.toString(charset).trim().toInt()
} catch (e: Exception) {
0
} catch (_: Exception) {
1
}
}

View file

@ -1,5 +1,7 @@
package com.pixelized.headache.repository.event
import androidx.compose.ui.graphics.Color
data class Event(
val id: Long,
val title: String,
@ -25,74 +27,21 @@ data class Event(
}
}
sealed class Pill(
data class Pill(
val id: Id,
val label: String,
val amount: Int,
val description: String,
val color: Color,
val isValid: Boolean,
val isMisspelled: Boolean,
) {
class Unknown(
amount: Int,
description: String,
) : Pill(
label = "?",
amount = amount,
description = description,
isValid = false,
isMisspelled = false,
)
class Ibuprofene400(
amount: Int,
description: String,
isValid: Boolean,
isMisspelled: Boolean,
) : Pill(
label = "Ibuprofène 400",
amount = amount,
description = description,
isValid = isValid,
isMisspelled = isMisspelled,
)
class Paracetamol1000(
amount: Int,
description: String,
isValid: Boolean,
isMisspelled: Boolean,
) : Pill(
label = "Paracétamol 1000",
amount = amount,
description = description,
isValid = isValid,
isMisspelled = isMisspelled,
)
class Spifen400(
amount: Int,
description: String,
isValid: Boolean,
isMisspelled: Boolean,
) : Pill(
label = "Spifen 400",
amount = amount,
description = description,
isValid = isValid,
isMisspelled = isMisspelled,
)
class Eletriptan40(
amount: Int,
description: String,
isValid: Boolean,
isMisspelled: Boolean,
) : Pill(
label = "Élétriptan 40",
amount = amount,
description = description,
isValid = isValid,
isMisspelled = isMisspelled,
)
enum class Id(val value: Long) {
UNKNOWN(-1L),
IBUPROFENE_400(0L),
PARACETAMOL_1000(1L),
SPIFEN_400(2L),
ELETRIPTAN_40(3L),
}
}
}

View file

@ -1,158 +0,0 @@
package com.pixelized.headache.repository.event
import android.icu.util.Calendar
import com.pixelized.headache.utils.extention.event
import java.util.Date
import java.util.regex.Pattern
import javax.inject.Inject
class EventFactory @Inject constructor() {
private val amountSplitRegex = Pattern.compile("""\s[xX]\s*""")
private val calendar = Calendar.getInstance()
fun parseEvent(
start: Long,
id: Long,
title: String,
description: String,
): Event {
val date = calendar.apply { time = Date(start) }.event
val pills = description
.takeIf { it.isNotBlank() }
?.split("\n")
?.mapNotNull {
when {
it.isBlank() -> null
else -> parsePill(it)
}
}
?: listOf()
return Event(
id = id,
title = title,
description = description,
date = date,
pills = pills,
isValid = pills.all { it.isValid },
isMisspelled = pills.any { it.isMisspelled },
)
}
@Throws(Exception::class)
fun parsePill(pill: String): Event.Pill {
val split = pill.split(regex = amountSplitRegex)
val label = split.getOrNull(0) ?: error("missing label")
val amount = split.getOrNull(1)?.trim()?.toIntOrNull() ?: 1
return when {
label.contains(SPIFEN_400, ignoreCase = true) -> Event.Pill.Spifen400(
amount = amount,
description = pill,
isValid = true,
isMisspelled = false,
)
label.contains(ELETRIPTAN_40, ignoreCase = true) -> Event.Pill.Eletriptan40(
amount = amount,
description = pill,
isValid = true,
isMisspelled = false,
)
label.contains(IBUPROFENE_400, ignoreCase = true) -> Event.Pill.Ibuprofene400(
amount = amount,
description = pill,
isValid = true,
isMisspelled = false,
)
label.contains(PARACETAMOL_1000, ignoreCase = true) -> Event.Pill.Paracetamol1000(
amount = amount,
description = pill,
isValid = true,
isMisspelled = false,
)
SPIFEN_400_LIST.any { label.contains(it, ignoreCase = true) } -> Event.Pill.Spifen400(
amount = amount,
description = pill,
isValid = true,
isMisspelled = true,
)
ELETRIPTAN_40_LIST.any {
label.contains(
it,
ignoreCase = true
)
} -> Event.Pill.Eletriptan40(
amount = amount,
description = pill,
isValid = true,
isMisspelled = true,
)
IBUPROFENE_400_LIST.any {
label.contains(
it,
ignoreCase = true
)
} -> Event.Pill.Ibuprofene400(
amount = amount,
description = pill,
isValid = true,
isMisspelled = true,
)
PARACETAMOL_1000_LIST.any {
label.contains(
it,
ignoreCase = true
)
} -> Event.Pill.Paracetamol1000(
amount = amount,
description = pill,
isValid = true,
isMisspelled = true,
)
else -> Event.Pill.Unknown(
amount = amount,
description = pill,
)
}
}
companion object {
private const val PARACETAMOL_1000 = "Paracétamol 1000"
private val PARACETAMOL_1000_LIST = listOf<String>(
"Doliprane 1000",
)
private const val IBUPROFENE_400 = "Ibuprofène 400"
private val IBUPROFENE_400_LIST = listOf(
"Ibuprofene 400",
"Ibuprofen 400",
"Ibuprofen",
)
private const val SPIFEN_400 = "Spifen 400"
private val SPIFEN_400_LIST = listOf<String>(
"Spifen",
)
private const val ELETRIPTAN_40 = "Élétriptan 40"
private val ELETRIPTAN_40_LIST = listOf(
"Eletriptan 40",
"Életriptan 40",
"Élitriptan 40",
"Elitriptan 40",
"Eletripan 40",
"Elétripan 40",
"Elitripan 40",
"Elitripan",
)
}
}

View file

@ -1,17 +1,27 @@
package com.pixelized.headache.repository.event
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.icu.util.Calendar
import android.icu.util.TimeZone
import android.net.Uri
import android.provider.CalendarContract
import com.pixelized.headache.repository.calendar.GoogleCalendarIdRepository
import com.pixelized.headache.repository.event.factory.EventFactory
import com.pixelized.headache.utils.extention.event
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@ -21,41 +31,120 @@ class EventRepository @Inject constructor(
private val calendarIdRepository: GoogleCalendarIdRepository,
private val factory: EventFactory,
) {
private val timeZone = TimeZone.getTimeZone("UTC")
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val eventFlow = hashMapOf<Long, MutableStateFlow<List<Event>>>()
private val eventFlow = MutableStateFlow(mapOf<Long, Event>())
private val eventListFlow: StateFlow<Collection<Event>> = eventFlow
.map { events -> events.values.sortedBy { it.date } }
.stateIn(
scope = scope,
started = SharingStarted.Lazily,
initialValue = emptyList(),
)
init {
scope.launch {
calendarIdRepository.calendarId.collect { id ->
calendarIdRepository.calendarId
.onEach { id ->
fetchEvents(
calendarId = id,
startDate = Event.Date(day = 1, month = Calendar.JANUARY, year = 2023),
endDate = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.event,
refresh = true,
)
}
.launchIn(scope = scope)
}
fun eventsFlow(): StateFlow<Map<Long, Event>> = eventFlow
fun eventsListFlow(): StateFlow<Collection<Event>> = eventListFlow
fun event(id: Long): Event? = eventFlow.value.get(key = id)
fun createEvent(
calendarId: Long = calendarIdRepository.calendarId.value,
title: String,
description: String,
year: Int,
month: Int,
day: Int,
): Boolean {
val (startMillis, endMillis) = Calendar.getInstance(timeZone).apply {
set(year, month, day, 0, 0, 0)
set(Calendar.MILLISECOND, 0)
}.let {
val startMillis = it.timeInMillis
it.add(Calendar.DAY_OF_MONTH, 1)
val endMillis = it.timeInMillis
startMillis to endMillis
}
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, title)
put(CalendarContract.Events.DESCRIPTION, description)
put(CalendarContract.Events.DTSTART, startMillis)
put(CalendarContract.Events.DTEND, endMillis)
put(CalendarContract.Events.AVAILABILITY, CalendarContract.Events.AVAILABILITY_FREE)
put(CalendarContract.Events.ALL_DAY, 1)
put(CalendarContract.Events.EVENT_TIMEZONE, "UTC")
}
return context.contentResolver.insert(
CalendarContract.Events.CONTENT_URI,
values
) != null
}
fun eventFlow(
calendarId: Long,
): StateFlow<List<Event>> {
return eventFlow.getOrPut(calendarId) { MutableStateFlow(emptyList()) }
fun updateEvent(
id: Long,
title: String? = null,
description: String? = null,
year: Int,
month: Int,
day: Int,
): Boolean {
val (startMillis, endMillis) = Calendar.getInstance(timeZone).apply {
set(year, month, day, 0, 0, 0)
set(Calendar.MILLISECOND, 0)
}.let {
val startMillis = it.timeInMillis
it.add(Calendar.DAY_OF_MONTH, 1)
val endMillis = it.timeInMillis
startMillis to endMillis
}
val values = ContentValues().apply {
title?.let { put(CalendarContract.Events.TITLE, it) }
description?.let { put(CalendarContract.Events.DESCRIPTION, it) }
put(CalendarContract.Events.DTSTART, startMillis)
put(CalendarContract.Events.DTEND, endMillis)
}
val updateUri: Uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id)
val rows: Int = context.contentResolver.update(updateUri, values, null, null)
return rows > 0
}
fun fetchEvents(
calendarId: Long,
fun deleteEvent(
eventId: Long,
): Boolean {
val deleteUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
val success = context.contentResolver.delete(deleteUri, null, null) > 0
if (success) {
eventFlow.value = eventFlow.value.toMutableMap().also {
it.remove(eventId)
}
}
return success
}
suspend fun fetchEvents(
calendarId: Long = calendarIdRepository.calendarId.value,
startDate: Event.Date,
endDate: Event.Date,
refresh: Boolean,
): List<Event> {
val flow = eventFlow.getOrPut(calendarId) { MutableStateFlow(emptyList()) }
if (flow.value.isNotEmpty() && refresh.not()) {
return flow.value
}
val events = mutableListOf<Event>()
) = withContext(Dispatchers.IO) {
val startCalendar = Calendar.getInstance().apply { this.event = startDate }
val endCalendar = Calendar.getInstance().apply { this.event = endDate }
@ -84,25 +173,23 @@ class EventRepository @Inject constructor(
"${CalendarContract.Events.DTSTART} ASC"
)
cursor?.use {
val idIdx = it.getColumnIndex(CalendarContract.Events._ID)
val titleIdx = it.getColumnIndex(CalendarContract.Events.TITLE)
val descIdx = it.getColumnIndex(CalendarContract.Events.DESCRIPTION)
val startIdx = it.getColumnIndex(CalendarContract.Events.DTSTART)
eventFlow.value = eventFlow.value.toMutableMap().also { events ->
cursor?.use {
val idIdx = it.getColumnIndex(CalendarContract.Events._ID)
val titleIdx = it.getColumnIndex(CalendarContract.Events.TITLE)
val descIdx = it.getColumnIndex(CalendarContract.Events.DESCRIPTION)
val startIdx = it.getColumnIndex(CalendarContract.Events.DTSTART)
while (it.moveToNext()) {
val event = factory.parseEvent(
id = it.getLong(idIdx),
title = it.getString(titleIdx) ?: "",
description = it.getString(descIdx) ?: "",
start = it.getLong(startIdx),
)
events.add(event)
while (it.moveToNext()) {
val event = factory.parseEvent(
id = it.getLong(idIdx),
title = it.getString(titleIdx) ?: "",
description = it.getString(descIdx) ?: "",
start = it.getLong(startIdx),
)
events[event.id] = event
}
}
}
flow.value = events
return events
}
}

View file

@ -0,0 +1,42 @@
package com.pixelized.headache.repository.event.factory
import android.icu.util.Calendar
import com.pixelized.headache.repository.event.Event
import com.pixelized.headache.utils.extention.event
import java.util.Date
import javax.inject.Inject
class EventFactory @Inject constructor(
private val pillFactory: PillFactory,
) {
private val calendar = Calendar.getInstance()
fun parseEvent(
start: Long,
id: Long,
title: String,
description: String,
): Event {
val date = calendar.apply { time = Date(start) }.event
val pills = description
.takeIf { it.isNotBlank() }
?.split("\n")
?.mapNotNull {
when {
it.isBlank() -> null
else -> pillFactory.parsePill(pill = it)
}
}
?: listOf()
return Event(
id = id,
title = title,
description = description,
date = date,
pills = pills,
isValid = pills.all { it.isValid },
isMisspelled = pills.any { it.isMisspelled },
)
}
}

View file

@ -0,0 +1,158 @@
package com.pixelized.headache.repository.event.factory
import com.pixelized.headache.repository.event.Event
import com.pixelized.headache.ui.theme.color.HeadacheColorPalette
import java.util.regex.Pattern
import javax.inject.Inject
class PillFactory @Inject constructor() {
private val amountSplitRegex = Pattern.compile("""\s[xX]\s*""")
@Throws(Exception::class)
fun parsePill(pill: String): Event.Pill {
val split = pill.split(regex = amountSplitRegex)
val label = split.getOrNull(0) ?: error("missing label")
val amount = split.getOrNull(1)?.trim()?.toIntOrNull() ?: 1
return when {
label == SPIFEN_400_LABEL -> {
Event.Pill(
id = Event.Pill.Id.SPIFEN_400,
label = SPIFEN_400_LABEL,
color = HeadacheColorPalette.Pill.Spifen400,
amount = amount,
description = pill,
isValid = true,
isMisspelled = false,
)
}
label.contains(ELETRIPTAN_40_LABEL, ignoreCase = true) -> {
Event.Pill(
id = Event.Pill.Id.ELETRIPTAN_40,
label = ELETRIPTAN_40_LABEL,
color = HeadacheColorPalette.Pill.Eletriptan40,
amount = amount,
description = pill,
isValid = true,
isMisspelled = false,
)
}
label.contains(IBUPROFENE_400_LABEL, ignoreCase = true) -> {
Event.Pill(
id = Event.Pill.Id.IBUPROFENE_400,
label = IBUPROFENE_400_LABEL,
color = HeadacheColorPalette.Pill.Ibuprofene400,
amount = amount,
description = pill,
isValid = true,
isMisspelled = false,
)
}
label.contains(PARACETAMOL_1000_LABEL, ignoreCase = true) -> {
Event.Pill(
id = Event.Pill.Id.PARACETAMOL_1000,
label = PARACETAMOL_1000_LABEL,
color = HeadacheColorPalette.Pill.Paracetamol1000,
amount = amount,
description = pill,
isValid = true,
isMisspelled = false,
)
}
SPIFEN_400_LIST.any { label.contains(it, ignoreCase = true) } -> {
Event.Pill(
id = Event.Pill.Id.SPIFEN_400,
label = SPIFEN_400_LABEL,
color = HeadacheColorPalette.Pill.Spifen400,
amount = amount,
description = pill,
isValid = true,
isMisspelled = true,
)
}
ELETRIPTAN_40_LIST.any { label.contains(it, ignoreCase = true) } -> {
Event.Pill(
id = Event.Pill.Id.ELETRIPTAN_40,
label = ELETRIPTAN_40_LABEL,
color = HeadacheColorPalette.Pill.Eletriptan40,
amount = amount,
description = pill,
isValid = true,
isMisspelled = true,
)
}
IBUPROFENE_400_LIST.any { label.contains(it, ignoreCase = true) } -> {
Event.Pill(
id = Event.Pill.Id.IBUPROFENE_400,
label = IBUPROFENE_400_LABEL,
color = HeadacheColorPalette.Pill.Ibuprofene400,
amount = amount,
description = pill,
isValid = true,
isMisspelled = true,
)
}
PARACETAMOL_1000_LIST.any { label.contains(it, ignoreCase = true) } -> {
Event.Pill(
id = Event.Pill.Id.PARACETAMOL_1000,
label = PARACETAMOL_1000_LABEL,
color = HeadacheColorPalette.Pill.Paracetamol1000,
amount = amount,
description = pill,
isValid = true,
isMisspelled = true,
)
}
else -> {
Event.Pill(
id = Event.Pill.Id.UNKNOWN,
label = "?",
color = HeadacheColorPalette.Pill.Unknown,
amount = amount,
description = pill,
isValid = false,
isMisspelled = false,
)
}
}
}
companion object {
private const val PARACETAMOL_1000_LABEL = "Paracétamol 1000"
private val PARACETAMOL_1000_LIST = listOf<String>(
"Doliprane 1000",
)
private const val IBUPROFENE_400_LABEL = "Ibuprofène 400"
private val IBUPROFENE_400_LIST = listOf(
"Ibuprofene 400",
"Ibuprofen 400",
"Ibuprofen",
)
private const val SPIFEN_400_LABEL = "Spifen 400"
private val SPIFEN_400_LIST = listOf<String>(
"Spifen",
)
private const val ELETRIPTAN_40_LABEL = "Élétriptan 40"
private val ELETRIPTAN_40_LIST = listOf(
"Eletriptan 40",
"Életriptan 40",
"Élitriptan 40",
"Elitriptan 40",
"Eletripan 40",
"Elétripan 40",
"Elitripan 40",
"Elitripan",
)
}
}

View file

@ -0,0 +1,42 @@
package com.pixelized.headache.ui.common.error
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.ui.platform.LocalContext
import com.pixelized.headache.ui.page.LocalSnack
import kotlinx.coroutines.flow.Flow
@Stable
sealed interface ErrorToast
@Stable
data class StringErrorMessage(
val message: String,
) : ErrorToast
@Stable
data class ResourceErrorMessage(
@StringRes val message: Int,
) : ErrorToast
@Composable
fun HandleErrorMessage(
context: Context = LocalContext.current,
snack: SnackbarHostState = LocalSnack.current,
errors: Flow<ErrorToast>,
) {
LaunchedEffect(Unit) {
errors.collect { message ->
snack.showSnackbar(
message = when (message) {
is StringErrorMessage -> message.message
is ResourceErrorMessage -> context.getString(message.message)
},
)
}
}
}

View file

@ -3,6 +3,7 @@ package com.pixelized.headache.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator
@ -19,12 +20,14 @@ val LocalNavigator = staticCompositionLocalOf<Navigator> {
@Composable
fun MainNavDisplay(
modifier: Modifier = Modifier,
navigator: Navigator,
) {
CompositionLocalProvider(
LocalNavigator provides navigator,
) {
NavDisplay(
modifier = modifier,
backStack = navigator.backStack,
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),

View file

@ -6,8 +6,8 @@ import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.entry
import com.pixelized.headache.repository.event.Event
import com.pixelized.headache.ui.navigation.Navigator
import com.pixelized.headache.ui.page.event.EventPage
import com.pixelized.headache.ui.page.event.EventViewModel
import com.pixelized.headache.ui.page.event.list.EventPage
import com.pixelized.headache.ui.page.event.list.EventViewModel
import com.pixelized.headache.utils.extention.event
import java.util.Date

View file

@ -1,14 +1,62 @@
package com.pixelized.headache.ui.page
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import com.pixelized.headache.ui.navigation.MainNavDisplay
import com.pixelized.headache.ui.navigation.Navigator
val LocalSnack = staticCompositionLocalOf<SnackbarHostState> {
error("Local SnackHost no yet ready")
}
val LocalErrorSnack = staticCompositionLocalOf<SnackbarHostState> {
error("Local SnackHost for Error no yet ready")
}
@Composable
fun MainPage(
navigator: Navigator,
snack: SnackbarHostState = remember { SnackbarHostState() },
errorSnack: SnackbarHostState = remember { SnackbarHostState() },
) {
MainNavDisplay(
navigator = navigator,
)
CompositionLocalProvider(
LocalSnack provides snack,
LocalErrorSnack provides errorSnack,
) {
Scaffold(
contentWindowInsets = remember { WindowInsets(0, 0, 0, 0) },
snackbarHost = {
SnackbarHost(
hostState = snack,
)
SnackbarHost(
hostState = errorSnack,
snackbar = {
Snackbar(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
actionContentColor = MaterialTheme.colorScheme.onError,
snackbarData = it,
)
}
)
},
content = { padding ->
MainNavDisplay(
modifier = Modifier.padding(paddingValues = padding),
navigator = navigator,
)
}
)
}
}

View file

@ -0,0 +1,243 @@
package com.pixelized.headache.ui.page.event.edit
import android.annotation.SuppressLint
import android.icu.text.DateFormat
import android.icu.text.SimpleDateFormat
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SheetState
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.headache.R
import com.pixelized.headache.ui.common.error.HandleErrorMessage
import com.pixelized.headache.ui.theme.HeadacheTheme
import com.pixelized.headache.utils.extention.capitalize
import kotlinx.coroutines.launch
import java.util.Date
import java.util.Locale
@Stable
data class EventEditBottomSheetUio(
val id: Long?,
val date: Date,
val pills: List<EventPillEditUio>,
)
@Stable
data object EventEditDefault {
@Stable
val padding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 16.dp)
@Stable
val spacing: DpSize = DpSize(width = 8.dp, height = 16.dp)
@SuppressLint("ConstantLocale")
@Stable
val formatter = SimpleDateFormat("EEEE dd MMMM yyyy", Locale.getDefault())
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventEditBottomSheet(
viewModel: EventEditBottomSheetViewModel = hiltViewModel(),
snack: SnackbarHostState = remember { SnackbarHostState() },
sheetState: SheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
),
onDismissRequest: () -> Unit,
) {
viewModel.data.value?.let { item ->
val scope = rememberCoroutineScope()
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismissRequest,
) {
Box(
contentAlignment = Alignment.BottomCenter,
) {
EventEditContent(
item = item,
onAddPill = viewModel::onAddPill,
onRemovePill = viewModel::onRemovePill,
onDelete = { scope.launch { viewModel.delete() } },
onConfirm = { scope.launch { viewModel.confirm() } },
)
SnackbarHost(
hostState = snack,
snackbar = {
Snackbar(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
actionContentColor = MaterialTheme.colorScheme.onError,
snackbarData = it,
)
}
)
}
}
}
HandleErrorMessage(
snack = snack,
errors = viewModel.errors
)
}
@Composable
private fun EventEditContent(
modifier: Modifier = Modifier,
padding: PaddingValues = EventEditDefault.padding,
spacing: DpSize = EventEditDefault.spacing,
formatter: DateFormat = EventEditDefault.formatter,
item: EventEditBottomSheetUio,
onAddPill: (EventPillEditUio) -> Unit,
onRemovePill: (EventPillEditUio) -> Unit,
onDelete: () -> Unit,
onConfirm: () -> Unit,
) {
Column(
modifier = Modifier
.padding(paddingValues = padding)
.then(other = modifier),
verticalArrangement = Arrangement.spacedBy(space = spacing.height),
) {
Text(
style = MaterialTheme.typography.titleLarge,
text = formatter.format(item.date).capitalize()
)
HorizontalDivider()
LazyColumn(
modifier = Modifier.weight(weight = 1f, fill = false),
) {
items(
items = item.pills
) {
EventPillEdit(
item = it,
onAdd = onAddPill,
onRemove = onRemovePill,
)
}
}
HorizontalDivider()
Row(
horizontalArrangement = Arrangement.spacedBy(space = spacing.width),
) {
if (item.id != null) {
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = onDelete,
) {
Text(
text = stringResource(R.string.action_delete)
)
}
}
Button(
modifier = Modifier.weight(2f),
onClick = onConfirm,
) {
Text(
text = stringResource(R.string.action_confirm)
)
}
}
}
}
@Composable
@Preview
private fun EventEditPreview(
@PreviewParameter(EventEditPreviewPreviewProvider::class) preview: EventEditBottomSheetUio,
) {
HeadacheTheme {
Surface {
val item = remember { mutableStateOf(preview) }
EventEditContent(
item = item.value,
onAddPill = { pill ->
item.value = item.value.let { event ->
val pills = event.pills.toMutableList()
val pillIndex = pills.indexOfFirst { it.id == pill.id }
pills[pillIndex] =
pills[pillIndex].let { pill -> pill.copy(amount = pill.amount + 1) }
event.copy(pills = pills)
}
},
onRemovePill = { pill ->
item.value = item.value.let { event ->
val pills = event.pills.toMutableList()
val pillIndex = pills.indexOfFirst { it.id == pill.id }
pills[pillIndex] =
pills[pillIndex].let { pill -> pill.copy(amount = pill.amount - 1) }
event.copy(pills = pills)
}
},
onDelete = { },
onConfirm = { },
)
}
}
}
private class EventEditPreviewPreviewProvider : PreviewParameterProvider<EventEditBottomSheetUio> {
override val values: Sequence<EventEditBottomSheetUio>
get() = sequenceOf(
EventEditBottomSheetUio(
id = null,
date = Date(),
pills = listOf(
EventPillEditUio(id = 0, label = "Paracétamol 1000", 0),
EventPillEditUio(id = 1, label = "Ibuprofène 400", 0),
EventPillEditUio(id = 2, label = "Spifen 400", 0),
EventPillEditUio(id = 3, label = "Élétriptan 40", 0),
)
),
EventEditBottomSheetUio(
id = 1,
date = Date(),
pills = listOf(
EventPillEditUio(id = 0, label = "Paracétamol 1000", 0),
EventPillEditUio(id = 1, label = "Ibuprofène 400", 0),
EventPillEditUio(id = 2, label = "Spifen 400", 2),
EventPillEditUio(id = 3, label = "Élétriptan 40", 1),
)
),
)
}

View file

@ -0,0 +1,126 @@
package com.pixelized.headache.ui.page.event.edit
import android.icu.util.Calendar
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.pixelized.headache.R
import com.pixelized.headache.repository.event.EventRepository
import com.pixelized.headache.ui.common.error.ErrorToast
import com.pixelized.headache.ui.common.error.ResourceErrorMessage
import com.pixelized.headache.utils.extention.event
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import javax.inject.Inject
import kotlin.math.max
@HiltViewModel
class EventEditBottomSheetViewModel @Inject constructor(
private val repository: EventRepository,
private val factory: EventEditFactory,
) : ViewModel() {
private val _data = mutableStateOf<EventEditBottomSheetUio?>(null)
val data: State<EventEditBottomSheetUio?> = _data
private val _errors = MutableSharedFlow<ErrorToast>()
val errors: SharedFlow<ErrorToast> = _errors
fun show(
eventId: Long? = null,
) {
val event = eventId?.let { id -> repository.event(id = id) }
_data.value = factory.convertToUio(event = event)
}
fun dismiss() {
_data.value = null
}
fun onAddPill(pill: EventPillEditUio) {
updatePill(
pill = pill,
delta = 1,
)
}
fun onRemovePill(pill: EventPillEditUio) {
updatePill(
pill = pill,
delta = -1,
)
}
fun updatePill(pill: EventPillEditUio, delta: Int) {
_data.value = _data.value?.let { data ->
val index = data.pills.indexOfFirst {
it.id == pill.id
}
val newPill = data.pills[index].copy(
amount = max(data.pills[index].amount + delta, 0)
)
data.copy(
pills = data.pills.toMutableList().also {
it[index] = newPill
}
)
}
}
suspend fun delete() {
val event = _data.value ?: return
val eventId = event.id ?: return
val success = repository.deleteEvent(eventId = eventId)
if (success) {
_data.value = null
} else {
val message = ResourceErrorMessage(message = R.string.error_edit_calendar)
_errors.emit(message)
}
}
suspend fun confirm() {
val event = _data.value ?: return
val date = Calendar.getInstance().apply { time = event.date }.event
val description = factory.convertToDescription(pills = event.pills)
val success = if (event.id == null) {
repository.createEvent(
title = "Céphalée",
description = description,
year = date.year,
month = date.month,
day = date.day,
)
} else {
repository.updateEvent(
id = event.id,
title = "Céphalée",
description = description,
year = date.year,
month = date.month,
day = date.day,
)
}
if (success) {
repository.fetchEvents(
startDate = Calendar.getInstance().apply {
time = event.date
add(Calendar.DAY_OF_MONTH, -1)
}.event,
endDate = Calendar.getInstance().apply {
time = event.date
add(Calendar.DAY_OF_MONTH, 1)
}.event,
)
_data.value = null
} else {
val message = ResourceErrorMessage(message = R.string.error_edit_calendar)
_errors.emit(message)
}
}
}

View file

@ -0,0 +1,100 @@
package com.pixelized.headache.ui.page.event.edit
import android.icu.util.Calendar
import com.pixelized.headache.repository.event.Event
import com.pixelized.headache.utils.extention.event
import java.util.Date
import javax.inject.Inject
class EventEditFactory @Inject constructor() {
fun convertToUio(
event: Event?,
): EventEditBottomSheetUio {
return when (event) {
null -> EventEditBottomSheetUio(
id = null,
date = Date(),
pills = listOf(
EventPillEditUio(
id = Event.Pill.Id.IBUPROFENE_400.value,
label = "Paracétamol 1000",
amount = 0,
),
EventPillEditUio(
id = Event.Pill.Id.PARACETAMOL_1000.value,
label = "Ibuprofène 400",
amount = 0,
),
EventPillEditUio(
id = Event.Pill.Id.SPIFEN_400.value,
label = "Spifen 400",
amount = 0,
),
EventPillEditUio(
id = Event.Pill.Id.ELETRIPTAN_40.value,
label = "Élétriptan 40",
amount = 0,
),
),
)
else -> {
val date = Calendar.getInstance().apply { this.event = event.date }.time
val ibuprofeneAmount = event.pills.sumOf {
if (it.id == Event.Pill.Id.IBUPROFENE_400) it.amount else 0
}
val paracetamolAmount = event.pills.sumOf {
if (it.id == Event.Pill.Id.PARACETAMOL_1000) it.amount else 0
}
val spifenAmount = event.pills.sumOf {
if (it.id == Event.Pill.Id.SPIFEN_400) it.amount else 0
}
val eletriptanAmount = event.pills.sumOf {
if (it.id == Event.Pill.Id.ELETRIPTAN_40) it.amount else 0
}
EventEditBottomSheetUio(
id = event.id,
date = date,
pills = listOf(
EventPillEditUio(
id = Event.Pill.Id.IBUPROFENE_400.value,
label = "Paracétamol 1000",
amount = ibuprofeneAmount,
),
EventPillEditUio(
id = Event.Pill.Id.PARACETAMOL_1000.value,
label = "Ibuprofène 400",
amount = paracetamolAmount,
),
EventPillEditUio(
id = Event.Pill.Id.SPIFEN_400.value,
label = "Spifen 400",
amount = spifenAmount,
),
EventPillEditUio(
id = Event.Pill.Id.ELETRIPTAN_40.value,
label = "Élétriptan 40",
amount = eletriptanAmount,
),
),
)
}
}
}
fun convertToDescription(
pills: List<EventPillEditUio>,
): String {
return pills
.mapNotNull { it.takeIf { it.amount > 0 } }
.joinToString(separator = "\n") {
when (it.amount) {
1 -> it.label
else -> "${it.label} x${it.amount}"
}
}
}
}

View file

@ -0,0 +1,159 @@
package com.pixelized.headache.ui.page.event.edit
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pixelized.headache.ui.theme.HeadacheTheme
@Stable
data class EventPillEditUio(
val id: Long,
val label: String,
val amount: Int,
)
@Stable
data object EventPillEditDefault {
@Stable
val padding: PaddingValues = PaddingValues(horizontal = 0.dp, vertical = 0.dp)
@Stable
val spacing: Dp = 4.dp
@Stable
val minAmountWidth: Dp = 32.dp
@Stable
val buttonOutShape: Shape = RoundedCornerShape(
topStart = 32.dp,
bottomStart = 32.dp,
topEnd = 4.dp,
bottomEnd = 4.dp
)
@Stable
val buttonInShape: Shape = RoundedCornerShape(
topEnd = 32.dp,
bottomEnd = 32.dp,
topStart = 4.dp,
bottomStart = 4.dp
)
}
@Composable
fun EventPillEdit(
modifier: Modifier = Modifier,
padding: PaddingValues = EventPillEditDefault.padding,
spacing: Dp = EventPillEditDefault.spacing,
minAmountWidth: Dp = EventPillEditDefault.minAmountWidth,
buttonOutShape: Shape = EventPillEditDefault.buttonOutShape,
buttonInShape: Shape = EventPillEditDefault.buttonInShape,
item: EventPillEditUio,
onAdd: (EventPillEditUio) -> Unit,
onRemove: (EventPillEditUio) -> Unit,
) {
Row(
modifier = Modifier
.padding(paddingValues = padding)
.then(other = modifier),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(space = spacing),
) {
Text(
modifier = Modifier.weight(weight = 1f),
style = MaterialTheme.typography.titleMedium,
text = item.label
)
IconButton(
onClick = { onRemove(item) },
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.onPrimary,
containerColor = MaterialTheme.colorScheme.primary,
),
shape = buttonOutShape,
) {
Icon(
imageVector = Icons.Default.Remove,
contentDescription = null,
)
}
AnimatedContent(
targetState = item.amount,
transitionSpec = {
val direction = if (targetState < initialState) -1 else 1
val enter = fadeIn() + slideInVertically { -direction * it }
val exit = fadeOut() + slideOutVertically { direction * it }
enter togetherWith exit using SizeTransform(clip = false)
},
) {
Text(
modifier = Modifier.widthIn(min = minAmountWidth),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
text = "$it",
)
}
IconButton(
onClick = { onAdd(item) },
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.onPrimary,
containerColor = MaterialTheme.colorScheme.primary,
),
shape = buttonInShape,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
)
}
}
}
@Composable
@Preview
private fun EventPillEditPreview() {
HeadacheTheme {
Surface {
EventPillEdit(
item = EventPillEditUio(
id = 0,
label = "Élétriptan 40",
amount = 2,
),
onAdd = { },
onRemove = { },
)
}
}
}

View file

@ -1,14 +1,16 @@
package com.pixelized.headache.ui.page.event
package com.pixelized.headache.ui.page.event.list
import android.annotation.SuppressLint
import android.icu.text.SimpleDateFormat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@ -27,6 +29,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pixelized.headache.ui.theme.HeadacheTheme
import com.pixelized.headache.ui.theme.color.HeadacheColorPalette
import java.util.Date
import java.util.Locale
@ -72,9 +75,12 @@ fun EventItem(
colorBoxPadding: PaddingValues = EventItemDefault.colorBoxPadding,
formatter: SimpleDateFormat = EventItemDefault.formatter,
item: EventItemUio,
onItem: (EventItemUio) -> Unit,
) {
Box(
modifier = Modifier
.clickable { onItem(item) }
.fillMaxWidth()
.heightIn(min = minHeigh)
.height(IntrinsicSize.Min)
.then(other = modifier),
@ -125,6 +131,7 @@ private fun EventItemPreview(
Surface {
EventItem(
item = preview,
onItem = { },
)
}
}
@ -141,6 +148,7 @@ private class EventPreviewProvider() : PreviewParameterProvider<EventItemUio> {
label = "Spifen 400",
amount = 1,
description = "Spifen 400",
color = HeadacheColorPalette.Additional.Pink,
isValid = true,
isMisspelled = false,
),
@ -148,6 +156,7 @@ private class EventPreviewProvider() : PreviewParameterProvider<EventItemUio> {
label = "Élétriptan 40",
amount = 1,
description = "Élétriptan 40",
color = HeadacheColorPalette.Additional.Purple,
isValid = true,
isMisspelled = false,
),
@ -170,6 +179,7 @@ private class EventPreviewProvider() : PreviewParameterProvider<EventItemUio> {
label = "Spifen 400",
amount = 1,
description = "Spifen 400",
color = HeadacheColorPalette.Additional.Yellow,
isValid = true,
isMisspelled = false,
),
@ -177,6 +187,7 @@ private class EventPreviewProvider() : PreviewParameterProvider<EventItemUio> {
label = "Élétriptan 40",
amount = 1,
description = "Élitriptan 40",
color = HeadacheColorPalette.Additional.Purple,
isValid = true,
isMisspelled = true,
),
@ -192,6 +203,7 @@ private class EventPreviewProvider() : PreviewParameterProvider<EventItemUio> {
label = "Spifen 400",
amount = 1,
description = "Spifen 400",
color = HeadacheColorPalette.Additional.Yellow,
isValid = true,
isMisspelled = false,
),
@ -199,6 +211,7 @@ private class EventPreviewProvider() : PreviewParameterProvider<EventItemUio> {
label = "?",
amount = 1,
description = "Élitriptan 40",
color = HeadacheColorPalette.Additional.Purple,
isValid = false,
isMisspelled = false,
),

View file

@ -1,4 +1,4 @@
package com.pixelized.headache.ui.page.event
package com.pixelized.headache.ui.page.event.list
import android.icu.util.Calendar
import com.pixelized.headache.repository.event.Event
@ -10,7 +10,7 @@ class EventItemFactory @Inject constructor() {
private val calendar = Calendar.getInstance()
fun convertToUio(
events: List<Event>,
events: Collection<Event>,
): List<EventItemUio> {
return events
.sortedByDescending { it.date }
@ -38,6 +38,7 @@ class EventItemFactory @Inject constructor() {
label = pill.label,
amount = pill.amount,
description = pill.description,
color = pill.color,
isValid = pill.isValid,
isMisspelled = pill.isMisspelled,
)

View file

@ -1,6 +1,10 @@
package com.pixelized.headache.ui.page.event
package com.pixelized.headache.ui.page.event.list
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
@ -8,40 +12,56 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.pixelized.headache.R
import com.pixelized.headache.ui.navigation.LocalNavigator
import com.pixelized.headache.ui.page.event.edit.EventEditBottomSheet
import com.pixelized.headache.ui.page.event.edit.EventEditBottomSheetViewModel
import com.pixelized.headache.ui.theme.HeadacheTheme
import com.pixelized.headache.ui.theme.color.HeadacheColorPalette
import java.util.Date
@Stable
data object EventPageDefault {
@Stable
val padding: PaddingValues = PaddingValues(vertical = 8.dp)
val listPadding: PaddingValues = PaddingValues(bottom = 56.dp + 16.dp * 2)
@Stable
val spacing: Dp = 0.dp
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventPage(
viewModel: EventViewModel = hiltViewModel(),
editViewModel: EventEditBottomSheetViewModel = hiltViewModel(),
) {
val navigation = LocalNavigator.current
val events = viewModel.events.collectAsStateWithLifecycle()
@ -56,6 +76,13 @@ fun EventPage(
onBack = { navigation.popBackstack() },
onMisspelledFilter = viewModel::toggleMisspelledFilter,
onInvalidFilter = viewModel::toggleErrorFilter,
onAddEvent = { editViewModel.show() },
onEvent = { editViewModel.show(eventId = it.id) }
)
EventEditBottomSheet(
viewModel = editViewModel,
onDismissRequest = { editViewModel.dismiss() },
)
}
@ -63,7 +90,7 @@ fun EventPage(
@OptIn(ExperimentalMaterial3Api::class)
private fun EventContent(
modifier: Modifier = Modifier,
padding: PaddingValues = EventPageDefault.padding,
listPadding: PaddingValues = EventPageDefault.listPadding,
spacing: Dp = EventPageDefault.spacing,
invalidFilter: State<Boolean>,
misspelledFilter: State<Boolean>,
@ -71,6 +98,8 @@ private fun EventContent(
onBack: () -> Unit,
onInvalidFilter: () -> Unit,
onMisspelledFilter: () -> Unit,
onAddEvent: () -> Unit,
onEvent: (EventItemUio) -> Unit,
) {
Scaffold(
modifier = modifier,
@ -126,28 +155,104 @@ private fun EventContent(
}
)
},
floatingActionButton = {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.primary,
shape = CircleShape,
onClick = onAddEvent,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
)
}
},
content = { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues = paddingValues)
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(space = spacing),
contentPadding = padding,
reverseLayout = true,
contentPadding = listPadding,
) {
items(
items = events.value,
key = { it.id },
contentType = { "EventItem" },
) { item ->
EventItem(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
item = item,
)
AnimatedContent(
modifier = Modifier.animateItem(),
targetState = item,
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { animatedItem ->
EventItem(
modifier = Modifier.fillMaxWidth(),
item = animatedItem,
onItem = onEvent,
)
}
}
}
}
)
}
@Composable
@Preview
private fun EventContentPreview(
@PreviewParameter(EventContentPreviewProvider::class) preview: List<EventItemUio>,
) {
HeadacheTheme {
Surface {
EventContent(
invalidFilter = remember { mutableStateOf(false) },
misspelledFilter = remember { mutableStateOf(false) },
events = remember { mutableStateOf(preview) },
onBack = { },
onInvalidFilter = { },
onMisspelledFilter = { },
onAddEvent = { },
onEvent = { },
)
}
}
}
private class EventContentPreviewProvider : PreviewParameterProvider<List<EventItemUio>> {
override val values: Sequence<List<EventItemUio>>
get() = sequenceOf(
listOf(
EventItemUio(
id = 0,
date = Date(),
pills = emptyList(),
isValid = true,
isMisspelled = false,
),
EventItemUio(
id = 1,
date = Date(),
pills = listOf(
PillUio(
label = "Spifen 400",
amount = 1,
description = "Spifen 400",
color = HeadacheColorPalette.Additional.Yellow,
isValid = true,
isMisspelled = false,
),
PillUio(
label = "Élétriptan 40",
amount = 1,
description = "Élitriptan 40",
color = HeadacheColorPalette.Additional.Purple,
isValid = true,
isMisspelled = true,
),
),
isValid = true,
isMisspelled = false,
),
),
)
}

View file

@ -1,4 +1,4 @@
package com.pixelized.headache.ui.page.event
package com.pixelized.headache.ui.page.event.list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -16,14 +16,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
@HiltViewModel(assistedFactory = EventViewModel.Factory::class)
class EventViewModel @AssistedInject constructor(
googleCalendarIdRepository: GoogleCalendarIdRepository,
eventRepository: EventRepository,
eventItemFactory: EventItemFactory,
@Assisted val argument: EventDestination,
@ -36,43 +33,36 @@ class EventViewModel @AssistedInject constructor(
val invalidFilter: StateFlow<Boolean> = invalidFilterFlow
@OptIn(ExperimentalCoroutinesApi::class)
val events: StateFlow<List<EventItemUio>> = googleCalendarIdRepository.calendarId
.flatMapLatest { id: Long? ->
when (id) {
null -> flowOf(emptyList())
else -> {
combine(
when (argument.date) {
null -> eventRepository.eventFlow(calendarId = id)
else -> eventRepository.eventFlow(calendarId = id)
.mapLatest { events ->
events.filter { event ->
event.date isSameMonth argument.date
}
}
},
misspelledFilterFlow,
invalidFilterFlow,
) { events, misspelled, invalid ->
if (!misspelled && !invalid) {
events
} else {
events.filter {
misspelled && it.isMisspelled || invalid && it.isValid.not()
}
}
val events: StateFlow<List<EventItemUio>> = combine(
when (argument.date) {
null -> eventRepository.eventsListFlow()
else -> eventRepository.eventsListFlow()
.mapLatest { events ->
events.filter { event ->
event.date isSameMonth argument.date
}
}
},
misspelledFilterFlow,
invalidFilterFlow,
transform = { events, misspelled, invalid ->
if (!misspelled && !invalid) {
events
} else {
events.filter {
misspelled && it.isMisspelled || invalid && it.isValid.not()
}
}
}
.mapLatest { events: List<Event> ->
eventItemFactory.convertToUio(events = events)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = emptyList(),
).mapLatest { events: Collection<Event> ->
eventItemFactory.convertToUio(
events = events,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = emptyList(),
)
fun toggleMisspelledFilter() {
misspelledFilterFlow.value = !misspelledFilterFlow.value

View file

@ -1,13 +1,18 @@
package com.pixelized.headache.ui.page.event
package com.pixelized.headache.ui.page.event.list
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -15,12 +20,14 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pixelized.headache.ui.theme.HeadacheTheme
import com.pixelized.headache.ui.theme.color.HeadacheColorPalette
@Stable
data class PillUio(
val label: String,
val amount: Int,
val description: String,
val color: Color,
val isValid: Boolean,
val isMisspelled: Boolean,
)
@ -44,7 +51,18 @@ fun PillItem(
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.typography.bodyMedium,
text = "- ${item.label}: ${item.amount}",
text = "-",
)
Box(
modifier = Modifier
.align(alignment = Alignment.CenterVertically)
.size(12.dp)
.background(color = item.color)
)
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.typography.bodyMedium,
text = "${item.label}: ${item.amount}",
)
Text(
modifier = Modifier.alignByBaseline(),
@ -80,6 +98,7 @@ private class PillPreviewProvider : PreviewParameterProvider<PillUio> {
label = "Élétriptan 40",
amount = 2,
description = "Élétriptan 400 x2",
color = HeadacheColorPalette.Additional.Purple,
isValid = true,
isMisspelled = false,
),
@ -87,6 +106,7 @@ private class PillPreviewProvider : PreviewParameterProvider<PillUio> {
label = "Élétriptan 400",
amount = 2,
description = "Élitriptan 400 x2",
color = HeadacheColorPalette.Additional.Purple,
isValid = true,
isMisspelled = true,
),
@ -94,6 +114,7 @@ private class PillPreviewProvider : PreviewParameterProvider<PillUio> {
label = "?",
amount = 2,
description = "Ilitriptan 400 x2",
color = HeadacheColorPalette.Additional.Purple,
isValid = false,
isMisspelled = false,
),

View file

@ -1,17 +1,25 @@
package com.pixelized.headache.ui.page.summary
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -21,8 +29,12 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -30,32 +42,42 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.pixelized.headache.R
import com.pixelized.headache.ui.navigation.LocalNavigator
import com.pixelized.headache.ui.navigation.destination.navigateToEventPage
import com.pixelized.headache.ui.page.event.edit.EventEditBottomSheet
import com.pixelized.headache.ui.page.event.edit.EventEditBottomSheetViewModel
import com.pixelized.headache.ui.page.summary.item.MonthSummaryBox
import com.pixelized.headache.ui.page.summary.item.MonthSummaryBoxUio
import com.pixelized.headache.ui.page.summary.item.MonthSummaryCell
import com.pixelized.headache.ui.page.summary.item.MonthSummaryItem
import com.pixelized.headache.ui.page.summary.item.MonthSummaryItemUio
import com.pixelized.headache.ui.page.summary.item.MonthSummaryTitle
import com.pixelized.headache.ui.page.summary.item.MonthSummaryTitleUio
import com.pixelized.headache.ui.theme.HeadacheTheme
import com.pixelized.headache.ui.theme.color.HeadacheColorPalette
import java.util.Date
@Stable
data object MonthSummaryPageDefault {
@Stable
val padding: PaddingValues = PaddingValues(vertical = 8.dp)
val listPadding: PaddingValues = PaddingValues(bottom = 56.dp + 16.dp * 2)
@Stable
val spacing: Dp = 0.dp
}
@Stable
sealed interface MonthSummaryItem {
val date: Date
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MonthSummaryPage(
viewModel: MonthSummaryViewModel = hiltViewModel(),
editViewModel: EventEditBottomSheetViewModel = hiltViewModel(),
) {
val navigation = LocalNavigator.current
val boxMode = viewModel.boxMode.collectAsStateWithLifecycle()
val events = viewModel.events.collectAsStateWithLifecycle()
MonthSummaryContent(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.systemBarsPadding()
.fillMaxSize(),
events = events,
boxMode = boxMode,
onBack = {
@ -66,7 +88,15 @@ fun MonthSummaryPage(
},
onItem = {
navigation.navigateToEventPage(date = it.date)
}
},
onAddEvent = {
editViewModel.show()
},
)
EventEditBottomSheet(
viewModel = editViewModel,
onDismissRequest = { editViewModel.dismiss() },
)
}
@ -75,12 +105,13 @@ fun MonthSummaryPage(
private fun MonthSummaryContent(
modifier: Modifier = Modifier,
spacing: Dp = MonthSummaryPageDefault.spacing,
padding: PaddingValues = MonthSummaryPageDefault.padding,
listPadding: PaddingValues = MonthSummaryPageDefault.listPadding,
boxMode: State<Boolean>,
events: State<List<MonthSummaryItem>>,
events: State<Map<MonthSummaryCell, List<MonthSummaryCell>>>,
onBack: () -> Unit,
onDisplay: () -> Unit,
onItem: (MonthSummaryItem) -> Unit,
onItem: (MonthSummaryCell) -> Unit,
onAddEvent: () -> Unit,
) {
Scaffold(
modifier = modifier,
@ -100,56 +131,65 @@ private fun MonthSummaryContent(
Text(text = stringResource(R.string.month_summary_title))
},
actions = {
val color = animateColorAsState(
when (boxMode.value) {
true -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface
}
)
IconButton(
onClick = onDisplay,
AnimatedContent(
targetState = boxMode.value,
transitionSpec = { fadeIn() togetherWith fadeOut() },
) {
Icon(
imageVector = Icons.Default.BarChart,
tint = color.value,
contentDescription = null,
)
IconButton(
onClick = onDisplay,
) {
Icon(
imageVector = when (it) {
true -> Icons.AutoMirrored.Filled.Article
else -> Icons.Default.BarChart
},
contentDescription = null,
)
}
}
}
)
},
floatingActionButton = {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.primary,
shape = CircleShape,
onClick = onAddEvent,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
)
}
},
content = { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues = paddingValues)
.fillMaxSize(),
contentPadding = padding,
contentPadding = listPadding,
verticalArrangement = Arrangement.spacedBy(space = spacing),
reverseLayout = true,
reverseLayout = false,
) {
items(
items = events.value,
key = { it.date },
contentType = {
when (it) {
is MonthSummaryBoxUio -> "MonthSummaryBoxUio"
is MonthSummaryItemUio -> "MonthSummaryItemUio"
}
},
) { item ->
when (item) {
is MonthSummaryBoxUio -> MonthSummaryBox(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
item = item,
events.value.forEach { entry ->
stickyHeader {
MonthSummaryCell(
item = entry.key,
onItem = onItem,
)
is MonthSummaryItemUio -> MonthSummaryItem(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
}
items(
items = entry.value,
key = { it.date },
contentType = {
when (it) {
is MonthSummaryBoxUio -> "MonthSummaryBoxUio"
is MonthSummaryItemUio -> "MonthSummaryItemUio"
is MonthSummaryTitleUio -> "MonthSummaryTitleUio"
}
},
) { item ->
MonthSummaryCell(
item = item,
onItem = onItem,
)
@ -158,4 +198,81 @@ private fun MonthSummaryContent(
}
}
)
}
@Composable
private fun MonthSummaryCell(
item: MonthSummaryCell,
onItem: (MonthSummaryCell) -> Unit,
) {
AnimatedContent(
modifier = Modifier.fillMaxWidth(),
targetState = item,
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { item ->
when (item) {
is MonthSummaryTitleUio -> MonthSummaryTitle(
item = item,
)
is MonthSummaryBoxUio -> MonthSummaryBox(
item = item,
onItem = onItem,
)
is MonthSummaryItemUio -> MonthSummaryItem(
item = item,
onItem = onItem,
)
}
}
}
@Composable
@Preview
private fun MonthSummaryPreview() {
HeadacheTheme {
MonthSummaryContent(
boxMode = remember { mutableStateOf(false) },
events = remember {
mutableStateOf(
mapOf(
MonthSummaryTitleUio(
date = Date(),
) to listOf(
MonthSummaryItemUio(
date = Date(),
days = 8,
pills = listOf(
MonthSummaryPillItemUio(
label = "Spifen 400",
amount = 4,
color = HeadacheColorPalette.Pill.Spifen400,
),
MonthSummaryPillItemUio(
label = "Élétriptan 40",
amount = 2,
color = HeadacheColorPalette.Pill.Eletriptan40,
),
),
),
MonthSummaryBoxUio(
date = Date(),
headacheRatio = 8f / 30f,
headacheAmount = 8,
headacheColor = Color.Red,
pillRatio = 6f / 20f,
pillAmount = 6,
pillColor = Color.Blue,
),
),
)
)
},
onBack = { },
onDisplay = { },
onItem = { },
onAddEvent = { },
)
}
}

View file

@ -0,0 +1,88 @@
package com.pixelized.headache.ui.page.summary
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pixelized.headache.ui.theme.HeadacheTheme
import com.pixelized.headache.ui.theme.color.HeadacheColorPalette
@Stable
data class MonthSummaryPillItemUio(
val label: String,
val amount: Int,
val color: Color,
)
@Stable
data object MonthSummaryPillItemDefault {
@Stable
val padding: PaddingValues = PaddingValues(0.dp)
@Stable
val colorBoxSize: Dp = 8.dp
@Stable
val spacing: Dp = 4.dp
}
@Composable
fun MonthSummaryPillItem(
modifier: Modifier = Modifier,
padding: PaddingValues = MonthSummaryPillItemDefault.padding,
spacing: Dp = MonthSummaryPillItemDefault.spacing,
colorBoxSize: Dp = MonthSummaryPillItemDefault.colorBoxSize,
item: MonthSummaryPillItemUio,
) {
Row(
modifier = Modifier
.padding(paddingValues = padding)
.then(other = modifier),
horizontalArrangement = Arrangement.spacedBy(space = spacing),
) {
Text(
style = MaterialTheme.typography.bodyMedium,
text = "-",
)
Box(
modifier = Modifier
.align(alignment = Alignment.CenterVertically)
.size(size = colorBoxSize)
.background(color = item.color)
)
Text(
style = MaterialTheme.typography.bodyMedium,
text = "${item.label} : ${item.amount}",
)
}
}
@Composable
@Preview
private fun MonthSummaryPillItemPreview() {
HeadacheTheme {
Surface {
MonthSummaryPillItem(
item = MonthSummaryPillItemUio(
label = "Élétriptan 40",
amount = 1,
color = HeadacheColorPalette.Pill.Eletriptan40,
)
)
}
}
}

View file

@ -2,23 +2,22 @@ package com.pixelized.headache.ui.page.summary
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.headache.repository.calendar.GoogleCalendarIdRepository
import com.pixelized.headache.repository.event.Event
import com.pixelized.headache.repository.event.EventRepository
import com.pixelized.headache.ui.page.summary.item.MonthSummaryCell
import com.pixelized.headache.ui.page.summary.item.MonthSummaryFactory
import com.pixelized.headache.ui.page.summary.item.MonthSummaryTitleUio
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class MonthSummaryViewModel @Inject constructor(
googleCalendarIdRepository: GoogleCalendarIdRepository,
eventRepository: EventRepository,
eventItemFactory: MonthSummaryFactory,
) : ViewModel() {
@ -27,23 +26,20 @@ class MonthSummaryViewModel @Inject constructor(
val boxMode: StateFlow<Boolean> = displayTypeFlow
@OptIn(ExperimentalCoroutinesApi::class)
val events: StateFlow<List<MonthSummaryItem>> = googleCalendarIdRepository.calendarId
.flatMapLatest { id: Long? ->
when (id) {
null -> flowOf(emptyList())
else -> eventRepository.eventFlow(calendarId = id)
}
}.combine(displayTypeFlow) { events: List<Event>, display ->
val events: StateFlow<Map<MonthSummaryCell, List<MonthSummaryCell>>> = combine(
eventRepository.eventsListFlow(),
displayTypeFlow,
transform = { events: Collection<Event>, display ->
when (display) {
true -> eventItemFactory.convertToBoxUio(events = events)
else -> eventItemFactory.convertToItemUio(events = events)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = emptyList(),
)
).stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = emptyMap(),
)
fun toggleDisplay() {
displayTypeFlow.value = displayTypeFlow.value.not()

View file

@ -1,4 +1,4 @@
package com.pixelized.headache.ui.page.summary
package com.pixelized.headache.ui.page.summary.item
import android.annotation.SuppressLint
import android.icu.text.SimpleDateFormat
@ -30,6 +30,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.pixelized.headache.ui.theme.HeadacheTheme
import com.pixelized.headache.utils.extention.capitalize
import java.util.Date
import java.util.Locale
@ -42,7 +43,7 @@ data object MonthSummaryBoxDefault {
val spacing: DpSize = DpSize(width = 8.dp, height = 4.dp)
@Stable
val labelWidth: Dp = 64.dp + 16.dp
val labelWidth: Dp = 32.dp + 16.dp
@Stable
val boxHeight: Dp = 16.dp
@ -52,7 +53,7 @@ data object MonthSummaryBoxDefault {
@SuppressLint("ConstantLocale")
@Stable
val formatter = SimpleDateFormat("MMM yyyy", Locale.getDefault())
val formatter = SimpleDateFormat("MMM", Locale.getDefault())
}
@Stable
@ -64,7 +65,7 @@ data class MonthSummaryBoxUio(
val pillRatio: Float?,
val pillAmount: Int,
val pillColor: Color,
) : MonthSummaryItem
) : MonthSummaryCell
@Composable
fun MonthSummaryBox(
@ -90,7 +91,7 @@ fun MonthSummaryBox(
modifier = Modifier.width(width = labelWidth),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.End,
text = formatter.format(item.date),
text = formatter.format(item.date).capitalize(),
)
Column(
verticalArrangement = Arrangement.spacedBy(space = spacing.height),

View file

@ -0,0 +1,9 @@
package com.pixelized.headache.ui.page.summary.item
import androidx.compose.runtime.Stable
import java.util.Date
@Stable
sealed interface MonthSummaryCell {
val date: Date
}

View file

@ -1,34 +1,50 @@
package com.pixelized.headache.ui.page.summary
package com.pixelized.headache.ui.page.summary.item
import android.icu.util.Calendar
import androidx.compose.ui.graphics.Color
import com.pixelized.headache.repository.event.Event
import com.pixelized.headache.ui.page.summary.MonthSummaryPillItemUio
import com.pixelized.headache.utils.extention.event
import javax.inject.Inject
import kotlin.math.max
import kotlin.reflect.KClass
class MonthSummaryFactory @Inject constructor() {
private val calendar = Calendar.getInstance()
fun convertToItemUio(
events: List<Event>,
): List<MonthSummaryItemUio> {
events: Collection<Event>,
): Map<MonthSummaryCell, List<MonthSummaryCell>> {
return events
.fold(hashMapOf<Event.Date, MutableList<Event>>()) { acc, event ->
acc.also { it.getOrPut(event.date.copy(day = 1)) { mutableListOf() }.add(event) }
}
.mapKeys { entry ->
.map { entry ->
val pills = entry.value
.fold(hashMapOf<String, Int>()) { acc, event ->
.fold(hashMapOf<Event.Pill.Id, EventFolder>()) { acc, event ->
event.pills.forEach { pill ->
acc[pill.label] = acc.getOrDefault(pill.label, 0) + pill.amount
val value = acc.getOrElse(
key = pill.id,
defaultValue = {
EventFolder(
label = pill.label,
amount = 0,
color = pill.color,
)
},
)
value.amount += 1
acc[pill.id] = value
}
acc
}
.mapKeys {
it.key to it.value
.map { entry ->
MonthSummaryPillItemUio(
label = entry.value.label,
color = entry.value.color,
amount = entry.value.amount,
)
}
.keys
.toList()
MonthSummaryItemUio(
@ -37,14 +53,13 @@ class MonthSummaryFactory @Inject constructor() {
pills = pills,
)
}
.keys
.toList()
.sortedByDescending { it.date }
.groupByMonth()
}
fun convertToBoxUio(
events: List<Event>,
): List<MonthSummaryBoxUio> {
events: Collection<Event>,
): Map<MonthSummaryCell, List<MonthSummaryCell>> {
var maxPillAmount = 0
return events
.fold(hashMapOf<Event.Date, MutableList<Event>>()) { acc, event ->
@ -57,9 +72,10 @@ class MonthSummaryFactory @Inject constructor() {
)
entry.key
}
.mapKeys { entry ->
val pillAmount =
entry.value.sumOf { events -> events.pills.sumOf { pill -> pill.amount } }
.map { entry ->
val pillAmount = entry.value.sumOf { events ->
events.pills.sumOf { pill -> pill.amount }
}
val monthMaxDay = calendar.apply {
event = entry.key
add(Calendar.MONTH, 1)
@ -77,8 +93,25 @@ class MonthSummaryFactory @Inject constructor() {
pillColor = Color.Blue,
)
}
.keys
.toList()
.sortedByDescending { it.date }
.groupByMonth()
}
fun List<MonthSummaryCell>.groupByMonth(): Map<MonthSummaryCell, List<MonthSummaryCell>> {
return this.groupBy {
MonthSummaryTitleUio(
date = calendar.apply {
time = it.date
set(Calendar.DAY_OF_YEAR, 1)
set(Calendar.MILLISECONDS_IN_DAY, 0)
}.time,
)
}
}
private class EventFolder(
val label: String,
val color: Color,
var amount: Int,
)
}

View file

@ -1,4 +1,4 @@
package com.pixelized.headache.ui.page.summary
package com.pixelized.headache.ui.page.summary.item
import android.annotation.SuppressLint
import android.icu.text.SimpleDateFormat
@ -20,7 +20,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.pixelized.headache.ui.page.summary.MonthSummaryPillItem
import com.pixelized.headache.ui.page.summary.MonthSummaryPillItemUio
import com.pixelized.headache.ui.theme.HeadacheTheme
import com.pixelized.headache.ui.theme.color.HeadacheColorPalette
import com.pixelized.headache.utils.extention.capitalize
import java.util.Date
import java.util.Locale
@ -28,8 +32,8 @@ import java.util.Locale
data class MonthSummaryItemUio(
override val date: Date,
val days: Int,
val pills: List<Pair<String, Int>>,
) : MonthSummaryItem
val pills: List<MonthSummaryPillItemUio>,
) : MonthSummaryCell
@Stable
object MonthSummaryItemDefault {
@ -42,7 +46,7 @@ object MonthSummaryItemDefault {
@SuppressLint("ConstantLocale")
@Stable
val formatter = SimpleDateFormat("MMMM yyyy", Locale.getDefault())
val formatter = SimpleDateFormat("MMMM", Locale.getDefault())
}
@Composable
@ -83,20 +87,14 @@ fun MonthSummaryItem(
}
Column {
item.pills.forEach {
Text(
style = MaterialTheme.typography.bodyMedium,
text = "- ${it.first} : ${it.second}",
MonthSummaryPillItem(
item = it,
)
}
}
}
}
@Composable
@Stable
fun String.capitalize() =
replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
@Composable
@Preview
private fun MonthSummaryItemPreview(
@ -120,10 +118,26 @@ private class PreviewProvider() : PreviewParameterProvider<MonthSummaryItemUio>
date = Date(),
days = 11,
pills = listOf(
"Paracétamol 1000" to 2,
"Ibuprofen 400" to 4,
"Spifen 400" to 6,
"Eletripan 40" to 0,
MonthSummaryPillItemUio(
label = "Paracétamol 1000",
amount = 2,
color = HeadacheColorPalette.Pill.Paracetamol1000,
),
MonthSummaryPillItemUio(
label = "Ibuprofen 400",
amount = 4,
color = HeadacheColorPalette.Pill.Ibuprofene400,
),
MonthSummaryPillItemUio(
label = "Spifen 400",
amount = 6,
color = HeadacheColorPalette.Pill.Spifen400,
),
MonthSummaryPillItemUio(
label = "Eletripan 40",
amount = 0,
color = HeadacheColorPalette.Pill.Eletriptan40,
),
),
)
)

View file

@ -0,0 +1,71 @@
package com.pixelized.headache.ui.page.summary.item
import android.annotation.SuppressLint
import android.icu.text.SimpleDateFormat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.dp
import java.util.Date
import java.util.Locale
@Stable
data class MonthSummaryTitleUio(
override val date: Date,
) : MonthSummaryCell
@Stable
object MonthSummaryTitleDefault {
@Stable
val padding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
@SuppressLint("ConstantLocale")
@Stable
val formatter = SimpleDateFormat("yyyy", Locale.getDefault())
}
val backgroundBrush: Brush
@Composable
@Stable
get() {
val colorScheme = MaterialTheme.colorScheme
return remember(colorScheme) {
Brush.verticalGradient(
listOf(
colorScheme.surface,
colorScheme.surface,
colorScheme.surface,
colorScheme.surface.copy(alpha = 0f),
)
)
}
}
@Composable
fun MonthSummaryTitle(
modifier: Modifier = Modifier,
padding: PaddingValues = MonthSummaryTitleDefault.padding,
formatter: SimpleDateFormat = MonthSummaryTitleDefault.formatter,
item: MonthSummaryTitleUio,
) {
Box(
modifier = Modifier
.background(brush = backgroundBrush)
.then(other = modifier),
) {
Text(
modifier = Modifier.padding(paddingValues = padding),
style = MaterialTheme.typography.titleLarge,
text = formatter.format(item.date)
)
}
}

View file

@ -1,11 +0,0 @@
package com.pixelized.headache.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View file

@ -1,57 +1,42 @@
package com.pixelized.headache.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import com.pixelized.headache.ui.theme.color.HeadacheColors
import com.pixelized.headache.ui.theme.color.headacheDarkColorScheme
import com.pixelized.headache.ui.theme.color.headacheLightColorScheme
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
val LocalLwaTheme = compositionLocalOf<HeadacheTheme> {
error("Local Snack Controller is not yet ready")
}
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
val MaterialTheme.headache: HeadacheTheme
@Stable
@Composable
@ReadOnlyComposable
get() = LocalLwaTheme.current
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
@Stable
data class HeadacheTheme(
val colorScheme: HeadacheColors,
)
@Composable
fun HeadacheTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
darkTheme -> headacheDarkColorScheme()
else -> headacheLightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
colorScheme = colorScheme.base,
typography = Typography,
content = content
)

View file

@ -0,0 +1,60 @@
package com.pixelized.headache.ui.theme.color
import androidx.compose.ui.graphics.Color
import javax.annotation.concurrent.Immutable
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@Immutable
object HeadacheColorPalette {
@Immutable
object Pill {
val Unknown = Additional.Red
val Ibuprofene400 = Additional.Blue
val Paracetamol1000 = Additional.Green
val Spifen400 = Additional.Yellow
val Eletriptan40 = Additional.Pink
}
@Immutable
object Additional {
val VeryDarkBlue: Color = Color(0xFF09179D)
val DarkBlue: Color = Color(0xFF1A2BDB)
val MediumBlue: Color = Color(0xFF1523A2)
val Blue: Color = Color(0xFF2970F2)
val LightBlue: Color = Color(0xFF1A91DB)
val VeryLightBlue: Color = Color(0xFF1EDDEF)
val VeryDarkPurple: Color = Color(0xFF5F0E9E)
val DarkPurple: Color = Color(0xFF8330DB)
val Purple: Color = Color(0xFF9B54C3)
val LightPurple: Color = Color(0xFFBC52D9)
val VeryLightPurple: Color = Color(0xFFC856D1)
val VeryDarkGreen: Color = Color(0xFF16544A)
val DarkGreen: Color = Color(0xFF207A6B)
val Green: Color = Color(0xFF269482)
val LightGreen: Color = Color(0xFF2AA18D)
val VeryLightGreen: Color = Color(0xFF3AE0C5)
val VeryDarkRed: Color = Color(0xFF631221)
val DarkRed: Color = Color(0xFFA21D36)
val Red: Color = Color(0xFFC92443)
val LightRed: Color = Color(0xFFE32849)
val VeryLightRed: Color = Color(0xFFF02B4F)
val VeryDarkPink: Color = Color(0xFF960064)
val DarkPink: Color = Color(0xFFBD007E)
val Pink: Color = Color(0xFFD6008F)
val LightPink: Color = Color(0xFFE35BB5)
val VeryLightPink: Color = Color(0xFFFF66CC)
val VeryDarkYellow: Color = Color(0xFFB76036)
val DarkYellow: Color = Color(0xFFD48341)
val Yellow: Color = Color(0xFFF3A850)
val LightYellow: Color = Color(0xFFF5BF63)
val VeryLightYellow: Color = Color(0xFFF9D679)
}
}

View file

@ -0,0 +1,58 @@
package com.pixelized.headache.ui.theme.color
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.ln
@Stable
data class HeadacheColors(
val base: ColorScheme,
)
@Composable
@Stable
fun headacheDarkColorScheme(
base: ColorScheme = darkColorScheme(),
) = HeadacheColors(
base = base,
)
@Composable
@Stable
fun headacheLightColorScheme(
base: ColorScheme = lightColorScheme(),
) = HeadacheColors(
base = base,
)
@ReadOnlyComposable
@Composable
fun calculateElevatedColor(
color: Color,
onColor: Color = MaterialTheme.colorScheme.surface,
elevation: Dp,
): Color {
return if (elevation > 0.dp) {
val foregroundColor = calculateForegroundColor(onColor, elevation)
foregroundColor.compositeOver(color)
} else {
color
}
}
@ReadOnlyComposable
@Composable
private fun calculateForegroundColor(color: Color, elevation: Dp): Color {
val alpha = ((4.5f * ln(elevation.value + 1)) + 2f) / 100f
return color.copy(alpha = alpha)
}

View file

@ -0,0 +1,24 @@
package com.pixelized.headache.utils.extention
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
/**
* is an extension function for Modifier.
* It allows you to attach visibility detection logic to any composable element
*/
fun Modifier.isElementVisible(onVisibilityChanged: (Boolean) -> Unit): Modifier {
var isVisible = false
return this.onGloballyPositioned { layoutCoordinates ->
val localIsVisible = layoutCoordinates.parentLayoutCoordinates?.let {
val parentBounds = it.boundsInWindow()
val childBounds = layoutCoordinates.boundsInWindow()
parentBounds.overlaps(childBounds)
} ?: false
if (isVisible != localIsVisible) {
isVisible = localIsVisible
onVisibilityChanged(localIsVisible)
}
}
}

View file

@ -0,0 +1,10 @@
package com.pixelized.headache.utils.extention
import androidx.compose.ui.Modifier
fun Modifier.thenIf(condition: Boolean, block: Modifier.() -> Modifier): Modifier {
return when (condition) {
true -> block(this)
else -> this
}
}

View file

@ -0,0 +1,14 @@
package com.pixelized.headache.utils.extention
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import java.util.Locale
@Composable
@Stable
fun String.capitalize() = replaceFirstChar {
when {
it.isLowerCase() -> it.titlecase(Locale.getDefault())
else -> it.toString()
}
}

View file

@ -0,0 +1,13 @@
package com.pixelized.headache.utils
import androidx.compose.foundation.text.KeyboardActionScope
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@Composable
fun rememberKeyboardActions(
onAny: KeyboardActionScope.() -> Unit,
): KeyboardActions {
return remember { KeyboardActions(onAny = onAny) }
}

View file

@ -1,7 +1,12 @@
<resources>
<string name="app_name">Céphalée</string>
<string name="action_confirm">Confirmer</string>
<string name="action_delete">Supprimer</string>
<string name="error_edit_calendar">L\'édition du calendrier a échouée</string>
<string name="calendar_chooser_title">Choix du calendrier</string>
<string name="event_title">Évennement migraineux</string>
<string name="month_summary_title">Suivit des migraines</string>
<string name="month_summary_title">Suivi des migraines</string>
</resources>

View file

@ -1,6 +1,11 @@
<resources>
<string name="app_name">Headache</string>
<string name="action_confirm">Confirm</string>
<string name="action_delete">Delete</string>
<string name="error_edit_calendar">Calendar edit failed.</string>
<string name="calendar_chooser_title">Choose your calendar</string>
<string name="event_title">Headache event</string>
<string name="month_summary_title">Headache summary</string>

View file

@ -1,10 +1,10 @@
package com.pixelized.headache
import com.pixelized.headache.repository.event.EventFactory
import com.pixelized.headache.repository.event.factory.PillFactory
import org.junit.Test
class EventFactoryText {
private val factory = EventFactory()
class EventFactoryTest {
private val factory = PillFactory()
@Test
fun testPill() {