Compare commits

...

41 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
Geoffroy Bonneville
4e2f3c720c Add wide screen and landscape support 2025-10-17 21:31:41 -04:00
Geoffroy Bonneville
571b82dc98 Fix due date when migrating from v1
Update packages
2025-10-17 21:14:31 -04:00
Geoffroy Bonneville
2962c459d1 Fix undo after swiping issues 2025-10-15 11:25:18 -04:00
Geoffroy Bonneville
038a97672f Restore previous swiping mechanism 2025-10-10 20:54:25 -04:00
Geoffroy Bonneville
30d3efa9de Reverted TaskItemVM to a dumb state-like VM
Task Edit events are now carried to the parent composable
2025-10-10 18:23:07 -04:00
Geoffroy Bonneville
53e716a690 Light cleanup and refactoring 2025-10-10 16:11:39 -04:00
Geoffroy Bonneville
6c9e5efe38 Added undo snackbar for list deletion
Made all destinations show the menu instead of the back button (for now)
2025-10-10 16:04:18 -04:00
Geoffroy Bonneville
c579a5d252 Restore due date on task list items
Replace toast with snackbar with an undo action
2025-10-10 15:43:36 -04:00
Geoffroy Bonneville
c57210494a Prevent navigation to current destination 2025-10-10 11:24:49 -04:00
Geoffroy Bonneville
c3dd615d15 Use events for task clicks 2025-10-10 10:59:32 -04:00
Geoffroy Bonneville
e07f389fac Create UI events
Create a event bus singleton
Handle navigation through events
Handle task creation and edition through events
2025-10-09 22:00:27 -04:00
Geoffroy Bonneville
313e514624 Cleanup 2025-10-09 16:44:35 -04:00
Geoffroy Bonneville
8e78f9b464 Fix navigation issue when navigating back to a deleted list
Some refactoring
2025-10-09 16:43:27 -04:00
72 changed files with 3618 additions and 567 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@@ -3,6 +3,58 @@
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<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">
<value>
<AndroidTestResultsTableState>
@@ -16,6 +68,32 @@
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1756619873">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="2129352037">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>

View File

@@ -100,6 +100,18 @@
<option name="screenX" value="1200" />
<option name="screenY" value="1920" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="samsung" />
<option name="codename" value="a06" />
<option name="id" value="a06" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A06" />
<option name="screenDensity" value="300" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
@@ -136,6 +148,18 @@
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="samsung" />
<option name="codename" value="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>
<option name="api" value="34" />
<option name="brand" value="samsung" />
@@ -160,6 +184,30 @@
<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="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>
<option name="api" value="34" />
<option name="brand" value="google" />
@@ -232,6 +280,18 @@
<option name="screenX" value="1080" />
<option name="screenY" value="2640" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="blazer" />
<option name="id" value="blazer" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 10 Pro" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2410" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
@@ -306,6 +366,18 @@
<option name="screenX" value="2220" />
<option name="screenY" value="1080" />
</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>
<option name="api" value="34" />
<option name="brand" value="samsung" />
@@ -439,6 +511,18 @@
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="frankel" />
<option name="id" value="frankel" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 10" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
@@ -657,6 +741,18 @@
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="mustang" />
<option name="id" value="mustang" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 10 Pro XL" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1080" />
<option name="screenY" value="2404" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
@@ -705,6 +801,18 @@
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</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>
<option name="api" value="34" />
<option name="brand" value="samsung" />
@@ -755,6 +863,18 @@
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="rango" />
<option name="id" value="rango" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 10 Pro Fold" />
<option name="screenDensity" value="390" />
<option name="screenX" value="2076" />
<option name="screenY" value="2152" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />

View File

@@ -2,13 +2,10 @@
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="donextv2">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="overdueCount_correctlyCalculated()">
<SelectionState runConfigName="donext">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>

View File

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

View File

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

230
Gemfile.lock Normal file
View File

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

View File

@@ -14,7 +14,6 @@ buildscript {
}
dependencies {
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:compose-compiler-gradle-plugin:2.0.21'
@@ -25,7 +24,7 @@ buildscript {
plugins {
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
id("com.google.dagger.hilt.android") version "2.57.1" apply false
id("com.google.dagger.hilt.android") version "2.57.2" apply false
}
allprojects {
@@ -37,6 +36,4 @@ allprojects {
tasks.register('clean', Delete) {
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
}
}
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 {
implementation fileTree(dir: 'libs', include: ['*.jar'])
@@ -51,7 +42,7 @@ dependencies {
implementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}"
// Lifecycle components
def lifecycleVersion = '2.9.3'
def lifecycleVersion = '2.9.4'
implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"

View File

@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@@ -6,6 +8,10 @@ plugins {
id("com.google.dagger.hilt.android")
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
android {
namespace = "com.wismna.geoffroy.donext"
compileSdk = 36
@@ -14,12 +20,25 @@ android {
applicationId = "com.wismna.geoffroy.donext"
minSdk = 26
targetSdk = 36
versionCode = 34
versionName = "2.0"
versionCode = 35
versionName = "2.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_FILE") ?: "./upload.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("ALIAS_NAME")
keyPassword = System.getenv("ALIAS_PASSWORD")
}
}
sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas"))
}
buildTypes {
release {
// Enables code-related app optimization.
@@ -30,14 +49,17 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs["release"]
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
buildFeatures {
compose = true
@@ -46,28 +68,46 @@ android {
composeOptions {
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 {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
implementation("androidx.activity:activity-compose:1.11.0")
implementation(platform("androidx.compose:compose-bom:2025.09.01"))
implementation(platform("androidx.compose:compose-bom:2025.11.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material3:material3-window-size-class:1.4.0")
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation("androidx.navigation:navigation-compose:2.9.5")
implementation("androidx.navigation:navigation-compose:2.9.6")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation("androidx.test.ext:junit-ktx:1.3.0")
implementation("sh.calvin.reorderable:reorderable:3.0.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.09.01"))
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("com.google.truth:truth:1.4.5")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
val roomVersion = "2.8.1"
val roomVersion = "2.8.3"
implementation("androidx.room:room-runtime:$roomVersion")
androidTestImplementation("androidx.room:room-testing:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
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.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.data.local.dao.TaskDao
import com.wismna.geoffroy.donext.data.local.dao.TaskListDao
import com.wismna.geoffroy.donext.domain.model.Priority
import junit.framework.TestCase
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
@@ -17,9 +15,10 @@ 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 {
class TaskListDaoTest {
private lateinit var db: AppDatabase
private lateinit var taskDao: TaskDao
@@ -40,6 +39,73 @@ class TaskDaoTest {
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
fun overdueCount_correctlyCalculated() = runBlocking {
listDao.insertTaskList(TaskListEntity(name = "Work", order = 0))
@@ -85,7 +151,7 @@ class TaskDaoTest {
val lists = listDao.getTaskListsWithOverdue(now)
TestCase.assertEquals(1, lists.first().first().overdueCount)
assertThat(lists.first().first().overdueCount).isEqualTo(1)
}
@Test
@@ -94,7 +160,7 @@ class TaskDaoTest {
val listId = listDao.getTaskLists().first().first().id
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
taskDao.insertTask(
@@ -129,7 +195,7 @@ class TaskDaoTest {
priority = Priority.NORMAL
)
)
// One task due in the future
// One task due today but done
taskDao.insertTask(
TaskEntity(
name = "TodayDone",
@@ -140,7 +206,7 @@ class TaskDaoTest {
priority = Priority.NORMAL
)
)
// One task due in the future
// One task due today but deleted
taskDao.insertTask(
TaskEntity(
name = "TodayDeleted",
@@ -155,7 +221,7 @@ class TaskDaoTest {
val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd)
TestCase.assertEquals(1, tasks.first().count())
TestCase.assertEquals("Prepare slides", tasks.first().first().name)
assertThat(tasks.first()).hasSize(1)
assertThat(tasks.first().first().name).isEqualTo("Today")
}
}

View File

@@ -7,6 +7,7 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.data.Converters
import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity
@@ -40,11 +41,24 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE tasks ADD COLUMN duedate_temp INTEGER")
// Populate temporary column
db.execSQL("""
WITH offset AS (
SELECT (strftime('%s', 'now', 'localtime') - strftime('%s', 'now')) / 3600.0 AS diff
)
UPDATE tasks
SET duedate_temp =
CASE
WHEN duedate IS NULL OR duedate = '' THEN NULL
ELSE (strftime('%s', duedate || 'T00:00:00Z') * 1000)
ELSE (
strftime(
'%s',
duedate || ' 00:00:00',
CASE
WHEN (SELECT diff FROM offset) >= 0
THEN '-' || (SELECT diff FROM offset) || ' hours'
ELSE '+' || abs((SELECT diff FROM offset)) || ' hours'
END
) * 1000
)
END
""".trimIndent())
@@ -124,10 +138,11 @@ abstract class AppDatabase : RoomDatabase() {
super.onCreate(db)
// insert default lists
CoroutineScope(Dispatchers.IO).launch {
val res = context.resources
val dao = DB_INSTANCE?.taskListDao()
dao?.insertTaskList(TaskListEntity(name = "Personal", order = 1))
dao?.insertTaskList(TaskListEntity(name = "Work", order = 2))
dao?.insertTaskList(TaskListEntity(name = "Shopping", order = 3))
dao?.insertTaskList(TaskListEntity(name = res.getString(R.string.sample_list_personal), order = 1))
dao?.insertTaskList(TaskListEntity(name = res.getString(R.string.sample_list_work), order = 2))
dao?.insertTaskList(TaskListEntity(name = res.getString(R.string.sample_list_shopping), order = 3))
}
}
})

View File

@@ -28,7 +28,7 @@ interface TaskListDao {
END
), 0) AS overdueCount
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
GROUP BY tl.id
ORDER BY tl.display_order ASC

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import javax.inject.Inject
class DeleteTaskListUseCase@Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke(taskListId: Long) {
repository.deleteTaskList(taskListId, true)
suspend operator fun invoke(taskListId: Long, isDeleted: Boolean) {
repository.deleteTaskList(taskListId, isDeleted)
}
}

View File

@@ -5,17 +5,21 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import com.wismna.geoffroy.donext.presentation.screen.MainScreen
import com.wismna.geoffroy.donext.presentation.ui.theme.DoNextTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DoNextTheme(darkTheme = isSystemInDarkTheme(), dynamicColor = false) { MainScreen() }
val windowSizeClass = calculateWindowSizeClass(this)
DoNextTheme(darkTheme = isSystemInDarkTheme(), dynamicColor = false) { MainScreen(windowSizeClass) }
}
}
}

View File

@@ -1,27 +1,30 @@
package com.wismna.geoffroy.donext.presentation.screen
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.presentation.viewmodel.DueTodayViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@Composable
fun DueTodayTasksScreen(
modifier: Modifier = Modifier,
viewModel: DueTodayViewModel = hiltViewModel(),
onTaskClick: (task: Task) -> Unit
) {
val tasks = viewModel.dueTodayTasks
@@ -31,27 +34,26 @@ fun DueTodayTasksScreen(
modifier = modifier.fillMaxSize(),
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 {
val context = LocalContext.current
LazyColumn(
modifier = modifier.padding(8.dp)
) {
items(tasks, key = { it.id!! }) { task ->
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!)
Toast.makeText(context, "Task done", Toast.LENGTH_SHORT).show()
},
onSwipeRight = {
viewModel.deleteTask(task.id!!)
Toast.makeText(context, "Task moved to recycle bin", Toast.LENGTH_SHORT)
.show()
}
task = task,
onSwipeLeft = { viewModel.updateTaskDone(task.id!!) },
onSwipeRight = { viewModel.deleteTask(task.id!!) },
onTaskClick = { viewModel.onTaskClicked(task) }
)
}
}

View File

@@ -2,13 +2,19 @@
package com.wismna.geoffroy.donext.presentation.screen
import android.content.res.Configuration
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
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.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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -18,25 +24,43 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.PermanentNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.window.Dialog
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
@@ -45,23 +69,20 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@Composable
fun MainScreen(
modifier: Modifier = Modifier,
windowSizeClass: WindowSizeClass,
viewModel: MainViewModel = hiltViewModel()
) {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// TODO: find a way to get rid of this
val taskViewModel: TaskViewModel = hiltViewModel()
if (viewModel.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -70,33 +91,103 @@ fun MainScreen(
return
}
if (viewModel.showTaskSheet) {
TaskBottomSheet(taskViewModel) { viewModel.showTaskSheet = false }
}
if (viewModel.showAddListSheet) {
AddListBottomSheet { viewModel.showAddListSheet = false }
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
viewModel.setCurrentDestination(navBackStackEntry)
ModalNavigationDrawer(
drawerContent = {
MenuScreen (
currentDestination = viewModel.currentDestination,
onNavigate = { route ->
scope.launch {
drawerState.close()
navController.navigate(route) {
restoreState = true
val isExpandedScreen = windowSizeClass.widthSizeClass >= WindowWidthSizeClass.Medium
val orientation = LocalConfiguration.current.orientation
val isLandscape = orientation == Configuration.ORIENTATION_LANDSCAPE
val isLargeLayout = isExpandedScreen || isLandscape
if (isLargeLayout) {
PermanentNavigationDrawer(
drawerContent = {
MenuScreen(
modifier = Modifier.width(240.dp),
currentDestination = viewModel.currentDestination
)
}
) {
Row(Modifier.fillMaxSize()) {
// Main app content area
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 {
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)
ModalNavigationDrawer(
drawerContent = {
MenuScreen(currentDestination = viewModel.currentDestination)
},
drawerState = drawerState
) {
AppContent(
viewModel = viewModel,
navController = navController,
drawerState = drawerState
)
},
drawerState = drawerState
) {
AppContent(viewModel = viewModel, taskViewModel = taskViewModel, navController = navController, scope = scope, drawerState = drawerState)
}
}
}
@@ -104,34 +195,80 @@ fun MainScreen(
fun AppContent(
modifier : Modifier = Modifier,
viewModel: MainViewModel,
taskViewModel: TaskViewModel,
navController: NavHostController,
scope: CoroutineScope,
drawerState: DrawerState
drawerState: DrawerState? = null
) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.uiEventBus.events.collectLatest { event ->
when (event) {
is UiEvent.Navigate -> {
drawerState?.close()
navController.navigate(event.route)
}
is UiEvent.NavigateBack -> navController.popBackStack()
is UiEvent.ShowUndoSnackbar -> {
val result = snackbarHostState.showSnackbar(
message = context.getString(event.message),
actionLabel = context.getString(R.string.snackbar_action),
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
event.undoAction()
}
}
else -> Unit
}
}
}
LaunchedEffect(Unit) {
viewModel.uiEventBus.stickyEvents.collect { event ->
when (event) {
is UiEvent.EditTask -> { viewModel.showTaskSheet = true }
else -> Unit
}
}
}
Scaffold(
modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer),
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
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(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
navigationIcon = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
if (viewModel.currentDestination.showBackButton) {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
} else {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(
Icons.Default.Menu,
contentDescription = "Open navigation drawer"
)
if (drawerState != null) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimaryContainer) {
if (viewModel.currentDestination.showBackButton) {
IconButton(onClick = { viewModel.navigateBack() }) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "Back"
)
}
} else {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(
Icons.Default.Menu,
contentDescription = "Open navigation drawer"
)
}
}
}
}
@@ -146,7 +283,7 @@ fun AppContent(
is AppDestination.RecycleBin -> {
EmptyRecycleBinAction()
}
else -> null
else -> Unit
}
}
)
@@ -154,12 +291,13 @@ fun AppContent(
floatingActionButton = {
when (val dest = viewModel.currentDestination) {
is AppDestination.TaskList -> {
TaskListFab(
taskListId = dest.taskListId,
showBottomSheet = { viewModel.showTaskSheet = it }
ExtendedFloatingActionButton(
onClick = { viewModel.onNewTaskButtonClicked(dest.taskListId) },
icon = { Icon(Icons.Filled.Add, "Create a task.") },
text = { Text(stringResource(R.string.action_create_list)) },
)
}
else -> null
else -> Unit
}
}
) { contentPadding ->
@@ -192,40 +330,28 @@ fun AppContent(
type = NavType.LongType
})
) { navBackStackEntry ->
// TODO: when task list has been deleted, we should not navigate to it event if in the stack
val taskListViewModel: TaskListViewModel = hiltViewModel(navBackStackEntry)
TaskListScreen(
viewModel = taskListViewModel,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
val taskListId = navBackStackEntry.arguments?.getLong("taskListId") ?: return@composable
val listExists by remember(taskListId, viewModel.destinations) {
derivedStateOf { viewModel.doesListExist(taskListId) }
}
LaunchedEffect(listExists) {
if (!viewModel.doesListExist(taskListId)) {
viewModel.navigateBack()
}
)
}
val taskListViewModel: TaskListViewModel = hiltViewModel(navBackStackEntry)
TaskListScreen(viewModel = taskListViewModel)
}
composable(AppDestination.ManageLists.route) {
ManageListsScreen(
modifier = Modifier,
showAddListSheet = {viewModel.showAddListSheet = true}
)
ManageListsScreen(modifier = Modifier)
}
composable(AppDestination.DueTodayList.route) {
DueTodayTasksScreen (
modifier = Modifier,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
)
DueTodayTasksScreen (modifier = Modifier)
}
composable(AppDestination.RecycleBin.route) {
RecycleBinScreen(
modifier = Modifier,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
)
RecycleBinScreen(modifier = Modifier)
}
}
}

View File

@@ -6,13 +6,16 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -22,6 +25,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -30,7 +34,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -44,12 +47,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.clearAndSetSemantics
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -58,10 +63,28 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
@Composable
fun ManageListsScreen(
modifier: Modifier,
viewModel: ManageListsViewModel = hiltViewModel(),
showAddListSheet: () -> Unit
viewModel: ManageListsViewModel = hiltViewModel()
) {
var lists = viewModel.taskLists.toMutableList()
if (lists.isEmpty()) {
// Placeholder when no task lists exist
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
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
}
val lazyListState = rememberLazyListState()
val reorderState = rememberReorderableLazyListState(
lazyListState = lazyListState,
@@ -204,7 +227,7 @@ fun ManageListsScreen(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddListBottomSheet(
fun AddListScreen(
viewModel: ManageListsViewModel = hiltViewModel(),
onDismiss: () -> Unit
) {
@@ -214,35 +237,34 @@ fun AddListBottomSheet(
titleFocusRequester.requestFocus()
}
ModalBottomSheet(onDismissRequest = onDismiss) {
var name by remember { mutableStateOf("") }
//var type by remember { mutableStateOf(ListType.Default) }
//var description by remember { mutableStateOf("") }
var name by remember { mutableStateOf("") }
//var type by remember { mutableStateOf(ListType.Default) }
//var description by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
Text("New List", style = MaterialTheme.typography.titleMedium)
Column(modifier = Modifier.padding(16.dp)) {
Text(stringResource(R.string.tasklist_new_title), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
/*TextField(
Spacer(Modifier.height(8.dp))
/*TextField(
value = name,
onValueChange = { name = it },
label = { Text("List Name") },
singleLine = true
)*/
OutlinedTextField(
value = name,
singleLine = true,
onValueChange = { name = it },
label = { Text("Title") },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester)
)
OutlinedTextField(
value = name,
singleLine = true,
onValueChange = { name = it },
label = { Text(stringResource(R.string.tasklist_new_name)) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester)
)
Spacer(Modifier.height(8.dp))
//DropdownSelector(selected = type, onSelect = { type = it })
Spacer(Modifier.height(8.dp))
//DropdownSelector(selected = type, onSelect = { type = it })
/*Spacer(Modifier.height(8.dp))
/*Spacer(Modifier.height(8.dp))
TextField(
value = description,
onValueChange = { description = it },
@@ -250,19 +272,18 @@ fun AddListBottomSheet(
maxLines = 3
)*/
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
//TextButton(onClick = onDismiss) { Text("Cancel") }
//Spacer(Modifier.width(8.dp))
Button(
onClick = {
viewModel.createTaskList(name/*, type, description*/, viewModel.taskCount + 1)
onDismiss()
},
enabled = name.isNotBlank()
) {
Text("Create")
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
//TextButton(onClick = onDismiss) { Text("Cancel") }
//Spacer(Modifier.width(8.dp))
Button(
onClick = {
viewModel.createTaskList(name/*, type, description*/, viewModel.taskCount + 1)
onDismiss()
},
enabled = name.isNotBlank()
) {
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.LineWeight
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.Badge
import androidx.compose.material3.HorizontalDivider
@@ -21,44 +23,48 @@ import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.presentation.viewmodel.MenuViewModel
@Composable
fun MenuScreen(
modifier: Modifier = Modifier,
viewModel: MenuViewModel = hiltViewModel(),
currentDestination: AppDestination,
onNavigate: (String) -> Unit
) {
ModalDrawerSheet(
modifier = modifier,
drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant,
drawerContentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Column(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 8.dp),
.padding(vertical = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "Task Lists",
text = stringResource(R.string.navigation_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
NavigationDrawerItem(
label = {
Row (modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Due Today")
Text(stringResource(R.string.navigation_due_today))
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,
onClick = { onNavigate(AppDestination.DueTodayList.route) },
onClick = { viewModel.navigateTo(AppDestination.DueTodayList.route, currentDestination.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
HorizontalDivider()
@@ -71,10 +77,10 @@ fun MenuScreen(
overflow = TextOverflow.Ellipsis
)
},
icon = { Icon(Icons.Default.LineWeight, contentDescription = list.name) },
icon = { Icon(Icons.Default.Checklist, contentDescription = list.name) },
selected = currentDestination is AppDestination.TaskList &&
currentDestination.taskListId == list.id,
onClick = { onNavigate("taskList/${list.id}") },
onClick = { viewModel.navigateTo("taskList/${list.id}", currentDestination.route) },
badge = {
if (list.overdueCount > 0) {
Badge { Text(list.overdueCount.toString()) }
@@ -88,17 +94,17 @@ fun MenuScreen(
Column {
HorizontalDivider()
NavigationDrawerItem(
label = { Text("Recycle Bin") },
icon = { Icon(Icons.Default.Delete, contentDescription = "Recycle Bin") },
selected = currentDestination is AppDestination.RecycleBin,
onClick = { onNavigate(AppDestination.RecycleBin.route) },
label = { Text(stringResource(R.string.navigation_edit_lists)) },
icon = { Icon(Icons.Default.EditNote, contentDescription = stringResource(R.string.navigation_edit_lists)) },
selected = currentDestination is AppDestination.ManageLists,
onClick = { viewModel.navigateTo(AppDestination.ManageLists.route, currentDestination.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
NavigationDrawerItem(
label = { Text("Edit Lists") },
icon = { Icon(Icons.Default.EditNote, contentDescription = "Edit Lists") },
selected = currentDestination is AppDestination.ManageLists,
onClick = { onNavigate(AppDestination.ManageLists.route) },
label = { Text(stringResource(R.string.navigation_recycle_bin)) },
icon = { Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.navigation_recycle_bin)) },
selected = currentDestination is AppDestination.RecycleBin,
onClick = { viewModel.navigateTo(AppDestination.RecycleBin.route, currentDestination.route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}

View File

@@ -2,12 +2,15 @@ package com.wismna.geoffroy.donext.presentation.screen
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
@@ -19,27 +22,25 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Task
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.TaskItemViewModel
@Composable
fun RecycleBinScreen(
modifier: Modifier = Modifier,
viewModel: RecycleBinViewModel = hiltViewModel(),
onTaskClick: (task: Task) -> Unit
) {
val tasks = viewModel.deletedTasks
val taskToDelete by viewModel.taskToDeleteFlow.collectAsStateWithLifecycle()
if (tasks.isEmpty()) {
// Placeholder when recycle bin is empty
@@ -47,14 +48,49 @@ fun RecycleBinScreen(
modifier = modifier.fillMaxSize(),
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
}
val grouped = tasks.groupBy { it.listName }
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(
modifier = modifier.padding(8.dp)
) {
@@ -77,16 +113,10 @@ fun RecycleBinScreen(
items(items, key = { it.task.id!! }) { item ->
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(item.task),
onTaskClick = { onTaskClick(item.task) },
onSwipeLeft = {
viewModel.restore(item.task.id!!)
Toast.makeText(context, "Task restored", Toast.LENGTH_SHORT).show()
},
onSwipeRight = {
viewModel.deleteForever(item.task.id!!)
Toast.makeText(context, "Task deleted", Toast.LENGTH_SHORT).show()
}
task = item.task,
onSwipeLeft = { viewModel.restore(item.task.id!!) },
onSwipeRight = { viewModel.onTaskDeleteRequest(item.task.id!!) },
onTaskClick = { viewModel.onTaskClicked(item.task) }
)
}
}
@@ -96,42 +126,41 @@ fun RecycleBinScreen(
@Composable
fun EmptyRecycleBinAction(viewModel: RecycleBinViewModel = hiltViewModel()) {
val isEmpty = viewModel.deletedTasks.isEmpty()
var showConfirmDialog by remember { mutableStateOf(false) }
val emptyRecycleBin by viewModel.emptyRecycleBinFlow.collectAsStateWithLifecycle()
IconButton(
onClick = { showConfirmDialog = true },
onClick = { viewModel.onEmptyRecycleBinRequest() },
enabled = !isEmpty) {
Icon(
Icons.Default.DeleteSweep,
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
)
}
if (showConfirmDialog) {
if (emptyRecycleBin) {
AlertDialog(
onDismissRequest = { showConfirmDialog = false },
title = { Text("Empty Recycle Bin") },
onDismissRequest = { viewModel.onCancelEmptyRecycleBinRequest() },
title = { Text(stringResource(R.string.dialog_empty_task_title)) },
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 = {
TextButton(
onClick = {
viewModel.emptyRecycleBin()
showConfirmDialog = false
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete")
Text(stringResource(R.string.dialog_empty_task_delete))
}
},
dismissButton = {
TextButton(onClick = { showConfirmDialog = false }) {
Text("Cancel")
TextButton(onClick = { viewModel.onCancelEmptyRecycleBinRequest() }) {
Text(stringResource(R.string.dialog_empty_task_cancel))
}
}
)

View File

@@ -32,35 +32,36 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@Composable
fun TaskItemScreen(
modifier: Modifier = Modifier,
viewModel: TaskItemViewModel,
onTaskClick: (taskId: Long) -> Unit,
task: Task,
onSwipeLeft: () -> Unit,
onSwipeRight: () -> Unit
onSwipeRight: () -> Unit,
onTaskClick: (task: Task) -> Unit
) {
// TODO: change this
val viewModel = TaskItemViewModel(task)
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
when (it) {
SwipeToDismissBoxValue.StartToEnd -> { onSwipeRight() }
SwipeToDismissBoxValue.EndToStart -> { onSwipeLeft() }
SwipeToDismissBoxValue.Settled -> return@rememberSwipeToDismissBoxState false
}
return@rememberSwipeToDismissBoxState true
if (it == SwipeToDismissBoxValue.StartToEnd) onSwipeRight()
else if (it == SwipeToDismissBoxValue.EndToStart) onSwipeLeft()
return@rememberSwipeToDismissBoxState false
},
// positional threshold of 25%
positionalThreshold = { it * .25f }
)
val baseStyle = MaterialTheme.typography.bodyLarge.copy(
fontWeight = when (viewModel.priority) {
Priority.HIGH -> FontWeight.Bold
@@ -76,7 +77,7 @@ fun TaskItemScreen(
)
Card(
modifier = modifier,
onClick = { onTaskClick(viewModel.id) },
onClick = { onTaskClick(task) },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
@@ -184,12 +185,12 @@ fun DismissBackground(dismissState: SwipeToDismissBoxState, isDone: Boolean, isD
Icon(
if (isDeleted) Icons.Default.DeleteForever else Icons.Default.DeleteOutline,
tint = Color.LightGray,
contentDescription = "Delete"
contentDescription = stringResource(R.string.task_action_delete)
)
Text(
color = MaterialTheme.colorScheme.onPrimary,
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)
@@ -198,12 +199,16 @@ fun DismissBackground(dismissState: SwipeToDismissBoxState, isDone: Boolean, isD
if (isDeleted) Icons.Default.RestoreFromTrash else
if (isDone) Icons.Outlined.Unpublished else Icons.Outlined.CheckCircle,
tint = Color.LightGray,
contentDescription = "Archive"
contentDescription = stringResource(R.string.task_action_done)
)
Text(
color = MaterialTheme.colorScheme.onPrimary,
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

@@ -1,43 +1,61 @@
package com.wismna.geoffroy.donext.presentation.screen
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
@Composable
fun TaskListScreen(
modifier: Modifier = Modifier,
viewModel: TaskListViewModel = hiltViewModel<TaskListViewModel>(),
onTaskClick: (Task) -> Unit) {
) {
val tasks = viewModel.tasks
if (tasks.isEmpty()) {
// Placeholder when recycle bin is empty
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
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
}
// Split tasks into active and done
val (active, done) = remember(tasks) {
tasks.partition { !it.isDone }
}
val context = LocalContext.current
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
@@ -50,16 +68,10 @@ fun TaskListScreen(
) { task ->
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!, true)
Toast.makeText(context, "Task done", Toast.LENGTH_SHORT).show()
},
onSwipeRight = {
viewModel.deleteTask(task.id!!)
Toast.makeText(context, "Task moved to recycle bin", Toast.LENGTH_SHORT).show()
}
task = task,
onSwipeLeft = { viewModel.updateTaskDone(task.id!!, true) },
onSwipeRight = { viewModel.deleteTask(task.id!!) },
onTaskClick = { viewModel.onTaskClicked(task) }
)
}
@@ -81,34 +93,11 @@ fun TaskListScreen(
) { task ->
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!, false)
Toast.makeText(context, "Task in progress", Toast.LENGTH_SHORT).show()
},
onSwipeRight = {
viewModel.deleteTask(task.id!!)
Toast.makeText(context, "Task moved to recycle bin", Toast.LENGTH_SHORT).show()
},
task = task,
onSwipeLeft = { viewModel.updateTaskDone(task.id!!, false) },
onSwipeRight = { viewModel.deleteTask(task.id!!) },
onTaskClick = { viewModel.onTaskClicked(task) }
)
}
}
}
@Composable
fun TaskListFab(
taskListId: Long,
viewModel: TaskViewModel = hiltViewModel(),
showBottomSheet: (Boolean) -> Unit = {}
) {
ExtendedFloatingActionButton(
onClick = {
viewModel.startNewTask(taskListId)
showBottomSheet(true)
},
icon = { Icon(Icons.Filled.Add, "Create a task.") },
text = { Text("Create a task") },
)
}

View File

@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Clear
@@ -18,7 +20,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
@@ -27,23 +28,23 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.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.model.Priority
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
@@ -52,159 +53,153 @@ import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskBottomSheet(
viewModel: TaskViewModel,
fun TaskScreen(
viewModel: TaskViewModel = hiltViewModel(),
onDismiss: () -> Unit
) {
val titleFocusRequester = remember { FocusRequester() }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
titleFocusRequester.requestFocus()
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState) {
Column(Modifier.padding(16.dp)) {
Text(
viewModel.screenTitle(),
style = MaterialTheme.typography.titleLarge
Column(Modifier.padding(16.dp).verticalScroll(rememberScrollState())) {
Text(
stringResource(
if (viewModel.isDeleted) R.string.task_title_deleted
else
if (viewModel.isEditing()) R.string.task_title_edit
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 ---
OutlinedTextField(
value = viewModel.title,
singleLine = true,
readOnly = viewModel.isDeleted,
onValueChange = { viewModel.onTitleChanged(it) },
label = { Text("Title") },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester)
)
Spacer(Modifier.height(12.dp))
// --- Due Date ---
var showDatePicker by remember { mutableStateOf(false) }
val formattedDate = viewModel.dueDate?.toLocalDate()?.format(
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
)
?: ""
// --- Description ---
OutlinedTextField(
value = viewModel.description,
readOnly = viewModel.isDeleted,
onValueChange = { viewModel.onDescriptionChanged(it) },
label = { Text("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("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")
}
}
OutlinedTextField(
value = formattedDate,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.task_due_date)) },
trailingIcon = {
Row {
if (viewModel.dueDate != null) {
IconButton(
onClick = { showDatePicker = true },
enabled = !viewModel.isDeleted) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick due date")
onClick = { viewModel.onDueDateChanged(null) },
enabled = !viewModel.isDeleted
) {
Icon(Icons.Default.Clear, contentDescription = "Clear due date")
}
}
},
modifier = Modifier.fillMaxWidth()
IconButton(
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) {
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
}
}
)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
datePickerState.selectedDateMillis?.let { viewModel.onDueDateChanged(it) }
showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
}
) {
DatePicker(state = datePickerState)
}) { Text(stringResource(R.string.dialog_due_date_ok)) }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text(stringResource(R.string.dialog_due_date_cancel)) }
}
) {
DatePicker(state = datePickerState)
}
if (!viewModel.isDeleted) {
Spacer(Modifier.height(16.dp))
}
if (!viewModel.isDeleted) {
Spacer(Modifier.height(16.dp))
Row (
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
Row(
modifier = Modifier.fillMaxWidth(),
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 ---
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")
}
Text(stringResource(if (viewModel.isEditing()) R.string.task_save_edit else R.string.task_save_new))
}
}
}
@@ -228,7 +223,7 @@ fun SingleChoiceSegmentedButton(
enabled = isEnabled,
onClick = { onValueChange(Priority.fromValue(index)) },
selected = index == value.value,
label = { Text(label) }
label = { Text(stringResource(label)) }
)
}
}

View File

@@ -0,0 +1,15 @@
package com.wismna.geoffroy.donext.presentation.ui.events
import com.wismna.geoffroy.donext.domain.model.Task
sealed class UiEvent {
data class Navigate(val route: String) : UiEvent()
data object NavigateBack : UiEvent()
data class EditTask(val task: Task) : UiEvent()
data class CreateNewTask(val taskListId: Long) : UiEvent()
data object CloseTask : UiEvent()
data class ShowUndoSnackbar(
val message: Int,
val undoAction: () -> Unit
) : UiEvent()
}

View File

@@ -0,0 +1,31 @@
package com.wismna.geoffroy.donext.presentation.ui.events
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UiEventBus @Inject constructor() {
// Non-replayable (e.g. navigation, snackbar)
private val _events = MutableSharedFlow<UiEvent>(replay = 0, extraBufferCapacity = 1)
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) {
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,10 +5,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.R
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -17,30 +20,60 @@ import javax.inject.Inject
@HiltViewModel
class DueTodayViewModel @Inject constructor(
getDueTodayTasks: GetDueTodayTasksUseCase,
private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
private val toggleTaskDone: ToggleTaskDoneUseCase
getDueTodayTasksUseCase: GetDueTodayTasksUseCase,
private val toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase,
private val toggleTaskDoneUseCase: ToggleTaskDoneUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() {
var dueTodayTasks by mutableStateOf<List<Task>>(emptyList())
private set
init {
getDueTodayTasks()
getDueTodayTasksUseCase()
.onEach { tasks ->
dueTodayTasks = tasks
}
.launchIn(viewModelScope)
}
fun updateTaskDone(taskId: Long) {
fun onTaskClicked(task: Task) {
viewModelScope.launch {
toggleTaskDone(taskId, true)
uiEventBus.send(UiEvent.EditTask(task))
}
}
fun updateTaskDone(taskId: Long) {
viewModelScope.launch {
toggleTaskDoneUseCase(taskId, true)
uiEventBus.send(
UiEvent.ShowUndoSnackbar(
message = R.string.snackbar_message_task_done,
undoAction = {
viewModelScope.launch {
toggleTaskDoneUseCase(taskId, false)
}
}
)
)
}
}
fun deleteTask(taskId: Long) {
viewModelScope.launch {
toggleTaskDeleted(taskId, true)
toggleTaskDeletedUseCase(taskId, true)
uiEventBus.send(
UiEvent.ShowUndoSnackbar(
message = R.string.snackbar_message_task_recycle,
undoAction = {
viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, false)
}
}
)
)
}
}
}

View File

@@ -8,14 +8,18 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.NavBackStackEntry
import com.wismna.geoffroy.donext.domain.model.AppDestination
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
getTaskListsUseCase: GetTaskListsUseCase
getTaskListsUseCase: GetTaskListsUseCase,
val uiEventBus: UiEventBus
) : ViewModel() {
var isLoading by mutableStateOf(true)
@@ -42,14 +46,35 @@ class MainViewModel @Inject constructor(
AppDestination.ManageLists +
AppDestination.RecycleBin +
AppDestination.DueTodayList
isLoading = false
if (startDestination == AppDestination.ManageLists && destinations.isNotEmpty()) {
startDestination = destinations.first()
}
isLoading = false
}
.launchIn(viewModelScope)
}
fun navigateBack() {
viewModelScope.launch {
uiEventBus.send(UiEvent.NavigateBack)
}
}
fun onNewTaskButtonClicked(taskLisId: Long) {
showTaskSheet = true
viewModelScope.launch {
uiEventBus.send(UiEvent.CreateNewTask(taskLisId))
}
}
fun onDismissTaskSheet() {
showTaskSheet = false
viewModelScope.launch {
uiEventBus.send(UiEvent.CloseTask)
uiEventBus.clearSticky()
}
}
fun setCurrentDestination(navBackStackEntry: NavBackStackEntry?) {
val route = navBackStackEntry?.destination?.route
val taskListId = navBackStackEntry?.arguments?.getLong("taskListId")
@@ -61,4 +86,10 @@ class MainViewModel @Inject constructor(
}
} ?: startDestination
}
fun doesListExist(taskListId: Long): Boolean {
return destinations.any { dest ->
dest is AppDestination.TaskList && dest.taskListId == taskListId
}
}
}

View File

@@ -6,11 +6,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.R
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -22,7 +25,8 @@ class ManageListsViewModel @Inject constructor(
getTaskListsUseCase: GetTaskListsUseCase,
private val addTaskListUseCase: AddTaskListUseCase,
private val updateTaskListUseCase: UpdateTaskListUseCase,
private val deleteTaskListUseCase: DeleteTaskListUseCase
private val deleteTaskListUseCase: DeleteTaskListUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() {
var taskLists by mutableStateOf<List<TaskList>>(emptyList())
@@ -51,7 +55,18 @@ class ManageListsViewModel @Inject constructor(
}
fun deleteTaskList(taskListId: Long) {
viewModelScope.launch {
deleteTaskListUseCase(taskListId)
deleteTaskListUseCase(taskListId, true)
uiEventBus.send(
UiEvent.ShowUndoSnackbar(
message = R.string.snackbar_message_task_list_recycle,
undoAction = {
viewModelScope.launch {
deleteTaskListUseCase(taskListId, false)
}
}
)
)
}
}

View File

@@ -9,17 +9,20 @@ import androidx.lifecycle.viewModelScope
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MenuViewModel @Inject constructor(
getTaskListsWithOverdue: GetTaskListsWithOverdueUseCase,
getDueTodayTasks: GetDueTodayTasksUseCase
getDueTodayTasks: GetDueTodayTasksUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() {
var taskLists by mutableStateOf<List<TaskListWithOverdue>>(emptyList())
private set
@@ -38,4 +41,12 @@ class MenuViewModel @Inject constructor(
}
.launchIn(viewModelScope)
}
fun navigateTo(route: String, currentRoute: String) {
if (route != currentRoute) {
viewModelScope.launch {
uiEventBus.send(UiEvent.Navigate(route))
}
}
}
}

View File

@@ -3,13 +3,18 @@ package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
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.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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -18,21 +23,36 @@ import javax.inject.Inject
@HiltViewModel
class RecycleBinViewModel @Inject constructor(
private val getDeletedTasks: GetDeletedTasksUseCase,
private val restoreTask: ToggleTaskDeletedUseCase,
private val permanentlyDeleteTask: PermanentlyDeleteTaskUseCase,
private val emptyRecycleBinUseCase: EmptyRecycleBinUseCase
private val getDeletedTasksUseCase: GetDeletedTasksUseCase,
private val toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase,
private val permanentlyDeleteTaskUseCase: PermanentlyDeleteTaskUseCase,
private val emptyRecycleBinUseCase: EmptyRecycleBinUseCase,
private val uiEventBus: UiEventBus,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
companion object {
private const val TASK_TO_DELETE = "taskToDeleteId"
private const val EMPTY_RECYCLE_BIN = "emptyRecycleBin"
}
var deletedTasks by mutableStateOf<List<TaskWithListName>>(emptyList())
private set
val taskToDeleteFlow = savedStateHandle.getStateFlow<Long?>(TASK_TO_DELETE, null)
val emptyRecycleBinFlow = savedStateHandle.getStateFlow<Boolean>(EMPTY_RECYCLE_BIN, false)
init {
loadDeletedTasks()
}
fun onTaskClicked(task: Task) {
viewModelScope.launch {
uiEventBus.send(UiEvent.EditTask(task))
}
}
fun loadDeletedTasks() {
getDeletedTasks()
getDeletedTasksUseCase()
.onEach { tasks ->
deletedTasks = tasks
}
@@ -41,20 +61,50 @@ class RecycleBinViewModel @Inject constructor(
fun restore(taskId: Long) {
viewModelScope.launch {
restoreTask(taskId, false)
loadDeletedTasks()
toggleTaskDeletedUseCase(taskId, false)
uiEventBus.send(
UiEvent.ShowUndoSnackbar(
message = R.string.snackbar_message_task_restore,
undoAction = {
viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, true)
}
}
)
)
}
}
fun deleteForever(taskId: Long) {
viewModelScope.launch {
permanentlyDeleteTask(taskId)
loadDeletedTasks()
}
fun onEmptyRecycleBinRequest() {
savedStateHandle[EMPTY_RECYCLE_BIN] = true
}
fun onCancelEmptyRecycleBinRequest() {
savedStateHandle[EMPTY_RECYCLE_BIN] = false
}
fun emptyRecycleBin() {
viewModelScope.launch {
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.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
class TaskItemViewModel(task: Task) {
class TaskItemViewModel(
task: Task,
private val clock: Clock = Clock.systemDefaultZone()) {
val id: Long = task.id!!
val name: String = task.name
val description: String? = task.description
@@ -18,17 +20,17 @@ class TaskItemViewModel(task: Task) {
val isDeleted: Boolean = task.isDeleted
val priority: Priority = task.priority
val today: LocalDate = LocalDate.now(ZoneId.systemDefault())
val today: LocalDate = LocalDate.now(clock)
val isOverdue: Boolean = task.dueDate?.let { millis ->
val dueDate = millis.toLocalDate()
val dueDate = millis.toLocalDate(clock)
dueDate.isBefore(today)
} ?: false
val dueDateText: String? = task.dueDate?.let { formatDueDate(it) }
private fun formatDueDate(dueMillis: Long): String {
val dueDate = dueMillis.toLocalDate()
val dueDate = dueMillis.toLocalDate(clock)
return when {
dueDate.isEqual(today) -> "Today"

View File

@@ -6,10 +6,13 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.R
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -18,10 +21,11 @@ import javax.inject.Inject
@HiltViewModel
class TaskListViewModel @Inject constructor(
getTasks: GetTasksForListUseCase,
private val toggleTaskDone: ToggleTaskDoneUseCase,
private val toggleTaskDeleted: ToggleTaskDeletedUseCase,
savedStateHandle: SavedStateHandle
savedStateHandle: SavedStateHandle,
getTasksUseCase: GetTasksForListUseCase,
private val toggleTaskDoneUseCase: ToggleTaskDoneUseCase,
private val toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() {
var tasks by mutableStateOf<List<Task>>(emptyList())
@@ -32,7 +36,7 @@ class TaskListViewModel @Inject constructor(
private val taskListId: Long = checkNotNull(savedStateHandle["taskListId"])
init {
getTasks(taskListId)
getTasksUseCase(taskListId)
.onEach { list ->
tasks = list
isLoading = false
@@ -40,14 +44,42 @@ class TaskListViewModel @Inject constructor(
.launchIn(viewModelScope)
}
fun onTaskClicked(task: Task) {
viewModelScope.launch {
uiEventBus.send(UiEvent.EditTask(task))
}
}
fun updateTaskDone(taskId: Long, isDone: Boolean) {
viewModelScope.launch {
toggleTaskDone(taskId, isDone)
toggleTaskDoneUseCase(taskId, isDone)
uiEventBus.send(
UiEvent.ShowUndoSnackbar(
message = if (isDone) R.string.snackbar_message_task_done else R.string.snackbar_message_task_undone,
undoAction = {
viewModelScope.launch {
toggleTaskDoneUseCase(taskId, !isDone)
}
}
)
)
}
}
fun deleteTask(taskId: Long) {
viewModelScope.launch {
toggleTaskDeleted(taskId, true)
toggleTaskDeletedUseCase(taskId, true)
uiEventBus.send(
UiEvent.ShowUndoSnackbar(
message = R.string.snackbar_message_task_recycle,
undoAction = {
viewModelScope.launch {
toggleTaskDeletedUseCase(taskId, false)
}
}
)
)
}
}
}

View File

@@ -9,6 +9,8 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import java.time.Instant
@@ -19,7 +21,8 @@ import javax.inject.Inject
@HiltViewModel
class TaskViewModel @Inject constructor(
private val createTaskUseCase: AddTaskUseCase,
private val updateTaskUseCase: UpdateTaskUseCase
private val updateTaskUseCase: UpdateTaskUseCase,
private val uiEventBus: UiEventBus
) : ViewModel() {
var title by mutableStateOf("")
@@ -34,34 +37,25 @@ class TaskViewModel @Inject constructor(
private set
var isDeleted by mutableStateOf(false)
private set
var taskListId by mutableStateOf<Long?>(null)
private set
var editingTaskId by mutableStateOf<Long?>(null)
private set
private var editingTaskId: Long? = null
private var taskListId: Long? = null
init {
viewModelScope.launch {
uiEventBus.stickyEvents.collect { event ->
when (event) {
is UiEvent.CreateNewTask -> startNewTask(event.taskListId)
is UiEvent.EditTask -> startEditTask(event.task)
is UiEvent.CloseTask -> reset()
else -> {}
}
}
}
}
fun screenTitle(): String = if (isDeleted) "Task details" else if (isEditing()) "Edit Task" else "New Task"
fun isEditing(): Boolean = editingTaskId != null
fun startNewTask(selectedListId: Long) {
editingTaskId = null
taskListId = selectedListId
title = ""
description = ""
priority = Priority.NORMAL
dueDate = null
isDeleted = false
}
fun startEditTask(task: Task) {
editingTaskId = task.id
taskListId = task.taskListId
title = task.name
description = task.description ?: ""
priority = task.priority
dueDate = task.dueDate
isDone = task.isDone
isDeleted = task.isDeleted
}
fun onTitleChanged(value: String) { title = value }
fun onDescriptionChanged(value: String) { description = value }
fun onPriorityChanged(value: Priority) { priority = value }
@@ -84,13 +78,32 @@ class TaskViewModel @Inject constructor(
} else {
createTaskUseCase(taskListId!!, title, description, priority, dueDate)
}
// reset state after save
reset()
onDone?.invoke()
}
}
fun reset() {
private fun startNewTask(selectedListId: Long) {
editingTaskId = null
taskListId = selectedListId
title = ""
description = ""
priority = Priority.NORMAL
dueDate = null
isDeleted = false
}
private fun startEditTask(task: Task) {
editingTaskId = task.id
taskListId = task.taskListId
title = task.name
description = task.description ?: ""
priority = task.priority
dueDate = task.dueDate
isDone = task.isDone
isDeleted = task.isDeleted
}
private fun reset() {
editingTaskId = null
taskListId = null
title = ""

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>
<string name="app_name">DoNext</string>
<string name="title_activity_main">MainActivity</string>
<string name="app_name" translatable="false">DoNext</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>

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
package_name("com.wismna.geoffroy.donext") # e.g. com.krausefx.app
json_key_file(ENV["SUPPLY_JSON_KEY"] || "./fastlane/keys/donext-f9e67-5038064982b0")
package_name("com.wismna.geoffroy.donext")

View File

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