Compare commits

..

18 Commits

Author SHA1 Message Date
Geoffroy Bonneville
c2c146a28e Fix fastlane gradle path 2025-11-10 12:46:04 -05:00
Geoffroy Bonneville
0b479e10c8 Fix pipeline 2025-11-10 12:38:45 -05:00
Geoffroy Bonneville
0fbdec570a Fix fastlane gradle file read 2025-11-10 12:34:45 -05:00
Geoffroy Bonneville
e44bb99479 Fix fastlane local version retrieval 2025-11-10 12:23:46 -05:00
Geoffroy Bonneville
dcaecbf185 Fix scrolling in menu
Improve pipeline
2025-11-10 12:14:38 -05:00
Geoffroy Bonneville
e6f81fa177 Shrink changelog 2025-11-09 19:18:17 -05:00
Geoffroy Bonneville
b3af094eed Fix key store path 2025-11-09 18:54:08 -05:00
Geoffroy Bonneville
49a58a8977 Sign AAB 2025-11-09 18:42:33 -05:00
Geoffroy Bonneville
af0bb51f01 Bump up Ruby version
Use Bundle in pipeline
2025-11-09 15:33:16 -05:00
Geoffroy Bonneville
5e418211bf Use fastlane for deployment 2025-11-09 15:27:41 -05:00
Geoffroy Bonneville
92dc0ffa2d Use fastlane for deployment 2025-11-09 15:27:11 -05:00
Geoffroy Bonneville
b48655e40a Fix deploy pipeline (ter) 2025-11-08 22:09:54 -05:00
Geoffroy Bonneville
7a453d0d79 Fix deploy pipeline (bis) 2025-11-08 22:07:24 -05:00
Geoffroy Bonneville
2e5970dc97 Fix deploy pipeline 2025-11-08 16:19:56 -05:00
Geoffroy Bonneville
6df720fb8c Update a package
Run tests in pipeline
2025-11-08 14:45:32 -05:00
Geoffroy Bonneville
92263bc4ec Update screenshots and metadata
Update pipeline to build new module
2025-11-08 14:33:39 -05:00
Geoffroy Bonneville
157b577397 Fix imports 2025-11-07 17:01:07 -05:00
Geoffroy Bonneville
c47ce57c31 Add remaining ViewModels Unit Tests
Update packages
Make Clock a dependency in TaskItemViewModel
2025-11-07 16:59:28 -05:00
45 changed files with 1742 additions and 144 deletions

View File

@@ -5,10 +5,9 @@ on:
branches:
- master
pull_request:
workflow_dispatch:
env:
BUILD_DIR: donext/build/outputs/bundle/release
MODULE: donextv2
jobs:
build:
@@ -16,7 +15,7 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v3
@@ -24,10 +23,6 @@ jobs:
distribution: 'temurin'
java-version: '17'
- name: Replace tokens in build.gradle
run: |
sed -i 's/#{\([^}]*\)}/${\1}/g' donext/build.gradle
- name: Cache Gradle
uses: actions/cache@v3
with:
@@ -38,34 +33,26 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
- name: Build AAB
run: ./gradlew bundleRelease
- name: Decode signing key
run: echo "${{ secrets.SIGNING_KEY_BASE64 }}" | base64 --decode > ${{ env.MODULE }}/upload.jks
- name: Upload artifact
uses: actions/upload-artifact@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
name: release-aab
path: ${{ env.BUILD_DIR }}/*release*.aab
ruby-version: 3.4
deploy-google-play:
needs: build
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
- name: Install Fastlane
run: bundle install
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: release-aab
path: release
- name: Configure Google credentials
run: echo '${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}' > service-account.json
- name: Deploy to Google Play (production)
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.wismna.geoffroy.donext
releaseFiles: release/app-release.aab
track: beta
metadataRootDirectory: fastlane/metadata/android
whatsNewDirectory: fastlane/metadata/android/en-US/changelogs
status: completed
- name: Run Fastlane
env:
MODULE: ${{ env.MODULE }}
SUPPLY_JSON_KEY: service-account.json
KEYSTORE_FILE: upload.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
ALIAS_NAME: ${{ secrets.ALIAS_NAME }}
ALIAS_PASSWORD: ${{ secrets.ALIAS_PASSWORD }}
run: bundle exec fastlane internal

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
*.iml
*.apk
*.jks
.gradle
/local.properties
/.idea/workspace.xml
@@ -7,3 +8,5 @@
.DS_Store
/build
/captures
/fastlane/keys/
/donext*/release/

View File

@@ -29,6 +29,32 @@
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-885367779">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="676896396">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1337588336">
<value>
<AndroidTestResultsTableState>
@@ -42,6 +68,32 @@
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1756619873">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="2129352037">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>

View File

@@ -100,6 +100,18 @@
<option name="screenX" value="1200" />
<option name="screenY" value="1920" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="samsung" />
<option name="codename" value="a06" />
<option name="id" value="a06" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A06" />
<option name="screenDensity" value="300" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
@@ -184,6 +196,18 @@
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="samsung" />
<option name="codename" value="a56x" />
<option name="id" value="a56x" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-A566E" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />

View File

@@ -8,12 +8,6 @@
<SelectionState runConfigName="donext">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="DatabaseMigrationTest">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="migrate_v6_to_v7_preserves_data_and_transforms_columns()">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

View File

@@ -1,3 +1,4 @@
source "https://rubygems.org"
gem "fastlane"
gem 'abbrev'

230
Gemfile.lock Normal file
View File

@@ -0,0 +1,230 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
abbrev (0.1.2)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1181.0)
aws-sdk-core (3.236.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.117.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.203.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
bigdecimal (3.3.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.228.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.16.0)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.17.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.8.0)
os (1.1.4)
plist (3.7.2)
public_suffix (6.0.2)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
x64-mingw-ucrt
DEPENDENCIES
abbrev
fastlane
BUNDLED WITH
2.7.2

View File

@@ -24,7 +24,7 @@ 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.1" apply false
id("com.google.dagger.hilt.android") version "2.57.2" apply false
}
allprojects {

View File

@@ -42,7 +42,7 @@ dependencies {
implementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}"
// Lifecycle components
def lifecycleVersion = '2.9.3'
def lifecycleVersion = '2.9.4'
implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"

View File

@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@@ -18,12 +20,21 @@ android {
applicationId = "com.wismna.geoffroy.donext"
minSdk = 26
targetSdk = 36
versionCode = 34
versionName = "2.0"
versionCode = 35
versionName = "2.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_FILE") ?: "./upload.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("ALIAS_NAME")
keyPassword = System.getenv("ALIAS_PASSWORD")
}
}
sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas"))
}
@@ -38,14 +49,17 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs["release"]
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
buildFeatures {
compose = true
@@ -70,23 +84,24 @@ 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.10.01"))
implementation(platform("androidx.compose:compose-bom:2025.11.00"))
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.5")
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")
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.12")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("io.mockk:mockk:1.14.6")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("com.google.truth:truth:1.4.5")
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.10.01"))
androidTestImplementation(platform("androidx.compose:compose-bom:2025.11.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("com.google.truth:truth:1.4.4")
androidTestImplementation("com.google.truth:truth:1.4.5")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")

View File

@@ -3,15 +3,11 @@ package com.wismna.geoffroy.donext.data.local.dao
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.local.AppDatabase
import com.wismna.geoffroy.donext.domain.model.Priority
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertNull
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
@@ -19,6 +15,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
import kotlin.collections.first
@RunWith(AndroidJUnit4::class)
class TaskDaoTest {
@@ -61,9 +58,9 @@ class TaskDaoTest {
val inserted = taskDao.getTasksForList(listId).first().first()
val fetched = taskDao.getTaskById(inserted.id)
assertNotNull(fetched)
assertEquals("Do laundry", fetched!!.name)
assertEquals(listId, fetched.taskListId)
assertThat(fetched).isNotNull()
assertThat(fetched!!.name).isEqualTo("Do laundry")
assertThat(fetched.taskListId).isEqualTo(listId)
}
@Test
@@ -77,8 +74,8 @@ class TaskDaoTest {
taskDao.insertTask(done)
taskDao.insertTask(high)
val tasks = taskDao.getTasksForList(listId).first()
assertEquals(listOf("High", "Normal", "Done"), tasks.map { it.name })
val taskPriorities = taskDao.getTasksForList(listId).first().map { it.name }
assertThat(taskPriorities).containsExactly("High", "Normal", "Done").inOrder()
}
@Test
@@ -92,7 +89,7 @@ class TaskDaoTest {
taskDao.updateTask(updated)
val fetched = taskDao.getTaskById(inserted.id)
assertEquals("Updated", fetched!!.name)
assertThat(fetched!!.name).isEqualTo("Updated")
}
@Test
@@ -103,10 +100,10 @@ class TaskDaoTest {
val inserted = taskDao.getTasksForList(listId).first().first()
taskDao.toggleTaskDone(inserted.id, true)
assertTrue(taskDao.getTaskById(inserted.id)!!.isDone)
assertThat(taskDao.getTaskById(inserted.id)!!.isDone).isTrue()
taskDao.toggleTaskDone(inserted.id, false)
assertFalse(taskDao.getTaskById(inserted.id)!!.isDone)
assertThat(taskDao.getTaskById(inserted.id)!!.isDone).isFalse()
}
@Test
@@ -118,7 +115,7 @@ class TaskDaoTest {
taskDao.toggleTaskDeleted(inserted.id, true)
val deletedTask = taskDao.getTaskById(inserted.id)
assertTrue(deletedTask!!.isDeleted)
assertThat(deletedTask!!.isDeleted).isTrue()
}
@Test
@@ -132,11 +129,10 @@ class TaskDaoTest {
taskDao.toggleAllTasksFromListDeleted(listId, true)
val fetched = taskDao.getTasksForList(listId).first()
assertTrue(fetched.isEmpty()) // filtered by deleted = 0
assertThat(fetched).isEmpty()
// confirm soft deletion
val softDeleted = fetched.size < 2
assertTrue(softDeleted)
assertThat(fetched).hasSize(0)
}
@Test
@@ -147,7 +143,7 @@ class TaskDaoTest {
val inserted = taskDao.getTasksForList(listId).first().first()
taskDao.permanentDeleteTask(inserted.id)
assertNull(taskDao.getTaskById(inserted.id))
assertThat(taskDao.getTaskById(inserted.id)).isNull()
}
@Test
@@ -160,8 +156,8 @@ class TaskDaoTest {
taskDao.permanentDeleteAllDeletedTasks()
val remaining = taskDao.getTasksForList(listId).first()
assertEquals(1, remaining.size)
assertEquals("Active", remaining.first().name)
assertThat(remaining).hasSize(1)
assertThat(remaining.first().name).isEqualTo("Active")
}
@Test
@@ -171,9 +167,9 @@ class TaskDaoTest {
taskDao.insertTask(deleted)
val results = taskDao.getDeletedTasksWithListName().first()
assertEquals(1, results.size)
assertEquals("Trash", results.first().task.name)
assertEquals("Work", results.first().listName)
assertThat(results).hasSize(1)
assertThat(results.first().task.name).isEqualTo("Trash")
assertThat(results.first().listName).isEqualTo("Work")
}
@Test
@@ -209,7 +205,7 @@ class TaskDaoTest {
taskDao.insertTask(tomorrow)
val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd).first()
assertEquals(1, tasks.size)
assertEquals("Today", tasks.first().name)
assertThat(tasks).hasSize(1)
assertThat(tasks.first().name).isEqualTo("Today")
}
}

View File

@@ -3,13 +3,11 @@ package com.wismna.geoffroy.donext.data.local.dao
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.local.AppDatabase
import com.wismna.geoffroy.donext.domain.model.Priority
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
@@ -17,6 +15,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
import kotlin.collections.first
@RunWith(AndroidJUnit4::class)
class TaskListDaoTest {
@@ -46,8 +45,8 @@ class TaskListDaoTest {
listDao.insertTaskList(taskList)
val lists = listDao.getTaskLists().first()
assertEquals(1, lists.size)
assertEquals("Personal", lists.first().name)
assertThat(lists).hasSize(1)
assertThat(lists.first().name).isEqualTo("Personal")
}
@Test
@@ -58,9 +57,9 @@ class TaskListDaoTest {
val inserted = listDao.getTaskLists().first().first()
val fetched = listDao.getTaskListById(inserted.id)
assertNotNull(fetched)
assertEquals("Groceries", fetched!!.name)
assertEquals(inserted.id, fetched.id)
assertThat(fetched).isNotNull()
assertThat(fetched!!.name).isEqualTo("Groceries")
assertThat(fetched.id).isEqualTo(inserted.id)
}
@Test
@@ -73,7 +72,7 @@ class TaskListDaoTest {
listDao.updateTaskList(updated)
val fetched = listDao.getTaskListById(inserted.id)
assertEquals("Updated Work", fetched!!.name)
assertThat(fetched!!.name).isEqualTo("Updated Work")
}
@Test
@@ -86,12 +85,12 @@ class TaskListDaoTest {
// getTaskLists() filters deleted = 0, so result should be empty
val activeLists = listDao.getTaskLists().first()
assertTrue(activeLists.isEmpty())
assertThat(activeLists).isEmpty()
// But the entity still exists in DB
val softDeleted = listDao.getTaskListById(inserted.id)
assertNotNull(softDeleted)
assertTrue(softDeleted!!.isDeleted)
assertThat(softDeleted).isNotNull()
assertThat(softDeleted!!.isDeleted).isTrue()
}
@Test
@@ -103,8 +102,8 @@ class TaskListDaoTest {
listDao.insertTaskList(second)
listDao.insertTaskList(third)
val lists = listDao.getTaskLists().first()
assertEquals(listOf("Alpha", "Beta", "Zeta"), lists.map { it.name })
val listNames = listDao.getTaskLists().first().map { it.name }
assertThat(listNames).containsExactly("Alpha", "Beta", "Zeta").inOrder()
}
@Test
@@ -152,7 +151,7 @@ class TaskListDaoTest {
val lists = listDao.getTaskListsWithOverdue(now)
assertEquals(1, lists.first().first().overdueCount)
assertThat(lists.first().first().overdueCount).isEqualTo(1)
}
@Test
@@ -222,7 +221,7 @@ class TaskListDaoTest {
val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd)
assertEquals(1, tasks.first().count())
assertEquals("Today", tasks.first().first().name)
assertThat(tasks.first()).hasSize(1)
assertThat(tasks.first().first().name).isEqualTo("Today")
}
}

View File

@@ -1,10 +1,10 @@
package com.wismna.geoffroy.donext.domain.extension
import java.time.Clock
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
fun Long.toLocalDate(): LocalDate =
fun Long.toLocalDate(clock: Clock = Clock.systemDefaultZone()): LocalDate =
Instant.ofEpochMilli(this)
.atZone(ZoneId.systemDefault())
.atZone(clock.zone)
.toLocalDate()

View File

@@ -283,7 +283,7 @@ fun AppContent(
is AppDestination.RecycleBin -> {
EmptyRecycleBinAction()
}
else -> null
else -> Unit
}
}
)
@@ -297,7 +297,7 @@ fun AppContent(
text = { Text(stringResource(R.string.action_create_list)) },
)
}
else -> null
else -> Unit
}
}
) { contentPadding ->

View File

@@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Delete
@@ -43,7 +45,8 @@ fun MenuScreen(
Column(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 8.dp),
.padding(vertical = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween
) {
Column {

View File

@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Clear
@@ -61,7 +63,7 @@ fun TaskScreen(
titleFocusRequester.requestFocus()
}
Column(Modifier.padding(16.dp)) {
Column(Modifier.padding(16.dp).verticalScroll(rememberScrollState())) {
Text(
stringResource(
if (viewModel.isDeleted) R.string.task_title_deleted

View File

@@ -3,14 +3,16 @@ package com.wismna.geoffroy.donext.presentation.viewmodel
import com.wismna.geoffroy.donext.domain.extension.toLocalDate
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
class TaskItemViewModel(task: Task) {
class TaskItemViewModel(
task: Task,
private val clock: Clock = Clock.systemDefaultZone()) {
val id: Long = task.id!!
val name: String = task.name
val description: String? = task.description
@@ -18,17 +20,17 @@ class TaskItemViewModel(task: Task) {
val isDeleted: Boolean = task.isDeleted
val priority: Priority = task.priority
val today: LocalDate = LocalDate.now(ZoneId.systemDefault())
val today: LocalDate = LocalDate.now(clock)
val isOverdue: Boolean = task.dueDate?.let { millis ->
val dueDate = millis.toLocalDate()
val dueDate = millis.toLocalDate(clock)
dueDate.isBefore(today)
} ?: false
val dueDateText: String? = task.dueDate?.let { formatDueDate(it) }
private fun formatDueDate(dueMillis: Long): String {
val dueDate = dueMillis.toLocalDate()
val dueDate = dueMillis.toLocalDate(clock)
return when {
dueDate.isEqual(today) -> "Today"

View File

@@ -1,5 +1,6 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
@@ -9,7 +10,6 @@ import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import io.mockk.*
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -60,7 +60,7 @@ class DueTodayViewModelTest {
tasksFlow.emit(taskList)
advanceUntilIdle()
assertEquals(taskList, viewModel.dueTodayTasks)
assertThat(viewModel.dueTodayTasks).isEqualTo(taskList)
}
@Test

View File

@@ -0,0 +1,138 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.navigation.NavBackStackEntry
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val uiEventBus: UiEventBus = mockk(relaxUnitFun = true)
private lateinit var getTaskListsFlow: MutableSharedFlow<List<TaskList>>
private lateinit var getTaskListsUseCase: GetTaskListsUseCase
private lateinit var viewModel: MainViewModel
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
getTaskListsFlow = MutableSharedFlow()
getTaskListsUseCase = mockk {
every { this@mockk.invoke() } returns getTaskListsFlow
}
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `initially isLoading is true and destinations are empty`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
assertThat(viewModel.isLoading).isTrue()
assertThat(viewModel.destinations).isEmpty()
}
@Test
fun `when task lists are emitted they populate destinations and isLoading becomes false`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
advanceUntilIdle()
val lists = listOf(
TaskList(id = 1L, name = "Work", isDeleted = false, order = 0),
TaskList(id = 2L, name = "Personal", isDeleted = false, order = 1)
)
getTaskListsFlow.emit(lists)
advanceUntilIdle()
val expectedTaskDestinations = lists.map {
AppDestination.TaskList(it.id!!, it.name)
}
assertThat(viewModel.destinations).containsAtLeastElementsIn(expectedTaskDestinations)
assertThat(viewModel.destinations).containsAtLeast(
AppDestination.ManageLists,
AppDestination.RecycleBin,
AppDestination.DueTodayList
)
assertThat(viewModel.isLoading).isFalse()
}
@Test
fun `navigateBack sends UiEvent_NavigateBack`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
viewModel.navigateBack()
advanceUntilIdle()
coVerify { uiEventBus.send(UiEvent.NavigateBack) }
}
@Test
fun `onNewTaskButtonClicked sets showTaskSheet true and sends CreateNewTask`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
val taskListId = 42L
viewModel.onNewTaskButtonClicked(taskListId)
advanceUntilIdle()
assertThat(viewModel.showTaskSheet).isTrue()
coVerify { uiEventBus.send(UiEvent.CreateNewTask(taskListId)) }
}
@Test
fun `onDismissTaskSheet sets showTaskSheet false and clears sticky`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
viewModel.showTaskSheet = true
viewModel.onDismissTaskSheet()
advanceUntilIdle()
assertThat(viewModel.showTaskSheet).isFalse()
coVerify { uiEventBus.send(UiEvent.CloseTask) }
coVerify { uiEventBus.clearSticky() }
}
@Test
fun `doesListExist returns true when taskListId is present`() = runTest {
val lists = listOf(TaskList(id = 1L, name = "Work", isDeleted = false, order = 0))
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
assertThat(viewModel.doesListExist(1L)).isTrue()
assertThat(viewModel.doesListExist(99L)).isFalse()
}
@Test
fun `setCurrentDestination sets currentDestination based on navBackStackEntry`() = runTest {
val lists = listOf(TaskList(id = 1L, name = "Work", isDeleted = false, order = 0))
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
val entry = mockk<NavBackStackEntry> {
every { destination.route } returns AppDestination.TaskList(1L, "Work").route
every { arguments?.getLong("taskListId") } returns 1L
}
viewModel.setCurrentDestination(entry)
assertThat(viewModel.currentDestination).isEqualTo(AppDestination.TaskList(1L, "Work"))
}
}

View File

@@ -0,0 +1,162 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.AddTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskListUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import com.wismna.geoffroy.donext.R
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ManageListsViewModelTest {
private lateinit var getTaskListsUseCase: GetTaskListsUseCase
private lateinit var addTaskListUseCase: AddTaskListUseCase
private lateinit var updateTaskListUseCase: UpdateTaskListUseCase
private lateinit var deleteTaskListUseCase: DeleteTaskListUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var getTaskListsFlow: MutableSharedFlow<List<TaskList>>
private lateinit var viewModel: ManageListsViewModel
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
Dispatchers.setMain(StandardTestDispatcher())
getTaskListsUseCase = mockk()
addTaskListUseCase = mockk(relaxed = true)
updateTaskListUseCase = mockk(relaxed = true)
deleteTaskListUseCase = mockk(relaxed = true)
uiEventBus = mockk(relaxed = true)
getTaskListsFlow = MutableSharedFlow()
every { getTaskListsUseCase() } returns getTaskListsFlow
viewModel = ManageListsViewModel(
getTaskListsUseCase,
addTaskListUseCase,
updateTaskListUseCase,
deleteTaskListUseCase,
uiEventBus
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `initially has empty task list`() = runTest {
assertThat(viewModel.taskLists).isEmpty()
assertThat(viewModel.taskCount).isEqualTo(0)
}
@Test
fun `emitting lists updates taskLists and taskCount`() = runTest {
val lists = listOf(
TaskList(id = 1L, name = "Work", isDeleted = false, order = 0),
TaskList(id = 2L, name = "Home", isDeleted = false, order = 1)
)
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
assertThat(viewModel.taskLists).isEqualTo(lists)
assertThat(viewModel.taskCount).isEqualTo(2)
}
@Test
fun `createTaskList calls use case`() = runTest {
val title = "Groceries"
val order = 1
viewModel.createTaskList(title, order)
advanceUntilIdle()
coVerify { addTaskListUseCase(title, order) }
}
@Test
fun `updateTaskListName calls use case`() = runTest {
val taskList = TaskList(id = 1L, name = "Updated", isDeleted = false, order = 0)
viewModel.updateTaskListName(taskList)
advanceUntilIdle()
coVerify { updateTaskListUseCase(1L, "Updated", 0) }
}
@Test
fun `deleteTaskList calls use case and sends snackbar`() = runTest {
val taskListId = 10L
viewModel.deleteTaskList(taskListId)
advanceUntilIdle()
coVerify { deleteTaskListUseCase(taskListId, true) }
coVerify {
uiEventBus.send(
match {
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_list_recycle
}
)
}
}
@Test
fun `moveTaskList reorders the task list correctly`() = runTest {
val lists = listOf(
TaskList(id = 1L, name = "A", isDeleted = false, order = 0),
TaskList(id = 2L, name = "B", isDeleted = false, order = 1),
TaskList(id = 3L, name = "C", isDeleted = false, order = 2)
)
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
viewModel.moveTaskList(fromIndex = 0, toIndex = 2)
assertThat(viewModel.taskLists.map { it.id }).isEqualTo(listOf(2L, 3L, 1L))
}
@Test
fun `commitTaskListOrder updates only reordered lists`() = runTest {
val lists = listOf(
TaskList(id = 1L, name = "A", isDeleted = false, order = 0),
TaskList(id = 2L, name = "B", isDeleted = false, order = 1),
TaskList(id = 3L, name = "C", isDeleted = false, order = 2)
)
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
// Simulate reordering
viewModel.moveTaskList(fromIndex = 2, toIndex = 0)
viewModel.commitTaskListOrder()
advanceUntilIdle()
coVerify { updateTaskListUseCase(3L, "C", 0) }
coVerify { updateTaskListUseCase(1L, "A", 1) }
coVerify { updateTaskListUseCase(2L, "B", 2) }
}
}

View File

@@ -0,0 +1,152 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
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.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsWithOverdueUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import io.mockk.MockKAnnotations
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MenuViewModelTest {
private lateinit var getTaskListsWithOverdueUseCase: GetTaskListsWithOverdueUseCase
private lateinit var getDueTodayTasksUseCase: GetDueTodayTasksUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var taskListsFlow: MutableSharedFlow<List<TaskListWithOverdue>>
private lateinit var dueTodayTasksFlow: MutableSharedFlow<List<Task>>
private lateinit var viewModel: MenuViewModel
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
Dispatchers.setMain(StandardTestDispatcher())
getTaskListsWithOverdueUseCase = mockk()
getDueTodayTasksUseCase = mockk()
uiEventBus = mockk(relaxed = true)
taskListsFlow = MutableSharedFlow()
dueTodayTasksFlow = MutableSharedFlow()
every { getTaskListsWithOverdueUseCase() } returns taskListsFlow
every { getDueTodayTasksUseCase() } returns dueTodayTasksFlow
viewModel = MenuViewModel(
getTaskListsWithOverdueUseCase,
getDueTodayTasksUseCase,
uiEventBus
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
// --- TESTS ---
@Test
fun `initially has empty lists and zero due today`() = runTest {
assertThat(viewModel.taskLists).isEmpty()
assertThat(viewModel.dueTodayTasksCount).isEqualTo(0)
}
@Test
fun `emitting task lists updates taskLists`() = runTest {
val lists = listOf(
TaskListWithOverdue(id = 1L, name = "Work", overdueCount = 2),
TaskListWithOverdue(id = 2L, name = "Home", overdueCount = 0)
)
advanceUntilIdle()
taskListsFlow.emit(lists)
advanceUntilIdle()
assertThat(viewModel.taskLists).isEqualTo(lists)
}
@Test
fun `emitting due today tasks updates count`() = runTest {
val tasks = listOf(
Task(id = 1L, name = "Task A", taskListId = 1L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false),
Task(id = 2L, name = "Task B", taskListId = 1L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
)
advanceUntilIdle()
dueTodayTasksFlow.emit(tasks)
advanceUntilIdle()
assertThat(viewModel.dueTodayTasksCount).isEqualTo(2)
}
@Test
fun `navigateTo sends UiEvent when route is different`() = runTest {
val route = "tasks"
val currentRoute = "home"
viewModel.navigateTo(route, currentRoute)
advanceUntilIdle()
coVerify {
uiEventBus.send(
match {
it is UiEvent.Navigate && it.route == route
}
)
}
}
@Test
fun `navigateTo does nothing when route is the same`() = runTest {
val route = "tasks"
viewModel.navigateTo(route, route)
advanceUntilIdle()
coVerify(exactly = 0) { uiEventBus.send(any()) }
}
@Test
fun `emitting both task lists and due today tasks updates both states`() = runTest {
val lists = listOf(
TaskListWithOverdue(id = 1L, name = "Work", overdueCount = 3),
TaskListWithOverdue(id = 2L, name = "Personal", overdueCount = 1)
)
val tasks = listOf(
Task(id = 10L, name = "Buy groceries", taskListId = 2L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false),
Task(id = 11L, name = "Finish report", taskListId = 1L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
)
// Let the ViewModel collectors start
advanceUntilIdle()
// Emit from both flows (simulating data updates happening nearly simultaneously)
taskListsFlow.emit(lists)
dueTodayTasksFlow.emit(tasks)
advanceUntilIdle()
// Verify both internal states are updated independently and correctly
assertThat(viewModel.taskLists).isEqualTo(lists)
assertThat(viewModel.dueTodayTasksCount).isEqualTo(2)
}
}

View File

@@ -0,0 +1,214 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.lifecycle.SavedStateHandle
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.model.TaskWithListName
import com.wismna.geoffroy.donext.domain.usecase.EmptyRecycleBinUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetDeletedTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.PermanentlyDeleteTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import com.wismna.geoffroy.donext.R
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class RecycleBinViewModelTest {
private lateinit var getDeletedTasksUseCase: GetDeletedTasksUseCase
private lateinit var toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase
private lateinit var permanentlyDeleteTaskUseCase: PermanentlyDeleteTaskUseCase
private lateinit var emptyRecycleBinUseCase: EmptyRecycleBinUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var savedStateHandle: SavedStateHandle
private lateinit var getDeletedTasksFlow: MutableSharedFlow<List<TaskWithListName>>
private lateinit var viewModel: RecycleBinViewModel
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
Dispatchers.setMain(StandardTestDispatcher())
getDeletedTasksUseCase = mockk()
toggleTaskDeletedUseCase = mockk(relaxed = true)
permanentlyDeleteTaskUseCase = mockk(relaxed = true)
emptyRecycleBinUseCase = mockk(relaxed = true)
uiEventBus = mockk(relaxed = true)
savedStateHandle = SavedStateHandle()
getDeletedTasksFlow = MutableSharedFlow()
every { getDeletedTasksUseCase() } returns getDeletedTasksFlow
viewModel = RecycleBinViewModel(
getDeletedTasksUseCase,
toggleTaskDeletedUseCase,
permanentlyDeleteTaskUseCase,
emptyRecycleBinUseCase,
uiEventBus,
savedStateHandle
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
// --- TESTS ---
@Test
fun `initial state is empty`() = runTest {
assertThat(viewModel.deletedTasks).isEmpty()
assertThat(viewModel.taskToDeleteFlow.value).isNull()
assertThat(viewModel.emptyRecycleBinFlow.value).isFalse()
}
@Test
fun `emitting deleted tasks updates deletedTasks list`() = runTest {
val tasks = listOf(
TaskWithListName(Task(id = 1L, name = "Old task", taskListId = 0L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false), listName = "Work"),
TaskWithListName(Task(id = 2L, name = "Done task", taskListId = 0L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false), listName = "Home")
)
advanceUntilIdle()
getDeletedTasksFlow.emit(tasks)
advanceUntilIdle()
assertThat(viewModel.deletedTasks).isEqualTo(tasks)
}
@Test
fun `restore toggles deletion and shows undo snackbar`() = runTest {
val taskId = 5L
viewModel.restore(taskId)
advanceUntilIdle()
coVerify { toggleTaskDeletedUseCase(taskId, false) }
coVerify {
uiEventBus.send(
match {
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_restore
}
)
}
}
@Test
fun `onTaskClicked sends EditTask UiEvent`() = runTest {
val task = Task(id = 1L, name = "T", taskListId = 1L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
viewModel.onTaskClicked(task)
advanceUntilIdle()
coVerify { uiEventBus.send(UiEvent.EditTask(task)) }
}
@Test
fun `onEmptyRecycleBinRequest sets flag to true`() = runTest {
viewModel.onEmptyRecycleBinRequest()
assertThat(viewModel.emptyRecycleBinFlow.value).isTrue()
}
@Test
fun `onCancelEmptyRecycleBinRequest sets flag to false`() = runTest {
savedStateHandle["emptyRecycleBin"] = true
viewModel.onCancelEmptyRecycleBinRequest()
assertThat(viewModel.emptyRecycleBinFlow.value).isFalse()
}
@Test
fun `emptyRecycleBin calls use case and resets flag`() = runTest {
savedStateHandle["emptyRecycleBin"] = true
viewModel.emptyRecycleBin()
advanceUntilIdle()
coVerify { emptyRecycleBinUseCase() }
assertThat(viewModel.emptyRecycleBinFlow.value).isFalse()
}
@Test
fun `onTaskDeleteRequest sets taskToDelete id`() = runTest {
viewModel.onTaskDeleteRequest(42L)
assertThat(viewModel.taskToDeleteFlow.value).isEqualTo(42L)
}
@Test
fun `onConfirmDelete calls use case and clears task id`() = runTest {
savedStateHandle["taskToDeleteId"] = 7L
viewModel.onConfirmDelete()
advanceUntilIdle()
coVerify { permanentlyDeleteTaskUseCase(7L) }
assertThat(viewModel.taskToDeleteFlow.value).isNull()
}
@Test
fun `onCancelDelete clears task id`() = runTest {
savedStateHandle["taskToDeleteId"] = 10L
viewModel.onCancelDelete()
assertThat(viewModel.taskToDeleteFlow.value).isNull()
}
@Test
fun `simultaneous flow emissions update deleted tasks and flags independently`() = runTest {
val tasks = listOf(TaskWithListName(Task(id = 1L, name = "Trash", taskListId = 0L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false), listName = "Work"))
advanceUntilIdle()
// Emit tasks while also updating the recycle bin flag
getDeletedTasksFlow.emit(tasks)
savedStateHandle["emptyRecycleBin"] = true
advanceUntilIdle()
assertThat(viewModel.deletedTasks).isEqualTo(tasks)
assertThat(viewModel.emptyRecycleBinFlow.value).isTrue()
}
@Test
fun `restore snackbar undoAction re-deletes the task`() = runTest {
val taskId = 99L
val eventSlot = slot<UiEvent>()
// Intercept UiEvent.ShowUndoSnackbar to get the undoAction
coEvery { uiEventBus.send(capture(eventSlot)) } just Runs
viewModel.restore(taskId)
advanceUntilIdle()
// Ensure the event is a ShowUndoSnackbar
val snackbarEvent = eventSlot.captured as UiEvent.ShowUndoSnackbar
// Run the undo lambda
snackbarEvent.undoAction.invoke()
advanceUntilIdle()
// Verify that it re-deletes the task (sets deleted = true again)
coVerify { toggleTaskDeletedUseCase(taskId, true) }
}
}

View File

@@ -0,0 +1,134 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import org.junit.Before
import org.junit.Test
import java.time.*
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.*
class TaskItemViewModelTest {
private val fixedClock: Clock = Clock.fixed(
LocalDate.of(2025, 1, 10)
.atStartOfDay(ZoneId.systemDefault())
.toInstant(),
ZoneId.systemDefault()
)
private val today: LocalDate = LocalDate.now(fixedClock)
private lateinit var baseTask: Task
@Before
fun setup() {
baseTask = Task(
id = 1L,
taskListId = 1L,
name = "Test Task",
description = "Description",
priority = Priority.NORMAL,
isDone = false,
isDeleted = false,
dueDate = null
)
}
private fun millisForDaysFromFixedToday(daysOffset: Long): Long {
val targetDate = today.plusDays(daysOffset)
return targetDate
.atStartOfDay(fixedClock.zone)
.toInstant()
.toEpochMilli()
}
@Test
fun `initializes fields from Task`() {
val viewModel = TaskItemViewModel(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)
}
@Test
fun `isOverdue is true when due date is before today`() {
val overdueTask = baseTask.copy(dueDate = millisForDaysFromFixedToday(-1))
val viewModel = TaskItemViewModel(overdueTask)
assertThat(viewModel.isOverdue).isTrue()
}
@Test
fun `isOverdue is false when due date is today`() {
val dueToday = baseTask.copy(dueDate = millisForDaysFromFixedToday(0))
val viewModel = TaskItemViewModel(dueToday, fixedClock)
assertThat(viewModel.isOverdue).isFalse()
}
@Test
fun `isOverdue is false when due date is null`() {
val viewModel = TaskItemViewModel(baseTask.copy(dueDate = null))
assertThat(viewModel.isOverdue).isFalse()
}
@Test
fun `dueDateText is Today when due date is today`() {
val dueToday = baseTask.copy(dueDate = millisForDaysFromFixedToday(0))
val viewModel = TaskItemViewModel(dueToday, fixedClock)
assertThat(viewModel.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)
assertThat(viewModel.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)
assertThat(viewModel.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 expected = today
.plusDays(3)
.dayOfWeek
.getDisplayName(TextStyle.SHORT, Locale.getDefault())
assertThat(viewModel.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 expected = today
.plusDays(10)
.format(
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.getDefault())
)
assertThat(viewModel.dueDateText).isEqualTo(expected)
}
}

View File

@@ -0,0 +1,177 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.lifecycle.SavedStateHandle
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.GetTasksForListUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Priority
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class TaskListViewModelTest {
private lateinit var getTasksForListUseCase: GetTasksForListUseCase
private lateinit var toggleTaskDoneUseCase: ToggleTaskDoneUseCase
private lateinit var toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var savedStateHandle: SavedStateHandle
private lateinit var getTasksFlow: MutableSharedFlow<List<Task>>
private lateinit var viewModel: TaskListViewModel
private val testTaskListId = 100L
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
Dispatchers.setMain(StandardTestDispatcher())
getTasksForListUseCase = mockk()
toggleTaskDoneUseCase = mockk(relaxed = true)
toggleTaskDeletedUseCase = mockk(relaxed = true)
uiEventBus = mockk(relaxed = true)
savedStateHandle = SavedStateHandle(mapOf("taskListId" to testTaskListId))
getTasksFlow = MutableSharedFlow()
every { getTasksForListUseCase(testTaskListId) } returns getTasksFlow
viewModel = TaskListViewModel(
savedStateHandle,
getTasksForListUseCase,
toggleTaskDoneUseCase,
toggleTaskDeletedUseCase,
uiEventBus
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
// --- TESTS ---
@Test
fun `initial state is loading and tasks empty`() = runTest {
assertThat(viewModel.isLoading).isTrue()
assertThat(viewModel.tasks).isEmpty()
}
@Test
fun `emitting tasks updates list and stops loading`() = runTest {
val tasks = listOf(
Task(id = 1L, name = "Write docs", taskListId = testTaskListId, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false),
Task(id = 2L, name = "Code review", taskListId = testTaskListId, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
)
advanceUntilIdle()
getTasksFlow.emit(tasks)
advanceUntilIdle()
assertThat(viewModel.isLoading).isFalse()
assertThat(viewModel.tasks).isEqualTo(tasks)
}
@Test
fun `onTaskClicked sends EditTask event`() = runTest {
val task = Task(id = 1L, name = "Test task", taskListId = testTaskListId, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
viewModel.onTaskClicked(task)
advanceUntilIdle()
coVerify { uiEventBus.send(UiEvent.EditTask(task)) }
}
@Test
fun `updateTaskDone marks task done and sends snackbar with undo`() = runTest {
val taskId = 3L
viewModel.updateTaskDone(taskId, true)
advanceUntilIdle()
coVerify { toggleTaskDoneUseCase(taskId, true) }
coVerify {
uiEventBus.send(
match {
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_done
}
)
}
}
@Test
fun `updateTaskDone undoAction marks task undone`() = runTest {
val taskId = 7L
val eventSlot = slot<UiEvent>()
coEvery { uiEventBus.send(capture(eventSlot)) } just Runs
viewModel.updateTaskDone(taskId, true)
advanceUntilIdle()
val snackbar = eventSlot.captured as UiEvent.ShowUndoSnackbar
snackbar.undoAction.invoke()
advanceUntilIdle()
coVerify { toggleTaskDoneUseCase(taskId, false) }
}
@Test
fun `deleteTask marks task deleted and sends snackbar`() = runTest {
val taskId = 9L
viewModel.deleteTask(taskId)
advanceUntilIdle()
coVerify { toggleTaskDeletedUseCase(taskId, true) }
coVerify {
uiEventBus.send(
match {
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_recycle
}
)
}
}
@Test
fun `deleteTask undoAction restores task`() = runTest {
val taskId = 10L
val eventSlot = slot<UiEvent>()
coEvery { uiEventBus.send(capture(eventSlot)) } just Runs
viewModel.deleteTask(taskId)
advanceUntilIdle()
val snackbar = eventSlot.captured as UiEvent.ShowUndoSnackbar
snackbar.undoAction.invoke()
advanceUntilIdle()
coVerify { toggleTaskDeletedUseCase(taskId, false) }
}
}

View File

@@ -0,0 +1,254 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
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.usecase.AddTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import io.mockk.MockKAnnotations
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
@OptIn(ExperimentalCoroutinesApi::class)
class TaskViewModelTest {
private lateinit var createTaskUseCase: AddTaskUseCase
private lateinit var updateTaskUseCase: UpdateTaskUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var stickyEventsFlow: MutableSharedFlow<UiEvent>
private lateinit var viewModel: TaskViewModel
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
Dispatchers.setMain(StandardTestDispatcher())
createTaskUseCase = mockk(relaxed = true)
updateTaskUseCase = mockk(relaxed = true)
uiEventBus = mockk(relaxed = true)
stickyEventsFlow = MutableSharedFlow()
every { uiEventBus.stickyEvents } returns stickyEventsFlow
viewModel = TaskViewModel(
createTaskUseCase,
updateTaskUseCase,
uiEventBus
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
// --- TESTS ---
@Test
fun `initial state is blank and not editing`() = runTest {
assertThat(viewModel.title).isEmpty()
assertThat(viewModel.description).isEmpty()
assertThat(viewModel.priority).isEqualTo(Priority.NORMAL)
assertThat(viewModel.dueDate).isNull()
assertThat(viewModel.isDone).isFalse()
assertThat(viewModel.isDeleted).isFalse()
assertThat(viewModel.taskListId).isNull()
assertThat(viewModel.isEditing()).isFalse()
}
@Test
fun `CreateNewTask event resets fields and sets taskListId`() = runTest {
stickyEventsFlow.emit(UiEvent.CreateNewTask(42L))
advanceUntilIdle()
assertThat(viewModel.isEditing()).isFalse()
assertThat(viewModel.taskListId).isEqualTo(42L)
assertThat(viewModel.title).isEmpty()
assertThat(viewModel.description).isEmpty()
assertThat(viewModel.priority).isEqualTo(Priority.NORMAL)
assertThat(viewModel.dueDate).isNull()
assertThat(viewModel.isDeleted).isFalse()
}
@Test
fun `EditTask event populates fields from existing task`() = runTest {
val task = Task(
id = 7L,
taskListId = 9L,
name = "Fix bug",
description = "Null pointer issue",
priority = Priority.HIGH,
dueDate = Instant.parse("2025-10-01T12:00:00Z").toEpochMilli(),
isDone = true,
isDeleted = false
)
stickyEventsFlow.emit(UiEvent.EditTask(task))
advanceUntilIdle()
assertThat(viewModel.isEditing()).isTrue()
assertThat(viewModel.editingTaskId).isEqualTo(7L)
assertThat(viewModel.taskListId).isEqualTo(9L)
assertThat(viewModel.title).isEqualTo("Fix bug")
assertThat(viewModel.description).isEqualTo("Null pointer issue")
assertThat(viewModel.priority).isEqualTo(Priority.HIGH)
assertThat(viewModel.dueDate).isEqualTo(task.dueDate)
assertThat(viewModel.isDone).isTrue()
assertThat(viewModel.isDeleted).isFalse()
}
@Test
fun `CloseTask event resets state`() = runTest {
// set up as editing
stickyEventsFlow.emit(
UiEvent.EditTask(
Task(id = 1L, taskListId = 2L, name = "T", description = "D", priority = Priority.HIGH, isDone = false, isDeleted = false)
)
)
advanceUntilIdle()
stickyEventsFlow.emit(UiEvent.CloseTask)
advanceUntilIdle()
assertThat(viewModel.title).isEmpty()
assertThat(viewModel.description).isEmpty()
assertThat(viewModel.priority).isEqualTo(Priority.NORMAL)
assertThat(viewModel.editingTaskId).isNull()
assertThat(viewModel.taskListId).isNull()
}
@Test
fun `onTitleChanged updates title`() {
viewModel.onTitleChanged("New title")
assertThat(viewModel.title).isEqualTo("New title")
}
@Test
fun `onDescriptionChanged updates description`() {
viewModel.onDescriptionChanged("Some description")
assertThat(viewModel.description).isEqualTo("Some description")
}
@Test
fun `onPriorityChanged updates priority`() {
viewModel.onPriorityChanged(Priority.HIGH)
assertThat(viewModel.priority).isEqualTo(Priority.HIGH)
}
@Test
fun `onDueDateChanged normalizes date to start of day in system timezone`() {
val utcMidday = Instant.parse("2025-10-01T12:00:00Z").toEpochMilli()
viewModel.onDueDateChanged(utcMidday)
val expectedStartOfDay =
Instant.ofEpochMilli(utcMidday)
.atZone(ZoneOffset.UTC)
.toLocalDate()
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
assertThat(viewModel.dueDate).isEqualTo(expectedStartOfDay)
}
@Test
fun `save with blank title does nothing`() = runTest {
stickyEventsFlow.emit(UiEvent.CreateNewTask(1L))
advanceUntilIdle()
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()
viewModel.onTitleChanged("New Task")
viewModel.onDescriptionChanged("Description")
viewModel.onPriorityChanged(Priority.HIGH)
val due = Instant.parse("2025-10-01T12:00:00Z").toEpochMilli()
viewModel.onDueDateChanged(due)
viewModel.save()
advanceUntilIdle()
coVerify {
createTaskUseCase(
3L,
"New Task",
"Description",
Priority.HIGH,
viewModel.dueDate
)
}
}
@Test
fun `save updates task when editing`() = runTest {
val task = Task(
id = 10L,
taskListId = 5L,
name = "Old Task",
description = "Old desc",
priority = Priority.NORMAL,
dueDate = null,
isDone = false,
isDeleted = false
)
stickyEventsFlow.emit(UiEvent.EditTask(task))
advanceUntilIdle()
viewModel.onTitleChanged("Updated Task")
viewModel.onDescriptionChanged("Updated desc")
viewModel.save()
advanceUntilIdle()
coVerify {
updateTaskUseCase(
10L,
5L,
"Updated Task",
"Updated desc",
Priority.NORMAL,
null,
false
)
}
}
@Test
fun `save calls onDone callback after save completes`() = runTest {
var doneCalled = false
stickyEventsFlow.emit(UiEvent.CreateNewTask(2L))
advanceUntilIdle()
viewModel.onTitleChanged("Task")
viewModel.save { doneCalled = true }
advanceUntilIdle()
assertThat(doneCalled).isTrue()
}
}

View File

@@ -1,2 +1,2 @@
json_key_file("../..//Downloads/donext-f9e67-1184ae400b09.json") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one
package_name("com.wismna.geoffroy.donext") # e.g. com.krausefx.app
json_key_file(ENV["SUPPLY_JSON_KEY"] || "./fastlane/keys/donext-f9e67-5038064982b0")
package_name("com.wismna.geoffroy.donext")

View File

@@ -1,38 +1,30 @@
# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
# https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
# https://docs.fastlane.tools/plugins/available-plugins
#
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
default_platform(:android)
platform :android do
desc "Runs all the tests"
lane :test do
gradle(task: "test")
end
desc "Build, test, and deploy to Google Play"
lane :internal do
module_name = ENV["MODULE"]
# Read local versionCode
project_root = File.expand_path("..", __dir__)
gradle_path = File.join(project_root, module_name, "build.gradle.kts")
UI.message("Gradle file resolved at: #{gradle_path}")
gradle_file = File.read(gradle_path)
gradle_version = gradle_file[/versionCode\s*=\s*(\d+)/, 1].to_i
desc "Submit a new Beta Build to Crashlytics Beta"
lane :beta do
gradle(task: "clean assembleRelease")
crashlytics
# Read Play Store versionCode (track internal)
play_version = google_play_track_version_codes(
track: "internal"
).max.to_i
# sh "your_script.sh"
# You can also use other beta testing services here
end
if gradle_version <= play_version
UI.user_error!("VersionCode #{gradle_version} should be higher than Play Store version (#{play_version}). Aborting upload.")
end
desc "Deploy a new version to the Google Play"
lane :deploy do
gradle(task: "clean assembleRelease")
upload_to_play_store
gradle(task: "testDebugUnitTest")
gradle(task: "clean :#{module_name}:bundleRelease")
upload_to_play_store(
track: "internal",
aab: "#{module_name}/build/outputs/bundle/release/#{module_name}-release.aab"
)
end
end

40
fastlane/README.md Normal file
View File

@@ -0,0 +1,40 @@
fastlane documentation
----
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```sh
xcode-select --install
```
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
# Available Actions
## Android
### android deploy
```sh
[bundle exec] fastlane android deploy
```
Build, test, and deploy the production version to Google Play
### android internal
```sh
[bundle exec] fastlane android internal
```
Build, test, and deploy to Google Play
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).

View File

@@ -1 +1,8 @@
Update to SDK version 35
Complete UI overhaul that gives DoNext a nice, modern look.
Complete code rewrite to Kotlin and Android Jetpack, with a better architecture.
Task lists are now displayed in a navigation menu
Tasks are now ordered by priority and not cycles which were removed
All tasks can be Done or Removed instead of only the first one
Removed History page and split its features between the regular task lists and the new Recycle Bin
Today view is not a separate concept anymore and based on the tasks due date

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -1 +1 @@
A new way to manage your tasks!
A simple and fast app to manage your tasks!

View File

@@ -1 +1 @@
DoNext
DoNext v2

20
fastlane/report.xml Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="fastlane.lanes">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.0023664">
</testcase>
<testcase classname="fastlane.lanes" name="1: testDebugUnitTest" time="0.0434096">
<failure message="D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in &apos;Fastlane::Actions.execute_action&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:255:in &apos;block in Fastlane::Runner#execute_action&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in &apos;Dir.chdir&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in &apos;Fastlane::Runner#execute_action&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:157:in &apos;Fastlane::Runner#trigger_action_by_name&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/fast_file.rb:159:in &apos;Fastlane::FastFile#method_missing&apos;&#10;Fastfile:16:in &apos;block (2 levels) in Fastlane::FastFile#parsing_binding&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane.rb:41:in &apos;Fastlane::Lane#call&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:49:in &apos;block in Fastlane::Runner#execute&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in &apos;Dir.chdir&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in &apos;Fastlane::Runner#execute&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane_manager.rb:46:in &apos;Fastlane::LaneManager.cruise_lane&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/command_line_handler.rb:34:in &apos;Fastlane::CommandLineHandler.handle&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:110:in &apos;block (2 levels) in Fastlane::CommandsGenerator#run&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/commander-4.6.0/lib/commander/command.rb:187:in &apos;Commander::Command#call&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/commander-4.6.0/lib/commander/command.rb:157:in &apos;Commander::Command#run&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/commander-4.6.0/lib/commander/runner.rb:444:in &apos;Commander::Runner#run_active_command&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in &apos;Commander::Runner#run!&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/commander-4.6.0/lib/commander/delegates.rb:18:in &apos;Commander::Delegates#run!&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:363:in &apos;Fastlane::CommandsGenerator#run&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:43:in &apos;Fastlane::CommandsGenerator.start&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in &apos;Fastlane::CLIToolsDistributor.take_off&apos;&#10;D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/bin/fastlane:23:in &apos;&lt;top (required)&gt;&apos;&#10;D:/Ruby34-x64/bin/fastlane:25:in &apos;Kernel#load&apos;&#10;D:/Ruby34-x64/bin/fastlane:25:in &apos;&lt;main&gt;&apos;&#10;&#10;Exit status of command &apos;D:/source/DoNext/gradlew testDebugUnitTest -p .&apos; was 1 instead of 0.&#10;&#10;ERROR: JAVA_HOME is not set and no &apos;java&apos; command could be found in your PATH.&#10;&#10;Please set the JAVA_HOME variable in your environment to match the&#10;location of your Java installation.&#10;" />
</testcase>
</testsuite>
</testsuites>