Compare commits
41 Commits
7dddc62377
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2c146a28e | ||
|
|
0b479e10c8 | ||
|
|
0fbdec570a | ||
|
|
e44bb99479 | ||
|
|
dcaecbf185 | ||
|
|
e6f81fa177 | ||
|
|
b3af094eed | ||
|
|
49a58a8977 | ||
|
|
af0bb51f01 | ||
|
|
5e418211bf | ||
|
|
92dc0ffa2d | ||
|
|
b48655e40a | ||
|
|
7a453d0d79 | ||
|
|
2e5970dc97 | ||
|
|
6df720fb8c | ||
|
|
92263bc4ec | ||
|
|
157b577397 | ||
|
|
c47ce57c31 | ||
|
|
fc3672b17b | ||
|
|
85f1e66a62 | ||
|
|
f0efc3dade | ||
|
|
d626a381ba | ||
|
|
a000eff8cc | ||
|
|
f297427d13 | ||
|
|
60cd307a87 | ||
|
|
c424d883ba | ||
|
|
35416f4725 | ||
|
|
dc46386e0d | ||
|
|
4e2f3c720c | ||
|
|
571b82dc98 | ||
|
|
2962c459d1 | ||
|
|
038a97672f | ||
|
|
30d3efa9de | ||
|
|
53e716a690 | ||
|
|
6c9e5efe38 | ||
|
|
c579a5d252 | ||
|
|
c57210494a | ||
|
|
c3dd615d15 | ||
|
|
e07f389fac | ||
|
|
313e514624 | ||
|
|
8e78f9b464 |
53
.github/workflows/android.yaml
vendored
@@ -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
@@ -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/
|
||||
|
||||
78
.idea/androidTestResultsUserPreferences.xml
generated
@@ -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>
|
||||
|
||||
120
.idea/caches/deviceStreaming.xml
generated
@@ -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" />
|
||||
|
||||
5
.idea/deploymentTargetSelector.xml
generated
@@ -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>
|
||||
|
||||
1
.idea/dictionaries/project.xml
generated
@@ -2,6 +2,7 @@
|
||||
<dictionary name="project">
|
||||
<words>
|
||||
<w>donext</w>
|
||||
<w>snackbar</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
230
Gemfile.lock
Normal 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
|
||||
@@ -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 {
|
||||
@@ -38,5 +37,3 @@ allprojects {
|
||||
tasks.register('clean', Delete) {
|
||||
delete rootProject.layout.buildDirectory
|
||||
}
|
||||
|
||||
apply plugin: 'org.sonarqube'
|
||||
BIN
donext.backup
Normal 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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") },
|
||||
)
|
||||
}
|
||||
@@ -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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
54
donextv2/src/main/res/values-fr/strings.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
# This file contains the fastlane.tools configuration
|
||||
# You can find the documentation at https://docs.fastlane.tools
|
||||
#
|
||||
# For a list of all available actions, check out
|
||||
#
|
||||
# https://docs.fastlane.tools/actions
|
||||
#
|
||||
# For a list of all available plugins, check out
|
||||
#
|
||||
# https://docs.fastlane.tools/plugins/available-plugins
|
||||
#
|
||||
|
||||
# Uncomment the line if you want fastlane to automatically update itself
|
||||
# update_fastlane
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
desc "Runs all the tests"
|
||||
lane :test do
|
||||
gradle(task: "test")
|
||||
end
|
||||
desc "Build, test, and deploy to Google Play"
|
||||
lane :internal do
|
||||
module_name = ENV["MODULE"]
|
||||
# Read local versionCode
|
||||
project_root = File.expand_path("..", __dir__)
|
||||
gradle_path = File.join(project_root, module_name, "build.gradle.kts")
|
||||
UI.message("Gradle file resolved at: #{gradle_path}")
|
||||
gradle_file = File.read(gradle_path)
|
||||
gradle_version = gradle_file[/versionCode\s*=\s*(\d+)/, 1].to_i
|
||||
|
||||
desc "Submit a new Beta Build to Crashlytics Beta"
|
||||
lane :beta do
|
||||
gradle(task: "clean assembleRelease")
|
||||
crashlytics
|
||||
# Read Play Store versionCode (track internal)
|
||||
play_version = google_play_track_version_codes(
|
||||
track: "internal"
|
||||
).max.to_i
|
||||
|
||||
# sh "your_script.sh"
|
||||
# You can also use other beta testing services here
|
||||
end
|
||||
if gradle_version <= play_version
|
||||
UI.user_error!("VersionCode #{gradle_version} should be higher than Play Store version (#{play_version}). Aborting upload.")
|
||||
end
|
||||
|
||||
desc "Deploy a new version to the Google Play"
|
||||
lane :deploy do
|
||||
gradle(task: "clean assembleRelease")
|
||||
upload_to_play_store
|
||||
gradle(task: "testDebugUnitTest")
|
||||
gradle(task: "clean :#{module_name}:bundleRelease")
|
||||
upload_to_play_store(
|
||||
track: "internal",
|
||||
aab: "#{module_name}/build/outputs/bundle/release/#{module_name}-release.aab"
|
||||
)
|
||||
end
|
||||
end
|
||||
40
fastlane/README.md
Normal file
@@ -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).
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 121 KiB |
@@ -1 +1 @@
|
||||
A new way to manage your tasks!
|
||||
A simple and fast app to manage your tasks!
|
||||
@@ -1 +1 @@
|
||||
DoNext
|
||||
DoNext v2
|
||||
20
fastlane/report.xml
Normal 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 'Fastlane::Actions.execute_action' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:255:in 'block in Fastlane::Runner#execute_action' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in 'Dir.chdir' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in 'Fastlane::Runner#execute_action' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:157:in 'Fastlane::Runner#trigger_action_by_name' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/fast_file.rb:159:in 'Fastlane::FastFile#method_missing' Fastfile:16:in 'block (2 levels) in Fastlane::FastFile#parsing_binding' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane.rb:41:in 'Fastlane::Lane#call' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:49:in 'block in Fastlane::Runner#execute' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in 'Dir.chdir' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in 'Fastlane::Runner#execute' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane_manager.rb:46:in 'Fastlane::LaneManager.cruise_lane' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/command_line_handler.rb:34:in 'Fastlane::CommandLineHandler.handle' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:110:in 'block (2 levels) in Fastlane::CommandsGenerator#run' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/commander-4.6.0/lib/commander/command.rb:187:in 'Commander::Command#call' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/commander-4.6.0/lib/commander/command.rb:157:in 'Commander::Command#run' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/commander-4.6.0/lib/commander/runner.rb:444:in 'Commander::Runner#run_active_command' 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 'Commander::Runner#run!' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/commander-4.6.0/lib/commander/delegates.rb:18:in 'Commander::Delegates#run!' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:363:in 'Fastlane::CommandsGenerator#run' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:43:in 'Fastlane::CommandsGenerator.start' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in 'Fastlane::CLIToolsDistributor.take_off' D:/Ruby34-x64/lib/ruby/gems/3.4.0/gems/fastlane-2.228.0/bin/fastlane:23:in '<top (required)>' D:/Ruby34-x64/bin/fastlane:25:in 'Kernel#load' D:/Ruby34-x64/bin/fastlane:25:in '<main>' Exit status of command 'D:/source/DoNext/gradlew testDebugUnitTest -p .' was 1 instead of 0. ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation. " />
|
||||
|
||||
</testcase>
|
||||
|
||||
</testsuite>
|
||||
</testsuites>
|
||||