diff --git a/desktop/src/main/kotlin/SystemTray.kt b/desktop/src/main/kotlin/SystemTray.kt
index 061e7a7f..85db9b78 100644
--- a/desktop/src/main/kotlin/SystemTray.kt
+++ b/desktop/src/main/kotlin/SystemTray.kt
@@ -4,8 +4,8 @@ import com.kdroid.composetray.tray.api.Tray
import foliary.foliary.generated.resources.Res
import foliary.foliary.generated.resources.app_name
import foliary.foliary.generated.resources.ic_foliary_tray
-import foliary.foliary.generated.resources.open_app
-import foliary.foliary.generated.resources.quit
+import foliary.foliary.generated.resources.system_tray_open_app
+import foliary.foliary.generated.resources.system_tray_quit
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import java.awt.Window
@@ -13,8 +13,8 @@ import java.awt.Window
@Composable
fun ApplicationScope.FoliaryTray(window: () -> Window) {
val appName = stringResource(Res.string.app_name)
- val openApp = stringResource(Res.string.open_app)
- val quit = stringResource(Res.string.quit)
+ val openApp = stringResource(Res.string.system_tray_open_app)
+ val quit = stringResource(Res.string.system_tray_quit)
Tray(
icon = painterResource(Res.drawable.ic_foliary_tray),
diff --git a/foliary/src/commonMain/composeResources/values-af/strings.xml b/foliary/src/commonMain/composeResources/values-af/strings.xml
index 942e246f..4d70ede2 100644
--- a/foliary/src/commonMain/composeResources/values-af/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-af/strings.xml
@@ -28,6 +28,6 @@
Teken uit
Profiel
Foliary
- Maak Foliary oop
- Verlaat
+ Maak Foliary oop
+ Verlaat
diff --git a/foliary/src/commonMain/composeResources/values-ar/strings.xml b/foliary/src/commonMain/composeResources/values-ar/strings.xml
index 8ee9b80f..2ccf5837 100644
--- a/foliary/src/commonMain/composeResources/values-ar/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-ar/strings.xml
@@ -28,6 +28,6 @@
تسجيل الخروج
الملف الشخصي
فولياري
- افتح فولياري
- إنهاء
+ افتح فولياري
+ إنهاء
diff --git a/foliary/src/commonMain/composeResources/values-az/strings.xml b/foliary/src/commonMain/composeResources/values-az/strings.xml
index 72fc4559..99e4c0c0 100644
--- a/foliary/src/commonMain/composeResources/values-az/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-az/strings.xml
@@ -28,6 +28,6 @@
Hesabdan çıx
Profil
Foliary
- Foliary-ni aç
- Çıx
+ Foliary-ni aç
+ Çıx
diff --git a/foliary/src/commonMain/composeResources/values-bar/strings.xml b/foliary/src/commonMain/composeResources/values-bar/strings.xml
index 617ddcc8..a34e5b71 100644
--- a/foliary/src/commonMain/composeResources/values-bar/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-bar/strings.xml
@@ -28,6 +28,6 @@
Ausloggn
Profil
Foliary
- Foliary öffna
- Beendn
+ Foliary öffna
+ Beendn
diff --git a/foliary/src/commonMain/composeResources/values-be/strings.xml b/foliary/src/commonMain/composeResources/values-be/strings.xml
index 69ca4a4e..5ba04334 100644
--- a/foliary/src/commonMain/composeResources/values-be/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-be/strings.xml
@@ -28,6 +28,6 @@
Выйсці
Профіль
Foliary
- Адкрыць Foliary
- Выхад
+ Адкрыць Foliary
+ Выхад
diff --git a/foliary/src/commonMain/composeResources/values-bg/strings.xml b/foliary/src/commonMain/composeResources/values-bg/strings.xml
index 584cff44..1b42efdb 100644
--- a/foliary/src/commonMain/composeResources/values-bg/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-bg/strings.xml
@@ -28,6 +28,6 @@
Излизане
Профил
Foliary
- Отвори Foliary
- Изход
+ Отвори Foliary
+ Изход
diff --git a/foliary/src/commonMain/composeResources/values-bn/strings.xml b/foliary/src/commonMain/composeResources/values-bn/strings.xml
index 51937a42..5c469077 100644
--- a/foliary/src/commonMain/composeResources/values-bn/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-bn/strings.xml
@@ -28,6 +28,6 @@
লগ আউট করুন
প্রোফাইল
ফোলিয়ারি
- ফোলিয়ারি খুলুন
- প্রস্থান করুন
+ ফোলিয়ারি খুলুন
+ প্রস্থান করুন
diff --git a/foliary/src/commonMain/composeResources/values-ca/strings.xml b/foliary/src/commonMain/composeResources/values-ca/strings.xml
index dd23c750..a6e92534 100644
--- a/foliary/src/commonMain/composeResources/values-ca/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-ca/strings.xml
@@ -28,6 +28,6 @@
Tancar la sessió
Perfil
Foliary
- Obrir Foliary
- Sortir
+ Obrir Foliary
+ Sortir
diff --git a/foliary/src/commonMain/composeResources/values-cs/strings.xml b/foliary/src/commonMain/composeResources/values-cs/strings.xml
index b2dc3941..2b61a226 100644
--- a/foliary/src/commonMain/composeResources/values-cs/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-cs/strings.xml
@@ -28,6 +28,6 @@
Odhlásit se
Profil
Foliary
- Otevřít Foliary
- Ukončit
+ Otevřít Foliary
+ Ukončit
diff --git a/foliary/src/commonMain/composeResources/values-cy/strings.xml b/foliary/src/commonMain/composeResources/values-cy/strings.xml
index a37a2ee2..cd5b3252 100644
--- a/foliary/src/commonMain/composeResources/values-cy/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-cy/strings.xml
@@ -28,6 +28,6 @@
Allgofnodi
Proffil
Foliary
- Agor Foliary
- Gadael
+ Agor Foliary
+ Gadael
diff --git a/foliary/src/commonMain/composeResources/values-da/strings.xml b/foliary/src/commonMain/composeResources/values-da/strings.xml
index cd5e8792..809e2974 100644
--- a/foliary/src/commonMain/composeResources/values-da/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-da/strings.xml
@@ -28,6 +28,6 @@
Log ud
Profil
Foliary
- Åbn Foliary
- Afslut
+ Åbn Foliary
+ Afslut
diff --git a/foliary/src/commonMain/composeResources/values-de/strings.xml b/foliary/src/commonMain/composeResources/values-de/strings.xml
index b4bcd51d..4a710ce2 100644
--- a/foliary/src/commonMain/composeResources/values-de/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-de/strings.xml
@@ -28,6 +28,6 @@
Abmelden
Profil
Foliary
- Foliary öffnen
- Beenden
+ Foliary öffnen
+ Beenden
diff --git a/foliary/src/commonMain/composeResources/values-el/strings.xml b/foliary/src/commonMain/composeResources/values-el/strings.xml
index efd9042e..87cc1866 100644
--- a/foliary/src/commonMain/composeResources/values-el/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-el/strings.xml
@@ -28,6 +28,6 @@
Αποσύνδεση
Προφίλ
Foliary
- Άνοιγμα του Foliary
- Έξοδος
+ Άνοιγμα του Foliary
+ Έξοδος
diff --git a/foliary/src/commonMain/composeResources/values-en/strings.xml b/foliary/src/commonMain/composeResources/values-en/strings.xml
index 04cc92b8..7a02bddf 100644
--- a/foliary/src/commonMain/composeResources/values-en/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-en/strings.xml
@@ -65,9 +65,9 @@
Overdue
-
- Open Foliary
- Quit
+
+ Open Foliary
+ Quit
diff --git a/foliary/src/commonMain/composeResources/values-es/strings.xml b/foliary/src/commonMain/composeResources/values-es/strings.xml
index 6a915c8d..75c1c633 100644
--- a/foliary/src/commonMain/composeResources/values-es/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-es/strings.xml
@@ -28,6 +28,6 @@
Cerrar sesión
Perfil
Foliary
- Abrir Foliary
- Salir
+ Abrir Foliary
+ Salir
diff --git a/foliary/src/commonMain/composeResources/values-et/strings.xml b/foliary/src/commonMain/composeResources/values-et/strings.xml
index 820491e7..303c1d6a 100644
--- a/foliary/src/commonMain/composeResources/values-et/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-et/strings.xml
@@ -28,6 +28,6 @@
Logi välja
Profiil
Foliary
- Ava Foliary
- Välju
+ Ava Foliary
+ Välju
diff --git a/foliary/src/commonMain/composeResources/values-eu/strings.xml b/foliary/src/commonMain/composeResources/values-eu/strings.xml
index 92129476..2dea3fb3 100644
--- a/foliary/src/commonMain/composeResources/values-eu/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-eu/strings.xml
@@ -28,6 +28,6 @@
Saioa itxi
Profila
Foliary
- Ireki Foliary
- Irten
+ Ireki Foliary
+ Irten
diff --git a/foliary/src/commonMain/composeResources/values-fa/strings.xml b/foliary/src/commonMain/composeResources/values-fa/strings.xml
index 68869a36..1caec81f 100644
--- a/foliary/src/commonMain/composeResources/values-fa/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-fa/strings.xml
@@ -28,6 +28,6 @@
خروج از حساب
پروفایل
فولیاری
- باز کردن فولیاری
- خروج
+ باز کردن فولیاری
+ خروج
diff --git a/foliary/src/commonMain/composeResources/values-fi/strings.xml b/foliary/src/commonMain/composeResources/values-fi/strings.xml
index 092f168f..51d6686a 100644
--- a/foliary/src/commonMain/composeResources/values-fi/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-fi/strings.xml
@@ -28,6 +28,6 @@
Kirjaudu ulos
Profiili
Foliary
- Avaa Foliary
- Lopeta
+ Avaa Foliary
+ Lopeta
diff --git a/foliary/src/commonMain/composeResources/values-fil/strings.xml b/foliary/src/commonMain/composeResources/values-fil/strings.xml
index be61600e..fc20d5d3 100644
--- a/foliary/src/commonMain/composeResources/values-fil/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-fil/strings.xml
@@ -28,6 +28,6 @@
Mag-log out
Profile
Foliary
- Buksan ang Foliary
- Lumabas
+ Buksan ang Foliary
+ Lumabas
diff --git a/foliary/src/commonMain/composeResources/values-fr/strings.xml b/foliary/src/commonMain/composeResources/values-fr/strings.xml
index 90530cc7..093904f3 100644
--- a/foliary/src/commonMain/composeResources/values-fr/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-fr/strings.xml
@@ -28,6 +28,6 @@
Se déconnecter
Profil
Foliary
- Ouvrir Foliary
- Quitter
+ Ouvrir Foliary
+ Quitter
diff --git a/foliary/src/commonMain/composeResources/values-ga/strings.xml b/foliary/src/commonMain/composeResources/values-ga/strings.xml
index 8fb2a5fe..761501f5 100644
--- a/foliary/src/commonMain/composeResources/values-ga/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-ga/strings.xml
@@ -28,6 +28,6 @@
Logáil amach
Próifíl
Foliary
- Oscail Foliary
- Scoir
+ Oscail Foliary
+ Scoir
diff --git a/foliary/src/commonMain/composeResources/values-gl/strings.xml b/foliary/src/commonMain/composeResources/values-gl/strings.xml
index 4c842a2f..9406cf5f 100644
--- a/foliary/src/commonMain/composeResources/values-gl/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-gl/strings.xml
@@ -28,6 +28,6 @@
Pechar sesión
Perfil
Foliary
- Abrir Foliary
- Saír
+ Abrir Foliary
+ Saír
diff --git a/foliary/src/commonMain/composeResources/values-he/strings.xml b/foliary/src/commonMain/composeResources/values-he/strings.xml
index 8541c2dc..37ed2e9a 100644
--- a/foliary/src/commonMain/composeResources/values-he/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-he/strings.xml
@@ -28,6 +28,6 @@
התנתקות
פרופיל
Foliary
- פתיחת Foliary
- יציאה
+ פתיחת Foliary
+ יציאה
diff --git a/foliary/src/commonMain/composeResources/values-hi/strings.xml b/foliary/src/commonMain/composeResources/values-hi/strings.xml
index 3eca2813..250b313e 100644
--- a/foliary/src/commonMain/composeResources/values-hi/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-hi/strings.xml
@@ -28,6 +28,6 @@
लॉग आउट करें
प्रोफ़ाइल
फोलियरी
- फोलियरी खोलें
- बाहर निकलें
+ फोलियरी खोलें
+ बाहर निकलें
diff --git a/foliary/src/commonMain/composeResources/values-hr/strings.xml b/foliary/src/commonMain/composeResources/values-hr/strings.xml
index 3f4228c3..c9d4344b 100644
--- a/foliary/src/commonMain/composeResources/values-hr/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-hr/strings.xml
@@ -28,6 +28,6 @@
Odjava
Profil
Foliary
- Otvori Foliary
- Izađi
+ Otvori Foliary
+ Izađi
diff --git a/foliary/src/commonMain/composeResources/values-hu/strings.xml b/foliary/src/commonMain/composeResources/values-hu/strings.xml
index a50ac37d..06414628 100644
--- a/foliary/src/commonMain/composeResources/values-hu/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-hu/strings.xml
@@ -28,6 +28,6 @@
Kijelentkezés
Profil
Foliary
- Foliary megnyitása
- Kilépés
+ Foliary megnyitása
+ Kilépés
diff --git a/foliary/src/commonMain/composeResources/values-id/strings.xml b/foliary/src/commonMain/composeResources/values-id/strings.xml
index d8d7c14f..3e783408 100644
--- a/foliary/src/commonMain/composeResources/values-id/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-id/strings.xml
@@ -28,6 +28,6 @@
Keluar
Profil
Foliary
- Buka Foliary
- Keluar
+ Buka Foliary
+ Keluar
diff --git a/foliary/src/commonMain/composeResources/values-it/strings.xml b/foliary/src/commonMain/composeResources/values-it/strings.xml
index cc8528d3..1c8e4d2f 100644
--- a/foliary/src/commonMain/composeResources/values-it/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-it/strings.xml
@@ -28,6 +28,6 @@
Disconnetti
Profilo
Foliary
- Apri Foliary
- Esci
+ Apri Foliary
+ Esci
diff --git a/foliary/src/commonMain/composeResources/values-ja/strings.xml b/foliary/src/commonMain/composeResources/values-ja/strings.xml
index 06bae8d1..29518207 100644
--- a/foliary/src/commonMain/composeResources/values-ja/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-ja/strings.xml
@@ -28,6 +28,6 @@
ログアウト
プロフィール
Foliary
- Foliaryを開く
- 終了
+ Foliaryを開く
+ 終了
diff --git a/foliary/src/commonMain/composeResources/values-kk/strings.xml b/foliary/src/commonMain/composeResources/values-kk/strings.xml
index def3cf9a..477ad49c 100644
--- a/foliary/src/commonMain/composeResources/values-kk/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-kk/strings.xml
@@ -28,6 +28,6 @@
Жүйеден шығу
Профиль
Foliary
- Foliary-ді ашу
- Шығу
+ Foliary-ді ашу
+ Шығу
diff --git a/foliary/src/commonMain/composeResources/values-km/strings.xml b/foliary/src/commonMain/composeResources/values-km/strings.xml
index 9f5b592f..686b8069 100644
--- a/foliary/src/commonMain/composeResources/values-km/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-km/strings.xml
@@ -28,6 +28,6 @@
ចាកចេញ
ប្រវត្តិរូប
Foliary
- បើក Foliary
- ចាកចេញ
+ បើក Foliary
+ ចាកចេញ
diff --git a/foliary/src/commonMain/composeResources/values-ko/strings.xml b/foliary/src/commonMain/composeResources/values-ko/strings.xml
index 129880ba..b141951e 100644
--- a/foliary/src/commonMain/composeResources/values-ko/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-ko/strings.xml
@@ -28,6 +28,6 @@
로그아웃
프로필
Foliary
- Foliary 열기
- 종료
+ Foliary 열기
+ 종료
diff --git a/foliary/src/commonMain/composeResources/values-lt/strings.xml b/foliary/src/commonMain/composeResources/values-lt/strings.xml
index b01f60a4..b1e8f8f7 100644
--- a/foliary/src/commonMain/composeResources/values-lt/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-lt/strings.xml
@@ -28,6 +28,6 @@
Atsijungti
Profilis
Foliary
- Atidaryti „Foliary“
- Išeiti
+ Atidaryti „Foliary“
+ Išeiti
diff --git a/foliary/src/commonMain/composeResources/values-lv/strings.xml b/foliary/src/commonMain/composeResources/values-lv/strings.xml
index b0ff8dba..851dbeb4 100644
--- a/foliary/src/commonMain/composeResources/values-lv/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-lv/strings.xml
@@ -28,6 +28,6 @@
Izrakstīties
Profils
Foliary
- Atvērt Foliary
- Iziet
+ Atvērt Foliary
+ Iziet
diff --git a/foliary/src/commonMain/composeResources/values-ms/strings.xml b/foliary/src/commonMain/composeResources/values-ms/strings.xml
index 671feb5f..dca79ef0 100644
--- a/foliary/src/commonMain/composeResources/values-ms/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-ms/strings.xml
@@ -28,6 +28,6 @@
Log keluar
Profil
Foliary
- Buka Foliary
- Keluar
+ Buka Foliary
+ Keluar
diff --git a/foliary/src/commonMain/composeResources/values-mt/strings.xml b/foliary/src/commonMain/composeResources/values-mt/strings.xml
index 0153f461..f7683db6 100644
--- a/foliary/src/commonMain/composeResources/values-mt/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-mt/strings.xml
@@ -28,6 +28,6 @@
Oħroġ mill-kont
Profil
Foliary
- Iftaħ Foliary
- Oħroġ
+ Iftaħ Foliary
+ Oħroġ
diff --git a/foliary/src/commonMain/composeResources/values-nap/strings.xml b/foliary/src/commonMain/composeResources/values-nap/strings.xml
index 4b3abb88..3f079919 100644
--- a/foliary/src/commonMain/composeResources/values-nap/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-nap/strings.xml
@@ -28,6 +28,6 @@
Scollegat\'te
Prufilo
Foliary
- Apre Foliary
- Jesce
+ Apre Foliary
+ Jesce
diff --git a/foliary/src/commonMain/composeResources/values-nl/strings.xml b/foliary/src/commonMain/composeResources/values-nl/strings.xml
index 58f12ffd..60321c9f 100644
--- a/foliary/src/commonMain/composeResources/values-nl/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-nl/strings.xml
@@ -28,6 +28,6 @@
Uitloggen
Profiel
Foliary
- Foliary openen
- Afsluiten
+ Foliary openen
+ Afsluiten
diff --git a/foliary/src/commonMain/composeResources/values-no/strings.xml b/foliary/src/commonMain/composeResources/values-no/strings.xml
index 0c5b2051..c71ec28a 100644
--- a/foliary/src/commonMain/composeResources/values-no/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-no/strings.xml
@@ -28,6 +28,6 @@
Logg ut
Profil
Foliary
- Åpne Foliary
- Avslutt
+ Åpne Foliary
+ Avslutt
diff --git a/foliary/src/commonMain/composeResources/values-pa/strings.xml b/foliary/src/commonMain/composeResources/values-pa/strings.xml
index 58b4edbd..9a30c697 100644
--- a/foliary/src/commonMain/composeResources/values-pa/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-pa/strings.xml
@@ -28,6 +28,6 @@
ਲੌਗ ਆਊਟ
ਪ੍ਰੋਫ਼ਾਈਲ
ਫੋਲੀਅਰੀ
- ਫੋਲੀਅਰੀ ਖੋਲ੍ਹੋ
- ਬੰਦ ਕਰੋ
+ ਫੋਲੀਅਰੀ ਖੋਲ੍ਹੋ
+ ਬੰਦ ਕਰੋ
diff --git a/foliary/src/commonMain/composeResources/values-pl/strings.xml b/foliary/src/commonMain/composeResources/values-pl/strings.xml
index 6d7d8868..90ed09d0 100644
--- a/foliary/src/commonMain/composeResources/values-pl/strings.xml
+++ b/foliary/src/commonMain/composeResources/values-pl/strings.xml
@@ -28,6 +28,6 @@
Wyloguj się
Profil
Foliary
- Otwórz Foliary
- Zakończ
+ Otwórz Foliary
+ Zakończ
diff --git a/foliary/src/commonMain/composeResources/values/strings.xml b/foliary/src/commonMain/composeResources/values/strings.xml
index 04cc92b8..7f6e097d 100644
--- a/foliary/src/commonMain/composeResources/values/strings.xml
+++ b/foliary/src/commonMain/composeResources/values/strings.xml
@@ -1,6 +1,8 @@
Foliary
+ Confirm
+ Cancel
@@ -59,15 +61,18 @@
Add description...
Save
Title is required
+ Due date
+ Add due date
+ Clear due date
Overdue
-
- Open Foliary
- Quit
+
+ Open Foliary
+ Quit
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt
index e8023215..8e3ddf0e 100644
--- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/provider/time/TimeProvider.kt
@@ -1,8 +1,11 @@
package dev.appoutlet.foliary.core.provider.time
+import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
+import kotlinx.datetime.format
+import kotlinx.datetime.format.MonthNames
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.koin.core.annotation.Single
@@ -10,20 +13,50 @@ import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
+private val StartOfDay = LocalTime(0, 0)
private val LastNanosecondOfTheDay = LocalTime.fromNanosecondOfDay((1.days.inWholeNanoseconds - 1))
interface TimeProvider {
fun now(): Instant
+ fun startOfToday(timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant
fun endOfToday(timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant
+ fun endOfDay(instant: Instant, timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant
+ fun displayText(instant: Instant, timeZone: TimeZone = TimeZone.currentSystemDefault()): String
}
@Single
class DefaultTimeProvider(private val clock: Clock = Clock.System) : TimeProvider {
+ private val dateFormat = LocalDate.Format {
+ day()
+ chars(" ")
+ monthName(MonthNames.ENGLISH_ABBREVIATED)
+ chars(" ")
+ year()
+ }
+
override fun now(): Instant = clock.now()
- override fun endOfToday(timeZone: TimeZone): Instant {
+ override fun startOfToday(timeZone: TimeZone): Instant {
return now().toLocalDateTime(timeZone).date
+ .atTime(StartOfDay)
+ .toInstant(timeZone)
+ }
+
+ override fun endOfDay(
+ instant: Instant,
+ timeZone: TimeZone
+ ): Instant {
+ return instant.toLocalDateTime(timeZone).date
.atTime(LastNanosecondOfTheDay)
.toInstant(timeZone)
}
+
+ override fun endOfToday(timeZone: TimeZone) = endOfDay(now(), timeZone)
+
+ override fun displayText(
+ instant: Instant,
+ timeZone: TimeZone
+ ): String {
+ return instant.toLocalDateTime(timeZone).date.format(dateFormat)
+ }
}
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt
new file mode 100644
index 00000000..3bc002a5
--- /dev/null
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePicker.kt
@@ -0,0 +1,195 @@
+package dev.appoutlet.foliary.core.ui.component.datepicker
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.DatePicker
+import androidx.compose.material3.DatePickerDefaults
+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.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.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import com.composables.icons.lucide.Lucide
+import com.composables.icons.lucide.X
+import dev.appoutlet.foliary.core.ui.component.button.FoliarySecondaryButton
+import dev.appoutlet.foliary.core.ui.component.card.FoliaryCard
+import foliary.foliary.generated.resources.Res
+import foliary.foliary.generated.resources.create_task_due_date_clear_a11y
+import foliary.foliary.generated.resources.general_cancel
+import foliary.foliary.generated.resources.general_confirm
+import org.jetbrains.compose.resources.stringResource
+import kotlin.time.Instant
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FoliaryDatePicker(
+ label: String,
+ placeholder: String,
+ selectedDate: Long?,
+ selectedDateDisplayText: String?,
+ onDateSelected: (Long?) -> Unit,
+ modifier: Modifier = Modifier,
+ minDateMillis: Long = Instant.DISTANT_PAST.toEpochMilliseconds(),
+ maxDateMillis: Long = Instant.DISTANT_FUTURE.toEpochMilliseconds(),
+) {
+ var showBottomSheet by rememberSaveable { mutableStateOf(false) }
+
+ Box(modifier = modifier) {
+ DatePickerCard(
+ label = label,
+ placeholder = placeholder,
+ selectedDateDisplayText = selectedDateDisplayText,
+ onClick = { showBottomSheet = true },
+ onClear = { onDateSelected(null) }
+ )
+ }
+
+ if (showBottomSheet) {
+ DatePickerBottomSheet(
+ selectedDateMillis = selectedDate,
+ minDateMillis = minDateMillis,
+ maxDateMillis = maxDateMillis,
+ onDismiss = { showBottomSheet = false },
+ onConfirm = { onDateSelected(it) }
+ )
+ }
+}
+
+@Composable
+private fun DatePickerCard(
+ label: String,
+ placeholder: String,
+ selectedDateDisplayText: String?,
+ onClick: () -> Unit,
+ onClear: () -> Unit,
+) {
+ val dateTextColor by animateColorAsState(
+ targetValue = if (selectedDateDisplayText != null) {
+ MaterialTheme.colorScheme.onSurface
+ } else {
+ MaterialTheme.colorScheme.onBackground
+ }
+ )
+
+ FoliaryCard(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("FoliaryDatePicker:Card")
+ .clickable(onClick = onClick),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp).weight(1f),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+
+ Text(
+ modifier = Modifier.testTag("FoliaryDatePicker:Value"),
+ text = selectedDateDisplayText ?: placeholder,
+ style = MaterialTheme.typography.bodyMedium,
+ color = dateTextColor
+ )
+ }
+
+ if (selectedDateDisplayText != null) {
+ DateClearButton(
+ modifier = Modifier.padding(end = 16.dp),
+ onClick = onClear,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DateClearButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ IconButton(
+ modifier = modifier.testTag("FoliaryDatePicker:ClearButton"),
+ onClick = onClick,
+ ) {
+ Icon(
+ imageVector = Lucide.X,
+ contentDescription = stringResource(Res.string.create_task_due_date_clear_a11y),
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DatePickerBottomSheet(
+ selectedDateMillis: Long?,
+ minDateMillis: Long,
+ maxDateMillis: Long,
+ onDismiss: () -> Unit,
+ onConfirm: (Long) -> Unit,
+) {
+ val datePickerState = rememberDatePickerState(
+ initialSelectedDateMillis = selectedDateMillis,
+ selectableDates = MinMaxSelectableDates(minDateMillis, maxDateMillis)
+ )
+
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.background
+ ) {
+ DatePicker(
+ state = datePickerState,
+ colors = DatePickerDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.background,
+ )
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
+ ) {
+ TextButton(onClick = onDismiss) {
+ Text(text = stringResource(Res.string.general_cancel))
+ }
+
+ FoliarySecondaryButton(
+ onClick = {
+ datePickerState.selectedDateMillis?.let(onConfirm)
+ onDismiss()
+ },
+ enabled = datePickerState.selectedDateMillis != null
+ ) {
+ Text(text = stringResource(Res.string.general_confirm))
+ }
+ }
+ }
+}
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDates.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDates.kt
new file mode 100644
index 00000000..2fdaccaa
--- /dev/null
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDates.kt
@@ -0,0 +1,13 @@
+package dev.appoutlet.foliary.core.ui.component.datepicker
+
+import androidx.compose.material3.SelectableDates
+import kotlin.time.Instant
+
+class MinMaxSelectableDates(
+ private val minDateMillis: Long = Instant.DISTANT_PAST.toEpochMilliseconds(),
+ private val maxDateMillis: Long = Instant.DISTANT_FUTURE.toEpochMilliseconds(),
+) : SelectableDates {
+ override fun isSelectableDate(utcTimeMillis: Long): Boolean {
+ return utcTimeMillis in minDateMillis..maxDateMillis
+ }
+}
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt
index 49413217..aacf10c0 100644
--- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreen.kt
@@ -29,12 +29,15 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import dev.appoutlet.foliary.core.ui.component.button.FoliaryBackIconButton
import dev.appoutlet.foliary.core.ui.component.button.FoliaryPrimaryButton
+import dev.appoutlet.foliary.core.ui.component.datepicker.FoliaryDatePicker
import dev.appoutlet.foliary.core.ui.component.modifier.widthInCompact
import dev.appoutlet.foliary.core.ui.component.textfield.FoliaryTextField
import dev.appoutlet.foliary.feature.main.getWindowDecorationPadding
import foliary.foliary.generated.resources.Res
import foliary.foliary.generated.resources.create_task_description_label
import foliary.foliary.generated.resources.create_task_description_placeholder
+import foliary.foliary.generated.resources.create_task_due_date_label
+import foliary.foliary.generated.resources.create_task_due_date_placeholder
import foliary.foliary.generated.resources.create_task_save
import foliary.foliary.generated.resources.create_task_title
import foliary.foliary.generated.resources.create_task_title_label
@@ -63,6 +66,18 @@ fun CreateTaskScreen(viewData: CreateTaskViewData, onEvent: (CreateTaskEvent) ->
Spacer(Modifier.size(16.dp))
TitleField(viewData.title, onEvent)
DescriptionField(viewData.description, onEvent)
+ FoliaryDatePicker(
+ modifier = Modifier.widthInCompact()
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .testTag("CreateTaskScreen:DueDatePicker"),
+ label = stringResource(Res.string.create_task_due_date_label),
+ placeholder = stringResource(Res.string.create_task_due_date_placeholder),
+ selectedDate = viewData.dueDate?.selectedDateMillis,
+ selectedDateDisplayText = viewData.dueDate?.selectedDateDisplayText,
+ onDateSelected = { onEvent(CreateTaskEvent.DueDateChanged(it)) },
+ minDateMillis = viewData.minDueDateMillis,
+ )
Spacer(Modifier.size(64.dp))
}
diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt
index aca629f4..8ae905cd 100644
--- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt
+++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModel.kt
@@ -7,6 +7,7 @@ import dev.appoutlet.foliary.core.provider.uuid.UuidProvider
import dev.appoutlet.foliary.data.task.TaskRepository
import dev.appoutlet.foliary.data.task.database.entity.Task
import org.koin.core.annotation.KoinViewModel
+import kotlin.time.Instant
import kotlin.uuid.Uuid
@KoinViewModel
@@ -15,17 +16,35 @@ class CreateTaskViewModel(
private val timeProvider: TimeProvider,
private val uuidProvider: UuidProvider,
) : MviViewModel() {
- override val container = container(CreateTaskViewData())
+ override val container = container(
+ CreateTaskViewData(
+ minDueDateMillis = timeProvider.startOfToday().toEpochMilliseconds(),
+ )
+ )
fun onEvent(event: CreateTaskEvent) {
when (event) {
is CreateTaskEvent.TitleChanged -> onTitleChange(event.title)
is CreateTaskEvent.DescriptionChanged -> onDescriptionChange(event.description)
+ is CreateTaskEvent.DueDateChanged -> onDueDateChange(event.dueDateMillis)
CreateTaskEvent.SaveClicked -> onSaveClick()
CreateTaskEvent.BackClicked -> onBackClick()
}
}
+ private fun onDueDateChange(dueDateMillis: Long?) = intent {
+ reduce {
+ state.copy(
+ dueDate = dueDateMillis?.let {
+ CreateTaskViewData.DueDateViewData(
+ selectedDateMillis = it,
+ selectedDateDisplayText = timeProvider.displayText(it.toInstant()),
+ )
+ }
+ )
+ }
+ }
+
private fun onTitleChange(title: String) = intent {
reduce {
state.copy(
@@ -47,7 +66,9 @@ class CreateTaskViewModel(
title = state.title.trim(),
description = state.description?.trim(),
creationDate = timeProvider.now(),
- dueDate = null,
+ dueDate = state.dueDate?.selectedDateMillis?.toInstant()?.let {
+ timeProvider.endOfDay(it)
+ },
completionDate = null,
priority = null,
url = null,
@@ -62,18 +83,32 @@ class CreateTaskViewModel(
private fun onBackClick() = intent {
postSideEffect(CreateTaskAction.NavigateBack)
}
+
+ private fun Long.toInstant() = Instant.fromEpochMilliseconds(this)
}
data class CreateTaskViewData(
val id: String? = null,
val title: String = "",
val description: String? = null,
+ val dueDate: DueDateViewData? = null,
+ val minDueDateMillis: Long,
val saveButtonEnabled: Boolean = false,
-)
+) {
+ data class DueDateViewData(
+ val selectedDateMillis: Long,
+ val selectedDateDisplayText: String,
+ ) {
+ companion object
+ }
+
+ companion object
+}
sealed interface CreateTaskEvent {
data class TitleChanged(val title: String) : CreateTaskEvent
data class DescriptionChanged(val description: String?) : CreateTaskEvent
+ data class DueDateChanged(val dueDateMillis: Long?) : CreateTaskEvent
data object SaveClicked : CreateTaskEvent
data object BackClicked : CreateTaskEvent
}
diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt
new file mode 100644
index 00000000..b5300447
--- /dev/null
+++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/FoliaryDatePickerTest.kt
@@ -0,0 +1,146 @@
+package dev.appoutlet.foliary.core.ui.component.datepicker
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runComposeUiTest
+import foliary.foliary.generated.resources.Res
+import foliary.foliary.generated.resources.create_task_due_date_clear_a11y
+import foliary.foliary.generated.resources.create_task_due_date_placeholder
+import foliary.foliary.generated.resources.general_confirm
+import io.kotest.matchers.shouldBe
+import org.jetbrains.compose.resources.getString
+import kotlin.test.Test
+
+@OptIn(ExperimentalTestApi::class)
+class FoliaryDatePickerTest {
+
+ @Test
+ fun `should show placeholder when selectedDate is null`() = runComposeUiTest {
+ val placeholder = getString(Res.string.create_task_due_date_placeholder)
+
+ setContent {
+ FoliaryDatePicker(
+ label = "Due date",
+ placeholder = placeholder,
+ selectedDate = null,
+ selectedDateDisplayText = null,
+ onDateSelected = {}
+ )
+ }
+
+ onNodeWithText(placeholder).assertIsDisplayed()
+ onNodeWithTag("FoliaryDatePicker:Value", useUnmergedTree = true).assertIsDisplayed()
+ onNodeWithTag("FoliaryDatePicker:ClearButton", useUnmergedTree = true).assertDoesNotExist()
+ }
+
+ @Test
+ fun `should show selected date when selectedDate is not null`() = runComposeUiTest {
+ val placeholder = getString(Res.string.create_task_due_date_placeholder)
+ val selectedDateDisplayText = "2026-07-21"
+
+ setContent {
+ FoliaryDatePicker(
+ label = "Due date",
+ placeholder = placeholder,
+ selectedDate = 1_752_960_000_000,
+ selectedDateDisplayText = selectedDateDisplayText,
+ onDateSelected = {}
+ )
+ }
+
+ onNodeWithText(selectedDateDisplayText).assertIsDisplayed()
+ onNodeWithTag("FoliaryDatePicker:Value", useUnmergedTree = true).assertIsDisplayed()
+ }
+
+ @Test
+ fun `should show clear button only when date is selected`() = runComposeUiTest {
+ val placeholder = getString(Res.string.create_task_due_date_placeholder)
+ val clearDescription = getString(Res.string.create_task_due_date_clear_a11y)
+
+ setContent {
+ FoliaryDatePicker(
+ label = "Due date",
+ placeholder = placeholder,
+ selectedDate = null,
+ selectedDateDisplayText = null,
+ onDateSelected = {}
+ )
+ }
+
+ onNodeWithContentDescription(clearDescription).assertDoesNotExist()
+ }
+
+ @Test
+ fun `clear button should call onDateSelected with null`() = runComposeUiTest {
+ val placeholder = getString(Res.string.create_task_due_date_placeholder)
+ val clearDescription = getString(Res.string.create_task_due_date_clear_a11y)
+ var cleared = false
+
+ setContent {
+ FoliaryDatePicker(
+ label = "Due date",
+ placeholder = placeholder,
+ selectedDate = 1_752_960_000_000,
+ selectedDateDisplayText = "2026-07-21",
+ onDateSelected = { cleared = it == null }
+ )
+ }
+
+ onNodeWithContentDescription(clearDescription)
+ .assertIsDisplayed()
+ .performClick()
+
+ cleared shouldBe true
+ }
+
+ @Test
+ fun `should open date picker bottom sheet when card is clicked`() = runComposeUiTest {
+ val placeholder = getString(Res.string.create_task_due_date_placeholder)
+ val confirmText = getString(Res.string.general_confirm)
+
+ setContent {
+ FoliaryDatePicker(
+ label = "Due date",
+ placeholder = placeholder,
+ selectedDate = null,
+ selectedDateDisplayText = null,
+ onDateSelected = {}
+ )
+ }
+
+ onNodeWithTag("FoliaryDatePicker:Card", useUnmergedTree = true).performClick()
+
+ waitForIdle()
+
+ onNodeWithText(confirmText).assertExists()
+ }
+
+ @Test
+ fun `should not open date picker bottom sheet when clear button is clicked`() = runComposeUiTest {
+ val placeholder = getString(Res.string.create_task_due_date_placeholder)
+ val clearDescription = getString(Res.string.create_task_due_date_clear_a11y)
+ val confirmText = getString(Res.string.general_confirm)
+
+ setContent {
+ FoliaryDatePicker(
+ label = "Due date",
+ placeholder = placeholder,
+ selectedDate = 1_752_960_000_000,
+ selectedDateDisplayText = "2026-07-21",
+ onDateSelected = {}
+ )
+ }
+
+ onNodeWithContentDescription(clearDescription)
+ .assertIsDisplayed()
+ .performClick()
+
+ waitForIdle()
+
+ onNodeWithText(confirmText).assertDoesNotExist()
+ }
+}
diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDatesTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDatesTest.kt
new file mode 100644
index 00000000..d0c1f97a
--- /dev/null
+++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/datepicker/MinMaxSelectableDatesTest.kt
@@ -0,0 +1,51 @@
+package dev.appoutlet.foliary.core.ui.component.datepicker
+
+import io.kotest.matchers.shouldBe
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.time.Instant
+
+class MinMaxSelectableDatesTest {
+
+ @Test
+ fun `should allow any date when using default bounds`() = runTest {
+ val subject = MinMaxSelectableDates()
+
+ subject.isSelectableDate(Instant.parse("2026-07-21T00:00:00Z").toEpochMilliseconds()) shouldBe true
+ }
+
+ @Test
+ fun `should allow date equal to min bound`() = runTest {
+ val subject = MinMaxSelectableDates(minDateMillis = 100)
+
+ subject.isSelectableDate(100) shouldBe true
+ }
+
+ @Test
+ fun `should allow date equal to max bound`() = runTest {
+ val subject = MinMaxSelectableDates(maxDateMillis = 100)
+
+ subject.isSelectableDate(100) shouldBe true
+ }
+
+ @Test
+ fun `should allow date between min and max bounds`() = runTest {
+ val subject = MinMaxSelectableDates(minDateMillis = 100, maxDateMillis = 200)
+
+ subject.isSelectableDate(150) shouldBe true
+ }
+
+ @Test
+ fun `should reject date below min bound`() = runTest {
+ val subject = MinMaxSelectableDates(minDateMillis = 100)
+
+ subject.isSelectableDate(99) shouldBe false
+ }
+
+ @Test
+ fun `should reject date above max bound`() = runTest {
+ val subject = MinMaxSelectableDates(maxDateMillis = 100)
+
+ subject.isSelectableDate(101) shouldBe false
+ }
+}
diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt
index ac3dc940..280b6487 100644
--- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt
+++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/DefaultTimeProviderTest.kt
@@ -16,6 +16,14 @@ class DefaultTimeProviderTest {
subject.now().toEpochMilliseconds() shouldBeAtLeast now.toEpochMilliseconds()
}
+ @Test
+ fun `should return the start of today for the configured time zone`() {
+ val timezone = TimeZone.UTC
+ val today = Instant.parse("2026-07-22T02:10:02.999Z")
+ val subject = DefaultTimeProvider(clock = FixedClock(today))
+ subject.startOfToday(timezone) shouldBe Instant.parse("2026-07-22T00:00:00Z")
+ }
+
@Test
fun `should return the end of today for the configured time zone`() {
val timezone = TimeZone.UTC
@@ -24,6 +32,38 @@ class DefaultTimeProviderTest {
subject.endOfToday(timezone) shouldBe Instant.parse("2026-07-22T23:59:59.999999999Z")
}
+ @Test
+ fun `should return the end of day for the configured time zone`() {
+ val timezone = TimeZone.UTC
+ val instant = Instant.parse("2026-07-22T02:10:02.999Z")
+ val subject = DefaultTimeProvider()
+ subject.endOfDay(instant, timezone) shouldBe Instant.parse("2026-07-22T23:59:59.999999999Z")
+ }
+
+ @Test
+ fun `should return the end of day respecting the time zone`() {
+ val instant = Instant.parse("2026-07-22T02:10:02.999Z")
+ val subject = DefaultTimeProvider()
+ subject.endOfDay(instant, TimeZone.UTC) shouldBe Instant.parse("2026-07-22T23:59:59.999999999Z")
+ subject.endOfDay(instant, TimeZone.of("UTC-10")) shouldBe Instant.parse("2026-07-22T09:59:59.999999999Z")
+ }
+
+ @Test
+ fun `should format instant to display text for the configured time zone`() {
+ val timezone = TimeZone.UTC
+ val instant = Instant.parse("2026-07-22T02:10:02.999Z")
+ val subject = DefaultTimeProvider()
+ subject.displayText(instant, timezone) shouldBe "22 Jul 2026"
+ }
+
+ @Test
+ fun `should format instant to display text respecting the time zone`() {
+ val instant = Instant.parse("2026-07-22T02:10:02.999Z")
+ val subject = DefaultTimeProvider()
+ subject.displayText(instant, TimeZone.UTC) shouldBe "22 Jul 2026"
+ subject.displayText(instant, TimeZone.of("UTC-10")) shouldBe "21 Jul 2026"
+ }
+
private class FixedClock(private val instant: Instant) : Clock {
override fun now(): Instant = instant
}
diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt
index f26de7ac..55d06508 100644
--- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt
+++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskScreenTest.kt
@@ -1,5 +1,8 @@
package dev.appoutlet.foliary.feature.createtask
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
@@ -15,6 +18,9 @@ import foliary.foliary.generated.resources.Res
import foliary.foliary.generated.resources.back_icon_button_a11y
import foliary.foliary.generated.resources.create_task_description_label
import foliary.foliary.generated.resources.create_task_description_placeholder
+import foliary.foliary.generated.resources.create_task_due_date_clear_a11y
+import foliary.foliary.generated.resources.create_task_due_date_label
+import foliary.foliary.generated.resources.create_task_due_date_placeholder
import foliary.foliary.generated.resources.create_task_save
import foliary.foliary.generated.resources.create_task_title
import foliary.foliary.generated.resources.create_task_title_label
@@ -28,7 +34,15 @@ class CreateTaskScreenTest {
@Test
fun `should render create task form`() = runComposeUiTest {
setContent {
- CreateTaskScreen(viewData = CreateTaskViewData(), onEvent = {})
+ CreateTaskScreen(
+ viewData = CreateTaskViewData.fixture(
+ title = "",
+ description = null,
+ dueDate = null,
+ saveButtonEnabled = false,
+ ),
+ onEvent = {}
+ )
}
onNodeWithText(getString(Res.string.create_task_title)).assertIsDisplayed()
@@ -36,6 +50,10 @@ class CreateTaskScreenTest {
onNodeWithText(getString(Res.string.create_task_title_placeholder)).assertIsDisplayed()
onNodeWithText(getString(Res.string.create_task_description_label)).assertIsDisplayed()
onNodeWithText(getString(Res.string.create_task_description_placeholder)).assertIsDisplayed()
+ onNodeWithText(getString(Res.string.create_task_due_date_label)).assertIsDisplayed()
+ onNodeWithText(getString(Res.string.create_task_due_date_placeholder)).assertIsDisplayed()
+ onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y))
+ .assertDoesNotExist()
onNodeWithText(getString(Res.string.create_task_save)).assertIsDisplayed().assertIsNotEnabled()
}
@@ -45,7 +63,7 @@ class CreateTaskScreenTest {
setContent {
CreateTaskScreen(
- viewData = CreateTaskViewData(saveButtonEnabled = true),
+ viewData = CreateTaskViewData.fixture(),
onEvent = events::add,
)
}
@@ -68,7 +86,15 @@ class CreateTaskScreenTest {
val description = "Use the garden store nearby"
setContent {
- CreateTaskScreen(viewData = CreateTaskViewData(), onEvent = {})
+ CreateTaskScreen(
+ viewData = CreateTaskViewData.fixture(
+ title = "",
+ description = null,
+ dueDate = null,
+ saveButtonEnabled = false,
+ ),
+ onEvent = {}
+ )
}
onNodeWithTag("CreateTaskScreen:TitleField", useUnmergedTree = true)
@@ -81,4 +107,37 @@ class CreateTaskScreenTest {
onNodeWithTag("CreateTaskScreen:DescriptionField", useUnmergedTree = true)
.assertTextEquals(description)
}
+
+ @Test
+ fun `should show selected due date and clear it`() = runComposeUiTest {
+ val dueDate = CreateTaskViewData.DueDateViewData.fixture()
+ val events = mutableListOf()
+ var viewData by mutableStateOf(CreateTaskViewData.fixture(dueDate = null))
+
+ setContent {
+ CreateTaskScreen(viewData = viewData, onEvent = events::add)
+ }
+
+ onNodeWithText(getString(Res.string.create_task_due_date_label)).assertIsDisplayed()
+ onNodeWithText(getString(Res.string.create_task_due_date_placeholder)).assertIsDisplayed()
+ onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y))
+ .assertDoesNotExist()
+
+ viewData = CreateTaskViewData.fixture(dueDate = dueDate)
+ waitForIdle()
+
+ onNodeWithText(dueDate.selectedDateDisplayText).assertIsDisplayed()
+ onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y))
+ .assertIsDisplayed()
+ .performClick()
+
+ events.contains(CreateTaskEvent.DueDateChanged(null)) shouldBe true
+
+ viewData = CreateTaskViewData.fixture(dueDate = null)
+ waitForIdle()
+
+ onNodeWithText(getString(Res.string.create_task_due_date_placeholder)).assertIsDisplayed()
+ onNodeWithContentDescription(getString(Res.string.create_task_due_date_clear_a11y))
+ .assertDoesNotExist()
+ }
}
diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewDataFixture.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewDataFixture.kt
new file mode 100644
index 00000000..0a9b3da0
--- /dev/null
+++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewDataFixture.kt
@@ -0,0 +1,25 @@
+package dev.appoutlet.foliary.feature.createtask
+
+fun CreateTaskViewData.Companion.fixture(
+ id: String? = null,
+ title: String = "Task fixture",
+ description: String? = "Task description",
+ dueDate: CreateTaskViewData.DueDateViewData? = CreateTaskViewData.DueDateViewData.fixture(),
+ minDueDateMillis: Long = 1_752_960_000_000,
+ saveButtonEnabled: Boolean = true,
+) = CreateTaskViewData(
+ id = id,
+ title = title,
+ description = description,
+ dueDate = dueDate,
+ minDueDateMillis = minDueDateMillis,
+ saveButtonEnabled = saveButtonEnabled,
+)
+
+fun CreateTaskViewData.DueDateViewData.Companion.fixture(
+ selectedDateMillis: Long = 1_752_960_000_000,
+ selectedDateDisplayText: String = "21 Jul 2026",
+) = CreateTaskViewData.DueDateViewData(
+ selectedDateMillis = selectedDateMillis,
+ selectedDateDisplayText = selectedDateDisplayText,
+)
diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt
index a69d752c..c7a646a0 100644
--- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt
+++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/createtask/CreateTaskViewModelTest.kt
@@ -9,11 +9,13 @@ import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
+import dev.mokkery.matcher.any
import dev.mokkery.matcher.capture.Capture
import dev.mokkery.matcher.capture.capture
import dev.mokkery.matcher.capture.get
import dev.mokkery.mock
import io.kotest.matchers.shouldBe
+import io.kotest.matchers.shouldNotBe
import kotlin.test.Test
import kotlin.time.Instant
import kotlin.uuid.Uuid
@@ -23,12 +25,18 @@ class CreateTaskViewModelTest :
private val mockTaskRepository = mock(mode = MockMode.autoUnit)
private val mockTimeProvider = mock()
private val mockUuidProvider = mock()
-
- override fun createViewModel() = CreateTaskViewModel(
- taskRepository = mockTaskRepository,
- timeProvider = mockTimeProvider,
- uuidProvider = mockUuidProvider
- )
+ private val startOfToday = Instant.parse("2026-07-22T00:00:00Z")
+ private val expectedDisplayText = "22 Jul 2026"
+
+ override fun createViewModel(): CreateTaskViewModel {
+ every(mockTimeProvider::startOfToday) returns startOfToday
+ every { mockTimeProvider.displayText(any()) } returns expectedDisplayText
+ return CreateTaskViewModel(
+ taskRepository = mockTaskRepository,
+ timeProvider = mockTimeProvider,
+ uuidProvider = mockUuidProvider
+ )
+ }
@Test
fun `TitleChanged - blank title button disabled`() = test {
@@ -65,6 +73,40 @@ class CreateTaskViewModelTest :
expectSideEffect(CreateTaskAction.NavigateBack)
}
+ @Test
+ fun `default state should have dueDate unset and minDueDate set to start of today`() = test {
+ currentState.dueDate shouldBe null
+ currentState.minDueDateMillis shouldBe startOfToday.toEpochMilliseconds()
+ }
+
+ @Test
+ fun `DueDateChanged - should update dueDate`() = test {
+ val dueDateMillis = 1_752_996_000_000
+
+ viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDateMillis))
+
+ awaitState().dueDate.also {
+ it?.selectedDateMillis shouldBe dueDateMillis
+ it?.selectedDateDisplayText shouldNotBe null
+ }
+ }
+
+ @Test
+ fun `DueDateChanged - null should clear dueDate`() = test {
+ val dueDateMillis = 1_752_996_000_000
+ val expectedDueDate = CreateTaskViewData.DueDateViewData.fixture(
+ selectedDateMillis = dueDateMillis,
+ selectedDateDisplayText = expectedDisplayText,
+ )
+
+ viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDateMillis))
+ expectState { copy(dueDate = expectedDueDate) }
+
+ viewModel.onEvent(CreateTaskEvent.DueDateChanged(null))
+
+ awaitState().dueDate shouldBe null
+ }
+
@Test
fun `SaveClicked - save task on task creation flow`() = test {
val id = Uuid.random()
@@ -77,7 +119,13 @@ class CreateTaskViewModelTest :
every(mockTimeProvider::now) returns creationDate
everySuspend { mockTaskRepository.save(capture(taskCapture)) } returns Unit
- currentState shouldBe CreateTaskViewData()
+ currentState shouldBe CreateTaskViewData.fixture(
+ title = "",
+ description = null,
+ dueDate = null,
+ minDueDateMillis = startOfToday.toEpochMilliseconds(),
+ saveButtonEnabled = false,
+ )
viewModel.onEvent(CreateTaskEvent.TitleChanged(title))
expectState { copy(title = title, saveButtonEnabled = true) }
@@ -95,9 +143,48 @@ class CreateTaskViewModelTest :
task.title shouldBe title
task.description shouldBe description
task.creationDate shouldBe creationDate
+ task.dueDate shouldBe null
}
// Navigate back after successful saving
awaitSideEffect() shouldBe CreateTaskAction.NavigateBack
}
+
+ @Test
+ fun `SaveClicked - should persist selected dueDate as end of day`() = test {
+ val id = Uuid.random()
+ val title = "Task title"
+ val dueDate = Instant.parse("2026-07-21T12:00:00Z")
+ val dueDateMillis = dueDate.toEpochMilliseconds()
+ val endOfDayDueDate = Instant.parse("2026-07-21T23:59:59.999999999Z")
+ val taskCapture = Capture.slot()
+ val creationDate = Instant.parse("2026-07-21T12:00:00Z")
+
+ every(mockUuidProvider::random) returns id
+ every(mockTimeProvider::now) returns creationDate
+ every { mockTimeProvider.endOfDay(dueDate) } returns endOfDayDueDate
+ everySuspend { mockTaskRepository.save(capture(taskCapture)) } returns Unit
+
+ viewModel.onEvent(CreateTaskEvent.TitleChanged(title))
+ expectState { copy(title = title, saveButtonEnabled = true) }
+
+ viewModel.onEvent(CreateTaskEvent.DueDateChanged(dueDateMillis))
+ expectState {
+ copy(
+ title = title,
+ dueDate = CreateTaskViewData.DueDateViewData.fixture(
+ selectedDateMillis = dueDateMillis,
+ selectedDateDisplayText = expectedDisplayText,
+ ),
+ saveButtonEnabled = true,
+ )
+ }
+
+ viewModel.onEvent(CreateTaskEvent.SaveClicked)
+ awaitState().saveButtonEnabled shouldBe false
+
+ taskCapture.get().dueDate shouldBe endOfDayDueDate
+
+ awaitSideEffect() shouldBe CreateTaskAction.NavigateBack
+ }
}
diff --git a/i18n.lock b/i18n.lock
index 4a98c724..da6b1e8c 100644
--- a/i18n.lock
+++ b/i18n.lock
@@ -43,6 +43,5 @@ checksums:
create_task_save: f7a2929f33bc420195e59ac5a8bcd454
create_task_title_required: a8541a9d81ad1f2d007e52328d8b4074
task_item_overdue: 24caaa2b5d7a2447ab7664e3771cf98c
- open_app: 98d48f4e2cbf39c43abbc6dd32eb4ff9
- quit: e4546c0aa9f1fa3e788cadec5e62f8ef
- back_icon_button_a11y: b9c9db6a69a2e078c3be411b12c6097f
+ system_tray_open_app: 98d48f4e2cbf39c43abbc6dd32eb4ff9
+ system_tray_quit: e4546c0aa9f1fa3e788cadec5e62f8ef