Compare commits

..

28 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
Geoffroy Bonneville
fc3672b17b Add DueTodayViewModel Unit Tests 2025-11-06 20:49:46 -05:00
Geoffroy Bonneville
85f1e66a62 Test database v6 -> v7 migration (finally!) 2025-11-06 18:22:03 -05:00
Geoffroy Bonneville
f0efc3dade Add task dao tests
Finish task list dao tests
2025-11-03 20:56:15 -05:00
Geoffroy Bonneville
d626a381ba Translate default lists
Change task list icon
Improve empty tasks and task lists screens
2025-10-30 13:32:53 -04:00
Geoffroy Bonneville
a000eff8cc Update packages
Add french translation
Migrate VM strings resources
2025-10-30 12:32:36 -04:00
Geoffroy Bonneville
f297427d13 Add string resources
All views now use resources
2025-10-29 22:37:14 -04:00
Geoffroy Bonneville
60cd307a87 Finally fix the double navigation issue event
Dialogs now survive screen rotation
Improve empty screens placeholders
2025-10-28 22:15:36 -04:00
Geoffroy Bonneville
c424d883ba Get overdue tasks excludes deleted tasks 2025-10-25 17:58:21 -04:00
Geoffroy Bonneville
35416f4725 Add delete task dialog confirmation 2025-10-23 22:16:59 -04:00
Geoffroy Bonneville
dc46386e0d Improve large layouts by adding task pane and new list dialog 2025-10-22 22:52:34 -04:00
69 changed files with 3176 additions and 384 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@@ -3,6 +3,58 @@
<component name="AndroidTestResultsUserPreferences"> <component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState"> <option name="androidTestResultsTableState">
<map> <map>
<entry key="-2002158262">
<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="-1209641426">
<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="-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"> <entry key="1337588336">
<value> <value>
<AndroidTestResultsTableState> <AndroidTestResultsTableState>
@@ -16,6 +68,32 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </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> </map>
</option> </option>
</component> </component>

View File

@@ -100,6 +100,18 @@
<option name="screenX" value="1200" /> <option name="screenX" value="1200" />
<option name="screenY" value="1920" /> <option name="screenY" value="1920" />
</PersistentDeviceSelectionData> </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> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="samsung" /> <option name="brand" value="samsung" />
@@ -136,6 +148,18 @@
<option name="screenX" value="1080" /> <option name="screenX" value="1080" />
<option name="screenY" value="2340" /> <option name="screenY" value="2340" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="samsung" />
<option name="codename" value="a16" />
<option name="id" value="a16" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-A165M" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="samsung" /> <option name="brand" value="samsung" />
@@ -160,6 +184,30 @@
<option name="screenX" value="1080" /> <option name="screenX" value="1080" />
<option name="screenY" value="2340" /> <option name="screenY" value="2340" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="samsung" />
<option name="codename" value="a36xq" />
<option name="id" value="a36xq" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-A366E" />
<option name="screenDensity" value="450" />
<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> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="google" /> <option name="brand" value="google" />
@@ -318,6 +366,18 @@
<option name="screenX" value="2220" /> <option name="screenX" value="2220" />
<option name="screenY" value="1080" /> <option name="screenY" value="1080" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="samsung" />
<option name="codename" value="dm1q" />
<option name="id" value="dm1q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="S23" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="samsung" /> <option name="brand" value="samsung" />
@@ -741,6 +801,18 @@
<option name="screenX" value="1080" /> <option name="screenX" value="1080" />
<option name="screenY" value="2400" /> <option name="screenY" value="2400" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="samsung" />
<option name="codename" value="psq" />
<option name="id" value="psq" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S25 Edge" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1440" />
<option name="screenY" value="3120" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="samsung" /> <option name="brand" value="samsung" />

View File

@@ -5,9 +5,6 @@
<SelectionState runConfigName="donextv2"> <SelectionState runConfigName="donextv2">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
</SelectionState> </SelectionState>
<SelectionState runConfigName="overdueCount_correctlyCalculated()">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="donext"> <SelectionState runConfigName="donext">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
</SelectionState> </SelectionState>

View File

@@ -2,6 +2,7 @@
<dictionary name="project"> <dictionary name="project">
<words> <words>
<w>donext</w> <w>donext</w>
<w>snackbar</w>
</words> </words>
</dictionary> </dictionary>
</component> </component>

View File

@@ -1,3 +1,4 @@
source "https://rubygems.org" source "https://rubygems.org"
gem "fastlane" 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

@@ -14,7 +14,6 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.13.0' classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.3'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21'
classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.21' classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.21'
@@ -25,7 +24,7 @@ buildscript {
plugins { plugins {
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false 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 { allprojects {
@@ -38,5 +37,3 @@ allprojects {
tasks.register('clean', Delete) { tasks.register('clean', Delete) {
delete rootProject.layout.buildDirectory delete rootProject.layout.buildDirectory
} }
apply plugin: 'org.sonarqube'

BIN
donext.backup Normal file

Binary file not shown.

View File

@@ -22,15 +22,6 @@ android {
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
} }
sonarqube {
properties {
property 'sonar.host.url', '#{sonar.host.url}'
property 'sonar.login', '#{sonar.login}'
property 'sonar.organization', '#{sonar.organization}'
property 'sonar.projectKey', '#{sonar.projectkey}'
property 'sonar.branch', 'master'
}
}
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
@@ -51,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.3' def lifecycleVersion = '2.9.4'
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"

View File

@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
@@ -6,6 +8,10 @@ plugins {
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
} }
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
android { android {
namespace = "com.wismna.geoffroy.donext" namespace = "com.wismna.geoffroy.donext"
compileSdk = 36 compileSdk = 36
@@ -14,12 +20,25 @@ android {
applicationId = "com.wismna.geoffroy.donext" applicationId = "com.wismna.geoffroy.donext"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 34 versionCode = 35
versionName = "2.0" versionName = "2.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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"))
}
buildTypes { buildTypes {
release { release {
// Enables code-related app optimization. // Enables code-related app optimization.
@@ -30,14 +49,17 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
signingConfig = signingConfigs["release"]
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
kotlinOptions { kotlin {
jvmTarget = "11" compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
} }
buildFeatures { buildFeatures {
compose = true compose = true
@@ -46,29 +68,46 @@ android {
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.1.1" kotlinCompilerExtensionVersion = "1.1.1"
} }
configurations.all {
resolutionStrategy {
eachDependency {
when (requested.module.toString()) {
// Required for forcing the serialization lib version used by MigrationTestHelper
"org.jetbrains.kotlinx:kotlinx-serialization-core-jvm" -> useVersion("1.8.0")
}
}
}
}
} }
dependencies { dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
implementation("androidx.activity:activity-compose:1.11.0") implementation("androidx.activity:activity-compose:1.11.0")
implementation(platform("androidx.compose:compose-bom:2025.10.00")) implementation(platform("androidx.compose:compose-bom:2025.11.00"))
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.5") implementation("androidx.navigation:navigation-compose:2.9.6")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0") implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation("androidx.test.ext:junit-ktx:1.3.0")
implementation("sh.calvin.reorderable:reorderable:3.0.0") implementation("sh.calvin.reorderable:reorderable:3.0.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.10.00")) testImplementation("junit:junit:4.13.2")
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.11.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("com.google.truth:truth:1.4.5")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")
val roomVersion = "2.8.2" val roomVersion = "2.8.3"
implementation("androidx.room:room-runtime:$roomVersion") implementation("androidx.room:room-runtime:$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.57.2"

View File

@@ -0,0 +1,171 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "a911cf75d24949c0b24bd212cacc860c",
"entities": [
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `description` TEXT, `cycle` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `done` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `displayorder` INTEGER NOT NULL, `todayorder` INTEGER NOT NULL, `list` INTEGER NOT NULL, `duedate` TEXT, `todaydate` TEXT, FOREIGN KEY(`list`) REFERENCES `tasklist`(`_id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "_id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "cycle",
"columnName": "cycle",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "done",
"columnName": "done",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "displayorder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "todayOrder",
"columnName": "todayorder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskList",
"columnName": "list",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dueDate",
"columnName": "duedate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "todayDate",
"columnName": "todaydate",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"_id"
]
},
"indices": [
{
"name": "index_tasks_list",
"unique": false,
"columnNames": [
"list"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list` ON `${TABLE_NAME}` (`list`)"
}
],
"foreignKeys": [
{
"table": "tasklist",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"list"
],
"referencedColumns": [
"_id"
]
}
]
},
{
"tableName": "tasklist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `visible` INTEGER NOT NULL, `displayorder` INTEGER NOT NULL, `taskCount` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "_id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visible",
"columnName": "visible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "displayorder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskCount",
"columnName": "taskCount",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [
{
"viewName": "TodayTasksView",
"createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM tasks WHERE todaydate = date('now','localtime')"
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a911cf75d24949c0b24bd212cacc860c')"
]
}
}

View File

@@ -0,0 +1,107 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "adb2abaced32bebf52bab45ae8069f40",
"entities": [
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `priority` INTEGER NOT NULL, `done` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `task_list_id` INTEGER NOT NULL, `due_date` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDone",
"columnName": "done",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDeleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskListId",
"columnName": "task_list_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dueDate",
"columnName": "due_date",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "task_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `display_order` INTEGER NOT NULL, `deleted` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "display_order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDeleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'adb2abaced32bebf52bab45ae8069f40')"
]
}
}

View File

@@ -0,0 +1,107 @@
package com.wismna.geoffroy.donext.data.local
import android.content.ContentValues
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Priority
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
@RunWith(AndroidJUnit4::class)
class DatabaseMigrationTest {
private val TEST_DB = "migration-test.db"
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
listOf(),
FrameworkSQLiteOpenHelperFactory()
)
/**
* This test recreates the old SQLite schema (v6 from DatabaseHelper),
* inserts sample legacy rows, runs AppDatabase.MIGRATION_6_7 and then
* validates the migrated data by calling the real DAOs you provided.
*/
@Test
@Throws(IOException::class)
fun migrate_v6_to_v7_preserves_data_and_transforms_columns() {
// Arrange
val context = InstrumentationRegistry.getInstrumentation().targetContext
val db: SupportSQLiteDatabase = helper.createDatabase(TEST_DB, 6)
val listValues = ContentValues().apply {
put("_id", 1)
put("name", "Legacy List")
put("displayorder", 10)
put("visible", 1)
put("taskCount", 0)
}
db.insert("tasklist", 0, listValues)
val taskValues = ContentValues().apply {
put("_id", 1) // explicit id
put("name", "Legacy Task")
put("description", "Old task description")
put("priority", 2)
put("cycle", 0)
put("done", 0)
put("deleted", 0)
put("displayorder", 5)
put("todayorder", 0)
put("list", 1) // references tasklist _id = 1
put("duedate", "2025-09-15") // legacy text date format that migration converts
put("todaydate", null as String?)
}
db.insert("tasks", 0, taskValues)
db.close()
// Act
helper.runMigrationsAndValidate(TEST_DB, 7, true, AppDatabase.MIGRATION_6_7)
val migratedRoom = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
.addMigrations(AppDatabase.MIGRATION_6_7)
.build()
// Assert
try {
val listDao = migratedRoom.taskListDao()
val taskDao = migratedRoom.taskDao()
runBlocking {
val migratedList = listDao.getTaskListById(1L)
assertThat(migratedList).isNotNull()
assertThat(migratedList!!.id).isEqualTo(1L)
assertThat(migratedList.name).isEqualTo("Legacy List")
assertThat(migratedList.isDeleted).isEqualTo(false)
}
runBlocking {
val migratedTask = taskDao.getTaskById(1L)
assertThat(migratedTask).isNotNull()
assertThat(migratedTask!!.id).isEqualTo(1L)
assertThat(migratedTask.name).isEqualTo("Legacy Task")
assertThat(migratedTask.description).isEqualTo("Old task description")
assertThat(migratedTask.priority).isEqualTo(Priority.HIGH)
assertThat(migratedTask.isDone).isEqualTo(false)
assertThat(migratedTask.isDeleted).isEqualTo(false)
assertThat(migratedTask.dueDate).isNotNull()
assertThat(migratedTask.dueDate!!).isGreaterThan(0L)
assertThat(migratedTask.taskListId).isEqualTo(1L)
}
} finally {
migratedRoom.close()
}
}
}

View File

@@ -0,0 +1,211 @@
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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
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 {
private lateinit var db: AppDatabase
private lateinit var taskDao: TaskDao
private lateinit var listDao: TaskListDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).allowMainThreadQueries().build()
taskDao = db.taskDao()
listDao = db.taskListDao()
}
@After
fun tearDown() {
db.close()
}
private suspend fun insertListAndReturnId(name: String = "List", order: Int = 0): Long {
listDao.insertTaskList(TaskListEntity(name = name, order = order))
return listDao.getTaskLists().first().first().id
}
@Test
fun insertAndGetTaskById_worksCorrectly() = runBlocking {
val listId = insertListAndReturnId()
val task = TaskEntity(
name = "Do laundry",
description = null,
taskListId = listId,
priority = Priority.NORMAL
)
taskDao.insertTask(task)
val inserted = taskDao.getTasksForList(listId).first().first()
val fetched = taskDao.getTaskById(inserted.id)
assertThat(fetched).isNotNull()
assertThat(fetched!!.name).isEqualTo("Do laundry")
assertThat(fetched.taskListId).isEqualTo(listId)
}
@Test
fun getTasksForList_returnsOrderedByDoneAndPriority() = runBlocking {
val listId = insertListAndReturnId()
val high = TaskEntity(name = "High", description = null, priority = Priority.HIGH, taskListId = listId)
val normal = TaskEntity(name = "Normal", description = null, priority = Priority.NORMAL, taskListId = listId)
val done = TaskEntity(name = "Done", description = null, priority = Priority.NORMAL, taskListId = listId, isDone = true)
taskDao.insertTask(normal)
taskDao.insertTask(done)
taskDao.insertTask(high)
val taskPriorities = taskDao.getTasksForList(listId).first().map { it.name }
assertThat(taskPriorities).containsExactly("High", "Normal", "Done").inOrder()
}
@Test
fun updateTask_updatesFields() = runBlocking {
val listId = insertListAndReturnId()
val task = TaskEntity(name = "Old", description = null, priority = Priority.NORMAL, taskListId = listId)
taskDao.insertTask(task)
val inserted = taskDao.getTasksForList(listId).first().first()
val updated = inserted.copy(name = "Updated")
taskDao.updateTask(updated)
val fetched = taskDao.getTaskById(inserted.id)
assertThat(fetched!!.name).isEqualTo("Updated")
}
@Test
fun toggleTaskDone_setsCorrectValue() = runBlocking {
val listId = insertListAndReturnId()
val task = TaskEntity(name = "Toggle me", description = null, priority = Priority.NORMAL, taskListId = listId, isDone = false)
taskDao.insertTask(task)
val inserted = taskDao.getTasksForList(listId).first().first()
taskDao.toggleTaskDone(inserted.id, true)
assertThat(taskDao.getTaskById(inserted.id)!!.isDone).isTrue()
taskDao.toggleTaskDone(inserted.id, false)
assertThat(taskDao.getTaskById(inserted.id)!!.isDone).isFalse()
}
@Test
fun toggleTaskDeleted_marksTaskDeleted() = runBlocking {
val listId = insertListAndReturnId()
val task = TaskEntity(name = "Trash me", description = null, priority = Priority.NORMAL, taskListId = listId)
taskDao.insertTask(task)
val inserted = taskDao.getTasksForList(listId).first().first()
taskDao.toggleTaskDeleted(inserted.id, true)
val deletedTask = taskDao.getTaskById(inserted.id)
assertThat(deletedTask!!.isDeleted).isTrue()
}
@Test
fun toggleAllTasksFromListDeleted_marksAllDeleted() = runBlocking {
val listId = insertListAndReturnId()
val tasks = listOf(
TaskEntity(name = "A", description = null, priority = Priority.NORMAL, taskListId = listId),
TaskEntity(name = "B", description = null, priority = Priority.NORMAL, taskListId = listId)
)
tasks.forEach { taskDao.insertTask(it) }
taskDao.toggleAllTasksFromListDeleted(listId, true)
val fetched = taskDao.getTasksForList(listId).first()
assertThat(fetched).isEmpty()
// confirm soft deletion
assertThat(fetched).hasSize(0)
}
@Test
fun permanentDeleteTask_removesFromDatabase() = runBlocking {
val listId = insertListAndReturnId()
val task = TaskEntity(name = "Temp", description = null, priority = Priority.NORMAL, taskListId = listId)
taskDao.insertTask(task)
val inserted = taskDao.getTasksForList(listId).first().first()
taskDao.permanentDeleteTask(inserted.id)
assertThat(taskDao.getTaskById(inserted.id)).isNull()
}
@Test
fun permanentDeleteAllDeletedTasks_removesAllDeleted() = runBlocking {
val listId = insertListAndReturnId()
val active = TaskEntity(name = "Active", description = null, priority = Priority.NORMAL, taskListId = listId)
val deleted = TaskEntity(name = "Deleted", description = null, priority = Priority.NORMAL, taskListId = listId, isDeleted = true)
taskDao.insertTask(active)
taskDao.insertTask(deleted)
taskDao.permanentDeleteAllDeletedTasks()
val remaining = taskDao.getTasksForList(listId).first()
assertThat(remaining).hasSize(1)
assertThat(remaining.first().name).isEqualTo("Active")
}
@Test
fun getDeletedTasksWithListName_returnsCorrectlyJoinedData() = runBlocking {
val listId = insertListAndReturnId(name = "Work")
val deleted = TaskEntity(name = "Trash", taskListId = listId, description = null, priority = Priority.NORMAL, isDeleted = true)
taskDao.insertTask(deleted)
val results = taskDao.getDeletedTasksWithListName().first()
assertThat(results).hasSize(1)
assertThat(results.first().task.name).isEqualTo("Trash")
assertThat(results.first().listName).isEqualTo("Work")
}
@Test
fun getDueTodayTasks_returnsTasksWithinRange() = runBlocking {
val listId = insertListAndReturnId()
val todayStart = Instant.parse("2025-09-15T00:00:00Z").toEpochMilli()
val todayEnd = Instant.parse("2025-09-15T23:59:59Z").toEpochMilli()
val yesterday = TaskEntity(
name = "Yesterday",
description = null,
priority = Priority.NORMAL,
taskListId = listId,
dueDate = Instant.parse("2025-09-14T12:00:00Z").toEpochMilli()
)
val today = TaskEntity(
name = "Today",
description = null,
priority = Priority.NORMAL,
taskListId = listId,
dueDate = Instant.parse("2025-09-15T12:00:00Z").toEpochMilli()
)
val tomorrow = TaskEntity(
name = "Tomorrow",
description = null,
priority = Priority.NORMAL,
taskListId = listId,
dueDate = Instant.parse("2025-09-16T12:00:00Z").toEpochMilli()
)
taskDao.insertTask(yesterday)
taskDao.insertTask(today)
taskDao.insertTask(tomorrow)
val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd).first()
assertThat(tasks).hasSize(1)
assertThat(tasks.first().name).isEqualTo("Today")
}
}

View File

@@ -1,15 +1,13 @@
package com.wismna.geoffroy.donext.data.local.repository package com.wismna.geoffroy.donext.data.local.dao
import androidx.room.Room import androidx.room.Room
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 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.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.local.AppDatabase import com.wismna.geoffroy.donext.data.local.AppDatabase
import com.wismna.geoffroy.donext.data.local.dao.TaskDao
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.Priority
import junit.framework.TestCase
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.After import org.junit.After
@@ -17,9 +15,10 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.time.Instant import java.time.Instant
import kotlin.collections.first
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class TaskDaoTest { class TaskListDaoTest {
private lateinit var db: AppDatabase private lateinit var db: AppDatabase
private lateinit var taskDao: TaskDao private lateinit var taskDao: TaskDao
@@ -40,6 +39,73 @@ class TaskDaoTest {
db.close() db.close()
} }
@Test
fun insertTaskList_insertsSuccessfully() = runBlocking {
val taskList = TaskListEntity(name = "Personal", order = 1)
listDao.insertTaskList(taskList)
val lists = listDao.getTaskLists().first()
assertThat(lists).hasSize(1)
assertThat(lists.first().name).isEqualTo("Personal")
}
@Test
fun getTaskListById_returnsCorrectEntity() = runBlocking {
val taskList = TaskListEntity(name = "Groceries", order = 0)
listDao.insertTaskList(taskList)
val inserted = listDao.getTaskLists().first().first()
val fetched = listDao.getTaskListById(inserted.id)
assertThat(fetched).isNotNull()
assertThat(fetched!!.name).isEqualTo("Groceries")
assertThat(fetched.id).isEqualTo(inserted.id)
}
@Test
fun updateTaskList_updatesSuccessfully() = runBlocking {
val taskList = TaskListEntity(name = "Work", order = 0)
listDao.insertTaskList(taskList)
val inserted = listDao.getTaskLists().first().first()
val updated = inserted.copy(name = "Updated Work")
listDao.updateTaskList(updated)
val fetched = listDao.getTaskListById(inserted.id)
assertThat(fetched!!.name).isEqualTo("Updated Work")
}
@Test
fun deleteTaskList_marksAsDeleted() = runBlocking {
val taskList = TaskListEntity(name = "Errands", order = 0)
listDao.insertTaskList(taskList)
val inserted = listDao.getTaskLists().first().first()
listDao.deleteTaskList(inserted.id, true)
// getTaskLists() filters deleted = 0, so result should be empty
val activeLists = listDao.getTaskLists().first()
assertThat(activeLists).isEmpty()
// But the entity still exists in DB
val softDeleted = listDao.getTaskListById(inserted.id)
assertThat(softDeleted).isNotNull()
assertThat(softDeleted!!.isDeleted).isTrue()
}
@Test
fun getTaskLists_returnsOrderedByDisplayOrder() = runBlocking {
val first = TaskListEntity(name = "Zeta", order = 2)
val second = TaskListEntity(name = "Alpha", order = 0)
val third = TaskListEntity(name = "Beta", order = 1)
listDao.insertTaskList(first)
listDao.insertTaskList(second)
listDao.insertTaskList(third)
val listNames = listDao.getTaskLists().first().map { it.name }
assertThat(listNames).containsExactly("Alpha", "Beta", "Zeta").inOrder()
}
@Test @Test
fun overdueCount_correctlyCalculated() = runBlocking { fun overdueCount_correctlyCalculated() = runBlocking {
listDao.insertTaskList(TaskListEntity(name = "Work", order = 0)) listDao.insertTaskList(TaskListEntity(name = "Work", order = 0))
@@ -85,7 +151,7 @@ class TaskDaoTest {
val lists = listDao.getTaskListsWithOverdue(now) val lists = listDao.getTaskListsWithOverdue(now)
TestCase.assertEquals(1, lists.first().first().overdueCount) assertThat(lists.first().first().overdueCount).isEqualTo(1)
} }
@Test @Test
@@ -94,7 +160,7 @@ class TaskDaoTest {
val listId = listDao.getTaskLists().first().first().id val listId = listDao.getTaskLists().first().first().id
val todayStart = Instant.parse("2025-09-15T00:00:00Z").toEpochMilli() val todayStart = Instant.parse("2025-09-15T00:00:00Z").toEpochMilli()
val todayEnd = Instant.parse("2025-09-15T23:59:99Z").toEpochMilli() val todayEnd = Instant.parse("2025-09-15T23:59:59Z").toEpochMilli()
// One task due yesterday // One task due yesterday
taskDao.insertTask( taskDao.insertTask(
@@ -129,7 +195,7 @@ class TaskDaoTest {
priority = Priority.NORMAL priority = Priority.NORMAL
) )
) )
// One task due in the future // One task due today but done
taskDao.insertTask( taskDao.insertTask(
TaskEntity( TaskEntity(
name = "TodayDone", name = "TodayDone",
@@ -140,7 +206,7 @@ class TaskDaoTest {
priority = Priority.NORMAL priority = Priority.NORMAL
) )
) )
// One task due in the future // One task due today but deleted
taskDao.insertTask( taskDao.insertTask(
TaskEntity( TaskEntity(
name = "TodayDeleted", name = "TodayDeleted",
@@ -155,7 +221,7 @@ class TaskDaoTest {
val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd) val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd)
TestCase.assertEquals(1, tasks.first().count()) assertThat(tasks.first()).hasSize(1)
TestCase.assertEquals("Prepare slides", tasks.first().first().name) assertThat(tasks.first().first().name).isEqualTo("Today")
} }
} }

View File

@@ -7,6 +7,7 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.data.Converters import com.wismna.geoffroy.donext.data.Converters
import com.wismna.geoffroy.donext.data.entities.TaskEntity import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity import com.wismna.geoffroy.donext.data.entities.TaskListEntity
@@ -137,10 +138,11 @@ abstract class AppDatabase : RoomDatabase() {
super.onCreate(db) super.onCreate(db)
// insert default lists // insert default lists
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val res = context.resources
val dao = DB_INSTANCE?.taskListDao() val dao = DB_INSTANCE?.taskListDao()
dao?.insertTaskList(TaskListEntity(name = "Personal", order = 1)) dao?.insertTaskList(TaskListEntity(name = res.getString(R.string.sample_list_personal), order = 1))
dao?.insertTaskList(TaskListEntity(name = "Work", order = 2)) dao?.insertTaskList(TaskListEntity(name = res.getString(R.string.sample_list_work), order = 2))
dao?.insertTaskList(TaskListEntity(name = "Shopping", order = 3)) dao?.insertTaskList(TaskListEntity(name = res.getString(R.string.sample_list_shopping), order = 3))
} }
} }
}) })

View File

@@ -28,7 +28,7 @@ interface TaskListDao {
END END
), 0) AS overdueCount ), 0) AS overdueCount
FROM task_lists tl FROM task_lists tl
LEFT JOIN tasks t ON t.task_list_id = tl.id LEFT JOIN tasks t ON t.task_list_id = tl.id AND t.deleted = 0
WHERE tl.deleted = 0 WHERE tl.deleted = 0
GROUP BY tl.id GROUP BY tl.id
ORDER BY tl.display_order ASC ORDER BY tl.display_order ASC

View File

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

View File

@@ -13,16 +13,13 @@ sealed class AppDestination(
object DueTodayList : AppDestination( object DueTodayList : AppDestination(
route = "todayList", route = "todayList",
title = "Due Today", title = "Due Today",
showBackButton = false,
) )
object ManageLists : AppDestination( object ManageLists : AppDestination(
route = "manageLists", route = "manageLists",
title = "Manage Lists", title = "Manage Lists",
showBackButton = false,
) )
object RecycleBin : AppDestination( object RecycleBin : AppDestination(
route = "recycleBin", route = "recycleBin",
title = "Recycle Bin", title = "Recycle Bin",
showBackButton = false,
) )
} }

View File

@@ -1,9 +1,11 @@
package com.wismna.geoffroy.donext.domain.model package com.wismna.geoffroy.donext.domain.model
enum class Priority(val value: Int, val label: String) { import com.wismna.geoffroy.donext.R
LOW(0, "Low"),
NORMAL(1, "Normal"), enum class Priority(val value: Int, val label: Int) {
HIGH(2, "High"); LOW(0, R.string.task_priority_low),
NORMAL(1, R.string.task_priority_normal),
HIGH(2, R.string.task_priority_high);
companion object { companion object {
fun fromValue(value: Int): Priority = fun fromValue(value: Int): Priority =

View File

@@ -1,16 +1,24 @@
package com.wismna.geoffroy.donext.presentation.screen package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.presentation.viewmodel.DueTodayViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.DueTodayViewModel
@Composable @Composable
@@ -26,7 +34,14 @@ fun DueTodayTasksScreen(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text("Nothing due today !") Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.CalendarToday,
contentDescription = "Due today background icon",
modifier = Modifier.size(60.dp),
tint = MaterialTheme.colorScheme.secondary)
Text(stringResource(R.string.today_no_tasks), color = MaterialTheme.colorScheme.secondary)
}
} }
} else { } else {
LazyColumn( LazyColumn(

View File

@@ -8,8 +8,13 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -24,6 +29,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.PermanentNavigationDrawer import androidx.compose.material3.PermanentNavigationDrawer
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -35,7 +41,9 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -49,7 +57,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
@@ -58,6 +69,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.AppDestination import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
@@ -79,33 +91,90 @@ fun MainScreen(
return return
} }
if (viewModel.showTaskSheet) {
TaskBottomSheet { viewModel.onDismissTaskSheet() }
}
if (viewModel.showAddListSheet) {
AddListBottomSheet { viewModel.showAddListSheet = false }
}
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
viewModel.setCurrentDestination(navBackStackEntry) viewModel.setCurrentDestination(navBackStackEntry)
val isExpandedScreen = windowSizeClass.widthSizeClass >= WindowWidthSizeClass.Medium val isExpandedScreen = windowSizeClass.widthSizeClass >= WindowWidthSizeClass.Medium
val orientation = LocalConfiguration.current.orientation val orientation = LocalConfiguration.current.orientation
val isLandscape = orientation == Configuration.ORIENTATION_LANDSCAPE val isLandscape = orientation == Configuration.ORIENTATION_LANDSCAPE
val showPermanentDrawer = isExpandedScreen || isLandscape val isLargeLayout = isExpandedScreen || isLandscape
if (showPermanentDrawer) { if (isLargeLayout) {
PermanentNavigationDrawer( PermanentNavigationDrawer(
drawerContent = { drawerContent = {
MenuScreen(currentDestination = viewModel.currentDestination) MenuScreen(
modifier = Modifier.width(240.dp),
currentDestination = viewModel.currentDestination
)
} }
) { ) {
AppContent( Row(Modifier.fillMaxSize()) {
viewModel = viewModel, // Main app content area
navController = navController Box(
) modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
AppContent(
viewModel = viewModel,
navController = navController
)
}
// Show side "details" pane for the task editor when requested
if (viewModel.showTaskSheet) {
VerticalDivider(
modifier = Modifier
.fillMaxHeight()
.width(1.dp)
)
Box(
modifier = Modifier
.width(380.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
) {
TaskScreen { viewModel.onDismissTaskSheet() }
}
}
if (viewModel.showAddListSheet) {
Dialog(onDismissRequest = { viewModel.showAddListSheet = false }) {
Surface(
shape = RoundedCornerShape(16.dp),
tonalElevation = 6.dp,
modifier = Modifier
.widthIn(max = 400.dp)
.wrapContentHeight()
.padding(16.dp)
) {
AddListScreen { viewModel.showAddListSheet = false }
}
}
}
}
} }
} else { } else {
if (viewModel.showTaskSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = {
scope.launch {
sheetState.hide()
viewModel.onDismissTaskSheet()
}
},
sheetState = sheetState) {
TaskScreen { viewModel.onDismissTaskSheet() }
}
}
if (viewModel.showAddListSheet) {
ModalBottomSheet(onDismissRequest = { viewModel.showAddListSheet = false }) {
AddListScreen { viewModel.showAddListSheet = false }
}
}
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
ModalNavigationDrawer( ModalNavigationDrawer(
drawerContent = { drawerContent = {
@@ -131,6 +200,7 @@ fun AppContent(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.uiEventBus.events.collectLatest { event -> viewModel.uiEventBus.events.collectLatest { event ->
@@ -140,11 +210,10 @@ fun AppContent(
navController.navigate(event.route) navController.navigate(event.route)
} }
is UiEvent.NavigateBack -> navController.popBackStack() is UiEvent.NavigateBack -> navController.popBackStack()
is UiEvent.EditTask -> { viewModel.showTaskSheet = true }
is UiEvent.ShowUndoSnackbar -> { is UiEvent.ShowUndoSnackbar -> {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
message = event.message, message = context.getString(event.message),
actionLabel = "Undo", actionLabel = context.getString(R.string.snackbar_action),
duration = SnackbarDuration.Short duration = SnackbarDuration.Short
) )
if (result == SnackbarResult.ActionPerformed) { if (result == SnackbarResult.ActionPerformed) {
@@ -155,13 +224,29 @@ fun AppContent(
} }
} }
} }
LaunchedEffect(Unit) {
viewModel.uiEventBus.stickyEvents.collect { event ->
when (event) {
is UiEvent.EditTask -> { viewModel.showTaskSheet = true }
else -> Unit
}
}
}
Scaffold( Scaffold(
modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer), modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
containerColor = Color.Transparent, containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(viewModel.currentDestination.title) }, title = { Text(
when (viewModel.currentDestination) {
is AppDestination.DueTodayList -> stringResource(R.string.navigation_due_today)
is AppDestination.ManageLists -> stringResource(R.string.navigation_edit_lists)
is AppDestination.RecycleBin -> stringResource(R.string.navigation_recycle_bin)
else -> viewModel.currentDestination.title
}
)},
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
@@ -198,7 +283,7 @@ fun AppContent(
is AppDestination.RecycleBin -> { is AppDestination.RecycleBin -> {
EmptyRecycleBinAction() EmptyRecycleBinAction()
} }
else -> null else -> Unit
} }
} }
) )
@@ -209,10 +294,10 @@ fun AppContent(
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = { viewModel.onNewTaskButtonClicked(dest.taskListId) }, onClick = { viewModel.onNewTaskButtonClicked(dest.taskListId) },
icon = { Icon(Icons.Filled.Add, "Create a task.") }, icon = { Icon(Icons.Filled.Add, "Create a task.") },
text = { Text("Create a task") }, text = { Text(stringResource(R.string.action_create_list)) },
) )
} }
else -> null else -> Unit
} }
} }
) { contentPadding -> ) { contentPadding ->

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -24,6 +25,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -32,7 +34,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -46,12 +47,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -70,7 +73,14 @@ fun ManageListsScreen(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text("Tap + to create a new task list.") Column (modifier = Modifier.padding(start = 10.dp, end = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.EditNote,
contentDescription = "Due today background icon",
modifier = Modifier.size(60.dp),
tint = MaterialTheme.colorScheme.secondary)
Text(stringResource(R.string.tasklists_no_task_list), color = MaterialTheme.colorScheme.secondary)
}
} }
return return
} }
@@ -217,7 +227,7 @@ fun ManageListsScreen(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AddListBottomSheet( fun AddListScreen(
viewModel: ManageListsViewModel = hiltViewModel(), viewModel: ManageListsViewModel = hiltViewModel(),
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
@@ -227,35 +237,34 @@ fun AddListBottomSheet(
titleFocusRequester.requestFocus() titleFocusRequester.requestFocus()
} }
ModalBottomSheet(onDismissRequest = onDismiss) { var name by remember { mutableStateOf("") }
var name by remember { mutableStateOf("") } //var type by remember { mutableStateOf(ListType.Default) }
//var type by remember { mutableStateOf(ListType.Default) } //var description by remember { mutableStateOf("") }
//var description by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text("New List", style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.tasklist_new_title), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
/*TextField( /*TextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("List Name") }, label = { Text("List Name") },
singleLine = true singleLine = true
)*/ )*/
OutlinedTextField( OutlinedTextField(
value = name, value = name,
singleLine = true, singleLine = true,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Title") }, label = { Text(stringResource(R.string.tasklist_new_name)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(titleFocusRequester) .focusRequester(titleFocusRequester)
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
//DropdownSelector(selected = type, onSelect = { type = it }) //DropdownSelector(selected = type, onSelect = { type = it })
/*Spacer(Modifier.height(8.dp)) /*Spacer(Modifier.height(8.dp))
TextField( TextField(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
@@ -263,19 +272,18 @@ fun AddListBottomSheet(
maxLines = 3 maxLines = 3
)*/ )*/
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
//TextButton(onClick = onDismiss) { Text("Cancel") } //TextButton(onClick = onDismiss) { Text("Cancel") }
//Spacer(Modifier.width(8.dp)) //Spacer(Modifier.width(8.dp))
Button( Button(
onClick = { onClick = {
viewModel.createTaskList(name/*, type, description*/, viewModel.taskCount + 1) viewModel.createTaskList(name/*, type, description*/, viewModel.taskCount + 1)
onDismiss() onDismiss()
}, },
enabled = name.isNotBlank() enabled = name.isNotBlank()
) { ) {
Text("Create") Text(stringResource(R.string.tasklist_new_create))
}
} }
} }
} }

View File

@@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.LineWeight
import androidx.compose.material.icons.filled.Today import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -21,41 +23,46 @@ import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.AppDestination import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.presentation.viewmodel.MenuViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.MenuViewModel
@Composable @Composable
fun MenuScreen( fun MenuScreen(
modifier: Modifier = Modifier,
viewModel: MenuViewModel = hiltViewModel(), viewModel: MenuViewModel = hiltViewModel(),
currentDestination: AppDestination, currentDestination: AppDestination,
) { ) {
ModalDrawerSheet( ModalDrawerSheet(
modifier = modifier,
drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant, drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant,
drawerContentColor = MaterialTheme.colorScheme.onSurfaceVariant drawerContentColor = MaterialTheme.colorScheme.onSurfaceVariant
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.padding(vertical = 8.dp), .padding(vertical = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween verticalArrangement = Arrangement.SpaceBetween
) { ) {
Column { Column {
Text( Text(
text = "DoNext v2", text = stringResource(R.string.navigation_title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { label = {
Row (modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row (modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Due Today") Text(stringResource(R.string.navigation_due_today))
Text(viewModel.dueTodayTasksCount.toString()) Text(viewModel.dueTodayTasksCount.toString())
} }
}, },
icon = { Icon(Icons.Default.Today, contentDescription = "Due Today") }, icon = { Icon(Icons.Default.Today, contentDescription = stringResource(R.string.navigation_due_today)) },
selected = currentDestination is AppDestination.DueTodayList, selected = currentDestination is AppDestination.DueTodayList,
onClick = { viewModel.navigateTo(AppDestination.DueTodayList.route, currentDestination.route) }, onClick = { viewModel.navigateTo(AppDestination.DueTodayList.route, currentDestination.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
@@ -70,7 +77,7 @@ fun MenuScreen(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
}, },
icon = { Icon(Icons.Default.LineWeight, contentDescription = list.name) }, icon = { Icon(Icons.Default.Checklist, contentDescription = list.name) },
selected = currentDestination is AppDestination.TaskList && selected = currentDestination is AppDestination.TaskList &&
currentDestination.taskListId == list.id, currentDestination.taskListId == list.id,
onClick = { viewModel.navigateTo("taskList/${list.id}", currentDestination.route) }, onClick = { viewModel.navigateTo("taskList/${list.id}", currentDestination.route) },
@@ -87,17 +94,17 @@ fun MenuScreen(
Column { Column {
HorizontalDivider() HorizontalDivider()
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("Recycle Bin") }, label = { Text(stringResource(R.string.navigation_edit_lists)) },
icon = { Icon(Icons.Default.Delete, contentDescription = "Recycle Bin") }, icon = { Icon(Icons.Default.EditNote, contentDescription = stringResource(R.string.navigation_edit_lists)) },
selected = currentDestination is AppDestination.RecycleBin, selected = currentDestination is AppDestination.ManageLists,
onClick = { viewModel.navigateTo(AppDestination.RecycleBin.route, currentDestination.route) }, onClick = { viewModel.navigateTo(AppDestination.ManageLists.route, currentDestination.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("Edit Lists") }, label = { Text(stringResource(R.string.navigation_recycle_bin)) },
icon = { Icon(Icons.Default.EditNote, contentDescription = "Edit Lists") }, icon = { Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.navigation_recycle_bin)) },
selected = currentDestination is AppDestination.ManageLists, selected = currentDestination is AppDestination.RecycleBin,
onClick = { viewModel.navigateTo(AppDestination.ManageLists.route, currentDestination.route) }, onClick = { viewModel.navigateTo(AppDestination.RecycleBin.route, currentDestination.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
) )
} }

View File

@@ -2,12 +2,15 @@ package com.wismna.geoffroy.donext.presentation.screen
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -19,16 +22,16 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.presentation.viewmodel.RecycleBinViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.RecycleBinViewModel
@Composable @Composable
@@ -37,6 +40,7 @@ fun RecycleBinScreen(
viewModel: RecycleBinViewModel = hiltViewModel(), viewModel: RecycleBinViewModel = hiltViewModel(),
) { ) {
val tasks = viewModel.deletedTasks val tasks = viewModel.deletedTasks
val taskToDelete by viewModel.taskToDeleteFlow.collectAsStateWithLifecycle()
if (tasks.isEmpty()) { if (tasks.isEmpty()) {
// Placeholder when recycle bin is empty // Placeholder when recycle bin is empty
@@ -44,14 +48,49 @@ fun RecycleBinScreen(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text("Recycle Bin is empty") Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.DeleteOutline,
contentDescription = "Recycle bin background icon",
modifier = Modifier.size(60.dp),
tint = MaterialTheme.colorScheme.secondary
)
Text(stringResource(R.string.recycle_bin_no_tasks), color = MaterialTheme.colorScheme.secondary)
}
} }
return return
} }
val grouped = tasks.groupBy { it.listName } val grouped = tasks.groupBy { it.listName }
val context = LocalContext.current val context = LocalContext.current
if (taskToDelete != null) {
AlertDialog(
onDismissRequest = { viewModel.onCancelDelete() },
title = { Text(stringResource(R.string.dialog_delete_task_title)) },
text = {
Text(stringResource(R.string.dialog_delete_task_description))
},
confirmButton = {
TextButton(
onClick = {
viewModel.onConfirmDelete()
Toast.makeText(context, "Task deleted", Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text(stringResource(R.string.dialog_delete_task_delete))
}
},
dismissButton = {
TextButton(onClick = { viewModel.onCancelDelete() }) {
Text(stringResource(R.string.dialog_delete_task_cancel))
}
}
)
}
LazyColumn( LazyColumn(
modifier = modifier.padding(8.dp) modifier = modifier.padding(8.dp)
) { ) {
@@ -76,11 +115,7 @@ fun RecycleBinScreen(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
task = item.task, task = item.task,
onSwipeLeft = { viewModel.restore(item.task.id!!) }, onSwipeLeft = { viewModel.restore(item.task.id!!) },
onSwipeRight = { onSwipeRight = { viewModel.onTaskDeleteRequest(item.task.id!!) },
// TODO: add confirmation dialog
viewModel.deleteForever(item.task.id!!)
Toast.makeText(context, "Task deleted", Toast.LENGTH_SHORT).show()
},
onTaskClick = { viewModel.onTaskClicked(item.task) } onTaskClick = { viewModel.onTaskClicked(item.task) }
) )
} }
@@ -91,42 +126,41 @@ fun RecycleBinScreen(
@Composable @Composable
fun EmptyRecycleBinAction(viewModel: RecycleBinViewModel = hiltViewModel()) { fun EmptyRecycleBinAction(viewModel: RecycleBinViewModel = hiltViewModel()) {
val isEmpty = viewModel.deletedTasks.isEmpty() val isEmpty = viewModel.deletedTasks.isEmpty()
var showConfirmDialog by remember { mutableStateOf(false) } val emptyRecycleBin by viewModel.emptyRecycleBinFlow.collectAsStateWithLifecycle()
IconButton( IconButton(
onClick = { showConfirmDialog = true }, onClick = { viewModel.onEmptyRecycleBinRequest() },
enabled = !isEmpty) { enabled = !isEmpty) {
Icon( Icon(
Icons.Default.DeleteSweep, Icons.Default.DeleteSweep,
modifier = Modifier.alpha(if (isEmpty) 0.5f else 1.0f), modifier = Modifier.alpha(if (isEmpty) 0.5f else 1.0f),
contentDescription = "Empty Recycle Bin", contentDescription = stringResource(R.string.dialog_empty_task_title),
tint = MaterialTheme.colorScheme.onPrimaryContainer tint = MaterialTheme.colorScheme.onPrimaryContainer
) )
} }
if (showConfirmDialog) { if (emptyRecycleBin) {
AlertDialog( AlertDialog(
onDismissRequest = { showConfirmDialog = false }, onDismissRequest = { viewModel.onCancelEmptyRecycleBinRequest() },
title = { Text("Empty Recycle Bin") }, title = { Text(stringResource(R.string.dialog_empty_task_title)) },
text = { text = {
Text("Are you sure you want to permanently delete all tasks in the recycle bin? This cannot be undone.") Text(stringResource(R.string.dialog_empty_task_description))
}, },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
viewModel.emptyRecycleBin() viewModel.emptyRecycleBin()
showConfirmDialog = false
}, },
colors = ButtonDefaults.textButtonColors( colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error contentColor = MaterialTheme.colorScheme.error
) )
) { ) {
Text("Delete") Text(stringResource(R.string.dialog_empty_task_delete))
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showConfirmDialog = false }) { TextButton(onClick = { viewModel.onCancelEmptyRecycleBinRequest() }) {
Text("Cancel") Text(stringResource(R.string.dialog_empty_task_cancel))
} }
} }
) )

View File

@@ -32,11 +32,13 @@ 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
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.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.viewmodel.TaskItemViewModel
@@ -57,7 +59,6 @@ fun TaskItemScreen(
else if (it == SwipeToDismissBoxValue.EndToStart) onSwipeLeft() else if (it == SwipeToDismissBoxValue.EndToStart) onSwipeLeft()
return@rememberSwipeToDismissBoxState false return@rememberSwipeToDismissBoxState false
}, },
// positional threshold of 25%
positionalThreshold = { it * .25f } positionalThreshold = { it * .25f }
) )
@@ -184,12 +185,12 @@ fun DismissBackground(dismissState: SwipeToDismissBoxState, isDone: Boolean, isD
Icon( Icon(
if (isDeleted) Icons.Default.DeleteForever else Icons.Default.DeleteOutline, if (isDeleted) Icons.Default.DeleteForever else Icons.Default.DeleteOutline,
tint = Color.LightGray, tint = Color.LightGray,
contentDescription = "Delete" contentDescription = stringResource(R.string.task_action_delete)
) )
Text( Text(
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
fontSize = 10.sp, fontSize = 10.sp,
text = if (isDeleted) "Delete" else "Recycle" text = if (isDeleted) stringResource(R.string.task_action_delete) else stringResource(R.string.task_action_recycle)
) )
} }
Spacer(modifier = Modifier) Spacer(modifier = Modifier)
@@ -198,12 +199,16 @@ fun DismissBackground(dismissState: SwipeToDismissBoxState, isDone: Boolean, isD
if (isDeleted) Icons.Default.RestoreFromTrash else if (isDeleted) Icons.Default.RestoreFromTrash else
if (isDone) Icons.Outlined.Unpublished else Icons.Outlined.CheckCircle, if (isDone) Icons.Outlined.Unpublished else Icons.Outlined.CheckCircle,
tint = Color.LightGray, tint = Color.LightGray,
contentDescription = "Archive" contentDescription = stringResource(R.string.task_action_done)
) )
Text( Text(
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
fontSize = 10.sp, fontSize = 10.sp,
text = if (isDeleted) "Restore" else if (isDone) "Undone" else "Done" text = stringResource(
if (isDeleted) R.string.task_action_restore
else
if (isDone) R.string.task_action_undone
else R.string.task_action_done)
) )
} }
} }

View File

@@ -2,20 +2,28 @@ package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
@Composable @Composable
@@ -31,7 +39,14 @@ fun TaskListScreen(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text("Tap + to create a new task.") Column (modifier = Modifier.padding(start = 10.dp, end = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Checklist,
contentDescription = "Due today background icon",
modifier = Modifier.size(60.dp),
tint = MaterialTheme.colorScheme.secondary)
Text(stringResource(R.string.tasklist_no_tasks), color = MaterialTheme.colorScheme.secondary)
}
} }
return return
} }

View File

@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
@@ -18,7 +20,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
@@ -27,24 +28,23 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.R
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
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -53,159 +53,153 @@ import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TaskBottomSheet( fun TaskScreen(
viewModel: TaskViewModel = hiltViewModel(), viewModel: TaskViewModel = hiltViewModel(),
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val titleFocusRequester = remember { FocusRequester() } val titleFocusRequester = remember { FocusRequester() }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
titleFocusRequester.requestFocus() titleFocusRequester.requestFocus()
} }
ModalBottomSheet( Column(Modifier.padding(16.dp).verticalScroll(rememberScrollState())) {
onDismissRequest = onDismiss, Text(
sheetState = sheetState) { stringResource(
Column(Modifier.padding(16.dp)) { if (viewModel.isDeleted) R.string.task_title_deleted
Text( else
viewModel.screenTitle(), if (viewModel.isEditing()) R.string.task_title_edit
style = MaterialTheme.typography.titleLarge else R.string.task_title_new),
style = MaterialTheme.typography.titleLarge
)
Spacer(Modifier.height(8.dp))
// --- Title ---
OutlinedTextField(
value = viewModel.title,
singleLine = true,
readOnly = viewModel.isDeleted,
onValueChange = { viewModel.onTitleChanged(it) },
label = { Text(stringResource(R.string.task_name)) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester)
)
Spacer(Modifier.height(12.dp))
// --- Description ---
OutlinedTextField(
value = viewModel.description,
readOnly = viewModel.isDeleted,
onValueChange = { viewModel.onDescriptionChanged(it) },
label = { Text(stringResource(R.string.task_description)) },
maxLines = 3,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
// --- Priority ---
Row(
modifier = Modifier.fillMaxWidth().padding(start = 17.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.task_priority), style = MaterialTheme.typography.labelLarge)
SingleChoiceSegmentedButton(
value = viewModel.priority,
isEnabled = !viewModel.isDeleted,
onValueChange = { viewModel.onPriorityChanged(it) }
) )
Spacer(Modifier.height(8.dp)) }
Spacer(Modifier.height(12.dp))
// --- Title --- // --- Due Date ---
OutlinedTextField( var showDatePicker by remember { mutableStateOf(false) }
value = viewModel.title, val formattedDate = viewModel.dueDate?.toLocalDate()?.format(
singleLine = true, DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
readOnly = viewModel.isDeleted, )
onValueChange = { viewModel.onTitleChanged(it) }, ?: ""
label = { Text("Title") },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester)
)
Spacer(Modifier.height(12.dp))
// --- Description --- OutlinedTextField(
OutlinedTextField( value = formattedDate,
value = viewModel.description, onValueChange = {},
readOnly = viewModel.isDeleted, readOnly = true,
onValueChange = { viewModel.onDescriptionChanged(it) }, label = { Text(stringResource(R.string.task_due_date)) },
label = { Text("Description") }, trailingIcon = {
maxLines = 3, Row {
modifier = Modifier.fillMaxWidth() if (viewModel.dueDate != null) {
)
Spacer(Modifier.height(12.dp))
// --- Priority ---
Row (
modifier = Modifier.fillMaxWidth().padding(start = 17.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Text("Priority", style = MaterialTheme.typography.labelLarge)
SingleChoiceSegmentedButton(
value = viewModel.priority,
isEnabled = !viewModel.isDeleted,
onValueChange = { viewModel.onPriorityChanged(it) }
)
}
Spacer(Modifier.height(12.dp))
// --- Due Date ---
var showDatePicker by remember { mutableStateOf(false) }
val formattedDate = viewModel.dueDate?.toLocalDate()?.format(
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
?: ""
OutlinedTextField(
value = formattedDate,
onValueChange = {},
readOnly = true,
label = { Text("Due Date") },
trailingIcon = {
Row {
if (viewModel.dueDate != null) {
IconButton(
onClick = { viewModel.onDueDateChanged(null) },
enabled = !viewModel.isDeleted) {
Icon(Icons.Default.Clear, contentDescription = "Clear due date")
}
}
IconButton( IconButton(
onClick = { showDatePicker = true }, onClick = { viewModel.onDueDateChanged(null) },
enabled = !viewModel.isDeleted) { enabled = !viewModel.isDeleted
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick due date") ) {
Icon(Icons.Default.Clear, contentDescription = "Clear due date")
} }
} }
}, IconButton(
modifier = Modifier.fillMaxWidth() onClick = { showDatePicker = true },
enabled = !viewModel.isDeleted
) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick due date")
}
}
},
modifier = Modifier.fillMaxWidth()
)
if (showDatePicker) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = viewModel.dueDate,
selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val todayStartUtcMillis = LocalDate.now(ZoneId.systemDefault())
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
return utcTimeMillis >= todayStartUtcMillis
}
}
) )
if (showDatePicker) { DatePickerDialog(
val datePickerState = rememberDatePickerState( onDismissRequest = { showDatePicker = false },
initialSelectedDateMillis = viewModel.dueDate, confirmButton = {
selectableDates = object: SelectableDates { TextButton(
override fun isSelectableDate(utcTimeMillis: Long): Boolean { onClick = {
val todayStartUtcMillis = LocalDate.now(ZoneId.systemDefault())
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
return utcTimeMillis >= todayStartUtcMillis
}
}
)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
datePickerState.selectedDateMillis?.let { viewModel.onDueDateChanged(it) } datePickerState.selectedDateMillis?.let { viewModel.onDueDateChanged(it) }
showDatePicker = false showDatePicker = false
}) { Text("OK") } }) { Text(stringResource(R.string.dialog_due_date_ok)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Cancel") } TextButton(onClick = { showDatePicker = false }) { Text(stringResource(R.string.dialog_due_date_cancel)) }
}
) {
DatePicker(state = datePickerState)
} }
) {
DatePicker(state = datePickerState)
} }
if (!viewModel.isDeleted) { }
Spacer(Modifier.height(16.dp)) if (!viewModel.isDeleted) {
Spacer(Modifier.height(16.dp))
Row ( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) {
// --- Cancel Button ---
Button(
onClick = { onDismiss() },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.primary
)
) { Text("Cancel") }
// --- Save Button ---
Button(
onClick = {
viewModel.save()
onDismiss()
},
enabled = viewModel.title.isNotBlank() && !viewModel.isDeleted,
) { ) {
// --- Cancel Button --- Text(stringResource(if (viewModel.isEditing()) R.string.task_save_edit else R.string.task_save_new))
Button(
onClick = {
scope.launch {
sheetState.hide()
onDismiss()
}
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.primary
)
) { Text("Cancel") }
// --- Save Button ---
Button(
onClick = {
scope.launch {
viewModel.save()
sheetState.hide()
onDismiss()
}
},
enabled = viewModel.title.isNotBlank() && !viewModel.isDeleted,
) {
Text(if (viewModel.isEditing()) "Save" else "Create")
}
} }
} }
} }
@@ -229,7 +223,7 @@ fun SingleChoiceSegmentedButton(
enabled = isEnabled, enabled = isEnabled,
onClick = { onValueChange(Priority.fromValue(index)) }, onClick = { onValueChange(Priority.fromValue(index)) },
selected = index == value.value, selected = index == value.value,
label = { Text(label) } label = { Text(stringResource(label)) }
) )
} }
} }

View File

@@ -9,7 +9,7 @@ sealed class UiEvent {
data class CreateNewTask(val taskListId: Long) : UiEvent() data class CreateNewTask(val taskListId: Long) : UiEvent()
data object CloseTask : UiEvent() data object CloseTask : UiEvent()
data class ShowUndoSnackbar( data class ShowUndoSnackbar(
val message: String, val message: Int,
val undoAction: () -> Unit val undoAction: () -> Unit
) : UiEvent() ) : UiEvent()
} }

View File

@@ -1,5 +1,6 @@
package com.wismna.geoffroy.donext.presentation.ui.events package com.wismna.geoffroy.donext.presentation.ui.events
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject import javax.inject.Inject
@@ -7,10 +8,24 @@ import javax.inject.Singleton
@Singleton @Singleton
class UiEventBus @Inject constructor() { class UiEventBus @Inject constructor() {
private val _events = MutableSharedFlow<UiEvent>(replay = 1) // Non-replayable (e.g. navigation, snackbar)
private val _events = MutableSharedFlow<UiEvent>(replay = 0, extraBufferCapacity = 1)
val events = _events.asSharedFlow() val events = _events.asSharedFlow()
// Replayable (e.g. edit/create task)
private val _stickyEvents = MutableSharedFlow<UiEvent>(replay = 1, extraBufferCapacity = 1)
val stickyEvents = _stickyEvents.asSharedFlow()
suspend fun send(event: UiEvent) { suspend fun send(event: UiEvent) {
_events.emit(event) when (event) {
is UiEvent.EditTask,
is UiEvent.CreateNewTask -> _stickyEvents.emit(event)
else -> _events.emit(event)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun clearSticky() {
_stickyEvents.resetReplayCache()
} }
} }

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
@@ -48,7 +49,7 @@ class DueTodayViewModel @Inject constructor(
uiEventBus.send( uiEventBus.send(
UiEvent.ShowUndoSnackbar( UiEvent.ShowUndoSnackbar(
message = "Task done", message = R.string.snackbar_message_task_done,
undoAction = { undoAction = {
viewModelScope.launch { viewModelScope.launch {
toggleTaskDoneUseCase(taskId, false) toggleTaskDoneUseCase(taskId, false)
@@ -65,7 +66,7 @@ class DueTodayViewModel @Inject constructor(
uiEventBus.send( uiEventBus.send(
UiEvent.ShowUndoSnackbar( UiEvent.ShowUndoSnackbar(
message = "Task moved to recycle bin", message = R.string.snackbar_message_task_recycle,
undoAction = { undoAction = {
viewModelScope.launch { viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, false) toggleTaskDeletedUseCase(taskId, false)

View File

@@ -71,6 +71,7 @@ class MainViewModel @Inject constructor(
showTaskSheet = false showTaskSheet = false
viewModelScope.launch { viewModelScope.launch {
uiEventBus.send(UiEvent.CloseTask) uiEventBus.send(UiEvent.CloseTask)
uiEventBus.clearSticky()
} }
} }

View File

@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.TaskList import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.AddTaskListUseCase import com.wismna.geoffroy.donext.domain.usecase.AddTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskListUseCase import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskListUseCase
@@ -58,7 +59,7 @@ class ManageListsViewModel @Inject constructor(
uiEventBus.send( uiEventBus.send(
UiEvent.ShowUndoSnackbar( UiEvent.ShowUndoSnackbar(
message = "Task list moved to recycle bin", message = R.string.snackbar_message_task_list_recycle,
undoAction = { undoAction = {
viewModelScope.launch { viewModelScope.launch {
deleteTaskListUseCase(taskListId, false) deleteTaskListUseCase(taskListId, false)

View File

@@ -3,8 +3,10 @@ package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskWithListName import com.wismna.geoffroy.donext.domain.model.TaskWithListName
import com.wismna.geoffroy.donext.domain.usecase.EmptyRecycleBinUseCase import com.wismna.geoffroy.donext.domain.usecase.EmptyRecycleBinUseCase
@@ -25,12 +27,20 @@ class RecycleBinViewModel @Inject constructor(
private val toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase, private val toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase,
private val permanentlyDeleteTaskUseCase: PermanentlyDeleteTaskUseCase, private val permanentlyDeleteTaskUseCase: PermanentlyDeleteTaskUseCase,
private val emptyRecycleBinUseCase: EmptyRecycleBinUseCase, private val emptyRecycleBinUseCase: EmptyRecycleBinUseCase,
private val uiEventBus: UiEventBus private val uiEventBus: UiEventBus,
private val savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
companion object {
private const val TASK_TO_DELETE = "taskToDeleteId"
private const val EMPTY_RECYCLE_BIN = "emptyRecycleBin"
}
var deletedTasks by mutableStateOf<List<TaskWithListName>>(emptyList()) var deletedTasks by mutableStateOf<List<TaskWithListName>>(emptyList())
private set private set
val taskToDeleteFlow = savedStateHandle.getStateFlow<Long?>(TASK_TO_DELETE, null)
val emptyRecycleBinFlow = savedStateHandle.getStateFlow<Boolean>(EMPTY_RECYCLE_BIN, false)
init { init {
loadDeletedTasks() loadDeletedTasks()
} }
@@ -52,11 +62,10 @@ class RecycleBinViewModel @Inject constructor(
fun restore(taskId: Long) { fun restore(taskId: Long) {
viewModelScope.launch { viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, false) toggleTaskDeletedUseCase(taskId, false)
loadDeletedTasks()
uiEventBus.send( uiEventBus.send(
UiEvent.ShowUndoSnackbar( UiEvent.ShowUndoSnackbar(
message = "Task restored", message = R.string.snackbar_message_task_restore,
undoAction = { undoAction = {
viewModelScope.launch { viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, true) toggleTaskDeletedUseCase(taskId, true)
@@ -67,15 +76,35 @@ class RecycleBinViewModel @Inject constructor(
} }
} }
fun deleteForever(taskId: Long) { fun onEmptyRecycleBinRequest() {
viewModelScope.launch { savedStateHandle[EMPTY_RECYCLE_BIN] = true
permanentlyDeleteTaskUseCase(taskId)
loadDeletedTasks()
}
} }
fun onCancelEmptyRecycleBinRequest() {
savedStateHandle[EMPTY_RECYCLE_BIN] = false
}
fun emptyRecycleBin() { fun emptyRecycleBin() {
viewModelScope.launch { viewModelScope.launch {
emptyRecycleBinUseCase() emptyRecycleBinUseCase()
savedStateHandle[EMPTY_RECYCLE_BIN] = false
} }
} }
fun onTaskDeleteRequest(taskId: Long) {
savedStateHandle[TASK_TO_DELETE] = taskId
}
fun onConfirmDelete() {
taskToDeleteFlow.value?.let {
viewModelScope.launch {
permanentlyDeleteTaskUseCase(it)
}
}
savedStateHandle[TASK_TO_DELETE] = null
}
fun onCancelDelete() {
savedStateHandle[TASK_TO_DELETE] = null
}
} }

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.extension.toLocalDate
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 java.time.Clock
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle 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(task: Task) { class TaskItemViewModel(
task: Task,
private val clock: Clock = Clock.systemDefaultZone()) {
val id: Long = task.id!! val id: Long = task.id!!
val name: String = task.name val name: String = task.name
val description: String? = task.description val description: String? = task.description
@@ -18,17 +20,17 @@ class TaskItemViewModel(task: Task) {
val isDeleted: Boolean = task.isDeleted val isDeleted: Boolean = task.isDeleted
val priority: Priority = task.priority 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 isOverdue: Boolean = task.dueDate?.let { millis ->
val dueDate = millis.toLocalDate() val dueDate = millis.toLocalDate(clock)
dueDate.isBefore(today) dueDate.isBefore(today)
} ?: false } ?: false
val dueDateText: String? = task.dueDate?.let { formatDueDate(it) } val dueDateText: String? = task.dueDate?.let { formatDueDate(it) }
private fun formatDueDate(dueMillis: Long): String { private fun formatDueDate(dueMillis: Long): String {
val dueDate = dueMillis.toLocalDate() val dueDate = dueMillis.toLocalDate(clock)
return when { return when {
dueDate.isEqual(today) -> "Today" dueDate.isEqual(today) -> "Today"

View File

@@ -6,6 +6,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.GetTasksForListUseCase import com.wismna.geoffroy.donext.domain.usecase.GetTasksForListUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
@@ -55,7 +56,7 @@ class TaskListViewModel @Inject constructor(
uiEventBus.send( uiEventBus.send(
UiEvent.ShowUndoSnackbar( UiEvent.ShowUndoSnackbar(
message = "Task done", message = if (isDone) R.string.snackbar_message_task_done else R.string.snackbar_message_task_undone,
undoAction = { undoAction = {
viewModelScope.launch { viewModelScope.launch {
toggleTaskDoneUseCase(taskId, !isDone) toggleTaskDoneUseCase(taskId, !isDone)
@@ -71,7 +72,7 @@ class TaskListViewModel @Inject constructor(
uiEventBus.send( uiEventBus.send(
UiEvent.ShowUndoSnackbar( UiEvent.ShowUndoSnackbar(
message = "Task moved to recycle bin", message = R.string.snackbar_message_task_recycle,
undoAction = { undoAction = {
viewModelScope.launch { viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, false) toggleTaskDeletedUseCase(taskId, false)

View File

@@ -37,13 +37,14 @@ class TaskViewModel @Inject constructor(
private set private set
var isDeleted by mutableStateOf(false) var isDeleted by mutableStateOf(false)
private set private set
var taskListId by mutableStateOf<Long?>(null)
private var editingTaskId: Long? = null private set
private var taskListId: Long? = null var editingTaskId by mutableStateOf<Long?>(null)
private set
init { init {
viewModelScope.launch { viewModelScope.launch {
uiEventBus.events.collect { event -> uiEventBus.stickyEvents.collect { event ->
when (event) { when (event) {
is UiEvent.CreateNewTask -> startNewTask(event.taskListId) is UiEvent.CreateNewTask -> startNewTask(event.taskListId)
is UiEvent.EditTask -> startEditTask(event.task) is UiEvent.EditTask -> startEditTask(event.task)
@@ -54,7 +55,6 @@ class TaskViewModel @Inject constructor(
} }
} }
fun screenTitle(): String = if (isDeleted) "Task details" else if (isEditing()) "Edit Task" else "New Task"
fun isEditing(): Boolean = editingTaskId != null fun isEditing(): Boolean = editingTaskId != null
fun onTitleChanged(value: String) { title = value } fun onTitleChanged(value: String) { title = value }
fun onDescriptionChanged(value: String) { description = value } fun onDescriptionChanged(value: String) { description = value }

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="navigation_edit_lists">Edition des listes</string>
<string name="navigation_recycle_bin">Corbeille</string>
<string name="navigation_due_today">À faire aujourd\'hui</string>
<string name="action_create_list">Créer une tâche</string>
<string name="tasklist_no_tasks">Cliquez sur + pour créer une nouvelle tâche.</string>
<string name="recycle_bin_no_tasks">La corbeille est vide</string>
<string name="today_no_tasks">Rien à faire aujourd\'hui !</string>
<string name="task_title_new">Nouvelle tâche</string>
<string name="task_title_edit">Edition de tâche</string>
<string name="task_title_deleted">Détails de la tâche</string>
<string name="task_name">Titre</string>
<string name="task_description">Description</string>
<string name="task_priority">Priorité</string>
<string name="task_priority_low">Basse</string>
<string name="task_priority_normal">Normale</string>
<string name="task_priority_high">Haute</string>
<string name="task_due_date">Echéance</string>
<string name="task_save_new">Créer</string>
<string name="task_save_edit">Sauvegarder</string>
<string name="task_cancel">Annuler</string>
<string name="due_date_yesterday">Hier</string>
<string name="due_date_today">Aujourd\'hui</string>
<string name="due_date_tomorrow">Demain</string>
<string name="task_action_recycle">Mettre à la corbeille</string>
<string name="task_action_delete">Supprimer</string>
<string name="task_action_restore">Restaurer</string>
<string name="task_action_done">Terminée</string>
<string name="task_action_undone">En cours</string>
<string name="dialog_due_date_ok">OK</string>
<string name="dialog_due_date_cancel">Annuler</string>
<string name="dialog_delete_task_title">Supprimer la tâche</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_delete">Supprimer</string>
<string name="dialog_empty_task_title">Vider la corbeille</string>
<string name="dialog_empty_task_description">Êtes-vous sûr de vider la corbeille ? Cette action ne peut être annulée.</string>
<string name="dialog_empty_task_cancel">Annuler</string>
<string name="dialog_empty_task_delete">Vider</string>
<string name="tasklist_new_title">Nouvelle liste</string>
<string name="tasklist_new_name">Titre</string>
<string name="tasklist_new_create">Créer</string>
<string name="snackbar_message_task_done">Tâche terminée.</string>
<string name="snackbar_message_task_undone">Tâche active.</string>
<string name="snackbar_message_task_recycle">Tâche placée dans la corbeille.</string>
<string name="snackbar_message_task_list_recycle">Liste de tâches déplacée dans la corbeille.</string>
<string name="snackbar_message_task_restore">Tâche restaurée</string>
<string name="snackbar_action">Annuler</string>
<string name="sample_list_personal">Personnel</string>
<string name="sample_list_work">Travail</string>
<string name="sample_list_shopping">Shopping</string>
<string name="tasklists_no_task_list">Aucune liste de tâches. Cliquez sur + pour en créer une.</string>
</resources>

View File

@@ -1,4 +1,69 @@
<resources> <resources>
<string name="app_name">DoNext</string> <string name="app_name" translatable="false">DoNext</string>
<string name="title_activity_main">MainActivity</string> <string name="title_activity_main" translatable="false">MainActivity</string>
<string name="navigation_title" translatable="false">DoNext v2</string>
<string name="navigation_edit_lists">Edit lists</string>
<string name="navigation_recycle_bin">Recycle Bin</string>
<string name="navigation_due_today">Due Today</string>
<string name="action_create_list">Create a task</string>
<string name="tasklist_no_tasks">Tap + to create a new task.</string>
<string name="recycle_bin_no_tasks">Recycle Bin is empty</string>
<string name="today_no_tasks">Nothing due today!</string>
<string name="task_title_new">New Task</string>
<string name="task_title_edit">Edit Task</string>
<string name="task_title_deleted">Task Details</string>
<string name="task_name">Title</string>
<string name="task_description">Description</string>
<string name="task_priority">Priority</string>
<string name="task_priority_low">Low</string>
<string name="task_priority_normal">Normal</string>
<string name="task_priority_high">High</string>
<string name="task_due_date">Due Date</string>
<string name="task_save_new">Create</string>
<string name="task_save_edit">Save</string>
<string name="task_cancel">Cancel</string>
<string name="due_date_yesterday">Yesterday</string>
<string name="due_date_today">Today</string>
<string name="due_date_tomorrow">Tomorrow</string>
<string name="task_action_recycle">Recycle</string>
<string name="task_action_delete">Delete</string>
<string name="task_action_restore">Restore</string>
<string name="task_action_done">Done</string>
<string name="task_action_undone">Undone</string>
<string name="dialog_due_date_ok">OK</string>
<string name="dialog_due_date_cancel">Cancel</string>
<string name="dialog_delete_task_title">Delete task</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_delete">Delete</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_cancel">Cancel</string>
<string name="dialog_empty_task_delete">Empty</string>
<string name="tasklists_no_task_list">No task lists. Tap the + button to create one.</string>
<string name="tasklist_new_title">New list</string>
<string name="tasklist_new_name">Title</string>
<string name="tasklist_new_create">Create</string>
<string name="snackbar_message_task_done">Task done</string>
<string name="snackbar_message_task_undone">Task undone</string>
<string name="snackbar_message_task_recycle">Task moved to recycle bin</string>
<string name="snackbar_message_task_list_recycle">Task list moved to recycle bin</string>
<string name="snackbar_message_task_restore">Task restored</string>
<string name="snackbar_action">Undo</string>
<string name="sample_list_personal">Personal</string>
<string name="sample_list_work">Work</string>
<string name="sample_list_shopping">Shopping</string>
</resources> </resources>

View File

@@ -0,0 +1,111 @@
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
import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase
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 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 DueTodayViewModelTest {
private lateinit var getDueTodayTasksUseCase: GetDueTodayTasksUseCase
private lateinit var toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase
private lateinit var toggleTaskDoneUseCase: ToggleTaskDoneUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var viewModel: DueTodayViewModel
private val testDispatcher = StandardTestDispatcher()
private val tasksFlow = MutableSharedFlow<List<Task>>(replay = 1)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
getDueTodayTasksUseCase = mockk()
toggleTaskDeletedUseCase = mockk(relaxed = true)
toggleTaskDoneUseCase = mockk(relaxed = true)
uiEventBus = mockk(relaxed = true)
coEvery { getDueTodayTasksUseCase.invoke() } returns tasksFlow
viewModel = DueTodayViewModel(
getDueTodayTasksUseCase,
toggleTaskDeletedUseCase,
toggleTaskDoneUseCase,
uiEventBus
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `dueTodayTasks updates when flow emits`() = runTest {
val taskList = listOf(Task(taskListId = 0, id = 1, name = "Test Task", description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false))
tasksFlow.emit(taskList)
advanceUntilIdle()
assertThat(viewModel.dueTodayTasks).isEqualTo(taskList)
}
@Test
fun `onTaskClicked sends EditTask event`() = runTest {
val task = Task(taskListId = 0, id = 42, name = "Click me", description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
viewModel.onTaskClicked(task)
advanceUntilIdle()
coVerify { uiEventBus.send(UiEvent.EditTask(task)) }
}
@Test
fun `updateTaskDone toggles done and sends snackbar with undo`() = runTest {
val taskId = 5L
viewModel.updateTaskDone(taskId)
advanceUntilIdle()
coVerify { toggleTaskDoneUseCase(taskId, true) }
coVerify {
uiEventBus.send(
match {
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_done
}
)
}
}
@Test
fun `deleteTask toggles deleted and sends snackbar with undo`() = runTest {
val taskId = 7L
viewModel.deleteTask(taskId)
advanceUntilIdle()
coVerify { toggleTaskDeletedUseCase(taskId, true) }
coVerify {
uiEventBus.send(
match{
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_recycle
}
)
}
}
}

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 json_key_file(ENV["SUPPLY_JSON_KEY"] || "./fastlane/keys/donext-f9e67-5038064982b0")
package_name("com.wismna.geoffroy.donext") # e.g. com.krausefx.app 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) default_platform(:android)
platform :android do platform :android do
desc "Runs all the tests" desc "Build, test, and deploy to Google Play"
lane :test do lane :internal do
gradle(task: "test") module_name = ENV["MODULE"]
end # 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" # Read Play Store versionCode (track internal)
lane :beta do play_version = google_play_track_version_codes(
gradle(task: "clean assembleRelease") track: "internal"
crashlytics ).max.to_i
# sh "your_script.sh" if gradle_version <= play_version
# You can also use other beta testing services here UI.user_error!("VersionCode #{gradle_version} should be higher than Play Store version (#{play_version}). Aborting upload.")
end end
desc "Deploy a new version to the Google Play" gradle(task: "testDebugUnitTest")
lane :deploy do gradle(task: "clean :#{module_name}:bundleRelease")
gradle(task: "clean assembleRelease") upload_to_play_store(
upload_to_play_store track: "internal",
aab: "#{module_name}/build/outputs/bundle/release/#{module_name}-release.aab"
)
end end
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>