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: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Run unit tests
run: ./gradlew :donextv2:testDebugUnitTest
- name: Decode signing key - name: Decode signing key
run: echo "${{ secrets.SIGNING_KEY_BASE64 }}" | base64 --decode > ${{ env.MODULE }}/upload.jks run: echo "${{ secrets.SIGNING_KEY_BASE64 }}" | base64 --decode > ${{ env.MODULE }}/upload.jks
+1
View File
@@ -10,3 +10,4 @@
/captures /captures
/fastlane/keys/ /fastlane/keys/
/donext*/release/ /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> <selectionStates>
<SelectionState runConfigName="donextv2"> <SelectionState runConfigName="donextv2">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState> </SelectionState>
<SelectionState runConfigName="donext"> <SelectionState runConfigName="donext">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
-1
View File
@@ -6,7 +6,6 @@
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" /> <option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
+5 -5
View File
@@ -13,9 +13,9 @@ buildscript {
google() google()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.13.0' classpath 'com.android.tools.build:gradle:9.3.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.10'
classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.21' classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.2.10'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
@@ -23,8 +23,8 @@ buildscript {
} }
plugins { plugins {
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false id("com.google.devtools.ksp") version '2.3.2' apply false
id("com.google.dagger.hilt.android") version "2.57.2" apply false id("com.google.dagger.hilt.android") version "2.60.1" apply false
} }
allprojects { allprojects {
+6 -6
View File
@@ -1,7 +1,7 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
android { android {
compileSdk 36 compileSdk 37
defaultConfig { defaultConfig {
applicationId "com.wismna.geoffroy.donext" applicationId "com.wismna.geoffroy.donext"
@@ -13,7 +13,7 @@ android {
buildTypes { buildTypes {
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
} }
namespace 'com.wismna.geoffroy.donext' namespace 'com.wismna.geoffroy.donext'
@@ -26,12 +26,12 @@ android {
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.7.1' 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.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.percentlayout:percentlayout:1.0.0' implementation 'androidx.percentlayout:percentlayout:1.0.0'
implementation 'androidx.recyclerview:recyclerview:1.4.0' implementation 'androidx.recyclerview:recyclerview:1.4.0'
implementation 'androidx.browser:browser:1.9.0' implementation 'androidx.browser:browser:1.10.0'
implementation 'net.danlew:android.joda:2.13.1' implementation 'net.danlew:android.joda:2.14.2'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.preference:preference:1.2.1' implementation 'androidx.preference:preference:1.2.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
@@ -42,7 +42,7 @@ dependencies {
implementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}" implementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}"
// Lifecycle components // Lifecycle components
def lifecycleVersion = '2.9.4' def lifecycleVersion = '2.11.0'
implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
+14 -17
View File
@@ -14,14 +14,14 @@ ksp {
android { android {
namespace = "com.wismna.geoffroy.donext" namespace = "com.wismna.geoffroy.donext"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "com.wismna.geoffroy.donext" applicationId = "com.wismna.geoffroy.donext"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 35 versionCode = 36
versionName = "2.0.1" versionName = "2.0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -65,9 +65,6 @@ android {
compose = true compose = true
viewBinding = true viewBinding = true
} }
composeOptions {
kotlinCompilerExtensionVersion = "1.1.1"
}
configurations.all { configurations.all {
resolutionStrategy { resolutionStrategy {
@@ -82,35 +79,35 @@ android {
} }
dependencies { dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
implementation("androidx.activity:activity-compose:1.11.0") implementation("androidx.activity:activity-compose:1.13.0")
implementation(platform("androidx.compose:compose-bom:2025.11.00")) implementation(platform("androidx.compose:compose-bom:2026.06.01"))
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material3:material3-window-size-class:1.4.0") implementation("androidx.compose.material3:material3-window-size-class:1.4.0")
implementation("androidx.compose.material:material-icons-extended:1.7.8") implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation("androidx.navigation:navigation-compose:2.9.6") implementation("androidx.navigation:navigation-compose:2.9.8")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0") implementation("androidx.hilt:hilt-navigation-compose:1.4.0")
implementation("sh.calvin.reorderable:reorderable:3.0.0") implementation("sh.calvin.reorderable:reorderable:3.1.0")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.14.6") testImplementation("io.mockk:mockk:1.14.11")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
testImplementation("com.google.truth:truth:1.4.5") testImplementation("com.google.truth:truth:1.4.5")
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0") 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("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("com.google.truth:truth:1.4.5") androidTestImplementation("com.google.truth:truth:1.4.5")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")
val roomVersion = "2.8.3" val roomVersion = "2.8.4"
implementation("androidx.room:room-runtime:$roomVersion") implementation("androidx.room:room-runtime:$roomVersion")
androidTestImplementation("androidx.room:room-testing:$roomVersion") androidTestImplementation("androidx.room:room-testing:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion") ksp("androidx.room:room-compiler:$roomVersion")
val hiltVersion = "2.57.2" val hiltVersion = "2.60.1"
implementation("com.google.dagger:hilt-android:$hiltVersion") implementation("com.google.dagger:hilt-android:$hiltVersion")
ksp("com.google.dagger:hilt-android-compiler:$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 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.data.local.repository.TaskRepositoryImpl
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
@@ -15,4 +17,9 @@ abstract class RepositoryModule {
abstract fun bindTaskRepository( abstract fun bindTaskRepository(
impl: TaskRepositoryImpl impl: TaskRepositoryImpl
): TaskRepository ): 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 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.TaskDao
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import com.wismna.geoffroy.donext.data.toDomain import com.wismna.geoffroy.donext.data.toDomain
import com.wismna.geoffroy.donext.data.toEntity import com.wismna.geoffroy.donext.data.toEntity
import com.wismna.geoffroy.donext.domain.model.Task 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.model.TaskWithListName
import com.wismna.geoffroy.donext.domain.repository.TaskRepository import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.map
class TaskRepositoryImpl @Inject constructor( class TaskRepositoryImpl @Inject constructor(
private val taskDao: TaskDao, private val taskDao: TaskDao
private val taskListDao: TaskListDao
): TaskRepository { ): TaskRepository {
override fun getTasksForList(listId: Long): Flow<List<Task>> { override fun getTasksForList(listId: Long): Flow<List<Task>> {
return taskDao.getTasksForList(listId).map {entity -> entity.map { it.toDomain() }} return taskDao.getTasksForList(listId).map {entity -> entity.map { it.toDomain() }}
@@ -58,28 +53,7 @@ class TaskRepositoryImpl @Inject constructor(
taskDao.permanentDeleteAllDeletedTasks() taskDao.permanentDeleteAllDeletedTasks()
} }
override fun getTaskLists(): Flow<List<TaskList>> { override suspend fun toggleAllTasksInListDeleted(taskListId: Long, isDeleted: Boolean) {
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) {
taskDao.toggleAllTasksFromListDeleted(taskListId, isDeleted) 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 package com.wismna.geoffroy.donext.domain.repository
import com.wismna.geoffroy.donext.domain.model.Task 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.model.TaskWithListName
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -17,11 +15,5 @@ interface TaskRepository {
suspend fun toggleTaskDone(taskId: Long, isDone: Boolean) suspend fun toggleTaskDone(taskId: Long, isDone: Boolean)
suspend fun permanentlyDeleteTask(taskId: Long) suspend fun permanentlyDeleteTask(taskId: Long)
suspend fun permanentlyDeleteAllDeletedTask() suspend fun permanentlyDeleteAllDeletedTask()
suspend fun toggleAllTasksInListDeleted(taskListId: Long, isDeleted: Boolean)
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,11 +1,11 @@
package com.wismna.geoffroy.donext.domain.usecase package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskList 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 import javax.inject.Inject
class AddTaskListUseCase @Inject constructor( class AddTaskListUseCase @Inject constructor(
private val repository: TaskRepository private val repository: TaskListRepository
) { ) {
suspend operator fun invoke(title: String, order: Int) { suspend operator fun invoke(title: String, order: Int) {
repository.insertTaskList( repository.insertTaskList(
@@ -1,12 +1,15 @@
package com.wismna.geoffroy.donext.domain.usecase package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject import javax.inject.Inject
class DeleteTaskListUseCase@Inject constructor( class DeleteTaskListUseCase @Inject constructor(
private val repository: TaskRepository private val taskRepository: TaskRepository,
private val taskListRepository: TaskListRepository
) { ) {
suspend operator fun invoke(taskListId: Long, isDeleted: Boolean) { 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.model.Task
import com.wismna.geoffroy.donext.domain.repository.TaskRepository import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.time.Clock
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject 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>> { operator fun invoke(): Flow<List<Task>> {
val todayStart = LocalDate.now() val today = LocalDate.now(clock)
.atStartOfDay(ZoneOffset.UTC) val todayStart = today
.atStartOfDay(clock.zone)
.toInstant() .toInstant()
.toEpochMilli() .toEpochMilli()
val todayEnd = LocalDate.now() val todayEnd = today
.plusDays(1) .plusDays(1)
.atStartOfDay(ZoneOffset.UTC) .atStartOfDay(clock.zone)
.toInstant() .toInstant()
.toEpochMilli() - 1 .toEpochMilli() - 1
return repository.getDueTodayTasks( return repository.getDueTodayTasks(
@@ -1,10 +1,10 @@
package com.wismna.geoffroy.donext.domain.usecase package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskList 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 kotlinx.coroutines.flow.Flow
import javax.inject.Inject 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() operator fun invoke(): Flow<List<TaskList>> = repository.getTaskLists()
} }
@@ -1,19 +1,22 @@
package com.wismna.geoffroy.donext.domain.usecase package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue 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 kotlinx.coroutines.flow.Flow
import java.time.Clock
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject 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>> { operator fun invoke(): Flow<List<TaskListWithOverdue>> {
return taskRepository.getTaskListsWithOverdue( return taskListRepository.getTaskListsWithOverdue(
LocalDate.now() LocalDate.now(clock)
.atStartOfDay(ZoneOffset.UTC) .atStartOfDay(clock.zone)
.toInstant() .toInstant()
.toEpochMilli() .toEpochMilli()
) )
} }
} }
@@ -1,23 +1,25 @@
package com.wismna.geoffroy.donext.domain.usecase package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import javax.inject.Inject import javax.inject.Inject
class ToggleTaskDeletedUseCase @Inject constructor( class ToggleTaskDeletedUseCase @Inject constructor(
private val repository: TaskRepository private val taskRepository: TaskRepository,
private val taskListRepository: TaskListRepository
) { ) {
suspend operator fun invoke(taskId: Long, isDeleted: Boolean) { suspend operator fun invoke(taskId: Long, isDeleted: Boolean) {
if (!isDeleted) { if (!isDeleted) {
val task = repository.getTaskById(taskId) val task = taskRepository.getTaskById(taskId)
if (task != null) { if (task != null) {
// If task list was soft-deleted, restore it as well // 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) { 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 package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.TaskList 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 import javax.inject.Inject
class UpdateTaskListUseCase @Inject constructor( class UpdateTaskListUseCase @Inject constructor(
private val repository: TaskRepository private val repository: TaskListRepository
) { ) {
suspend operator fun invoke(taskListId: Long, title: String, order: Int) { suspend operator fun invoke(taskListId: Long, title: String, order: Int) {
repository.updateTaskList( 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.extension.toLocalDate
import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.Priority
@@ -10,7 +10,7 @@ import java.time.format.FormatStyle
import java.time.format.TextStyle import java.time.format.TextStyle
import java.util.Locale import java.util.Locale
class TaskItemViewModel( class TaskItemUiState(
task: Task, task: Task,
private val clock: Clock = Clock.systemDefaultZone()) { private val clock: Clock = Clock.systemDefaultZone()) {
val id: Long = task.id!! val id: Long = task.id!!
@@ -63,6 +63,7 @@ fun RecycleBinScreen(
val grouped = tasks.groupBy { it.listName } val grouped = tasks.groupBy { it.listName }
val context = LocalContext.current val context = LocalContext.current
val taskDeletedMessage = stringResource(R.string.toast_task_permanently_deleted)
if (taskToDelete != null) { if (taskToDelete != null) {
AlertDialog( AlertDialog(
@@ -75,7 +76,7 @@ fun RecycleBinScreen(
TextButton( TextButton(
onClick = { onClick = {
viewModel.onConfirmDelete() viewModel.onConfirmDelete()
Toast.makeText(context, "Task deleted", Toast.LENGTH_SHORT).show() Toast.makeText(context, taskDeletedMessage, Toast.LENGTH_SHORT).show()
}, },
colors = ButtonDefaults.textButtonColors( colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error contentColor = MaterialTheme.colorScheme.error
@@ -28,6 +28,7 @@ import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.R
import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task 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 @Composable
fun TaskItemScreen( fun TaskItemScreen(
@@ -51,7 +52,7 @@ fun TaskItemScreen(
onSwipeRight: () -> Unit, onSwipeRight: () -> Unit,
onTaskClick: (task: Task) -> Unit onTaskClick: (task: Task) -> Unit
) { ) {
val viewModel = TaskItemViewModel(task) val uiState = remember(task) { TaskItemUiState(task) }
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { confirmValueChange = {
@@ -63,17 +64,17 @@ fun TaskItemScreen(
) )
val baseStyle = MaterialTheme.typography.bodyLarge.copy( val baseStyle = MaterialTheme.typography.bodyLarge.copy(
fontWeight = when (viewModel.priority) { fontWeight = when (uiState.priority) {
Priority.HIGH -> FontWeight.Bold Priority.HIGH -> FontWeight.Bold
Priority.NORMAL -> FontWeight.Normal Priority.NORMAL -> FontWeight.Normal
Priority.LOW -> FontWeight.Normal Priority.LOW -> FontWeight.Normal
}, },
color = when (viewModel.priority) { color = when (uiState.priority) {
Priority.HIGH -> MaterialTheme.colorScheme.onSurface Priority.HIGH -> MaterialTheme.colorScheme.onSurface
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
}, },
textDecoration = if (viewModel.isDone) TextDecoration.LineThrough else TextDecoration.None textDecoration = if (uiState.isDone) TextDecoration.LineThrough else TextDecoration.None
) )
Card( Card(
modifier = modifier, modifier = modifier,
@@ -87,8 +88,8 @@ fun TaskItemScreen(
backgroundContent = { backgroundContent = {
DismissBackground( DismissBackground(
dismissState, dismissState,
viewModel.isDone, uiState.isDone,
viewModel.isDeleted uiState.isDeleted
) )
}, },
content = { content = {
@@ -97,7 +98,7 @@ fun TaskItemScreen(
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer) .background(MaterialTheme.colorScheme.surfaceContainer)
.padding(8.dp) .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 verticalAlignment = Alignment.CenterVertically // centers checkbox + content
) { ) {
Box( Box(
@@ -108,12 +109,12 @@ fun TaskItemScreen(
) { ) {
// Title // Title
Text( Text(
text = viewModel.name, text = uiState.name,
fontSize = 18.sp, fontSize = 18.sp,
style = baseStyle, style = baseStyle,
modifier = Modifier modifier = Modifier
.align( .align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterStart if (uiState.description.isNullOrBlank()) Alignment.CenterStart
else Alignment.TopStart else Alignment.TopStart
), ),
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -121,19 +122,19 @@ fun TaskItemScreen(
) )
// Due date badge // Due date badge
viewModel.dueDateText?.let { dueMillis -> uiState.dueDateText?.let { dueMillis ->
Badge( Badge(
modifier = Modifier modifier = Modifier
.align( .align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterEnd if (uiState.description.isNullOrBlank()) Alignment.CenterEnd
else Alignment.TopEnd 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( Text(
modifier = Modifier.padding(start = 1.dp, end = 1.dp), modifier = Modifier.padding(start = 1.dp, end = 1.dp),
text = viewModel.dueDateText, text = uiState.dueDateText,
color = if (viewModel.isOverdue) Color.White else MaterialTheme.colorScheme.onPrimaryContainer, color = if (uiState.isOverdue) Color.White else MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
} }
@@ -147,9 +148,9 @@ fun TaskItemScreen(
.padding(top = 24.dp), .padding(top = 24.dp),
contentAlignment = Alignment.TopStart contentAlignment = Alignment.TopStart
) { ) {
if (!viewModel.description.isNullOrBlank()) { if (!uiState.description.isNullOrBlank()) {
Text( Text(
text = viewModel.description, text = uiState.description,
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
style = baseStyle.copy( style = baseStyle.copy(
fontSize = MaterialTheme.typography.bodyMedium.fontSize, fontSize = MaterialTheme.typography.bodyMedium.fontSize,
@@ -189,7 +189,7 @@ fun TaskScreen(
colors = ButtonDefaults.textButtonColors( colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.primary contentColor = MaterialTheme.colorScheme.primary
) )
) { Text("Cancel") } ) { Text(stringResource(R.string.task_cancel)) }
// --- Save Button --- // --- Save Button ---
Button( Button(
@@ -71,12 +71,14 @@ class TaskViewModel @Inject constructor(
fun save(onDone: (() -> Unit)? = null) { fun save(onDone: (() -> Unit)? = null) {
if (title.isBlank()) return if (title.isBlank()) return
val listId = taskListId ?: return
viewModelScope.launch { viewModelScope.launch {
if (isEditing()) { val currentEditingTaskId = editingTaskId
updateTaskUseCase(editingTaskId!!, taskListId!!, title, description, priority, dueDate, isDone) if (currentEditingTaskId != null) {
updateTaskUseCase(currentEditingTaskId, listId, title, description, priority, dueDate, isDone)
} else { } else {
createTaskUseCase(taskListId!!, title, description, priority, dueDate) createTaskUseCase(listId, title, description, priority, dueDate)
} }
onDone?.invoke() 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_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_cancel">Annuler</string>
<string name="dialog_delete_task_delete">Supprimer</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_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_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> <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_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_cancel">Cancel</string>
<string name="dialog_delete_task_delete">Delete</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_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> <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.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.Priority
@@ -11,7 +11,7 @@ import java.time.format.FormatStyle
import java.time.format.TextStyle import java.time.format.TextStyle
import java.util.* import java.util.*
class TaskItemViewModelTest { class TaskItemUiStateTest {
private val fixedClock: Clock = Clock.fixed( private val fixedClock: Clock = Clock.fixed(
LocalDate.of(2025, 1, 10) LocalDate.of(2025, 1, 10)
@@ -47,80 +47,80 @@ class TaskItemViewModelTest {
@Test @Test
fun `initializes fields from Task`() { fun `initializes fields from Task`() {
val viewModel = TaskItemViewModel(baseTask) val uiState = TaskItemUiState(baseTask)
assertThat(viewModel.id).isEqualTo(baseTask.id) assertThat(uiState.id).isEqualTo(baseTask.id)
assertThat(viewModel.name).isEqualTo(baseTask.name) assertThat(uiState.name).isEqualTo(baseTask.name)
assertThat(viewModel.description).isEqualTo(baseTask.description) assertThat(uiState.description).isEqualTo(baseTask.description)
assertThat(viewModel.isDone).isFalse() assertThat(uiState.isDone).isFalse()
assertThat(viewModel.isDeleted).isFalse() assertThat(uiState.isDeleted).isFalse()
assertThat(viewModel.priority).isEqualTo(Priority.NORMAL) assertThat(uiState.priority).isEqualTo(Priority.NORMAL)
} }
@Test @Test
fun `isOverdue is true when due date is before today`() { fun `isOverdue is true when due date is before today`() {
val overdueTask = baseTask.copy(dueDate = millisForDaysFromFixedToday(-1)) val overdueTask = baseTask.copy(dueDate = millisForDaysFromFixedToday(-1))
val viewModel = TaskItemViewModel(overdueTask) val uiState = TaskItemUiState(overdueTask)
assertThat(viewModel.isOverdue).isTrue() assertThat(uiState.isOverdue).isTrue()
} }
@Test @Test
fun `isOverdue is false when due date is today`() { fun `isOverdue is false when due date is today`() {
val dueToday = baseTask.copy(dueDate = millisForDaysFromFixedToday(0)) 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 @Test
fun `isOverdue is false when due date is null`() { 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 @Test
fun `dueDateText is Today when due date is today`() { fun `dueDateText is Today when due date is today`() {
val dueToday = baseTask.copy(dueDate = millisForDaysFromFixedToday(0)) 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 @Test
fun `dueDateText is Tomorrow when due date is tomorrow`() { fun `dueDateText is Tomorrow when due date is tomorrow`() {
val dueTomorrow = baseTask.copy(dueDate = millisForDaysFromFixedToday(1)) 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 @Test
fun `dueDateText is Yesterday when due date was yesterday`() { fun `dueDateText is Yesterday when due date was yesterday`() {
val dueYesterday = baseTask.copy(dueDate = millisForDaysFromFixedToday(-1)) 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 @Test
fun `dueDateText is day of week when within next 7 days`() { fun `dueDateText is day of week when within next 7 days`() {
val dueIn3Days = baseTask.copy(dueDate = millisForDaysFromFixedToday(3)) val dueIn3Days = baseTask.copy(dueDate = millisForDaysFromFixedToday(3))
val viewModel = TaskItemViewModel(dueIn3Days, fixedClock) val uiState = TaskItemUiState(dueIn3Days, fixedClock)
val expected = today val expected = today
.plusDays(3) .plusDays(3)
.dayOfWeek .dayOfWeek
.getDisplayName(TextStyle.SHORT, Locale.getDefault()) .getDisplayName(TextStyle.SHORT, Locale.getDefault())
assertThat(viewModel.dueDateText).isEqualTo(expected) assertThat(uiState.dueDateText).isEqualTo(expected)
} }
@Test @Test
fun `dueDateText is formatted date when more than 7 days away`() { fun `dueDateText is formatted date when more than 7 days away`() {
val dueIn10Days = baseTask.copy(dueDate = millisForDaysFromFixedToday(10)) val dueIn10Days = baseTask.copy(dueDate = millisForDaysFromFixedToday(10))
val viewModel = TaskItemViewModel(dueIn10Days) val uiState = TaskItemUiState(dueIn10Days)
val expected = today val expected = today
.plusDays(10) .plusDays(10)
@@ -129,6 +129,6 @@ class TaskItemViewModelTest {
.withLocale(Locale.getDefault()) .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()) } 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 @Test
fun `save creates task when not editing`() = runTest {stickyEventsFlow.emit(UiEvent.CreateNewTask(3L)) fun `save creates task when not editing`() = runTest {stickyEventsFlow.emit(UiEvent.CreateNewTask(3L))
advanceUntilIdle() advanceUntilIdle()
+10
View File
@@ -16,7 +16,17 @@
# This option should only be used with decoupled projects. More details, visit # 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 # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true # org.gradle.parallel=true
android.builtInKotlin=false
android.defaults.buildfeatures.resvalues=true
android.dependency.useConstraints=true
android.enableAppCompileTimeRClass=false
android.enableJetifier=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.useAndroidX=true
android.usesSdkInManifest.disallowed=false
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx4g 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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 ':donext'
include ':donextv2' include ':donextv2'