From 7bd900be68df40f4e71cfc87f61b77f1d4622c16 Mon Sep 17 00:00:00 2001 From: AntoineVerin <24255551+VerinAntoine@users.noreply.github.com> Date: Sat, 21 Oct 2023 13:35:07 +0200 Subject: [PATCH] v0.2 --- app/build.gradle.kts | 1 + .../worktime/database/TimeSpentDaoTest.kt | 59 ---------- .../fr/antoineverin/worktime/WtAppRoute.kt | 16 ++- .../worktime/database/dao/TimeSpentDao.kt | 3 + .../worktime/database/entities/TimeSpent.kt | 20 +++- .../worktime/ui/field/DateField.kt | 93 +++++++++++++++ .../worktime/ui/field/NumberField.kt | 49 ++++++++ .../worktime/ui/field/TimeField.kt | 82 +++++++++++++ .../worktime/ui/screen/AddEntryScreen.kt | 90 --------------- .../worktime/ui/screen/EditEntryScreen.kt | 84 ++++++++++++++ .../worktime/ui/screen/ListEntriesScreen.kt | 108 ++++++++++++++++++ .../worktime/ui/screen/MainScreen.kt | 8 +- .../ui/viewmodel/AddEntryViewModel.kt | 90 --------------- .../ui/viewmodel/EditEntryViewModel.kt | 94 +++++++++++++++ .../ui/viewmodel/ListEntriesViewModel.kt | 34 ++++++ .../ui/viewmodel/MainScreenViewModel.kt | 11 +- 16 files changed, 596 insertions(+), 246 deletions(-) delete mode 100644 app/src/androidTest/java/fr/antoineverin/worktime/database/TimeSpentDaoTest.kt create mode 100644 app/src/main/java/fr/antoineverin/worktime/ui/field/DateField.kt create mode 100644 app/src/main/java/fr/antoineverin/worktime/ui/field/NumberField.kt create mode 100644 app/src/main/java/fr/antoineverin/worktime/ui/field/TimeField.kt delete mode 100644 app/src/main/java/fr/antoineverin/worktime/ui/screen/AddEntryScreen.kt create mode 100644 app/src/main/java/fr/antoineverin/worktime/ui/screen/EditEntryScreen.kt create mode 100644 app/src/main/java/fr/antoineverin/worktime/ui/screen/ListEntriesScreen.kt delete mode 100644 app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/AddEntryViewModel.kt create mode 100644 app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/EditEntryViewModel.kt create mode 100644 app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/ListEntriesViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 512d616..c1e814a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,6 +26,7 @@ android { buildTypes { release { isMinifyEnabled = false + isDebuggable = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/app/src/androidTest/java/fr/antoineverin/worktime/database/TimeSpentDaoTest.kt b/app/src/androidTest/java/fr/antoineverin/worktime/database/TimeSpentDaoTest.kt deleted file mode 100644 index a9e0424..0000000 --- a/app/src/androidTest/java/fr/antoineverin/worktime/database/TimeSpentDaoTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package fr.antoineverin.worktime.database - -import android.content.Context -import androidx.room.Room -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import fr.antoineverin.worktime.database.dao.TimeSpentDao -import fr.antoineverin.worktime.database.entities.TimeSpent -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import java.time.LocalDate -import java.time.LocalTime -import java.time.YearMonth - -@RunWith(AndroidJUnit4::class) -class TimeSpentDaoTest { - - private lateinit var database: WtDatabase - private lateinit var timeSpentDao: TimeSpentDao - - @Before - fun init() { - val context = ApplicationProvider.getApplicationContext() - database = Room.inMemoryDatabaseBuilder(context, WtDatabase::class.java).build() - timeSpentDao = database.timeSpentDao() - } - - @After - fun close() { - database.close() - } - - @Test - fun testGetTimeSpent() { - val ts = TimeSpent( - 0, - YearMonth.of(2023, 3), - LocalDate.of(2023, 3, 4), - LocalTime.of(8, 30), - LocalTime.of(16, 45) - ) - val ts2 = TimeSpent( - 1, - YearMonth.of(2023, 4), - LocalDate.of(2023, 4, 4), - LocalTime.of(8, 30), - LocalTime.of(16, 45) - ) -// timeSpentDao.insert(ts) -// timeSpentDao.insert(ts2) -// assertThat(timeSpentDao.getAllTimeSpent()[0], equalTo(ts)) -// assertThat(timeSpentDao.getAllTimeSpent()[1], equalTo(ts2)) -// assertThat(timeSpentDao.getTimeSpentFromPeriod(YearMonth.of(2023, 3).toString())[0], equalTo(ts)) -// assertThat(timeSpentDao.getTimeSpentFromPeriod(YearMonth.of(2023, 4).toString())[0], equalTo(ts2)) - } - -} diff --git a/app/src/main/java/fr/antoineverin/worktime/WtAppRoute.kt b/app/src/main/java/fr/antoineverin/worktime/WtAppRoute.kt index 98bee7a..dab8edd 100644 --- a/app/src/main/java/fr/antoineverin/worktime/WtAppRoute.kt +++ b/app/src/main/java/fr/antoineverin/worktime/WtAppRoute.kt @@ -1,14 +1,24 @@ package fr.antoineverin.worktime import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType import androidx.navigation.compose.composable -import fr.antoineverin.worktime.ui.screen.AddEntryScreen +import androidx.navigation.navArgument +import fr.antoineverin.worktime.ui.screen.EditEntryScreen +import fr.antoineverin.worktime.ui.screen.ListEntriesScreen import fr.antoineverin.worktime.ui.screen.MainScreen fun NavGraphBuilder.wtAppRoute(appState: WtAppState) { composable(HOME_ROUTE) { MainScreen({ appState.navigate(it) }) } - composable(ADD_ENTRY) { AddEntryScreen(popUp = { appState.popUp() }) } + composable(LIST_ENTRIES) { ListEntriesScreen({ appState.navigate(it) }) } + composable( + route = "$EDIT_ENTRY/{entryId}", + arguments = listOf(navArgument("entryId") { type = NavType.IntType }) + ) { backEntryStack -> + backEntryStack.arguments?.getInt("entryId")?.let { EditEntryScreen(it, { appState.popUp() }) } + } } const val HOME_ROUTE = "home" -const val ADD_ENTRY = "add_entry" +const val LIST_ENTRIES = "entry/list" +const val EDIT_ENTRY = "entry/edit" diff --git a/app/src/main/java/fr/antoineverin/worktime/database/dao/TimeSpentDao.kt b/app/src/main/java/fr/antoineverin/worktime/database/dao/TimeSpentDao.kt index adda8e5..55ba20e 100644 --- a/app/src/main/java/fr/antoineverin/worktime/database/dao/TimeSpentDao.kt +++ b/app/src/main/java/fr/antoineverin/worktime/database/dao/TimeSpentDao.kt @@ -10,6 +10,9 @@ interface TimeSpentDao: WtDao { @Query("SELECT * FROM time_spent") suspend fun getAllTimeSpent(): List + @Query("SELECT * FROM time_spent WHERE id = :id") + suspend fun getTimeSpent(id: Int): TimeSpent + @Query("SELECT * FROM time_spent WHERE period = :period") suspend fun getTimeSpentFromPeriod(period: String): List diff --git a/app/src/main/java/fr/antoineverin/worktime/database/entities/TimeSpent.kt b/app/src/main/java/fr/antoineverin/worktime/database/entities/TimeSpent.kt index 9224a6b..2c537f2 100644 --- a/app/src/main/java/fr/antoineverin/worktime/database/entities/TimeSpent.kt +++ b/app/src/main/java/fr/antoineverin/worktime/database/entities/TimeSpent.kt @@ -2,9 +2,11 @@ package fr.antoineverin.worktime.database.entities import androidx.room.Entity import androidx.room.PrimaryKey +import java.time.Duration import java.time.LocalDate import java.time.LocalTime import java.time.YearMonth +import java.time.format.DateTimeFormatter @Entity(tableName = "time_spent") data class TimeSpent( @@ -13,4 +15,20 @@ data class TimeSpent( var date: LocalDate, var from: LocalTime, var to: LocalTime?, -) +) { + + fun formatDuration(): String { + return ( + from.format(DateTimeFormatter.ofPattern("HH:mm")) + + " - " + + (to?.format(DateTimeFormatter.ofPattern("HH:mm")) ?: "??:??") + ) + } + + fun getDuration(): Duration? { + if(to == null) return null + return Duration.ofSeconds(to!!.toSecondOfDay().toLong()) + .minusSeconds(from.toSecondOfDay().toLong()) + } + +} diff --git a/app/src/main/java/fr/antoineverin/worktime/ui/field/DateField.kt b/app/src/main/java/fr/antoineverin/worktime/ui/field/DateField.kt new file mode 100644 index 0000000..cfe665a --- /dev/null +++ b/app/src/main/java/fr/antoineverin/worktime/ui/field/DateField.kt @@ -0,0 +1,93 @@ +package fr.antoineverin.worktime.ui.field + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.core.text.isDigitsOnly +import fr.antoineverin.worktime.ui.screen.checkDigitAndRange +import java.time.DateTimeException +import java.time.LocalDate + + +@Composable +fun DateField( + value: DateFieldValue, + onValueChange: (DateFieldValue) -> Unit, + focusManager: FocusManager, + modifier: Modifier = Modifier, + imeAction: ImeAction = ImeAction.Next, + action: () -> Unit = { focusManager.moveFocus(FocusDirection.Next) } +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + NumberField( + label = "Day", + value = value.day, + onValueChange = { onValueChange(value.copy(day = it)) }, + checkValue = { checkDigitAndRange(it, 0..31) }, + focusManager = focusManager, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(5.dp)) + NumberField( + label = "Month", + value = value.month, + onValueChange = { onValueChange(value.copy(month = it)) }, + checkValue = { checkDigitAndRange(it, 0..12) }, + focusManager = focusManager, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(5.dp)) + NumberField( + label = "Year", + value = value.year, + onValueChange = { onValueChange(value.copy(year = it)) }, + checkValue = { it.isDigitsOnly() }, + focusManager = focusManager, + modifier = Modifier.weight(1f), + imeAction = imeAction, + action = action + ) + } +} + +data class DateFieldValue( + val day: String, + val month: String, + val year: String +) { + + fun isValid(): Boolean { + if (!(day.isDigitsOnly() && month.isDigitsOnly() && year.isDigitsOnly())) + return false + if (isEmpty()) + return false + return try { + LocalDate.of(year.toInt(), month.toInt(), day.toInt()) + true + }catch (e: DateTimeException) { + false + } + } + + fun isEmpty(): Boolean { + return day == "" || month == "" || year == "" + } + + fun toLocalDate(): LocalDate { + return LocalDate.of(year.toInt(), month.toInt(), day.toInt()) + } + +} diff --git a/app/src/main/java/fr/antoineverin/worktime/ui/field/NumberField.kt b/app/src/main/java/fr/antoineverin/worktime/ui/field/NumberField.kt new file mode 100644 index 0000000..9b5f575 --- /dev/null +++ b/app/src/main/java/fr/antoineverin/worktime/ui/field/NumberField.kt @@ -0,0 +1,49 @@ +package fr.antoineverin.worktime.ui.field + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NumberField( + label: String, + value: String, + onValueChange: (String) -> Unit, + checkValue: (String) -> Boolean, + focusManager: FocusManager, + modifier: Modifier = Modifier, + imeAction: ImeAction = ImeAction.Next, + action: () -> Unit = { focusManager.moveFocus(FocusDirection.Next) } +) { + var isError by remember { mutableStateOf(false) } + + TextField( + label = { Text(label) }, + value = value, + onValueChange = { + isError = !checkValue(it) + onValueChange(it) + }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Number, + imeAction = imeAction + ), + keyboardActions = KeyboardActions { action() }, + isError = isError, + modifier = modifier + ) +} diff --git a/app/src/main/java/fr/antoineverin/worktime/ui/field/TimeField.kt b/app/src/main/java/fr/antoineverin/worktime/ui/field/TimeField.kt new file mode 100644 index 0000000..7ae3ae0 --- /dev/null +++ b/app/src/main/java/fr/antoineverin/worktime/ui/field/TimeField.kt @@ -0,0 +1,82 @@ +package fr.antoineverin.worktime.ui.field + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.core.text.isDigitsOnly +import fr.antoineverin.worktime.ui.screen.checkDigitAndRange +import java.time.DateTimeException +import java.time.LocalTime + +@Composable +fun TimeField( + value: TimeFieldValue, + onValueChange: (TimeFieldValue) -> Unit, + focusManager: FocusManager, + modifier: Modifier = Modifier, + imeAction: ImeAction = ImeAction.Next, + action: () -> Unit = { focusManager.moveFocus(FocusDirection.Next) } +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + NumberField( + label = "Hour", + value = value.hours, + onValueChange = { onValueChange(value.copy(hours = it)) }, + checkValue = { checkDigitAndRange(it, 0..24) }, + focusManager = focusManager, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(5.dp)) + NumberField( + label = "Minutes", + value = value.minutes, + onValueChange = { onValueChange(value.copy(minutes = it)) }, + checkValue = { checkDigitAndRange(it, 0..60) }, + focusManager = focusManager, + modifier = Modifier.weight(1f), + imeAction = imeAction, + action = action + ) + } +} + +data class TimeFieldValue( + val minutes: String, + val hours: String +) { + + fun isValid(): Boolean { + if (!(minutes.isDigitsOnly() && hours.isDigitsOnly())) + return false + if (isEmpty()) + return false + return try { + LocalTime.of(hours.toInt(), minutes.toInt()) + true + }catch (e: DateTimeException) { + false + } + } + + fun isEmpty(): Boolean { + return minutes == "" || hours == "" + } + + fun toLocalTime(): LocalTime { + return LocalTime.of(hours.toInt(), minutes.toInt()) + } + +} diff --git a/app/src/main/java/fr/antoineverin/worktime/ui/screen/AddEntryScreen.kt b/app/src/main/java/fr/antoineverin/worktime/ui/screen/AddEntryScreen.kt deleted file mode 100644 index f3f1c08..0000000 --- a/app/src/main/java/fr/antoineverin/worktime/ui/screen/AddEntryScreen.kt +++ /dev/null @@ -1,90 +0,0 @@ -package fr.antoineverin.worktime.ui.screen - -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.width -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import fr.antoineverin.worktime.ui.viewmodel.AddEntryViewModel -import fr.antoineverin.worktime.ui.viewmodel.TimeValue - -@Composable -fun AddEntryScreen( - popUp: () -> Unit, - viewModel: AddEntryViewModel = hiltViewModel() -) { - Column( - modifier = Modifier.padding(5.dp) - ) { - TimeField(viewModel.getFrom(), { viewModel.onFromChange(it) }) - Spacer(modifier = Modifier.height(10.dp)) - TimeField(viewModel.getTo(), { viewModel.onToChange(it) }) - Spacer(modifier = Modifier.height(10.dp)) - Button(onClick = { viewModel.updateEntry(popUp) }) { - Text(text = "Valid") - } - } - - LaunchedEffect(viewModel) { - viewModel.fetchLastEntry() - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TimeField( - time: TimeValue, - onValueChange: (TimeValue) -> Unit, - modifier: Modifier = Modifier, -) { - val focusManager = LocalFocusManager.current - - Row( - modifier = modifier - ) { - TextField( - label = { Text(text = "Hours") }, - value = time.hours, - onValueChange = { onValueChange(time.copy(hours = it)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { - focusManager.moveFocus(FocusDirection.Next) - }), - modifier = Modifier.fillMaxWidth(.49f) - ) - Spacer(modifier = Modifier.width(10.dp)) - TextField( - label = { Text(text = "Min") }, - value = time.minutes, - onValueChange = { onValueChange(time.copy(minutes = it)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { - focusManager.moveFocus(FocusDirection.Down) - }), - ) - } -} - -@Preview -@Composable -fun TimeFieldPreview() { - TimeField(TimeValue(true, "13", "45"), { }) -} diff --git a/app/src/main/java/fr/antoineverin/worktime/ui/screen/EditEntryScreen.kt b/app/src/main/java/fr/antoineverin/worktime/ui/screen/EditEntryScreen.kt new file mode 100644 index 0000000..26a73f7 --- /dev/null +++ b/app/src/main/java/fr/antoineverin/worktime/ui/screen/EditEntryScreen.kt @@ -0,0 +1,84 @@ +package fr.antoineverin.worktime.ui.screen + +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.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.core.text.isDigitsOnly +import androidx.hilt.navigation.compose.hiltViewModel +import fr.antoineverin.worktime.ui.field.DateField +import fr.antoineverin.worktime.ui.field.TimeField +import fr.antoineverin.worktime.ui.viewmodel.EditEntryViewModel + +@Composable +fun EditEntryScreen( + entryId: Int, + popUp: () -> Unit, + viewModel: EditEntryViewModel = hiltViewModel() +) { + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier.fillMaxWidth().padding(5.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Label(name = "Date") { + DateField( + value = viewModel.date.value, + onValueChange = { viewModel.date.value = it; viewModel.checkInputValidity() }, + focusManager = focusManager, + imeAction = ImeAction.Done + ) + } + Spacer(Modifier.height(13.dp)) + Label(name = "From") { + TimeField( + value = viewModel.from.value, + onValueChange = { viewModel.from.value = it; viewModel.checkInputValidity() }, + focusManager = focusManager, + imeAction = ImeAction.Done + ) + } + Spacer(Modifier.height(13.dp)) + Label(name = "To") { + TimeField( + value = viewModel.to.value, + onValueChange = { viewModel.to.value = it; viewModel.checkInputValidity() }, + focusManager = focusManager, + imeAction = ImeAction.Done, + action = { viewModel.pushEntry(popUp) } + ) + } + Spacer(Modifier.height(24.dp)) + Button(onClick = { viewModel.pushEntry(popUp) }, enabled = viewModel.isValid.value) { + Text(text = "Valid") + } + } + + LaunchedEffect(viewModel) { + viewModel.fetchEntry(entryId) + } +} + +@Composable +private fun Label(name: String, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Column(modifier = modifier) { + Text(text = name) + Spacer(Modifier.height(5.dp)) + content() + } +} + +fun checkDigitAndRange(value: String, range: IntRange): Boolean { + return value.isDigitsOnly() && (value == "" || value.toInt() in range) +} diff --git a/app/src/main/java/fr/antoineverin/worktime/ui/screen/ListEntriesScreen.kt b/app/src/main/java/fr/antoineverin/worktime/ui/screen/ListEntriesScreen.kt new file mode 100644 index 0000000..a402042 --- /dev/null +++ b/app/src/main/java/fr/antoineverin/worktime/ui/screen/ListEntriesScreen.kt @@ -0,0 +1,108 @@ +package fr.antoineverin.worktime.ui.screen + +import androidx.compose.foundation.layout.Arrangement +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import fr.antoineverin.worktime.database.entities.TimeSpent +import fr.antoineverin.worktime.ui.theme.WorktimeTheme +import fr.antoineverin.worktime.ui.viewmodel.ListEntriesViewModel +import java.time.LocalDate +import java.time.LocalTime +import java.time.YearMonth +import java.time.format.DateTimeFormatter + +@Composable +fun ListEntriesScreen( + navigate: (String) -> Unit, + viewModel: ListEntriesViewModel = hiltViewModel() +) { + LazyColumn { + items( + items = viewModel.entries, + key = { entry -> entry.id } + ) { entry -> + EntryItem( + entry = entry, + delete = { viewModel.deleteEntry(it) }, + edit = { navigate("entry/edit/${it.id}") } + ) + } + } + + LaunchedEffect(viewModel) { + viewModel.entries.clear() + viewModel.fetchEntries() + } +} + +@Composable +private fun EntryItem( + entry: TimeSpent, + delete: (TimeSpent) -> Unit, + edit: (TimeSpent) -> Unit +) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column( + Modifier + .fillMaxWidth(.75f) + .padding(5.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = entry.date.format(DateTimeFormatter.ofPattern("EEEE dd"))) + Text(text = entry.formatDuration()) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = entry.period.format(DateTimeFormatter.ofPattern("MMMM yyyy"))) + Text(text = entry.getDuration()?.toHours().toString() + "h") + } + } + IconButton(onClick = { edit(entry) }) { + Icon(Icons.Filled.Edit, contentDescription = "Edit") + } + IconButton(onClick = { delete(entry) }) { + Icon(Icons.Filled.Delete, contentDescription = "Delete") + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EntryItemPreview() { + WorktimeTheme { + Column { + EntryItem(entry = TimeSpent( + 0, + YearMonth.now(), + LocalDate.now(), + LocalTime.of(8, 30), + LocalTime.of(17, 45) + ), { }, { }) + Spacer(modifier = Modifier.height(5.dp)) + EntryItem(entry = TimeSpent( + 0, + YearMonth.now(), + LocalDate.now(), + LocalTime.of(8, 30), + null + ), { }, { }) + } + } +} diff --git a/app/src/main/java/fr/antoineverin/worktime/ui/screen/MainScreen.kt b/app/src/main/java/fr/antoineverin/worktime/ui/screen/MainScreen.kt index 48aaaa9..2483133 100644 --- a/app/src/main/java/fr/antoineverin/worktime/ui/screen/MainScreen.kt +++ b/app/src/main/java/fr/antoineverin/worktime/ui/screen/MainScreen.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import fr.antoineverin.worktime.ADD_ENTRY +import fr.antoineverin.worktime.LIST_ENTRIES import fr.antoineverin.worktime.ui.viewmodel.MainScreenViewModel import java.time.Duration import java.time.YearMonth @@ -35,9 +35,13 @@ fun MainScreen( hoursObjective = viewModel.getHoursObjective() ) Spacer(modifier = Modifier.height(24.dp)) - Button(onClick = { navigate(ADD_ENTRY) }) { + Button(onClick = { viewModel.addEntry(navigate) }) { Text(text = "Work!") } + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = { navigate(LIST_ENTRIES) }) { + Text(text = "List entries") + } } LaunchedEffect(viewModel) { diff --git a/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/AddEntryViewModel.kt b/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/AddEntryViewModel.kt deleted file mode 100644 index 841b882..0000000 --- a/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/AddEntryViewModel.kt +++ /dev/null @@ -1,90 +0,0 @@ -package fr.antoineverin.worktime.ui.viewmodel - -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import fr.antoineverin.worktime.database.dao.TimeSpentDao -import fr.antoineverin.worktime.database.entities.TimeSpent -import kotlinx.coroutines.launch -import java.time.LocalDate -import java.time.LocalTime -import java.time.YearMonth -import javax.inject.Inject - -@HiltViewModel -class AddEntryViewModel @Inject constructor( - private val timeSpentDao: TimeSpentDao -): ViewModel() { - - private lateinit var timeSpent: TimeSpent - private val dateField = mutableStateOf(LocalDate.now()) - private val fromField = mutableStateOf(TimeValue(false, "", "")) - private val toField = mutableStateOf(TimeValue(false, "", "")) - - fun getFrom(): TimeValue { - return fromField.value - } - - fun onFromChange(time: TimeValue) { - fromField.value = time - } - - fun getTo(): TimeValue { - return toField.value - } - - fun onToChange(time: TimeValue) { - try { - time.minutes.toInt() - time.hours.toInt() - toField.value = time.copy(filled = true) - }catch(error: NumberFormatException) { - toField.value = time.copy(filled = false) - } - } - - fun fetchLastEntry() { - val now = LocalTime.now() - viewModelScope.launch { - val lastTimeSpent = timeSpentDao.getLastTimeSpent() - if((lastTimeSpent == null) || (lastTimeSpent.to != null)) { - timeSpent = TimeSpent(0, YearMonth.now(), LocalDate.now(), LocalTime.now(), null) - dateField.value = LocalDate.now() - fromField.value = TimeValue(true, now.hour.toString(), now.minute.toString()) - toField.value = TimeValue(false, "", "") - }else{ - timeSpent = lastTimeSpent - dateField.value = lastTimeSpent.date - fromField.value = TimeValue(true, lastTimeSpent.from.hour.toString(), lastTimeSpent.from.minute.toString()) - toField.value = TimeValue(true, now.hour.toString(), now.minute.toString()) - } - } - } - - fun updateEntry(popUp: () -> Unit) { - viewModelScope.launch { - timeSpent.date = dateField.value - timeSpent.from = timeValueToLocalTime(fromField.value)!! - timeSpent.to = timeValueToLocalTime(toField.value) - timeSpent.period = YearMonth.of(timeSpent.date.year, timeSpent.date.monthValue) - if(timeSpent.id == 0) - timeSpentDao.insert(timeSpent) - else - timeSpentDao.update(timeSpent) - popUp() - } - } - - private fun timeValueToLocalTime(value: TimeValue): LocalTime? { - if(!value.filled) return null - return LocalTime.of(value.hours.toInt(), value.minutes.toInt()) - } - -} - -data class TimeValue( - var filled: Boolean, - var hours: String, - var minutes: String, -) diff --git a/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/EditEntryViewModel.kt b/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/EditEntryViewModel.kt new file mode 100644 index 0000000..efb731f --- /dev/null +++ b/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/EditEntryViewModel.kt @@ -0,0 +1,94 @@ +package fr.antoineverin.worktime.ui.viewmodel + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import fr.antoineverin.worktime.database.dao.TimeSpentDao +import fr.antoineverin.worktime.database.entities.TimeSpent +import fr.antoineverin.worktime.ui.field.DateFieldValue +import fr.antoineverin.worktime.ui.field.TimeFieldValue +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.LocalTime +import java.time.YearMonth +import javax.inject.Inject + +@HiltViewModel +class EditEntryViewModel @Inject constructor( + val timeSpentDao: TimeSpentDao +): ViewModel() { + + private lateinit var entry: TimeSpent + val date = mutableStateOf(DateFieldValue("", "", "")) + val from = mutableStateOf(TimeFieldValue("", "")) + val to = mutableStateOf(TimeFieldValue("", "")) + val isValid = mutableStateOf(false) + + fun fetchEntry(id: Int) { + if (id == 0) { + setupWithEntry(TimeSpent( + 0, + YearMonth.now(), + LocalDate.now(), + LocalTime.now(), + null + )) + return + } + + viewModelScope.launch { + setupWithEntry(timeSpentDao.getTimeSpent(id)) + } + } + + fun pushEntry(popUp: () -> Unit) { + viewModelScope.launch { + entry.date = date.value.toLocalDate() + entry.period = YearMonth.of(entry.date.year, entry.date.monthValue) + entry.from = from.value.toLocalTime() + if (to.value.isEmpty()) entry.to = null + else entry.to = to.value.toLocalTime() + + if (entry.id == 0) + timeSpentDao.insert(entry) + else + timeSpentDao.update(entry) + popUp() + } + } + + fun checkInputValidity() { + if (date.value.isEmpty() || from.value.isEmpty()) + isValid.value = false + isValid.value = date.value.isValid() && from.value.isValid() && (to.value.isEmpty() || to.value.isValid()) + } + + private fun setupWithEntry(entry: TimeSpent) { + date.value = localDateToFieldValue(entry.date) + from.value = localTimeToFieldValue(entry.from) + + if (entry.to == null) { + if (entry.id == 0) to.value = TimeFieldValue("", "") + else to.value = localTimeToFieldValue(LocalTime.now()) + } else + to.value = localTimeToFieldValue(entry.to!!) + this.entry = entry + checkInputValidity() + } + + private fun localDateToFieldValue(value: LocalDate): DateFieldValue { + return DateFieldValue( + value.dayOfMonth.toString(), + value.monthValue.toString(), + value.year.toString() + ) + } + + private fun localTimeToFieldValue(value: LocalTime): TimeFieldValue { + return TimeFieldValue( + value.minute.toString(), + value.hour.toString() + ) + } +} diff --git a/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/ListEntriesViewModel.kt b/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/ListEntriesViewModel.kt new file mode 100644 index 0000000..d956910 --- /dev/null +++ b/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/ListEntriesViewModel.kt @@ -0,0 +1,34 @@ +package fr.antoineverin.worktime.ui.viewmodel + +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import fr.antoineverin.worktime.database.dao.TimeSpentDao +import fr.antoineverin.worktime.database.entities.TimeSpent +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ListEntriesViewModel @Inject constructor( + private val timeSpentDao: TimeSpentDao +): ViewModel() { + + val entries = mutableStateListOf() + + fun fetchEntries() { + viewModelScope.launch { + val list = timeSpentDao.getAllTimeSpent() + print(list.count()) + entries.addAll(list) + } + } + + fun deleteEntry(entry: TimeSpent) { + viewModelScope.launch { + timeSpentDao.delete(entry) + entries.remove(entry) + } + } + +} diff --git a/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/MainScreenViewModel.kt b/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/MainScreenViewModel.kt index 9652e91..1dac20a 100644 --- a/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/MainScreenViewModel.kt +++ b/app/src/main/java/fr/antoineverin/worktime/ui/viewmodel/MainScreenViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import fr.antoineverin.worktime.EDIT_ENTRY import fr.antoineverin.worktime.database.dao.TimeSpentDao import kotlinx.coroutines.launch import java.time.Duration @@ -25,7 +26,15 @@ class MainScreenViewModel @Inject constructor( return 150 } - suspend fun fetchTimeSpentSummary() { + fun addEntry(navigate: (String) -> Unit) { + viewModelScope.launch { + val entry = timeSpentDao.getLastTimeSpent() + if (entry == null || entry.to != null) navigate("$EDIT_ENTRY/0") + else navigate("$EDIT_ENTRY/${entry.id}") + } + } + + fun fetchTimeSpentSummary() { viewModelScope.launch { var time = Duration.ZERO timeSpentDao.getTimeSpentFromPeriod(YearMonth.now().toString()).forEach {