Compare commits

...

2 Commits

Author SHA1 Message Date
Geoffroy Bonneville 150058437f Fixes and technical improvements 2026-07-16 15:56:28 -04:00
Geoffroy Bonneville 897b1892e5 Update SDK, Gradle and packages
Fix timezone issues
Add timezone tests
2026-07-16 13:53:51 -04:00
38 changed files with 1592 additions and 260 deletions
+3
View File
@@ -33,6 +33,9 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
- name: Run unit tests
run: ./gradlew :donextv2:testDebugUnitTest
- name: Decode signing key
run: echo "${{ secrets.SIGNING_KEY_BASE64 }}" | base64 --decode > ${{ env.MODULE }}/upload.jks
+1
View File
@@ -10,3 +10,4 @@
/captures
/fastlane/keys/
/donext*/release/
.claude
+1118 -110
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,6 +4,7 @@
<selectionStates>
<SelectionState runConfigName="donextv2">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="donext">
<option name="selectionMode" value="DROPDOWN" />
-1
View File
@@ -6,7 +6,6 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
+5 -5
View File
@@ -13,9 +13,9 @@ buildscript {
google()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21'
classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.21'
classpath 'com.android.tools.build:gradle:9.3.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.10'
classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.2.10'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
@@ -23,8 +23,8 @@ buildscript {
}
plugins {
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
id("com.google.dagger.hilt.android") version "2.57.2" apply false
id("com.google.devtools.ksp") version '2.3.2' apply false
id("com.google.dagger.hilt.android") version "2.60.1" apply false
}
allprojects {
+6 -6
View File
@@ -1,7 +1,7 @@
apply plugin: 'com.android.application'
android {
compileSdk 36
compileSdk 37
defaultConfig {
applicationId "com.wismna.geoffroy.donext"
@@ -13,7 +13,7 @@ android {
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
namespace 'com.wismna.geoffroy.donext'
@@ -26,12 +26,12 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'com.google.android.material:material:1.13.0'
implementation 'com.google.android.material:material:1.14.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.percentlayout:percentlayout:1.0.0'
implementation 'androidx.recyclerview:recyclerview:1.4.0'
implementation 'androidx.browser:browser:1.9.0'
implementation 'net.danlew:android.joda:2.13.1'
implementation 'androidx.browser:browser:1.10.0'
implementation 'net.danlew:android.joda:2.14.2'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.preference:preference:1.2.1'
testImplementation 'junit:junit:4.13.2'
@@ -42,7 +42,7 @@ dependencies {
implementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}"
// Lifecycle components
def lifecycleVersion = '2.9.4'
def lifecycleVersion = '2.11.0'
implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
+14 -17
View File
@@ -14,14 +14,14 @@ ksp {
android {
namespace = "com.wismna.geoffroy.donext"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "com.wismna.geoffroy.donext"
minSdk = 26
targetSdk = 36
versionCode = 35
versionName = "2.0.1"
versionCode = 36
versionName = "2.0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -65,9 +65,6 @@ android {
compose = true
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.1.1"
}
configurations.all {
resolutionStrategy {
@@ -82,35 +79,35 @@ android {
}
dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
implementation("androidx.activity:activity-compose:1.11.0")
implementation(platform("androidx.compose:compose-bom:2025.11.00"))
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation(platform("androidx.compose:compose-bom:2026.06.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material3:material3-window-size-class:1.4.0")
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation("androidx.navigation:navigation-compose:2.9.6")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation("sh.calvin.reorderable:reorderable:3.0.0")
implementation("androidx.navigation:navigation-compose:2.9.8")
implementation("androidx.hilt:hilt-navigation-compose:1.4.0")
implementation("sh.calvin.reorderable:reorderable:3.1.0")
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.14.6")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.mockk:mockk:1.14.11")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
testImplementation("com.google.truth:truth:1.4.5")
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.11.00"))
androidTestImplementation(platform("androidx.compose:compose-bom:2026.06.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("com.google.truth:truth:1.4.5")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
val roomVersion = "2.8.3"
val roomVersion = "2.8.4"
implementation("androidx.room:room-runtime:$roomVersion")
androidTestImplementation("androidx.room:room-testing:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
val hiltVersion = "2.57.2"
val hiltVersion = "2.60.1"
implementation("com.google.dagger:hilt-android:$hiltVersion")
ksp("com.google.dagger:hilt-android-compiler:$hiltVersion")
}
@@ -0,0 +1,17 @@
package com.wismna.geoffroy.donext.data.injection
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ClockModule {
@Provides
@Singleton
fun provideClock(): Clock = Clock.systemDefaultZone()
}
@@ -1,6 +1,8 @@
package com.wismna.geoffroy.donext.data.injection
import com.wismna.geoffroy.donext.data.local.repository.TaskListRepositoryImpl
import com.wismna.geoffroy.donext.data.local.repository.TaskRepositoryImpl
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import dagger.Binds
import dagger.Module
@@ -15,4 +17,9 @@ abstract class RepositoryModule {
abstract fun bindTaskRepository(
impl: TaskRepositoryImpl
): TaskRepository
@Binds
abstract fun bindTaskListRepository(
impl: TaskListRepositoryImpl
): TaskListRepository
}
@@ -0,0 +1,39 @@
package com.wismna.geoffroy.donext.data.local.repository
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import com.wismna.geoffroy.donext.data.toDomain
import com.wismna.geoffroy.donext.data.toEntity
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class TaskListRepositoryImpl @Inject constructor(
private val taskListDao: TaskListDao
): TaskListRepository {
override fun getTaskLists(): Flow<List<TaskList>> {
return taskListDao.getTaskLists().map {entities -> entities.map { it.toDomain() }}
}
override suspend fun getTaskListById(taskListId: Long): TaskList? {
return taskListDao.getTaskListById(taskListId)?.toDomain()
}
override suspend fun insertTaskList(taskList: TaskList) {
taskListDao.insertTaskList(taskList.toEntity())
}
override suspend fun updateTaskList(taskList: TaskList) {
taskListDao.updateTaskList(taskList.toEntity())
}
override suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) {
taskListDao.deleteTaskList(taskListId, isDeleted)
}
override fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>> {
return taskListDao.getTaskListsWithOverdue(nowMillis)
}
}
@@ -1,22 +1,17 @@
package com.wismna.geoffroy.donext.data.local.repository
import com.wismna.geoffroy.donext.data.local.dao.TaskDao
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import com.wismna.geoffroy.donext.data.toDomain
import com.wismna.geoffroy.donext.data.toEntity
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.model.TaskWithListName
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import kotlin.collections.map
class TaskRepositoryImpl @Inject constructor(
private val taskDao: TaskDao,
private val taskListDao: TaskListDao
private val taskDao: TaskDao
): TaskRepository {
override fun getTasksForList(listId: Long): Flow<List<Task>> {
return taskDao.getTasksForList(listId).map {entity -> entity.map { it.toDomain() }}
@@ -58,28 +53,7 @@ class TaskRepositoryImpl @Inject constructor(
taskDao.permanentDeleteAllDeletedTasks()
}
override fun getTaskLists(): Flow<List<TaskList>> {
return taskListDao.getTaskLists().map {entities -> entities.map { it.toDomain() }}
}
override suspend fun getTaskListById(taskListId: Long): TaskList? {
return taskListDao.getTaskListById(taskListId)?.toDomain()
}
override suspend fun insertTaskList(taskList: TaskList) {
taskListDao.insertTaskList(taskList.toEntity())
}
override suspend fun updateTaskList(taskList: TaskList) {
taskListDao.updateTaskList(taskList.toEntity())
}
override suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) {
override suspend fun toggleAllTasksInListDeleted(taskListId: Long, isDeleted: Boolean) {
taskDao.toggleAllTasksFromListDeleted(taskListId, isDeleted)
taskListDao.deleteTaskList(taskListId, isDeleted)
}
override fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>> {
return taskListDao.getTaskListsWithOverdue(nowMillis).map { it }
}
}
@@ -0,0 +1,14 @@
package com.wismna.geoffroy.donext.domain.repository
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import kotlinx.coroutines.flow.Flow
interface TaskListRepository {
fun getTaskLists(): Flow<List<TaskList>>
suspend fun getTaskListById(taskListId: Long): TaskList?
suspend fun insertTaskList(taskList: TaskList)
suspend fun updateTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean)
fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>
}
@@ -1,8 +1,6 @@
package com.wismna.geoffroy.donext.domain.repository
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.model.TaskWithListName
import kotlinx.coroutines.flow.Flow
@@ -17,11 +15,5 @@ interface TaskRepository {
suspend fun toggleTaskDone(taskId: Long, isDone: Boolean)
suspend fun permanentlyDeleteTask(taskId: Long)
suspend fun permanentlyDeleteAllDeletedTask()
fun getTaskLists(): Flow<List<TaskList>>
suspend fun getTaskListById(taskListId: Long): TaskList?
suspend fun insertTaskList(taskList: TaskList)
suspend fun updateTaskList(taskList: TaskList)
suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean)
fun getTaskListsWithOverdue(nowMillis: Long): Flow<List<TaskListWithOverdue>>
suspend fun toggleAllTasksInListDeleted(taskListId: Long, isDeleted: Boolean)
}
@@ -1,11 +1,11 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import javax.inject.Inject
class AddTaskListUseCase @Inject constructor(
private val repository: TaskRepository
private val repository: TaskListRepository
) {
suspend operator fun invoke(title: String, order: Int) {
repository.insertTaskList(
@@ -1,12 +1,15 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject
class DeleteTaskListUseCase @Inject constructor(
private val repository: TaskRepository
private val taskRepository: TaskRepository,
private val taskListRepository: TaskListRepository
) {
suspend operator fun invoke(taskListId: Long, isDeleted: Boolean) {
repository.deleteTaskList(taskListId, isDeleted)
taskRepository.toggleAllTasksInListDeleted(taskListId, isDeleted)
taskListRepository.deleteTaskList(taskListId, isDeleted)
}
}
@@ -3,20 +3,24 @@ package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject
class GetDueTodayTasksUseCase @Inject constructor(private val repository: TaskRepository) {
class GetDueTodayTasksUseCase @Inject constructor(
private val repository: TaskRepository,
private val clock: Clock
) {
operator fun invoke(): Flow<List<Task>> {
val todayStart = LocalDate.now()
.atStartOfDay(ZoneOffset.UTC)
val today = LocalDate.now(clock)
val todayStart = today
.atStartOfDay(clock.zone)
.toInstant()
.toEpochMilli()
val todayEnd = LocalDate.now()
val todayEnd = today
.plusDays(1)
.atStartOfDay(ZoneOffset.UTC)
.atStartOfDay(clock.zone)
.toInstant()
.toEpochMilli() - 1
return repository.getDueTodayTasks(
@@ -1,10 +1,10 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetTaskListsUseCase @Inject constructor(private val repository: TaskRepository) {
class GetTaskListsUseCase @Inject constructor(private val repository: TaskListRepository) {
operator fun invoke(): Flow<List<TaskList>> = repository.getTaskLists()
}
@@ -1,17 +1,20 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import kotlinx.coroutines.flow.Flow
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject
class GetTaskListsWithOverdueUseCase @Inject constructor(private val taskRepository: TaskRepository) {
class GetTaskListsWithOverdueUseCase @Inject constructor(
private val taskListRepository: TaskListRepository,
private val clock: Clock
) {
operator fun invoke(): Flow<List<TaskListWithOverdue>> {
return taskRepository.getTaskListsWithOverdue(
LocalDate.now()
.atStartOfDay(ZoneOffset.UTC)
return taskListRepository.getTaskListsWithOverdue(
LocalDate.now(clock)
.atStartOfDay(clock.zone)
.toInstant()
.toEpochMilli()
)
@@ -1,23 +1,25 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject
class ToggleTaskDeletedUseCase @Inject constructor(
private val repository: TaskRepository
private val taskRepository: TaskRepository,
private val taskListRepository: TaskListRepository
) {
suspend operator fun invoke(taskId: Long, isDeleted: Boolean) {
if (!isDeleted) {
val task = repository.getTaskById(taskId)
val task = taskRepository.getTaskById(taskId)
if (task != null) {
// If task list was soft-deleted, restore it as well
val taskList = repository.getTaskListById(task.taskListId)
val taskList = taskListRepository.getTaskListById(task.taskListId)
if (taskList != null && taskList.isDeleted) {
repository.updateTaskList(taskList.copy(isDeleted = false))
taskListRepository.updateTaskList(taskList.copy(isDeleted = false))
}
}
}
repository.toggleTaskDeleted(taskId, isDeleted)
taskRepository.toggleTaskDeleted(taskId, isDeleted)
}
}
@@ -1,11 +1,11 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import javax.inject.Inject
class UpdateTaskListUseCase @Inject constructor(
private val repository: TaskRepository
private val repository: TaskListRepository
) {
suspend operator fun invoke(taskListId: Long, title: String, order: Int) {
repository.updateTaskList(
@@ -1,4 +1,4 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
package com.wismna.geoffroy.donext.presentation.model
import com.wismna.geoffroy.donext.domain.extension.toLocalDate
import com.wismna.geoffroy.donext.domain.model.Priority
@@ -10,7 +10,7 @@ import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
class TaskItemViewModel(
class TaskItemUiState(
task: Task,
private val clock: Clock = Clock.systemDefaultZone()) {
val id: Long = task.id!!
@@ -63,6 +63,7 @@ fun RecycleBinScreen(
val grouped = tasks.groupBy { it.listName }
val context = LocalContext.current
val taskDeletedMessage = stringResource(R.string.toast_task_permanently_deleted)
if (taskToDelete != null) {
AlertDialog(
@@ -75,7 +76,7 @@ fun RecycleBinScreen(
TextButton(
onClick = {
viewModel.onConfirmDelete()
Toast.makeText(context, "Task deleted", Toast.LENGTH_SHORT).show()
Toast.makeText(context, taskDeletedMessage, Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
@@ -28,6 +28,7 @@ import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -41,7 +42,7 @@ import androidx.compose.ui.unit.sp
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
import com.wismna.geoffroy.donext.presentation.model.TaskItemUiState
@Composable
fun TaskItemScreen(
@@ -51,7 +52,7 @@ fun TaskItemScreen(
onSwipeRight: () -> Unit,
onTaskClick: (task: Task) -> Unit
) {
val viewModel = TaskItemViewModel(task)
val uiState = remember(task) { TaskItemUiState(task) }
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
@@ -63,17 +64,17 @@ fun TaskItemScreen(
)
val baseStyle = MaterialTheme.typography.bodyLarge.copy(
fontWeight = when (viewModel.priority) {
fontWeight = when (uiState.priority) {
Priority.HIGH -> FontWeight.Bold
Priority.NORMAL -> FontWeight.Normal
Priority.LOW -> FontWeight.Normal
},
color = when (viewModel.priority) {
color = when (uiState.priority) {
Priority.HIGH -> MaterialTheme.colorScheme.onSurface
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
},
textDecoration = if (viewModel.isDone) TextDecoration.LineThrough else TextDecoration.None
textDecoration = if (uiState.isDone) TextDecoration.LineThrough else TextDecoration.None
)
Card(
modifier = modifier,
@@ -87,8 +88,8 @@ fun TaskItemScreen(
backgroundContent = {
DismissBackground(
dismissState,
viewModel.isDone,
viewModel.isDeleted
uiState.isDone,
uiState.isDeleted
)
},
content = {
@@ -97,7 +98,7 @@ fun TaskItemScreen(
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(8.dp)
.alpha(if (viewModel.isDone || viewModel.priority == Priority.LOW) 0.5f else 1f),
.alpha(if (uiState.isDone || uiState.priority == Priority.LOW) 0.5f else 1f),
verticalAlignment = Alignment.CenterVertically // centers checkbox + content
) {
Box(
@@ -108,12 +109,12 @@ fun TaskItemScreen(
) {
// Title
Text(
text = viewModel.name,
text = uiState.name,
fontSize = 18.sp,
style = baseStyle,
modifier = Modifier
.align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterStart
if (uiState.description.isNullOrBlank()) Alignment.CenterStart
else Alignment.TopStart
),
overflow = TextOverflow.Ellipsis,
@@ -121,19 +122,19 @@ fun TaskItemScreen(
)
// Due date badge
viewModel.dueDateText?.let { dueMillis ->
uiState.dueDateText?.let { dueMillis ->
Badge(
modifier = Modifier
.align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterEnd
if (uiState.description.isNullOrBlank()) Alignment.CenterEnd
else Alignment.TopEnd
),
containerColor = if (viewModel.isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer
containerColor = if (uiState.isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer
) {
Text(
modifier = Modifier.padding(start = 1.dp, end = 1.dp),
text = viewModel.dueDateText,
color = if (viewModel.isOverdue) Color.White else MaterialTheme.colorScheme.onPrimaryContainer,
text = uiState.dueDateText,
color = if (uiState.isOverdue) Color.White else MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.bodySmall
)
}
@@ -147,9 +148,9 @@ fun TaskItemScreen(
.padding(top = 24.dp),
contentAlignment = Alignment.TopStart
) {
if (!viewModel.description.isNullOrBlank()) {
if (!uiState.description.isNullOrBlank()) {
Text(
text = viewModel.description,
text = uiState.description,
color = MaterialTheme.colorScheme.tertiary,
style = baseStyle.copy(
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
@@ -189,7 +189,7 @@ fun TaskScreen(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.primary
)
) { Text("Cancel") }
) { Text(stringResource(R.string.task_cancel)) }
// --- Save Button ---
Button(
@@ -71,12 +71,14 @@ class TaskViewModel @Inject constructor(
fun save(onDone: (() -> Unit)? = null) {
if (title.isBlank()) return
val listId = taskListId ?: return
viewModelScope.launch {
if (isEditing()) {
updateTaskUseCase(editingTaskId!!, taskListId!!, title, description, priority, dueDate, isDone)
val currentEditingTaskId = editingTaskId
if (currentEditingTaskId != null) {
updateTaskUseCase(currentEditingTaskId, listId, title, description, priority, dueDate, isDone)
} else {
createTaskUseCase(taskListId!!, title, description, priority, dueDate)
createTaskUseCase(listId, title, description, priority, dueDate)
}
onDone?.invoke()
}
@@ -34,6 +34,7 @@
<string name="dialog_delete_task_description">Êtes-vous sûr de vouloir supprimer cette tâche ? Cette action ne peut être annulée.</string>
<string name="dialog_delete_task_cancel">Annuler</string>
<string name="dialog_delete_task_delete">Supprimer</string>
<string name="toast_task_permanently_deleted">Tâche supprimée</string>
<string name="dialog_empty_task_title">Vider la corbeille</string>
<string name="dialog_empty_task_description">Êtes-vous sûr de vider la corbeille ? Cette action ne peut être annulée.</string>
<string name="dialog_empty_task_cancel">Annuler</string>
+1
View File
@@ -44,6 +44,7 @@
<string name="dialog_delete_task_description">Are you sure you want to permanently delete this task? This cannot be undone.</string>
<string name="dialog_delete_task_cancel">Cancel</string>
<string name="dialog_delete_task_delete">Delete</string>
<string name="toast_task_permanently_deleted">Task deleted</string>
<string name="dialog_empty_task_title">Empty Recycle Bin</string>
<string name="dialog_empty_task_description">Are you sure you want to permanently delete all tasks in the recycle bin? This cannot be undone.</string>
@@ -0,0 +1,34 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DeleteTaskListUseCaseTest {
private val taskRepository = mockk<TaskRepository>(relaxed = true)
private val taskListRepository = mockk<TaskListRepository>(relaxed = true)
private val useCase = DeleteTaskListUseCase(taskRepository, taskListRepository)
@Test
fun `deleting a list also toggles all its tasks, list last`() = runTest {
useCase(5L, true)
coVerifyOrder {
taskRepository.toggleAllTasksInListDeleted(5L, true)
taskListRepository.deleteTaskList(5L, true)
}
}
@Test
fun `restoring a list also restores all its tasks`() = runTest {
useCase(5L, false)
coVerify { taskRepository.toggleAllTasksInListDeleted(5L, false) }
coVerify { taskListRepository.deleteTaskList(5L, false) }
}
}
@@ -0,0 +1,80 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneId
class GetDueTodayTasksUseCaseTest {
@Test
fun `computes today's window using the clock's own zone, not UTC`() {
// Paris in summer is UTC+2: local midnight for this date is 22:00 UTC
// the *previous* day. A window anchored to UTC midnight would miss it.
val zone = ZoneId.of("Europe/Paris")
val today = LocalDate.of(2026, 7, 16)
val clock = Clock.fixed(today.atStartOfDay(zone).plusHours(10).toInstant(), zone)
val repository = mockk<TaskRepository>()
every { repository.getDueTodayTasks(any(), any()) } returns flowOf(emptyList())
GetDueTodayTasksUseCase(repository, clock).invoke()
val expectedStart = today.atStartOfDay(zone).toInstant().toEpochMilli()
val expectedEnd = today.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - 1
verify { repository.getDueTodayTasks(expectedStart, expectedEnd) }
}
@Test
fun `window covers exactly local midnight to the millisecond before the next local midnight`() {
val zone = ZoneId.of("America/Montreal")
val today = LocalDate.of(2026, 1, 5)
val clock = Clock.fixed(today.atStartOfDay(zone).plusHours(6).toInstant(), zone)
val repository = mockk<TaskRepository>()
every { repository.getDueTodayTasks(any(), any()) } returns flowOf(emptyList())
GetDueTodayTasksUseCase(repository, clock).invoke()
val expectedStart = today.atStartOfDay(zone).toInstant().toEpochMilli()
val expectedEnd = today.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - 1
assertThat(expectedEnd - expectedStart).isEqualTo(24L * 60 * 60 * 1000 - 1)
verify { repository.getDueTodayTasks(expectedStart, expectedEnd) }
}
@Test
fun `passes through the tasks emitted by the repository`() = runTest {
val zone = ZoneId.systemDefault()
val clock = Clock.fixed(LocalDate.of(2026, 1, 1).atStartOfDay(zone).toInstant(), zone)
val tasks = listOf(
Task(
id = 1L,
taskListId = 1L,
name = "Task",
description = "",
priority = Priority.NORMAL,
isDone = false,
isDeleted = false
)
)
val repository = mockk<TaskRepository>()
every { repository.getDueTodayTasks(any(), any()) } returns flowOf(tasks)
val result = GetDueTodayTasksUseCase(repository, clock).invoke().first()
assertThat(result).isEqualTo(tasks)
}
}
@@ -0,0 +1,50 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneId
class GetTaskListsWithOverdueUseCaseTest {
@Test
fun `computes the overdue cutoff at local midnight, not UTC midnight`() {
// Paris in summer is UTC+2: local midnight for this date is 22:00 UTC
// the *previous* day. A cutoff anchored to UTC midnight would flag
// today's tasks as overdue a full day too early.
val zone = ZoneId.of("Europe/Paris")
val today = LocalDate.of(2026, 7, 16)
val clock = Clock.fixed(today.atStartOfDay(zone).plusHours(10).toInstant(), zone)
val repository = mockk<TaskListRepository>()
every { repository.getTaskListsWithOverdue(any()) } returns flowOf(emptyList())
GetTaskListsWithOverdueUseCase(repository, clock).invoke()
val expectedCutoff = today.atStartOfDay(zone).toInstant().toEpochMilli()
verify { repository.getTaskListsWithOverdue(expectedCutoff) }
}
@Test
fun `passes through the lists emitted by the repository`() = runTest {
val zone = ZoneId.systemDefault()
val clock = Clock.fixed(LocalDate.of(2026, 1, 1).atStartOfDay(zone).toInstant(), zone)
val lists = listOf(TaskListWithOverdue(id = 1L, name = "Work", overdueCount = 2))
val repository = mockk<TaskListRepository>()
every { repository.getTaskListsWithOverdue(any()) } returns flowOf(lists)
val result = GetTaskListsWithOverdueUseCase(repository, clock).invoke().first()
assertThat(result).isEqualTo(lists)
}
}
@@ -0,0 +1,62 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ToggleTaskDeletedUseCaseTest {
private val taskRepository = mockk<TaskRepository>(relaxed = true)
private val taskListRepository = mockk<TaskListRepository>(relaxed = true)
private val useCase = ToggleTaskDeletedUseCase(taskRepository, taskListRepository)
private val task = Task(
id = 1L,
taskListId = 5L,
name = "Task",
description = "",
priority = Priority.NORMAL,
isDone = false,
isDeleted = true
)
@Test
fun `restoring a task also restores its soft-deleted parent list`() = runTest {
val deletedList = TaskList(id = 5L, name = "List", isDeleted = true, order = 0)
coEvery { taskRepository.getTaskById(1L) } returns task
coEvery { taskListRepository.getTaskListById(5L) } returns deletedList
useCase(1L, false)
coVerify { taskListRepository.updateTaskList(deletedList.copy(isDeleted = false)) }
coVerify { taskRepository.toggleTaskDeleted(1L, false) }
}
@Test
fun `restoring a task whose parent list is not deleted leaves the list untouched`() = runTest {
val activeList = TaskList(id = 5L, name = "List", isDeleted = false, order = 0)
coEvery { taskRepository.getTaskById(1L) } returns task
coEvery { taskListRepository.getTaskListById(5L) } returns activeList
useCase(1L, false)
coVerify(exactly = 0) { taskListRepository.updateTaskList(any()) }
coVerify { taskRepository.toggleTaskDeleted(1L, false) }
}
@Test
fun `soft-deleting a task never touches its parent list`() = runTest {
useCase(1L, true)
coVerify(exactly = 0) { taskRepository.getTaskById(any()) }
coVerify(exactly = 0) { taskListRepository.getTaskListById(any()) }
coVerify { taskRepository.toggleTaskDeleted(1L, true) }
}
}
@@ -1,4 +1,4 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
package com.wismna.geoffroy.donext.presentation.model
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Priority
@@ -11,7 +11,7 @@ import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.*
class TaskItemViewModelTest {
class TaskItemUiStateTest {
private val fixedClock: Clock = Clock.fixed(
LocalDate.of(2025, 1, 10)
@@ -47,80 +47,80 @@ class TaskItemViewModelTest {
@Test
fun `initializes fields from Task`() {
val viewModel = TaskItemViewModel(baseTask)
val uiState = TaskItemUiState(baseTask)
assertThat(viewModel.id).isEqualTo(baseTask.id)
assertThat(viewModel.name).isEqualTo(baseTask.name)
assertThat(viewModel.description).isEqualTo(baseTask.description)
assertThat(viewModel.isDone).isFalse()
assertThat(viewModel.isDeleted).isFalse()
assertThat(viewModel.priority).isEqualTo(Priority.NORMAL)
assertThat(uiState.id).isEqualTo(baseTask.id)
assertThat(uiState.name).isEqualTo(baseTask.name)
assertThat(uiState.description).isEqualTo(baseTask.description)
assertThat(uiState.isDone).isFalse()
assertThat(uiState.isDeleted).isFalse()
assertThat(uiState.priority).isEqualTo(Priority.NORMAL)
}
@Test
fun `isOverdue is true when due date is before today`() {
val overdueTask = baseTask.copy(dueDate = millisForDaysFromFixedToday(-1))
val viewModel = TaskItemViewModel(overdueTask)
val uiState = TaskItemUiState(overdueTask)
assertThat(viewModel.isOverdue).isTrue()
assertThat(uiState.isOverdue).isTrue()
}
@Test
fun `isOverdue is false when due date is today`() {
val dueToday = baseTask.copy(dueDate = millisForDaysFromFixedToday(0))
val viewModel = TaskItemViewModel(dueToday, fixedClock)
val uiState = TaskItemUiState(dueToday, fixedClock)
assertThat(viewModel.isOverdue).isFalse()
assertThat(uiState.isOverdue).isFalse()
}
@Test
fun `isOverdue is false when due date is null`() {
val viewModel = TaskItemViewModel(baseTask.copy(dueDate = null))
val uiState = TaskItemUiState(baseTask.copy(dueDate = null))
assertThat(viewModel.isOverdue).isFalse()
assertThat(uiState.isOverdue).isFalse()
}
@Test
fun `dueDateText is Today when due date is today`() {
val dueToday = baseTask.copy(dueDate = millisForDaysFromFixedToday(0))
val viewModel = TaskItemViewModel(dueToday, fixedClock)
val uiState = TaskItemUiState(dueToday, fixedClock)
assertThat(viewModel.dueDateText).isEqualTo("Today")
assertThat(uiState.dueDateText).isEqualTo("Today")
}
@Test
fun `dueDateText is Tomorrow when due date is tomorrow`() {
val dueTomorrow = baseTask.copy(dueDate = millisForDaysFromFixedToday(1))
val viewModel = TaskItemViewModel(dueTomorrow, fixedClock)
val uiState = TaskItemUiState(dueTomorrow, fixedClock)
assertThat(viewModel.dueDateText).isEqualTo("Tomorrow")
assertThat(uiState.dueDateText).isEqualTo("Tomorrow")
}
@Test
fun `dueDateText is Yesterday when due date was yesterday`() {
val dueYesterday = baseTask.copy(dueDate = millisForDaysFromFixedToday(-1))
val viewModel = TaskItemViewModel(dueYesterday, fixedClock)
val uiState = TaskItemUiState(dueYesterday, fixedClock)
assertThat(viewModel.dueDateText).isEqualTo("Yesterday")
assertThat(uiState.dueDateText).isEqualTo("Yesterday")
}
@Test
fun `dueDateText is day of week when within next 7 days`() {
val dueIn3Days = baseTask.copy(dueDate = millisForDaysFromFixedToday(3))
val viewModel = TaskItemViewModel(dueIn3Days, fixedClock)
val uiState = TaskItemUiState(dueIn3Days, fixedClock)
val expected = today
.plusDays(3)
.dayOfWeek
.getDisplayName(TextStyle.SHORT, Locale.getDefault())
assertThat(viewModel.dueDateText).isEqualTo(expected)
assertThat(uiState.dueDateText).isEqualTo(expected)
}
@Test
fun `dueDateText is formatted date when more than 7 days away`() {
val dueIn10Days = baseTask.copy(dueDate = millisForDaysFromFixedToday(10))
val viewModel = TaskItemViewModel(dueIn10Days)
val uiState = TaskItemUiState(dueIn10Days)
val expected = today
.plusDays(10)
@@ -129,6 +129,6 @@ class TaskItemViewModelTest {
.withLocale(Locale.getDefault())
)
assertThat(viewModel.dueDateText).isEqualTo(expected)
assertThat(uiState.dueDateText).isEqualTo(expected)
}
}
@@ -180,6 +180,16 @@ class TaskViewModelTest {
coVerify(exactly = 0) { updateTaskUseCase(any(), any(), any(), any(), any(), any(), any()) }
}
@Test
fun `save before taskListId is set does nothing`() = runTest {
viewModel.onTitleChanged("Task")
viewModel.save()
advanceUntilIdle()
coVerify(exactly = 0) { createTaskUseCase(any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { updateTaskUseCase(any(), any(), any(), any(), any(), any(), any()) }
}
@Test
fun `save creates task when not editing`() = runTest {stickyEventsFlow.emit(UiEvent.CreateNewTask(3L))
advanceUntilIdle()
+10
View File
@@ -16,7 +16,17 @@
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
android.builtInKotlin=false
android.defaults.buildfeatures.resvalues=true
android.dependency.useConstraints=true
android.enableAppCompileTimeRClass=false
android.enableJetifier=false
android.newDsl=false
android.r8.optimizedResourceShrinking=false
android.r8.strictFullModeForKeepRules=false
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.uniquePackageNames=false
android.useAndroidX=true
android.usesSdkInManifest.disallowed=false
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx4g
+13
View File
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+3
View File
@@ -1,2 +1,5 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
}
include ':donext'
include ':donextv2'