From 251a23dbe1083437c6c41a8b8fd32b69ed48f222 Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Thu, 20 Aug 2026 08:46:48 +0200 Subject: [PATCH] CORE-001 Android-Projekt anlegen Feature --- .gradle/8.11.1/checksums/checksums.lock | Bin 0 -> 17 bytes .gradle/8.11.1/checksums/md5-checksums.bin | Bin 0 -> 18797 bytes .gradle/8.11.1/checksums/sha1-checksums.bin | Bin 0 -> 19361 bytes .../executionHistory/executionHistory.bin | Bin 0 -> 19003 bytes .../executionHistory/executionHistory.lock | Bin 0 -> 17 bytes .gradle/8.11.1/fileChanges/last-build.bin | Bin 0 -> 1 bytes .gradle/8.11.1/fileHashes/fileHashes.bin | Bin 0 -> 19147 bytes .gradle/8.11.1/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes .gradle/8.11.1/gc.properties | 0 .gradle/9.3.0/checksums/checksums.lock | Bin 0 -> 17 bytes .gradle/9.3.0/checksums/md5-checksums.bin | Bin 0 -> 30697 bytes .gradle/9.3.0/checksums/sha1-checksums.bin | Bin 0 -> 80051 bytes .../executionHistory/executionHistory.bin | Bin 0 -> 19663 bytes .../executionHistory/executionHistory.lock | Bin 0 -> 17 bytes .gradle/9.3.0/fileChanges/last-build.bin | Bin 0 -> 1 bytes .gradle/9.3.0/fileHashes/fileHashes.bin | Bin 0 -> 18997 bytes .gradle/9.3.0/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes .gradle/9.3.0/gc.properties | 0 .../buildOutputCleanup.lock | Bin 0 -> 17 bytes .gradle/buildOutputCleanup/cache.properties | 2 + .gradle/buildOutputCleanup/outputFiles.bin | Bin 0 -> 18623 bytes .gradle/file-system.probe | Bin 0 -> 8 bytes .gradle/vcs-1/gc.properties | 0 app/build.gradle.kts | 120 ++++++++ app/proguard-rules.pro | 3 + app/src/main/AndroidManifest.xml | 42 +++ .../java/de/medapp/wunddoku/MainActivity.kt | 42 +++ .../de/medapp/wunddoku/WunddokuApplication.kt | 15 + .../medapp/wunddoku/data/dao/BlockStateDao.kt | 28 ++ .../wunddoku/data/dao/CheckItemStateDao.kt | 19 ++ .../wunddoku/data/dao/DocumentationDao.kt | 73 +++++ .../de/medapp/wunddoku/data/dao/PatientDao.kt | 80 +++++ .../de/medapp/wunddoku/data/dao/PhotoDao.kt | 26 ++ .../medapp/wunddoku/data/dao/SignatureDao.kt | 23 ++ .../wunddoku/data/database/AppDatabase.kt | 65 ++++ .../wunddoku/data/entity/BlockStateEntity.kt | 45 +++ .../data/entity/CheckItemStateEntity.kt | 36 +++ .../data/entity/DocumentationEntity.kt | 52 ++++ .../wunddoku/data/entity/PatientEntity.kt | 35 +++ .../wunddoku/data/entity/PhotoEntity.kt | 35 +++ .../wunddoku/data/entity/SignatureEntity.kt | 34 ++ .../converter/DocumentationStatusConverter.kt | 28 ++ .../relations/DocumentationRelations.kt | 71 +++++ .../data/mapper/DocumentationMapper.kt | 80 +++++ .../wunddoku/data/mapper/PatientMapper.kt | 41 +++ .../repository/DocumentationRepository.kt | 291 ++++++++++++++++++ .../data/repository/PatientRepository.kt | 73 +++++ .../de/medapp/wunddoku/di/AppContainer.kt | 47 +++ .../wunddoku/di/ViewModelFactoryHelper.kt | 17 + .../domain/catalog/DocumentationCatalog.kt | 73 +++++ .../medapp/wunddoku/domain/model/CheckItem.kt | 18 ++ .../wunddoku/domain/model/Documentation.kt | 40 +++ .../domain/model/DocumentationBlock.kt | 26 ++ .../domain/model/DocumentationStatus.kt | 15 + .../medapp/wunddoku/domain/model/Patient.kt | 29 ++ .../wunddoku/domain/model/PatientListItem.kt | 17 + .../de/medapp/wunddoku/domain/model/Photo.kt | 19 ++ .../medapp/wunddoku/domain/model/Signature.kt | 19 ++ .../domain/usecase/BlockEditedPolicy.kt | 23 ++ .../de/medapp/wunddoku/media/PhotoStorage.kt | 65 ++++ .../signature/SignatureFileStorage.kt | 40 +++ .../edit/DocumentationEditScreen.kt | 181 +++++++++++ .../edit/DocumentationEditViewModel.kt | 167 ++++++++++ .../list/DocumentationListScreen.kt | 186 +++++++++++ .../list/DocumentationListViewModel.kt | 74 +++++ .../medapp/wunddoku/ui/navigation/NavGraph.kt | 72 +++++ .../medapp/wunddoku/ui/navigation/Screen.kt | 41 +++ .../ui/patients/form/PatientFormScreen.kt | 179 +++++++++++ .../ui/patients/form/PatientFormViewModel.kt | 184 +++++++++++ .../ui/patients/list/PatientListScreen.kt | 201 ++++++++++++ .../ui/patients/list/PatientListViewModel.kt | 106 +++++++ .../java/de/medapp/wunddoku/ui/theme/Theme.kt | 40 +++ .../res/drawable/ic_launcher_foreground.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 40 +++ app/src/main/res/values/themes.xml | 6 + .../main/res/xml/data_extraction_rules.xml | 19 ++ app/src/main/res/xml/file_paths.xml | 12 + build.gradle.kts | 10 + gradle.properties | 4 + gradle/gradle-daemon-jvm.properties | 3 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 252 +++++++++++++++ gradlew.bat | 94 ++++++ settings.gradle.kts | 21 ++ 88 files changed, 3733 insertions(+) create mode 100644 .gradle/8.11.1/checksums/checksums.lock create mode 100644 .gradle/8.11.1/checksums/md5-checksums.bin create mode 100644 .gradle/8.11.1/checksums/sha1-checksums.bin create mode 100644 .gradle/8.11.1/executionHistory/executionHistory.bin create mode 100644 .gradle/8.11.1/executionHistory/executionHistory.lock create mode 100644 .gradle/8.11.1/fileChanges/last-build.bin create mode 100644 .gradle/8.11.1/fileHashes/fileHashes.bin create mode 100644 .gradle/8.11.1/fileHashes/fileHashes.lock create mode 100644 .gradle/8.11.1/gc.properties create mode 100644 .gradle/9.3.0/checksums/checksums.lock create mode 100644 .gradle/9.3.0/checksums/md5-checksums.bin create mode 100644 .gradle/9.3.0/checksums/sha1-checksums.bin create mode 100644 .gradle/9.3.0/executionHistory/executionHistory.bin create mode 100644 .gradle/9.3.0/executionHistory/executionHistory.lock create mode 100644 .gradle/9.3.0/fileChanges/last-build.bin create mode 100644 .gradle/9.3.0/fileHashes/fileHashes.bin create mode 100644 .gradle/9.3.0/fileHashes/fileHashes.lock create mode 100644 .gradle/9.3.0/gc.properties create mode 100644 .gradle/buildOutputCleanup/buildOutputCleanup.lock create mode 100644 .gradle/buildOutputCleanup/cache.properties create mode 100644 .gradle/buildOutputCleanup/outputFiles.bin create mode 100644 .gradle/file-system.probe create mode 100644 .gradle/vcs-1/gc.properties create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/de/medapp/wunddoku/MainActivity.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/WunddokuApplication.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/dao/PatientDao.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/dao/PhotoDao.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/entity/PhotoEntity.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/entity/converter/DocumentationStatusConverter.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/di/ViewModelFactoryHelper.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationStatus.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/model/Photo.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/signature/SignatureFileStorage.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListScreen.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListViewModel.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/navigation/NavGraph.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/navigation/Screen.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListViewModel.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/theme/Theme.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gradle/8.11.1/checksums/checksums.lock b/.gradle/8.11.1/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..45220840b6edbe40c8afa5c33d6b63311f3077a0 GIT binary patch literal 17 UcmZQhJU#Q%SJpZE86ZFo06&2RE&u=k literal 0 HcmV?d00001 diff --git a/.gradle/8.11.1/checksums/md5-checksums.bin b/.gradle/8.11.1/checksums/md5-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..10c6b8fde930dc8e49df50cd66bab14e6ff899cd GIT binary patch literal 18797 zcmeI(%_~Gv9LDi$WHR1UY7`SYGA2w}Fol>J3nOpK@wQTiM4}XGvY4c#*_iS+vOpR; z3!!8oF|krM-kQABY*<(j?se|X%lrrWo;vrOe)l}xdp_HHPmLrQ(>~NDeYcG7DKi8R zKmY**5I_I{1Q0*~0R#|0009ILKmY**5cn?wE%HVZ#Ab|Uoia%slBA*}d7|IikvdE_ zPLH_l7|^~S92dFwIvUHS)cT56f6go(tXr?Pih6CiRxb#eKW^_ML3RDPRxfcLR=5+A zPSo`Vt?sN3jJYmGHr4f2T3s9E`jWWE6#)bgKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R(;uC_nY*%RN`=X+G~-R{6$+ec6*U zy?^}>CrL4j*;jK7A`#avkCxwwfG5!8bBqT^9=t2Q;mSObnC}t^Lwa=Lt-J3sInr;h z?6J+Xy;w%o#DYjzOQRFvwaG2RtgWZ@aoUyW+H`*iMJMi3I?ba8q1TYv)NFij?G*__m3T&~a-72Q3BvHCoB#j- literal 0 HcmV?d00001 diff --git a/.gradle/8.11.1/checksums/sha1-checksums.bin b/.gradle/8.11.1/checksums/sha1-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..26c318384b22fe3f353d7de0ebc2f3c12bcce180 GIT binary patch literal 19361 zcmeI(Ye-X300;1!ubPdrEkmnSTSQOH)U4F(0c+y3VwIT|6s^0RdypLT!H@AD+}*?f^1o;2cR%i)Wenrt zX5l`}@W*|4h!F%J009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=|BFBzyAW5h7!Qeq zbuclFVdnGMfm@cP=j+oa&LQNoJdOT;P|&j`KaDR`a(M;K%Wp+RNW#{uNv_b)yr!@0 zi11y!>yU!e&2gW=OQ@iq;xICZciq(NZzSS8W zTwYJ}=zO8lJE(;7Q7CD?+ms_Vz7RNZ^Lo;JPmxaP^mNG?lFK`3e%R2nTiv8;=I+0V z=Jd=kV{*wgd_w>N5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb z2tWV=5P$##AOHafKmY;|fB*#kEnt1=-k&|Zt;b}2wNL%;;_2d}VOP!`3^msFN8W2- z-%>9Xna-~w_i^6Bo&*1G&6G~t?0Yw0l2InsBwEZvu30J_X%#1v7EfT`@=vw=s1H6l z*XvEQso`3dZ2tAM%Ur93w4@8|TkH4U79h__?TldF*0Aw*UstUU)Xg;a=8?I^aXTvLlUm@^wtWM|P(R`@Fh& q2HRXts$ToKT5^0G>K^udS!8@5POsc)nj#4(;9B>|FRXcEZT$h3X2pmA literal 0 HcmV?d00001 diff --git a/.gradle/8.11.1/executionHistory/executionHistory.bin b/.gradle/8.11.1/executionHistory/executionHistory.bin new file mode 100644 index 0000000000000000000000000000000000000000..912e3ab6d53fcec4aad74948a750688497e838f4 GIT binary patch literal 19003 zcmeI(J!lj`6bJCx)5EWbD;T)KMlKdYvPg2Kh!#o~i9up@C(1$?cV{A3IL7Z#V;xXdbXSP>UfbNy%Mb>%?eN_S<~!RfbezVG-WLso&>{vxF_V6b{x7=B*Ir$`$?JTEe;#ztw+g?b|)*BC+?m3v_5iV{{BZ& zOe8t>+u8IBmJ%Xz7ih?>kSiuRjVzn_G>z0OZPL?}2Pq9$jrHrk+Q0mM=IPvn0x?dJ zZOuHHHT3=OCC0Ue6b&X-$dZ#`#Eh55^psOg^dd({6;o%9Wy+02>N&ME;!ILoxK7Tz tF^qYA&avLc2GxkU`*`W@ROeEQ94)_l{A2sR+pE_5^ literal 0 HcmV?d00001 diff --git a/.gradle/8.11.1/fileChanges/last-build.bin b/.gradle/8.11.1/fileChanges/last-build.bin new file mode 100644 index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d GIT binary patch literal 1 IcmZPo000310RR91 literal 0 HcmV?d00001 diff --git a/.gradle/8.11.1/fileHashes/fileHashes.bin b/.gradle/8.11.1/fileHashes/fileHashes.bin new file mode 100644 index 0000000000000000000000000000000000000000..d14825bb3d6140a4a5d25b161a4543a27b4e4f28 GIT binary patch literal 19147 zcmeI(Ye-X300;21rsA{gAx$(pTDq)OYB>arz?qL; zEKL&=D=H}vKSU3WpnPCaVVTU7AS{VYEbGpD4!Uli`w;&NclYqW=YQ`#zx#2UiV%tO zFS?Q0&1H6o5d&i-3k(h&{g;iT^zF zV8LNRRylG5C%xl`hp%e$6Zz9ohuHTArQ#j0O)2?$n!jXunQMz7zEU>L=h4|LFOR)= zA#?wbmgYv5*BHE{wNKUAG`C}U>y!R!AD7Nsd>$ia`MtZ9HCM++zR>wSEbp3hJ7V)b zaFWhH$MQ*2a*NH%y2msRWVy&Oen>pMR7S6(WV!2fl&)=~W-p&dHL~2}V4*2V)jmq| zaF+XMMVnO0T{?RGeJp23LPXL0IebC@0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_ z009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##{*AyI?l!*M?q%Mc7pJA4Sl##h zYn?Ppl_4MR;(qCFyfP7XbLLqb%6qwEfMw&(rx*4SM~!N0@9mE=ORK)~jfLGa4ZGA_ zVZ-;q{5`4P`iz<}hcjzM4(lu$ZY3eYM)3K9X4}ARu|dH_X(>5n_bnO+@?wOIo*=8x zgmmwu>mpt5XlHV(MZy}xS;vY?)rCIF zoyugj_UFe$8$XMNwST?v9;0qQ6jg?bl7aTn!P4&3aK1tRr9pa)!p7-SV)rT2NdM+V zhTUTmp7edloy`wLtVhDQ&p+NrL*6xYlU$}N%u{qI%Dt`ld(ejDq)FIF$g0=t?WK|9 zaqr%w-Kc4ryAF|y#};8DBO>nEZSCiZ$ZAi&hO@;*a~s6HV7;*66eej7nK)KVl*@

ng9R* literal 0 HcmV?d00001 diff --git a/.gradle/8.11.1/gc.properties b/.gradle/8.11.1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/9.3.0/checksums/checksums.lock b/.gradle/9.3.0/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..aaf54922b12cb512e831244d0a684b5041945ef5 GIT binary patch literal 17 VcmZSnw{+?6U(@vuGJpZ|Isi)?26+Gg literal 0 HcmV?d00001 diff --git a/.gradle/9.3.0/checksums/md5-checksums.bin b/.gradle/9.3.0/checksums/md5-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..f10dbb7de11d5c4bedfab1633c14f93a79e1eaca GIT binary patch literal 30697 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 N7^72-RRfU9{|CTMjwb*B literal 0 HcmV?d00001 diff --git a/.gradle/9.3.0/checksums/sha1-checksums.bin b/.gradle/9.3.0/checksums/sha1-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..a4c65ab019ceeb16fcb2476201913aa3534cb6a8 GIT binary patch literal 80051 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%0A1Ww3m;d7GB6Mnnm`M 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 literal 0 HcmV?d00001 diff --git a/.gradle/9.3.0/executionHistory/executionHistory.bin b/.gradle/9.3.0/executionHistory/executionHistory.bin new file mode 100644 index 0000000000000000000000000000000000000000..741ead9ddca27b2c4b62fc13346e4b647f2810e3 GIT binary patch literal 19663 zcmeI%OK1~87zgl48bqq%Qcx?t5CWpuZIiT3Yfo)cAE;HE#s@^!$?l}tHqEZHyG>hA zd}CE?izwm?5quy;?Zum~i(XX39$M`|MNlhhy@(eOXe*Fn5=PXGDTQ?a?_db1LDz!wmz1plRM78 zjzAtcq$*2|D?yp7x+%FOZY3l+t{E0*9(T6ol{{V=k$t{W5A~PIJ}*;>yJ95_vawPhDn3>dgQ@z^EG|O}mvP3xDLgT7p*V@=s*$Z$G z*RcpSIkqEo#_1-Db zC6BxE_Vjd2iw(Xx`Ton^;%Bp6hZ`whsm7V(p=07f!y_dylA|DdWb9Z($h)+C=Zl5= zFP@oye%q%#6>S42QmI~jaJo8muGuHcRGE=Z`D7AOj)o+{-Ttrl3nEY7eVoG|)O{W* x>Q86>;cy)_(#I>Ev}fYmOcLQ*r8n5u?(W-hVA*hl=5%$W-L6-=9QF^;kzYaE<|+UH literal 0 HcmV?d00001 diff --git a/.gradle/9.3.0/executionHistory/executionHistory.lock b/.gradle/9.3.0/executionHistory/executionHistory.lock new file mode 100644 index 0000000000000000000000000000000000000000..0e4e0b8dc1831d9e9731a714a62e8db5d2ac4976 GIT binary patch literal 17 UcmZQxFTG)|@lgd{1_)pV04ntZLjV8( literal 0 HcmV?d00001 diff --git a/.gradle/9.3.0/fileChanges/last-build.bin b/.gradle/9.3.0/fileChanges/last-build.bin new file mode 100644 index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d GIT binary patch literal 1 IcmZPo000310RR91 literal 0 HcmV?d00001 diff --git a/.gradle/9.3.0/fileHashes/fileHashes.bin b/.gradle/9.3.0/fileHashes/fileHashes.bin new file mode 100644 index 0000000000000000000000000000000000000000..c9ca934022173fe1983964eb7129412c89eec418 GIT binary patch literal 18997 zcmeI&{Yw*J00;29t|GlrYJ^H99gL+>PK}r~i&jHSA*D5Iy|8ID$}*fKm6%PMhy@`+ zv_^4e1Suv3_ERq;6}@8GQENp}GKNWFNK{Z*w>{5;jQb0U@4<8T@O_>y-1GVM&dPCV zNq^|YOp z7M;vF!f`peXxyxeJP7&gHk0KIX7>G{McDB+?5Jy|xy&mcVlLb+gnV zMMqL&MfZ)F?#0jBQoKE_;@)@aAM+gxLr*`EGl^yDGdsSK(z(+U$~f5(Sa8wt#cMuB z8c*JkMxRsLxEuU>Z&~F)WHhz5f796K9NGwy2KQUrxN6C>DfN2AvA{dDKEZVG90j0 zV62hg^k^Gd_h;{Neph7sxOiqYFRqDe%=fL*Hu3{UyJoG}R$|Kcx*Rp9`r{f04;O12 zL#F8kHn+aKmv8WlJlb+Cu93cVowi{+{_A6QI9GY!WLQ~Z@!ug0sYcz&al(h#IsO9K CFABB* literal 0 HcmV?d00001 diff --git a/.gradle/9.3.0/fileHashes/fileHashes.lock b/.gradle/9.3.0/fileHashes/fileHashes.lock new file mode 100644 index 0000000000000000000000000000000000000000..0ff5efc067a530b2dc3f869a8723fb5b3b77667f GIT binary patch literal 17 UcmZR6thwaF;&&&XF+czx07u^i@&Et; literal 0 HcmV?d00001 diff --git a/.gradle/9.3.0/gc.properties b/.gradle/9.3.0/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000000000000000000000000000000000000..9aff8315c0fe86349bbe609ef8ad86af60e09679 GIT binary patch literal 17 UcmZR+urAwtx$yK-1_+P<05aPI0ssI2 literal 0 HcmV?d00001 diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..f56856d --- /dev/null +++ b/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Thu Aug 20 08:33:55 CEST 2026 +gradle.version=8.11.1 diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000000000000000000000000000000000000..c45ea0aad0665babce65a15b64536b9bc5ef421a GIT binary patch literal 18623 zcmeI%&niSw9Ki9z!k;0erfx%d02WFffGB3AWND-9tdyp)l?7R__5`z-Y;0_7iDyt) zjbx`ZHr(l+)8qkc_?$ZT{`#J~IiKxa3LzW&iYs(ixjUK&5FkK+009C72oNAZfB*pk z1PBlyK!5;&zap@aU!+uACY$N)45PIWW{2~k|F%6*?|YhW7so3L>HopY=j-M4{>fJC zN2$LZKeSdS8+)C;9D6gF@ zKi + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/de/medapp/wunddoku/MainActivity.kt b/app/src/main/java/de/medapp/wunddoku/MainActivity.kt new file mode 100644 index 0000000..cdedee9 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/MainActivity.kt @@ -0,0 +1,42 @@ +package de.medapp.wunddoku + +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import de.medapp.wunddoku.ui.navigation.WunddokuNavGraph +import de.medapp.wunddoku.ui.theme.WunddokuTheme + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // FLAG_SECURE (Freigabe-Entscheidung aus Phase 1, Architektur-Dokument Abschnitt 13): + // verhindert, dass Wunddaten in Screenshots oder in der App-Switcher-Vorschau + // sichtbar werden, da die App sensible Patientendaten verarbeitet. + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + + setContent { + WunddokuApp() + } + } +} + +@Composable +private fun WunddokuApp() { + WunddokuTheme { + Surface(modifier = Modifier.fillMaxSize()) { + WunddokuNavGraph() + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/WunddokuApplication.kt b/app/src/main/java/de/medapp/wunddoku/WunddokuApplication.kt new file mode 100644 index 0000000..8845b12 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/WunddokuApplication.kt @@ -0,0 +1,15 @@ +package de.medapp.wunddoku + +import android.app.Application +import de.medapp.wunddoku.di.AppContainer + +class WunddokuApplication : Application() { + + lateinit var container: AppContainer + private set + + override fun onCreate() { + super.onCreate() + container = AppContainer(this) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt new file mode 100644 index 0000000..275ae25 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt @@ -0,0 +1,28 @@ +package de.medapp.wunddoku.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import de.medapp.wunddoku.data.entity.BlockStateEntity + +/** + * Bewusst schlanke DAO: Nur Datenbankzugriffe, keine Koordination mehrerer Tabellen (das + * übernimmt DocumentationRepository). Kein `update` für ganze Entities nötig, da nur + * `freeText`/`updatedAt` sich nach dem Erstellen je ändern (Autosave) – ein gezieltes + * Teil-Update ist hier klarer als ein vollständiges Entity-Update. + */ +@Dao +interface BlockStateDao { + + @Insert + suspend fun insertAll(blockStates: List) + + @Query("SELECT * FROM block_states WHERE id = :id") + suspend fun getById(id: String): BlockStateEntity? + + @Query("SELECT * FROM block_states WHERE documentationId = :documentationId ORDER BY blockOrder") + suspend fun getForDocumentation(documentationId: String): List + + @Query("UPDATE block_states SET freeText = :freeText, updatedAt = :updatedAt WHERE id = :id") + suspend fun updateFreeText(id: String, freeText: String, updatedAt: Long) +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt new file mode 100644 index 0000000..92394ba --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt @@ -0,0 +1,19 @@ +package de.medapp.wunddoku.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import de.medapp.wunddoku.data.entity.CheckItemStateEntity + +@Dao +interface CheckItemStateDao { + + @Insert + suspend fun insertAll(checkItemStates: List) + + @Query("SELECT * FROM check_item_states WHERE blockStateId = :blockStateId ORDER BY itemOrder") + suspend fun getForBlockState(blockStateId: String): List + + @Query("UPDATE check_item_states SET checked = :checked WHERE id = :id") + suspend fun setChecked(id: String, checked: Boolean) +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt new file mode 100644 index 0000000..cddd351 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt @@ -0,0 +1,73 @@ +package de.medapp.wunddoku.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import de.medapp.wunddoku.data.entity.DocumentationEntity +import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails +import de.medapp.wunddoku.data.entity.relations.DocumentationWithPatient +import kotlinx.coroutines.flow.Flow + +@Dao +interface DocumentationDao { + + @Insert + suspend fun insert(documentation: DocumentationEntity) + + @Update + suspend fun update(documentation: DocumentationEntity) + + @Delete + suspend fun delete(documentation: DocumentationEntity) + + @Query("SELECT * FROM documentations WHERE id = :id") + suspend fun getById(id: String): DocumentationEntity? + + /** + * Für die Startscreen-Liste (Aufgabenstellung Abschnitt 5): zeigt alle Dokumentationen mit + * Patientenbezug, ohne die vollständigen Blöcke zu laden – bewusst schlank für Performance + * bei größeren Datenmengen. + */ + @Transaction + @Query("SELECT * FROM documentations ORDER BY createdAt DESC") + fun observeAllWithPatient(): Flow> + + @Transaction + @Query( + """ + SELECT d.* FROM documentations d + JOIN patients p ON p.id = d.patientId + WHERE p.lastName LIKE '%' || :query || '%' + OR p.firstName LIKE '%' || :query || '%' + OR p.birthDate LIKE '%' || :query || '%' + ORDER BY d.createdAt DESC + """ + ) + fun search(query: String): Flow> + + @Transaction + @Query("SELECT * FROM documentations WHERE id = :id") + suspend fun getFullDocumentation(id: String): DocumentationWithDetails? + + /** + * Für "Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 9): lädt die zuletzt erstellte + * Dokumentation eines Patienten inklusive aller Blöcke/Checkboxen (Fotos/Unterschriften + * werden je nach Kopiersemantik separat behandelt, s. DocumentationRepository). + */ + @Transaction + @Query( + """ + SELECT * FROM documentations + WHERE patientId = :patientId + ORDER BY createdAt DESC + LIMIT 1 + """ + ) + suspend fun getLatestForPatient(patientId: String): DocumentationWithDetails? + + @Query("SELECT COUNT(*) FROM documentations WHERE patientId = :patientId") + suspend fun countForPatient(patientId: String): Int +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/PatientDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/PatientDao.kt new file mode 100644 index 0000000..a0b2d83 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/PatientDao.kt @@ -0,0 +1,80 @@ +package de.medapp.wunddoku.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Embedded +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import de.medapp.wunddoku.data.entity.PatientEntity +import kotlinx.coroutines.flow.Flow + +/** + * [PatientEntity] zusammen mit dem Erstellungsdatum der zuletzt angelegten Dokumentation dieses + * Patienten (oder `null`, falls noch keine existiert). Wird über eine Korrelations-Subquery in + * einem einzigen Datenbankzugriff berechnet, statt pro Patient eine eigene Folgeabfrage + * auszuführen (N+1-Problem) – wichtig für die geforderte Performance bei größeren Datenmengen + * (Aufgabenstellung Abschnitt 6). + */ +data class PatientWithLastDocumentation( + @Embedded val patient: PatientEntity, + val lastDocumentationAt: Long?, +) + +@Dao +interface PatientDao { + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insert(patient: PatientEntity) + + @Update + suspend fun update(patient: PatientEntity) + + @Delete + suspend fun delete(patient: PatientEntity) + + @Query("SELECT * FROM patients WHERE id = :id") + suspend fun getById(id: String): PatientEntity? + + @Query( + """ + SELECT p.*, (SELECT MAX(d.createdAt) FROM documentations d WHERE d.patientId = p.id) AS lastDocumentationAt + FROM patients p + ORDER BY p.lastName, p.firstName + """ + ) + fun observeAllWithLastDocumentation(): Flow> + + @Query( + """ + SELECT p.*, (SELECT MAX(d.createdAt) FROM documentations d WHERE d.patientId = p.id) AS lastDocumentationAt + FROM patients p + WHERE p.lastName LIKE '%' || :query || '%' + OR p.firstName LIKE '%' || :query || '%' + OR p.birthDate LIKE '%' || :query || '%' + ORDER BY p.lastName, p.firstName + """ + ) + fun searchWithLastDocumentation(query: String): Flow> + + /** + * Wird beim Anlegen einer neuen Dokumentation verwendet, um zu prüfen, ob ein Patient mit + * denselben Stammdaten bereits existiert (Aufgabenstellung Abschnitt 7 Schritt 1: "möglichst + * ein vorhandener Patient ausgewählt werden können, statt einen Duplikatdatensatz + * anzulegen"). Exakter Abgleich auf Name + Geburtsdatum als einfache, nachvollziehbare + * Heuristik für V1. + */ + @Query( + """ + SELECT * FROM patients + WHERE firstName = :firstName AND lastName = :lastName AND birthDate = :birthDate + LIMIT 1 + """ + ) + suspend fun findExisting(firstName: String, lastName: String, birthDate: String): PatientEntity? + + @Query("SELECT EXISTS(SELECT 1 FROM documentations WHERE patientId = :patientId)") + suspend fun hasDocumentations(patientId: String): Boolean +} + diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/PhotoDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/PhotoDao.kt new file mode 100644 index 0000000..b3b37ac --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/PhotoDao.kt @@ -0,0 +1,26 @@ +package de.medapp.wunddoku.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import de.medapp.wunddoku.data.entity.PhotoEntity + +@Dao +interface PhotoDao { + + @Insert + suspend fun insert(photo: PhotoEntity) + + @Delete + suspend fun delete(photo: PhotoEntity) + + @Query("SELECT * FROM photos WHERE id = :id") + suspend fun getById(id: String): PhotoEntity? + + @Query("SELECT * FROM photos WHERE documentationId = :documentationId ORDER BY orderIndex") + suspend fun getForDocumentation(documentationId: String): List + + @Query("SELECT COALESCE(MAX(orderIndex), -1) FROM photos WHERE documentationId = :documentationId") + suspend fun getMaxOrderIndex(documentationId: String): Int +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt new file mode 100644 index 0000000..7d5a682 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt @@ -0,0 +1,23 @@ +package de.medapp.wunddoku.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import de.medapp.wunddoku.data.entity.SignatureEntity + +@Dao +interface SignatureDao { + + @Insert + suspend fun insert(signature: SignatureEntity) + + @Delete + suspend fun delete(signature: SignatureEntity) + + @Query("SELECT * FROM signatures WHERE id = :id") + suspend fun getById(id: String): SignatureEntity? + + @Query("SELECT * FROM signatures WHERE documentationId = :documentationId") + suspend fun getForDocumentation(documentationId: String): List +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt b/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt new file mode 100644 index 0000000..71461fa --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt @@ -0,0 +1,65 @@ +package de.medapp.wunddoku.data.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import de.medapp.wunddoku.data.dao.BlockStateDao +import de.medapp.wunddoku.data.dao.CheckItemStateDao +import de.medapp.wunddoku.data.dao.DocumentationDao +import de.medapp.wunddoku.data.dao.PatientDao +import de.medapp.wunddoku.data.dao.PhotoDao +import de.medapp.wunddoku.data.dao.SignatureDao +import de.medapp.wunddoku.data.entity.BlockStateEntity +import de.medapp.wunddoku.data.entity.CheckItemStateEntity +import de.medapp.wunddoku.data.entity.DocumentationEntity +import de.medapp.wunddoku.data.entity.PatientEntity +import de.medapp.wunddoku.data.entity.PhotoEntity +import de.medapp.wunddoku.data.entity.SignatureEntity +import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter + +/** + * Zentrale Room-Datenbank. `exportSchema = true`, damit Room die Schema-Historie als JSON in + * app/schemas/ ablegt (im Gradle-Skript über `room { schemaDirectory(...) }` zu konfigurieren, + * s. Kommentar unten) – das ist die Grundlage für spätere, nachvollziehbare Migrationen. + */ +@Database( + entities = [ + PatientEntity::class, + DocumentationEntity::class, + BlockStateEntity::class, + CheckItemStateEntity::class, + PhotoEntity::class, + SignatureEntity::class, + ], + version = 1, + exportSchema = true, +) +@TypeConverters(DocumentationStatusConverter::class) +abstract class AppDatabase : RoomDatabase() { + + abstract fun patientDao(): PatientDao + abstract fun documentationDao(): DocumentationDao + abstract fun blockStateDao(): BlockStateDao + abstract fun checkItemStateDao(): CheckItemStateDao + abstract fun photoDao(): PhotoDao + abstract fun signatureDao(): SignatureDao + + companion object { + private const val DATABASE_NAME = "wunddoku.db" + + @Volatile + private var instance: AppDatabase? = null + + fun getInstance(context: Context): AppDatabase { + return instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + DATABASE_NAME, + ).build().also { instance = it } + } + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt new file mode 100644 index 0000000..5e476e5 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt @@ -0,0 +1,45 @@ +package de.medapp.wunddoku.data.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room-Entity für den Zustand eines einzelnen Dokumentationsblocks innerhalb einer konkreten + * Wunddokumentation. Generische Struktur statt 20 fester Spalten/Tabellen (Architektur-Dokument + * Abschnitt 2.3a) – neue Blöcke im [de.medapp.wunddoku.domain.catalog.DocumentationCatalog] + * erfordern keine Schema-Migration. + * + * [blockDefinitionId] verweist auf die ID im statischen Katalog, ist aber bewusst KEIN + * Foreign Key: Der Katalog ist keine Datenbanktabelle, sondern Kotlin-Code. + * + * [titleSnapshot]/[blockOrder] speichern den zum Erstellungszeitpunkt der Dokumentation + * gültigen Titel bzw. die gültige Reihenfolge (Architektur-Dokument Abschnitt 2.4) – das + * stellt sicher, dass eine später geöffnete, historische Dokumentation unverändert korrekt + * dargestellt wird, selbst wenn sich der Katalog danach ändert. + */ +@Entity( + tableName = "block_states", + foreignKeys = [ + ForeignKey( + entity = DocumentationEntity::class, + parentColumns = ["id"], + childColumns = ["documentationId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["documentationId"]), + Index(value = ["documentationId", "blockDefinitionId"], unique = true), + ], +) +data class BlockStateEntity( + @PrimaryKey val id: String, + val documentationId: String, + val blockDefinitionId: String, + val titleSnapshot: String, + val blockOrder: Int, + val freeText: String, + val updatedAt: Long, +) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt new file mode 100644 index 0000000..9494753 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt @@ -0,0 +1,36 @@ +package de.medapp.wunddoku.data.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room-Entity für den Zustand einer einzelnen Checkbox innerhalb eines [BlockStateEntity]. + * + * [labelSnapshot]/[itemOrder]: siehe Erläuterung bei [BlockStateEntity.titleSnapshot] – gleiche + * Begründung, auf Checkbox-Ebene. + */ +@Entity( + tableName = "check_item_states", + foreignKeys = [ + ForeignKey( + entity = BlockStateEntity::class, + parentColumns = ["id"], + childColumns = ["blockStateId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["blockStateId"]), + Index(value = ["blockStateId", "checkItemDefinitionId"], unique = true), + ], +) +data class CheckItemStateEntity( + @PrimaryKey val id: String, + val blockStateId: String, + val checkItemDefinitionId: String, + val labelSnapshot: String, + val itemOrder: Int, + val checked: Boolean, +) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt new file mode 100644 index 0000000..f30125c --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt @@ -0,0 +1,52 @@ +package de.medapp.wunddoku.data.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import de.medapp.wunddoku.domain.model.DocumentationStatus + +/** + * Room-Entity für eine Wunddokumentation. + * + * Foreign-Key-Verhalten (Architektur-Dokument Abschnitt 2.3f): + * - patientId -> Patient.id: RESTRICT. Ein Patient mit vorhandenen Dokumentationen kann in + * der Datenbank NICHT gelöscht werden. Das setzt die Anforderung "kein versehentliches + * Cascade-Delete von Dokumentationen" technisch durch – die UI muss das vorher explizit + * abfangen (siehe PatientRepository.hasDocumentations). + * - continuedFromDocumentationId -> Documentation.id: SET_NULL. Löschen einer alten + * Dokumentation darf eine neuere, davon "fortgesetzte" Dokumentation weder blockieren noch + * mitreißen; der Verweis wird beim Löschen des Vorgängers einfach entfernt. + */ +@Entity( + tableName = "documentations", + foreignKeys = [ + ForeignKey( + entity = PatientEntity::class, + parentColumns = ["id"], + childColumns = ["patientId"], + onDelete = ForeignKey.RESTRICT, + ), + ForeignKey( + entity = DocumentationEntity::class, + parentColumns = ["id"], + childColumns = ["continuedFromDocumentationId"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [ + Index(value = ["patientId", "createdAt"]), + Index(value = ["status"]), + Index(value = ["continuedFromDocumentationId"]), + ], +) +data class DocumentationEntity( + @PrimaryKey val id: String, + val patientId: String, + val status: DocumentationStatus, + val createdAt: Long, + val updatedAt: Long, + val continuedFromDocumentationId: String?, + val pdfLastGeneratedAt: Long?, + val catalogVersion: Int, +) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt new file mode 100644 index 0000000..d98dd2d --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt @@ -0,0 +1,35 @@ +package de.medapp.wunddoku.data.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room-Entity für einen Patienten. [id] ist eine UUID (kein Auto-Increment), damit Datensätze + * geräteübergreifend eindeutig bleiben – wichtig für die spätere optionale WLAN-Synchronisation + * (Architektur-Dokument Abschnitt 2.1). + * + * [birthDate] wird als ISO-8601-String (z. B. "1980-03-14") gespeichert statt über einen + * TypeConverter auf LocalDate: Der String ist bereits das native SQLite-Format, direkt + * sortierbar und ohne Konverter lesbar. Der Mapper (PatientMapper) übernimmt die Umwandlung + * zu/von `java.time.LocalDate` beim Übergang zur Domain-Schicht. + */ +@Entity( + tableName = "patients", + indices = [ + Index(value = ["lastName", "firstName"]), + Index(value = ["birthDate"]), + ], +) +data class PatientEntity( + @PrimaryKey val id: String, + val firstName: String, + val lastName: String, + val birthDate: String, + val address: String, + val phone1: String, + val phone2: String?, + val phone3: String?, + val createdAt: Long, + val updatedAt: Long, +) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/PhotoEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/PhotoEntity.kt new file mode 100644 index 0000000..121c6c1 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/PhotoEntity.kt @@ -0,0 +1,35 @@ +package de.medapp.wunddoku.data.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room-Entity für ein an eine Dokumentation angehängtes Foto. + * + * [fileName] ist ein relativer Pfad innerhalb von context.filesDir/photos/ – kein BLOB, keine + * absolute Pfadangabe (robust gegenüber möglichen Änderungen des App-Datenverzeichnisses durch + * das System). Siehe Aufgabenstellung Abschnitt 10. + */ +@Entity( + tableName = "photos", + foreignKeys = [ + ForeignKey( + entity = DocumentationEntity::class, + parentColumns = ["id"], + childColumns = ["documentationId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["documentationId", "orderIndex"]), + ], +) +data class PhotoEntity( + @PrimaryKey val id: String, + val documentationId: String, + val fileName: String, + val orderIndex: Int, + val createdAt: Long, +) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt new file mode 100644 index 0000000..9520bae --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt @@ -0,0 +1,34 @@ +package de.medapp.wunddoku.data.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room-Entity für eine digitale Unterschrift. Eigene Tabelle statt eines einzelnen Feldes an + * DocumentationEntity, um später mehrere Unterschriften pro Dokumentation zu unterstützen + * (Aufgabenstellung Abschnitt 11). [fileName] verweist auf eine PNG-Datei unter + * context.filesDir/signatures/ – kein BLOB in SQLite. + */ +@Entity( + tableName = "signatures", + foreignKeys = [ + ForeignKey( + entity = DocumentationEntity::class, + parentColumns = ["id"], + childColumns = ["documentationId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["documentationId"]), + ], +) +data class SignatureEntity( + @PrimaryKey val id: String, + val documentationId: String, + val signerName: String?, + val fileName: String, + val createdAt: Long, +) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/converter/DocumentationStatusConverter.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/converter/DocumentationStatusConverter.kt new file mode 100644 index 0000000..886e453 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/converter/DocumentationStatusConverter.kt @@ -0,0 +1,28 @@ +package de.medapp.wunddoku.data.entity.converter + +import androidx.room.TypeConverter +import de.medapp.wunddoku.domain.model.DocumentationStatus + +/** + * Speichert [DocumentationStatus] als lesbaren Text (z. B. "COMPLETED"), NICHT als + * Ordinal-Index (0, 1, ...). + * + * Kotlin-Hinweis: `@TypeConverter` markiert Funktionen, die Room bei Bedarf automatisch + * aufruft, um einen Typ, den SQLite nicht nativ kennt (hier: das Enum), in einen + * speicherbaren Typ (hier: String) umzuwandeln und zurück. Ich verwende bewusst einen + * expliziten Konverter statt mich auf automatisches Enum-Handling neuerer Room-Versionen zu + * verlassen – das macht das Verhalten unabhängig von der genauen Room-Version nachvollziehbar. + * + * Der Ordinal-Wert eines Enums hängt von der Deklarationsreihenfolge im Code ab. Würde später + * z. B. CANCELLED vor COMPLETED eingefügt, würden sich bei Ordinal-Speicherung alle + * bestehenden Datensätze plötzlich falsch zuordnen. Der Name ist stabil gegenüber + * Reihenfolgeänderungen und zusätzlich beim Debuggen in einem SQLite-Browser direkt lesbar. + */ +class DocumentationStatusConverter { + + @TypeConverter + fun fromStatus(status: DocumentationStatus): String = status.name + + @TypeConverter + fun toStatus(value: String): DocumentationStatus = DocumentationStatus.valueOf(value) +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt new file mode 100644 index 0000000..752aea7 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt @@ -0,0 +1,71 @@ +package de.medapp.wunddoku.data.entity.relations + +import androidx.room.Embedded +import androidx.room.Relation +import de.medapp.wunddoku.data.entity.BlockStateEntity +import de.medapp.wunddoku.data.entity.CheckItemStateEntity +import de.medapp.wunddoku.data.entity.DocumentationEntity +import de.medapp.wunddoku.data.entity.PatientEntity +import de.medapp.wunddoku.data.entity.PhotoEntity +import de.medapp.wunddoku.data.entity.SignatureEntity + +/** + * Verschachtelte Room-Relation: ein Block zusammen mit seinen Checkbox-Zuständen. + * + * Kotlin/Room-Hinweis: `@Embedded` bedeutet, dass die Spalten von [BlockStateEntity] direkt in + * dieses Objekt "eingebettet" werden (kein separates verschachteltes Objekt in der + * SQL-Ergebnisstruktur). `@Relation` lässt Room automatisch eine zweite Query ausführen, um + * alle [CheckItemStateEntity]-Zeilen zu laden, deren `blockStateId`-Spalte zur `id`-Spalte + * dieses BlockState passt. + */ +data class BlockStateWithItems( + @Embedded val blockState: BlockStateEntity, + @Relation( + parentColumn = "id", + entityColumn = "blockStateId", + ) + val checkItems: List, +) + +/** + * Vollständige Dokumentation inkl. aller Blöcke (mit ihren Checkboxen), Fotos und + * Unterschriften. Wird ausschließlich über `@Transaction`-Queries in [de.medapp.wunddoku.data.dao.DocumentationDao] + * geladen, damit das Ergebnis konsistent ist (Aufgabenstellung Abschnitt 16, letzter Punkt). + * + * Kotlin/Room-Hinweis: Eine mehrstufig verschachtelte `@Relation` (hier: Blöcke, die selbst + * wieder ihre Checkboxen per `@Relation` laden) wird von Room unterstützt, solange bei + * `entity = ...` explizit die zugrunde liegende Room-Entity angegeben wird – auch wenn der + * Ziel-Typ ([BlockStateWithItems]) selbst kein Entity, sondern eine weitere POJO/Relation ist. + */ +data class DocumentationWithDetails( + @Embedded val documentation: DocumentationEntity, + @Relation( + entity = BlockStateEntity::class, + parentColumn = "id", + entityColumn = "documentationId", + ) + val blocks: List, + @Relation( + parentColumn = "id", + entityColumn = "documentationId", + ) + val photos: List, + @Relation( + parentColumn = "id", + entityColumn = "documentationId", + ) + val signatures: List, +) + +/** + * Schlanke Relation für Listenansichten: Dokumentation + zugehöriger Patient, ohne Blöcke/ + * Fotos/Unterschriften zu laden. Wird für die Wunddokumentationsliste (Startscreen) verwendet. + */ +data class DocumentationWithPatient( + @Embedded val documentation: DocumentationEntity, + @Relation( + parentColumn = "patientId", + entityColumn = "id", + ) + val patient: PatientEntity, +) diff --git a/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt b/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt new file mode 100644 index 0000000..a4812f1 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt @@ -0,0 +1,80 @@ +package de.medapp.wunddoku.data.mapper + +import de.medapp.wunddoku.data.entity.BlockStateEntity +import de.medapp.wunddoku.data.entity.CheckItemStateEntity +import de.medapp.wunddoku.data.entity.relations.BlockStateWithItems +import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails +import de.medapp.wunddoku.data.entity.relations.DocumentationWithPatient +import de.medapp.wunddoku.domain.model.CheckItem +import de.medapp.wunddoku.domain.model.Documentation +import de.medapp.wunddoku.domain.model.DocumentationBlock +import de.medapp.wunddoku.domain.model.DocumentationSummary +import java.time.Instant + +/** + * Wandelt zwischen den Room-Relations und den Domain-Modellen [Documentation]/ + * [DocumentationSummary] um. Titel/Label kommen ausschließlich aus den Snapshot-Feldern + * ([BlockStateEntity.titleSnapshot], [CheckItemStateEntity.labelSnapshot]) – hier findet KEINE + * Auflösung über [de.medapp.wunddoku.domain.catalog.DocumentationCatalog] statt (Architektur- + * Dokument Abschnitt 2.4). + */ +fun DocumentationWithDetails.toDomain(): Documentation = Documentation( + id = documentation.id, + patientId = documentation.patientId, + status = documentation.status, + createdAt = Instant.ofEpochMilli(documentation.createdAt), + updatedAt = Instant.ofEpochMilli(documentation.updatedAt), + continuedFromDocumentationId = documentation.continuedFromDocumentationId, + catalogVersion = documentation.catalogVersion, + blocks = blocks + .sortedBy { it.blockState.blockOrder } + .map { it.toDomain() }, + photos = photos + .sortedBy { it.orderIndex } + .map { + de.medapp.wunddoku.domain.model.Photo( + id = it.id, + documentationId = it.documentationId, + fileName = it.fileName, + orderIndex = it.orderIndex, + createdAt = Instant.ofEpochMilli(it.createdAt), + ) + }, + signatures = signatures.map { + de.medapp.wunddoku.domain.model.Signature( + id = it.id, + documentationId = it.documentationId, + signerName = it.signerName, + fileName = it.fileName, + createdAt = Instant.ofEpochMilli(it.createdAt), + ) + }, +) + +fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock( + blockStateId = blockState.id, + definitionId = blockState.blockDefinitionId, + title = blockState.titleSnapshot, + order = blockState.blockOrder, + freeText = blockState.freeText, + items = checkItems + .sortedBy { it.itemOrder } + .map { it.toDomain() }, +) + +fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem( + checkItemStateId = id, + definitionId = checkItemDefinitionId, + label = labelSnapshot, + order = itemOrder, + checked = checked, +) + +fun DocumentationWithPatient.toSummary(): DocumentationSummary = DocumentationSummary( + id = documentation.id, + patientId = documentation.patientId, + patientFullName = "${patient.firstName} ${patient.lastName}", + status = documentation.status, + createdAt = Instant.ofEpochMilli(documentation.createdAt), + updatedAt = Instant.ofEpochMilli(documentation.updatedAt), +) diff --git a/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt b/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt new file mode 100644 index 0000000..4a3d881 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt @@ -0,0 +1,41 @@ +package de.medapp.wunddoku.data.mapper + +import de.medapp.wunddoku.data.entity.PatientEntity +import de.medapp.wunddoku.domain.model.Patient +import java.time.Instant +import java.time.LocalDate + +/** + * Wandelt zwischen der Room-Entity [PatientEntity] und dem Domain-Modell [Patient] um. + * + * Kotlin-Hinweis: Dies sind sogenannte Extension Functions (`fun PatientEntity.toDomain()`) + * – sie erweitern eine bestehende Klasse um eine Methode, ohne die Klasse selbst zu verändern + * oder von ihr zu erben. Der Aufruf sieht dadurch wie eine normale Methode aus: + * `patientEntity.toDomain()`. Praktisch, um Mapping-Logik nahe an den beteiligten Typen zu + * halten, ohne eine eigene Mapper-Klasse mit Konstruktor-Injection zu benötigen. + */ +fun PatientEntity.toDomain(): Patient = Patient( + id = id, + firstName = firstName, + lastName = lastName, + birthDate = LocalDate.parse(birthDate), + address = address, + phone1 = phone1, + phone2 = phone2, + phone3 = phone3, + createdAt = Instant.ofEpochMilli(createdAt), + updatedAt = Instant.ofEpochMilli(updatedAt), +) + +fun Patient.toEntity(): PatientEntity = PatientEntity( + id = id, + firstName = firstName, + lastName = lastName, + birthDate = birthDate.toString(), // LocalDate.toString() liefert bereits ISO-8601 (yyyy-MM-dd) + address = address, + phone1 = phone1, + phone2 = phone2, + phone3 = phone3, + createdAt = createdAt.toEpochMilli(), + updatedAt = updatedAt.toEpochMilli(), +) diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt new file mode 100644 index 0000000..30abece --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt @@ -0,0 +1,291 @@ +package de.medapp.wunddoku.data.repository + +import android.graphics.Bitmap +import android.net.Uri +import androidx.room.withTransaction +import de.medapp.wunddoku.data.dao.BlockStateDao +import de.medapp.wunddoku.data.dao.CheckItemStateDao +import de.medapp.wunddoku.data.dao.DocumentationDao +import de.medapp.wunddoku.data.dao.PhotoDao +import de.medapp.wunddoku.data.dao.SignatureDao +import de.medapp.wunddoku.data.database.AppDatabase +import de.medapp.wunddoku.data.entity.BlockStateEntity +import de.medapp.wunddoku.data.entity.CheckItemStateEntity +import de.medapp.wunddoku.data.entity.DocumentationEntity +import de.medapp.wunddoku.data.entity.PhotoEntity +import de.medapp.wunddoku.data.entity.SignatureEntity +import de.medapp.wunddoku.data.mapper.toDomain +import de.medapp.wunddoku.data.mapper.toSummary +import de.medapp.wunddoku.domain.catalog.DocumentationCatalog +import de.medapp.wunddoku.domain.model.Documentation +import de.medapp.wunddoku.domain.model.DocumentationStatus +import de.medapp.wunddoku.domain.model.DocumentationSummary +import de.medapp.wunddoku.domain.model.Photo +import de.medapp.wunddoku.domain.model.Signature +import de.medapp.wunddoku.media.PhotoStorage +import de.medapp.wunddoku.signature.SignatureFileStorage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.time.Instant +import java.util.UUID + +/** + * Koordiniert alle Lese-/Schreiboperationen rund um eine Wunddokumentation über mehrere DAOs + * hinweg. ViewModels sehen niemals mehr als dieses eine Repository für Dokumentations-bezogene + * Daten – Mehrfach-DAO-Koordination und Room-Transaktionen (`db.withTransaction`) sind + * ausschließlich hier gebündelt (Architektur-Dokument Abschnitt 5). + */ +class DocumentationRepository( + private val documentationDao: DocumentationDao, + private val blockStateDao: BlockStateDao, + private val checkItemStateDao: CheckItemStateDao, + private val photoDao: PhotoDao, + private val signatureDao: SignatureDao, + private val photoStorage: PhotoStorage, + private val signatureFileStorage: SignatureFileStorage, + private val db: AppDatabase, +) { + + fun observeAll(): Flow> = + documentationDao.observeAllWithPatient().map { list -> list.map { it.toSummary() } } + + fun search(query: String): Flow> = + documentationDao.search(query).map { list -> list.map { it.toSummary() } } + + suspend fun getFullDocumentation(id: String): Documentation? = + documentationDao.getFullDocumentation(id)?.toDomain() + + /** + * Legt eine neue, leere Wunddokumentation für [patientId] an: eine [DocumentationEntity] + * plus je einen [BlockStateEntity] pro Katalog-Block (mit Label-/Titel-Snapshot aus dem + * *aktuellen* Katalogstand, Architektur-Dokument Abschnitt 2.4) und je einen + * [CheckItemStateEntity] pro Katalog-Checkbox. Läuft in einer einzigen Room-Transaktion, + * damit niemals eine halb angelegte Dokumentation (z. B. Documentation-Zeile ohne + * zugehörige Blöcke) entstehen kann. + */ + suspend fun createEmpty(patientId: String): Documentation = db.withTransaction { + val now = Instant.now().toEpochMilli() + val documentationId = UUID.randomUUID().toString() + + documentationDao.insert( + DocumentationEntity( + id = documentationId, + patientId = patientId, + status = DocumentationStatus.IN_PROGRESS, + createdAt = now, + updatedAt = now, + continuedFromDocumentationId = null, + pdfLastGeneratedAt = null, + catalogVersion = DocumentationCatalog.CATALOG_VERSION, + ) + ) + + insertBlocksFromCatalog(documentationId, now) + + documentationDao.getFullDocumentation(documentationId)!!.toDomain() + } + + /** + * "Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 9, präzisiert in Architektur- + * Dokument Abschnitt 6): + * - Patient: gleiche patientId, kein neuer Patientendatensatz + * - BlockState/CheckItemState: vollständig von der Quelle kopiert, inkl. Snapshots und + * Reihenfolge der QUELLE (nicht neu aus dem aktuellen Katalog gezogen) – die fortgesetzte + * Dokumentation beginnt exakt auf dem inhaltlichen Stand des Vorgängers. + * - createdAt/updatedAt: neu + * - status: IN_PROGRESS + * - continuedFromDocumentationId: ID der Quelle + * - Unterschriften: werden NICHT kopiert (eine Unterschrift bestätigt nur die konkrete + * Dokumentation, unter der sie geleistet wurde) + * - Fotos: über [copyPhotos] steuerbar, Standard `false` – fachlich noch nicht final + * entschieden (siehe Architektur-Dokument Abschnitt 6) + */ + suspend fun continueDocumentation( + sourceDocumentationId: String, + copyPhotos: Boolean = false, + ): Documentation = db.withTransaction { + val source = documentationDao.getFullDocumentation(sourceDocumentationId) + ?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden") + + val now = Instant.now().toEpochMilli() + val newDocumentationId = UUID.randomUUID().toString() + + documentationDao.insert( + DocumentationEntity( + id = newDocumentationId, + patientId = source.documentation.patientId, + status = DocumentationStatus.IN_PROGRESS, + createdAt = now, + updatedAt = now, + continuedFromDocumentationId = sourceDocumentationId, + pdfLastGeneratedAt = null, + catalogVersion = source.documentation.catalogVersion, + ) + ) + + // Blöcke + Checkboxen 1:1 von der Quelle übernehmen (inkl. Snapshots/Reihenfolge) + for (sourceBlock in source.blocks) { + val newBlockStateId = UUID.randomUUID().toString() + blockStateDao.insertAll( + listOf( + BlockStateEntity( + id = newBlockStateId, + documentationId = newDocumentationId, + blockDefinitionId = sourceBlock.blockState.blockDefinitionId, + titleSnapshot = sourceBlock.blockState.titleSnapshot, + blockOrder = sourceBlock.blockState.blockOrder, + freeText = sourceBlock.blockState.freeText, + updatedAt = now, + ) + ) + ) + checkItemStateDao.insertAll( + sourceBlock.checkItems.map { item -> + CheckItemStateEntity( + id = UUID.randomUUID().toString(), + blockStateId = newBlockStateId, + checkItemDefinitionId = item.checkItemDefinitionId, + labelSnapshot = item.labelSnapshot, + itemOrder = item.itemOrder, + checked = item.checked, + ) + } + ) + } + + // Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben). + + if (copyPhotos) { + for (sourcePhoto in source.photos) { + val copiedFileName = photoStorage.duplicate(sourcePhoto.fileName) + photoDao.insert( + PhotoEntity( + id = UUID.randomUUID().toString(), + documentationId = newDocumentationId, + fileName = copiedFileName, + orderIndex = sourcePhoto.orderIndex, + createdAt = now, + ) + ) + } + } + + documentationDao.getFullDocumentation(newDocumentationId)!!.toDomain() + } + + /** + * Komfort-Methode für die Patientenliste (Aufgabenstellung Abschnitt 6, Aktion + * "Behandlung/Dokumentation fortsetzen"): ermittelt die zuletzt erstellte Dokumentation + * eines Patienten und ruft darauf [continueDocumentation] auf. Liefert `null`, wenn der + * Patient noch keine Dokumentation besitzt (UI muss diesen Fall abfangen, s. Aufgabenstellung + * Abschnitt 20). + */ + suspend fun continueLatestForPatient(patientId: String): Documentation? { + val latestId = documentationDao.getLatestForPatient(patientId)?.documentation?.id ?: return null + return continueDocumentation(latestId) + } + + suspend fun updateFreeText(blockStateId: String, text: String) { + blockStateDao.updateFreeText(blockStateId, text, Instant.now().toEpochMilli()) + } + + suspend fun setChecked(checkItemStateId: String, checked: Boolean) { + checkItemStateDao.setChecked(checkItemStateId, checked) + } + + suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo { + val fileName = photoStorage.copyFrom(sourceUri) + val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1 + val now = Instant.now() + val entity = PhotoEntity( + id = UUID.randomUUID().toString(), + documentationId = documentationId, + fileName = fileName, + orderIndex = nextOrder, + createdAt = now.toEpochMilli(), + ) + photoDao.insert(entity) + return Photo(entity.id, entity.documentationId, entity.fileName, entity.orderIndex, now) + } + + /** + * Löscht ein Foto. Bewusst NICHT als atomare Operation bezeichnet: Eine Room-Transaktion + * kann keine Dateisystemoperation einschließen. Reihenfolge: DB-Zeile zuerst, dann Datei – + * schlägt die Dateilöschung fehl, bleibt nur eine verwaiste Datei zurück (unkritisch), + * statt eines DB-Eintrags, der auf eine nicht mehr existente Datei verweist (würde beim + * Anzeigen/PDF-Export zu einem sichtbaren Fehler führen). Siehe Architektur-Dokument + * Abschnitt 5.1. + */ + suspend fun removePhoto(photoId: String) { + val photo = photoDao.getById(photoId) ?: return + photoDao.delete(photo) + runCatching { photoStorage.delete(photo.fileName) } + // Fehler beim Löschen der physischen Datei wird bewusst verschluckt: Die DB ist bereits + // konsistent, eine verwaiste Datei ist der unkritischere Fehlerfall. Kein Logging von + // Dateinamen mit Patientenbezug, s. Datenschutz-Abschnitt der Architektur. + } + + suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature { + val fileName = signatureFileStorage.save(bitmap) + val now = Instant.now() + val entity = SignatureEntity( + id = UUID.randomUUID().toString(), + documentationId = documentationId, + signerName = signerName, + fileName = fileName, + createdAt = now.toEpochMilli(), + ) + signatureDao.insert(entity) + return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, now) + } + + /** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. */ + suspend fun removeSignature(signatureId: String) { + val signature = signatureDao.getById(signatureId) ?: return + signatureDao.delete(signature) + runCatching { signatureFileStorage.delete(signature.fileName) } + } + + suspend fun delete(documentationId: String) { + val entity = documentationDao.getById(documentationId) ?: return + // Zugehörige Fotos/Signaturen zuerst physisch löschen (best effort), dann die + // Dokumentation selbst – CASCADE übernimmt das Entfernen der DB-Zeilen für + // BlockState/CheckItemState/Photo/Signature. + val photos = photoDao.getForDocumentation(documentationId) + val signatures = signatureDao.getForDocumentation(documentationId) + documentationDao.delete(entity) + photos.forEach { runCatching { photoStorage.delete(it.fileName) } } + signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } } + } + + private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) { + for (blockDef in DocumentationCatalog.blocks) { + val blockStateId = UUID.randomUUID().toString() + blockStateDao.insertAll( + listOf( + BlockStateEntity( + id = blockStateId, + documentationId = documentationId, + blockDefinitionId = blockDef.id, + titleSnapshot = blockDef.title, + blockOrder = blockDef.order, + freeText = "", + updatedAt = now, + ) + ) + ) + checkItemStateDao.insertAll( + blockDef.items.map { itemDef -> + CheckItemStateEntity( + id = UUID.randomUUID().toString(), + blockStateId = blockStateId, + checkItemDefinitionId = itemDef.id, + labelSnapshot = itemDef.label, + itemOrder = itemDef.order, + checked = false, + ) + } + ) + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt new file mode 100644 index 0000000..9574b13 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt @@ -0,0 +1,73 @@ +package de.medapp.wunddoku.data.repository + +import de.medapp.wunddoku.data.dao.PatientDao +import de.medapp.wunddoku.data.dao.PatientWithLastDocumentation +import de.medapp.wunddoku.data.mapper.toDomain +import de.medapp.wunddoku.data.mapper.toEntity +import de.medapp.wunddoku.domain.model.Patient +import de.medapp.wunddoku.domain.model.PatientListItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.time.Instant +import java.time.LocalDate +import java.util.UUID + +class PatientRepository( + private val patientDao: PatientDao, +) { + fun observeAll(): Flow> = + patientDao.observeAllWithLastDocumentation().map { list -> list.map { it.toListItem() } } + + fun search(query: String): Flow> = + patientDao.searchWithLastDocumentation(query).map { list -> list.map { it.toListItem() } } + + suspend fun getById(id: String): Patient? = patientDao.getById(id)?.toDomain() + + suspend fun findExisting(firstName: String, lastName: String, birthDate: LocalDate): Patient? = + patientDao.findExisting(firstName, lastName, birthDate.toString())?.toDomain() + + fun newId(): String = UUID.randomUUID().toString() + + /** + * Legt einen neuen Patienten an oder aktualisiert einen bestehenden, je nachdem, ob unter + * [patient.id] bereits ein Datensatz existiert. `createdAt`/`updatedAt` werden zentral hier + * gepflegt, damit ViewModels das nicht selbst verwalten müssen. + */ + suspend fun upsert(patient: Patient): Patient { + val existing = patientDao.getById(patient.id) + val now = Instant.now() + val toPersist = if (existing == null) { + patient.copy(createdAt = now, updatedAt = now) + } else { + patient.copy(createdAt = Instant.ofEpochMilli(existing.createdAt), updatedAt = now) + } + if (existing == null) { + patientDao.insert(toPersist.toEntity()) + } else { + patientDao.update(toPersist.toEntity()) + } + return toPersist + } + + suspend fun hasDocumentations(patientId: String): Boolean = + patientDao.hasDocumentations(patientId) + + /** + * Löscht den Patienten. Der Aufrufer (ViewModel) sollte vorher [hasDocumentations] prüfen, + * um dem Nutzer eine verständliche Fehlermeldung statt einer rohen Datenbank-Exception zu + * zeigen (Aufgabenstellung Abschnitt 20). Die eigentliche Integrität wird zusätzlich durch + * `ON DELETE RESTRICT` in der Datenbank selbst garantiert (Architektur-Dokument Abschnitt 2.3f). + */ + suspend fun delete(patientId: String) { + val entity = patientDao.getById(patientId) ?: return + patientDao.delete(entity) + } + + private fun PatientWithLastDocumentation.toListItem(): PatientListItem = PatientListItem( + id = patient.id, + fullName = "${patient.firstName} ${patient.lastName}", + birthDate = LocalDate.parse(patient.birthDate), + address = patient.address, + lastDocumentationAt = lastDocumentationAt?.let { Instant.ofEpochMilli(it) }, + ) +} diff --git a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt new file mode 100644 index 0000000..315d3a8 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt @@ -0,0 +1,47 @@ +package de.medapp.wunddoku.di + +import android.content.Context +import de.medapp.wunddoku.data.database.AppDatabase +import de.medapp.wunddoku.data.repository.DocumentationRepository +import de.medapp.wunddoku.data.repository.PatientRepository +import de.medapp.wunddoku.media.InternalPhotoStorage +import de.medapp.wunddoku.media.PhotoStorage +import de.medapp.wunddoku.signature.InternalSignatureFileStorage +import de.medapp.wunddoku.signature.SignatureFileStorage + +/** + * Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories, + * eine Handvoll ViewModels, wenige Storage-/Exporter-Klassen) reicht eine von Hand geschriebene + * Objekt-Fabrik. Das spart eine zusätzliche Abhängigkeit samt Annotationsverarbeitung, die für + * einen Kotlin-Einsteiger zusätzliche "Magie" ohne unmittelbaren Lernwert wäre (Architektur- + * Dokument Abschnitt 13). Bei deutlichem Projektwachstum ist das später nachrüstbar. + * + * Alle Objekte hier sind bewusst `by lazy`: Sie werden erst beim ersten Zugriff erzeugt, nicht + * bereits beim App-Start – unnötig für eine Klasse, die ohnehin nur einmal pro Prozesslebenszeit + * instanziiert wird, aber ein günstiges, verbreitetes Kotlin-Idiom für "einmalig und träge + * initialisieren". + */ +class AppContainer(context: Context) { + + private val database: AppDatabase by lazy { AppDatabase.getInstance(context) } + + private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) } + private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) } + + val patientRepository: PatientRepository by lazy { + PatientRepository(patientDao = database.patientDao()) + } + + val documentationRepository: DocumentationRepository by lazy { + DocumentationRepository( + documentationDao = database.documentationDao(), + blockStateDao = database.blockStateDao(), + checkItemStateDao = database.checkItemStateDao(), + photoDao = database.photoDao(), + signatureDao = database.signatureDao(), + photoStorage = photoStorage, + signatureFileStorage = signatureFileStorage, + db = database, + ) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/di/ViewModelFactoryHelper.kt b/app/src/main/java/de/medapp/wunddoku/di/ViewModelFactoryHelper.kt new file mode 100644 index 0000000..5e75094 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/di/ViewModelFactoryHelper.kt @@ -0,0 +1,17 @@ +package de.medapp.wunddoku.di + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import de.medapp.wunddoku.WunddokuApplication + +/** + * Kleiner Helfer, um innerhalb eines `viewModelFactory { initializer { ... } }`-Blocks an das + * [AppContainer] der [WunddokuApplication] zu kommen, ohne Hilt/Koin. + * + * Kotlin-Hinweis: `CreationExtras` ist der von Compose/Activity bereitgestellte Kontext, aus + * dem sich über den Standard-Schlüssel `APPLICATION_KEY` die aktuelle `Application`-Instanz + * auslesen lässt – der von Google empfohlene, moderne Weg für manuelles ViewModel-DI ohne + * Hilt (`viewModelFactory { initializer { MeinViewModel(extras.wunddokuApplication().container...) } }`). + */ +fun CreationExtras.wunddokuApplication(): WunddokuApplication = + this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as WunddokuApplication diff --git a/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt b/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt new file mode 100644 index 0000000..33fcc7a --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt @@ -0,0 +1,73 @@ +package de.medapp.wunddoku.domain.catalog + +/** + * Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen. + * + * WICHTIG: Die hier verwendeten Inhalte sind ausschließlich neutrale Platzhalter + * (Vorgabe aus der Aufgabenstellung, Abschnitt 25: "Erfinde keine medizinischen Inhalte"). + * Die späteren echten Inhalte (ca. 20 fachliche Blöcke) werden ausschließlich in dieser + * Datei ergänzt – keine andere Stelle im Code (UI, Datenbank) muss dafür geändert werden. + * + * Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich + * nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument + * Abschnitt 2.3b. + * + * [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives + * Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke + * oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie + * Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die + * historische Korrektheit (das übernehmen die Label-/Titel-Snapshots). + */ +object DocumentationCatalog { + + const val CATALOG_VERSION: Int = 1 + + val blocks: List = listOf( + BlockDefinition( + id = "block_01", + title = "Block 1", + order = 1, + items = listOf( + CheckItemDefinition(id = "block_01_a", label = "Option A", order = 1), + CheckItemDefinition(id = "block_01_b", label = "Option B", order = 2), + ), + ), + BlockDefinition( + id = "block_02", + title = "Block 2", + order = 2, + items = listOf( + CheckItemDefinition(id = "block_02_a", label = "Option A", order = 1), + CheckItemDefinition(id = "block_02_b", label = "Option B", order = 2), + CheckItemDefinition(id = "block_02_c", label = "Option C", order = 3), + ), + ), + BlockDefinition( + id = "block_03", + title = "Block 3", + order = 3, + items = listOf( + CheckItemDefinition(id = "block_03_a", label = "Option A", order = 1), + CheckItemDefinition(id = "block_03_b", label = "Option B", order = 2), + ), + ), + // Weitere Blöcke (insgesamt ca. 20) werden hier ergänzt, sobald die fachlichen + // Inhalte geliefert werden. Struktur und Reihenfolge bleiben identisch. + ) + + fun findBlock(blockDefinitionId: String): BlockDefinition? = + blocks.firstOrNull { it.id == blockDefinitionId } +} + +data class BlockDefinition( + val id: String, + val title: String, + val order: Int, + val items: List, +) + +data class CheckItemDefinition( + val id: String, + val label: String, + val order: Int, +) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt new file mode 100644 index 0000000..713cf9a --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt @@ -0,0 +1,18 @@ +package de.medapp.wunddoku.domain.model + +/** + * Zustand einer einzelnen Checkbox innerhalb eines Dokumentationsblocks. + * + * [label] stammt aus dem zum Erstellungszeitpunkt der Dokumentation gültigen Snapshot + * (CheckItemState.labelSnapshot in der Datenbank), NICHT aus einer Laufzeit-Auflösung des + * aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Das stellt sicher, dass + * historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt + * dargestellt werden. Siehe Architektur-Dokument Abschnitt 2.4. + */ +data class CheckItem( + val checkItemStateId: String, + val definitionId: String, + val label: String, + val order: Int, + val checked: Boolean, +) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt new file mode 100644 index 0000000..0c09d97 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt @@ -0,0 +1,40 @@ +package de.medapp.wunddoku.domain.model + +import java.time.Instant + +/** + * Eine vollständige Wunddokumentation inklusive aller Blöcke, Fotos und Unterschriften. + * + * [continuedFromDocumentationId] verweist auf die Vorgänger-Dokumentation, falls diese + * Dokumentation durch "Fortsetzen" entstanden ist (Architektur-Dokument Abschnitt 6) – rein + * informatives Metadatum, keine fachliche Pflichtlogik in V1. + * + * [catalogVersion] ist ebenfalls rein informativ (z. B. für spätere Auswertungen). Die + * korrekte historische Darstellung hängt NICHT von diesem Feld ab, sondern ausschließlich von + * den Label-/Titel-Snapshots in [DocumentationBlock]/[CheckItem] (Abschnitt 2.4). + */ +data class Documentation( + val id: String, + val patientId: String, + val status: DocumentationStatus, + val createdAt: Instant, + val updatedAt: Instant, + val continuedFromDocumentationId: String?, + val catalogVersion: Int, + val blocks: List, + val photos: List, + val signatures: List, +) + +/** + * Schlanke Projektion für Listenansichten (Startscreen), ohne die vollständigen Blöcke zu + * laden – vermeidet unnötig teure Queries, wenn nur Übersichtsdaten benötigt werden. + */ +data class DocumentationSummary( + val id: String, + val patientId: String, + val patientFullName: String, + val status: DocumentationStatus, + val createdAt: Instant, + val updatedAt: Instant, +) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt new file mode 100644 index 0000000..922f43d --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt @@ -0,0 +1,26 @@ +package de.medapp.wunddoku.domain.model + +import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy + +/** + * Ein einzelner Dokumentationsblock innerhalb einer Wunddokumentation (ca. 20 pro + * Dokumentation). [title] stammt wie bei [CheckItem.label] aus dem Erstellungs-Snapshot, nicht + * aus einer Laufzeit-Auflösung des Katalogs. + * + * [isEdited] wird bewusst NICHT in der Datenbank gespeichert, sondern hier als abgeleitete + * Eigenschaft berechnet ("computed property", kein eigenes Feld im Konstruktor). Das + * verhindert, dass ein gespeichertes Flag jemals vom tatsächlichen Inhalt abweichen kann. + * [BlockEditedPolicy] ist die einzige Stelle, die diese Regel definiert – sowohl die UI + * (Fortschrittsanzeige) als auch der spätere PDF-Export verwenden ausschließlich diese + * Eigenschaft. Siehe Architektur-Dokument Abschnitt 2.3c. + */ +data class DocumentationBlock( + val blockStateId: String, + val definitionId: String, + val title: String, + val order: Int, + val items: List, + val freeText: String, +) { + val isEdited: Boolean get() = BlockEditedPolicy.isEdited(this) +} diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationStatus.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationStatus.kt new file mode 100644 index 0000000..601ed79 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationStatus.kt @@ -0,0 +1,15 @@ +package de.medapp.wunddoku.domain.model + +/** + * Status einer Wunddokumentation. + * + * Bewusst ein einfaches `enum class` statt `sealed interface`/`sealed class`: Die Zustände + * tragen aktuell keine zustandsspezifischen Zusatzdaten, sondern stellen nur eine endliche + * Auswahl dar. `sealed interface` würde hier keinen Vorteil bringen und nur unnötige + * Komplexität hinzufügen. Eine Erweiterung, z. B. um CANCELLED, ist mit einem Enum genauso + * einfach möglich. Siehe Architektur-Dokument Abschnitt 2.5. + */ +enum class DocumentationStatus { + IN_PROGRESS, + COMPLETED, +} diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt new file mode 100644 index 0000000..f71d0c6 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt @@ -0,0 +1,29 @@ +package de.medapp.wunddoku.domain.model + +import java.time.Instant +import java.time.LocalDate + +/** + * Fachliches Patientenmodell. Bewusst unabhängig von Room- und Compose-Typen, damit sich die + * Persistenz (Room-Entity) oder die UI unabhängig voneinander weiterentwickeln können. + * + * Vor- und Nachname werden getrennt gespeichert statt als ein "name"-Feld: Das ermöglicht + * separate Suche/Sortierung nach Nachnamen (in Kliniken/Pflege üblich, z. B. "Nachname, + * Vorname"-Listendarstellung) und ist die robustere Variante für spätere Erweiterungen + * (z. B. Anrede + Nachname in Dokumenten), ohne den Namen nachträglich wieder aufteilen zu + * müssen. + */ +data class Patient( + val id: String, + val firstName: String, + val lastName: String, + val birthDate: LocalDate, + val address: String, + val phone1: String, + val phone2: String?, + val phone3: String?, + val createdAt: Instant, + val updatedAt: Instant, +) { + val fullName: String get() = "$firstName $lastName" +} diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt new file mode 100644 index 0000000..9ff7370 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt @@ -0,0 +1,17 @@ +package de.medapp.wunddoku.domain.model + +import java.time.Instant +import java.time.LocalDate + +/** + * Schlanke Projektion für die Patientenliste (Architektur-Dokument Abschnitt 6), inklusive + * Datum der letzten Wunddokumentation. Getrennt vom vollständigen [Patient]-Modell, damit die + * Listen-Query nicht implizit an eine bestimmte Detaildarstellung gekoppelt ist. + */ +data class PatientListItem( + val id: String, + val fullName: String, + val birthDate: LocalDate, + val address: String, + val lastDocumentationAt: Instant?, +) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Photo.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Photo.kt new file mode 100644 index 0000000..f54f620 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Photo.kt @@ -0,0 +1,19 @@ +package de.medapp.wunddoku.domain.model + +import java.time.Instant + +/** + * Ein an eine Wunddokumentation angehängtes Foto. + * + * [fileName] ist ein relativer Pfad innerhalb des privaten internen App-Speichers + * (context.filesDir/photos/...), kein absoluter Pfad und keine Galerie-URI – die + * ausgewählten Bilder werden beim Import dauerhaft in den App-Speicher kopiert + * (Architektur-Dokument Abschnitt 11). + */ +data class Photo( + val id: String, + val documentationId: String, + val fileName: String, + val orderIndex: Int, + val createdAt: Instant, +) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt new file mode 100644 index 0000000..b825574 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt @@ -0,0 +1,19 @@ +package de.medapp.wunddoku.domain.model + +import java.time.Instant + +/** + * Eine digitale Unterschrift zu einer Wunddokumentation. + * + * Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit später + * mehrere Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können + * (1:n-Beziehung), ohne Migration. [fileName] verweist auf eine PNG-Datei im internen + * App-Speicher (kein BLOB in SQLite). Siehe Architektur-Dokument Abschnitt 11. + */ +data class Signature( + val id: String, + val documentationId: String, + val signerName: String?, + val fileName: String, + val createdAt: Instant, +) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt b/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt new file mode 100644 index 0000000..eb0ba07 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt @@ -0,0 +1,23 @@ +package de.medapp.wunddoku.domain.usecase + +import de.medapp.wunddoku.domain.model.DocumentationBlock + +/** + * Zentrale, einzige Definition davon, wann ein Dokumentationsblock als "bearbeitet" gilt. + * + * Diese Regel wird an zwei Stellen verwendet, die zwingend dieselbe Definition teilen müssen: + * - UI: Fortschrittsanzeige, welche Blöcke bereits ausgefüllt wurden + * - PDF-Export: nur bearbeitete Blöcke werden ins PDF aufgenommen (Aufgabenstellung Abschnitt 12) + * + * Bewusst als eigenständiges Objekt statt als private Logik in ViewModel/Exporter, damit beide + * Stellen garantiert dasselbe Ergebnis liefern und die Regel an einer einzigen Stelle geändert + * werden kann, falls sich die fachliche Definition von "bearbeitet" später ändert. + */ +object BlockEditedPolicy { + + fun isEdited(block: DocumentationBlock): Boolean { + val hasFreeText = block.freeText.isNotBlank() + val hasCheckedItem = block.items.any { it.checked } + return hasFreeText || hasCheckedItem + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt new file mode 100644 index 0000000..961a38e --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt @@ -0,0 +1,65 @@ +package de.medapp.wunddoku.media + +import android.content.Context +import android.net.Uri +import java.io.File +import java.util.UUID + +/** + * Verwaltet die physischen Bilddateien im internen App-Speicher (context.filesDir/photos/). + * + * WICHTIG (Phase-Hinweis): Diese Implementierung kopiert Bilder in Phase 2 zunächst + * unverändert (1:1-Kopie der ausgewählten Datei). Downsampling großer Smartphone-Fotos und + * EXIF-Rotation (Aufgabenstellung Abschnitt 10) werden erst in Phase 4 ergänzt, wenn der + * Photo Picker tatsächlich angebunden wird – das Interface ist aber bereits so geschnitten, + * dass diese Erweiterung [copyFrom] intern betrifft, ohne die Repository-Schicht zu ändern. + * + * Bewusst als Interface + Implementierung: erlaubt, in Unit-Tests eine Fake-Implementierung + * einzusetzen, ohne echtes Dateisystem anzufassen (Aufgabenstellung Abschnitt 21). + */ +interface PhotoStorage { + /** Kopiert das Bild von [sourceUri] in den internen Speicher und liefert den relativen Dateinamen. */ + suspend fun copyFrom(sourceUri: Uri): String + + /** + * Erstellt eine physisch eigenständige Kopie einer bereits gespeicherten Datei und liefert + * deren neuen Dateinamen. Wird beim "Fortsetzen" mit `copyPhotos = true` benötigt: Eine neue + * DB-Zeile, die auf dieselbe Datei wie das Original zeigt, wäre falsch – sonst würde das + * Löschen des Fotos in einer Dokumentation es auch in der anderen entfernen. + */ + suspend fun duplicate(fileName: String): String + + /** Löscht die physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */ + suspend fun delete(fileName: String) + + /** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen (z. B. zur Anzeige). */ + fun resolve(fileName: String): File +} + +class InternalPhotoStorage(private val context: Context) : PhotoStorage { + + private val photosDir: File by lazy { + File(context.filesDir, "photos").apply { mkdirs() } + } + + override suspend fun copyFrom(sourceUri: Uri): String { + val fileName = "${UUID.randomUUID()}.jpg" + val targetFile = File(photosDir, fileName) + context.contentResolver.openInputStream(sourceUri)?.use { input -> + targetFile.outputStream().use { output -> input.copyTo(output) } + } ?: error("Konnte ausgewähltes Bild nicht öffnen (Uri nicht mehr gültig?): $sourceUri") + return fileName + } + + override suspend fun duplicate(fileName: String): String { + val newFileName = "${UUID.randomUUID()}.jpg" + resolve(fileName).copyTo(File(photosDir, newFileName), overwrite = false) + return newFileName + } + + override suspend fun delete(fileName: String) { + resolve(fileName).delete() + } + + override fun resolve(fileName: String): File = File(photosDir, fileName) +} diff --git a/app/src/main/java/de/medapp/wunddoku/signature/SignatureFileStorage.kt b/app/src/main/java/de/medapp/wunddoku/signature/SignatureFileStorage.kt new file mode 100644 index 0000000..1a9af4a --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/signature/SignatureFileStorage.kt @@ -0,0 +1,40 @@ +package de.medapp.wunddoku.signature + +import android.content.Context +import android.graphics.Bitmap +import java.io.File +import java.io.FileOutputStream +import java.util.UUID + +/** + * Verwaltet die physischen Signatur-PNGs im internen App-Speicher + * (context.filesDir/signatures/). Das eigentliche Zeichnen (SignaturePad-Composable) folgt in + * Phase 4; diese Klasse übernimmt nur die Dateiverwaltung, damit DocumentationRepository + * bereits jetzt gegen eine stabile Schnittstelle arbeiten kann. + */ +interface SignatureFileStorage { + suspend fun save(bitmap: Bitmap): String + suspend fun delete(fileName: String) + fun resolve(fileName: String): File +} + +class InternalSignatureFileStorage(private val context: Context) : SignatureFileStorage { + + private val signaturesDir: File by lazy { + File(context.filesDir, "signatures").apply { mkdirs() } + } + + override suspend fun save(bitmap: Bitmap): String { + val fileName = "${UUID.randomUUID()}.png" + FileOutputStream(File(signaturesDir, fileName)).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + return fileName + } + + override suspend fun delete(fileName: String) { + resolve(fileName).delete() + } + + override fun resolve(fileName: String): File = File(signaturesDir, fileName) +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt new file mode 100644 index 0000000..ee370a7 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt @@ -0,0 +1,181 @@ +package de.medapp.wunddoku.ui.documentation.edit + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import de.medapp.wunddoku.R +import de.medapp.wunddoku.domain.model.CheckItem +import de.medapp.wunddoku.domain.model.DocumentationBlock + +/** + * Bearbeitungsansicht für eine Wunddokumentation (Aufgabenstellung Abschnitt 7 Schritt 2, + * Abschnitt 8). Zeigt in Phase 2 einen Block nach dem anderen mit Vor-/Zurück-Buttons und + * Positionsanzeige ("7 / 20"). Die von der Aufgabenstellung bevorzugte Swipe-Geste zwischen + * Blöcken sowie Fotos/Signatur/PDF-Export folgen in den Phasen 3–5. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocumentationEditScreen( + onFinished: () -> Unit, + viewModel: DocumentationEditViewModel = viewModel(factory = DocumentationEditViewModel.factory()), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren + // Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). Siehe Architektur-Dokument + // Abschnitt 9.2: KEINE Garantie bei hartem Prozessabbruch, aber sinnvolle zusätzliche + // Absicherung für den häufigen Fall. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + viewModel.flushPendingChanges() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + if (uiState.blockCount > 0) { + Text( + stringResource( + R.string.documentation_edit_position, + uiState.currentBlockIndex + 1, + uiState.blockCount, + ) + ) + } else { + Text("") + } + }, + actions = { + // PDF-Export/Unterschrift folgen in Phase 4/5. "Fertig" beendet die + // Bearbeitung für V1 bereits jetzt (Inhalte sind ohnehin per Autosave + // gesichert, kein expliziter Speichern-Button nötig). + Button(onClick = onFinished) { Text("Fertig") } + }, + ) + }, + ) { padding -> + when { + uiState.isLoading -> Column( + modifier = Modifier.fillMaxSize().padding(padding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { CircularProgressIndicator() } + + uiState.error != null -> Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), + ) { Text(uiState.error!!, color = MaterialTheme.colorScheme.error) } + + else -> { + val documentation = uiState.documentation + val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex) + if (documentation == null || currentBlock == null) { + Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) { + Text("Diese Dokumentation enthält keine Blöcke.") + } + } else { + DocumentationBlockContent( + modifier = Modifier.fillMaxSize().padding(padding), + block = currentBlock, + onCheckedChanged = viewModel::onCheckedChanged, + onFreeTextChanged = { text -> viewModel.onFreeTextChanged(currentBlock.blockStateId, text) }, + canGoBack = uiState.currentBlockIndex > 0, + canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1, + onPrevious = viewModel::onPreviousBlock, + onNext = viewModel::onNextBlock, + ) + } + } + } + } +} + +@Composable +private fun DocumentationBlockContent( + modifier: Modifier = Modifier, + block: DocumentationBlock, + onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, + onFreeTextChanged: (String) -> Unit, + canGoBack: Boolean, + canGoNext: Boolean, + onPrevious: () -> Unit, + onNext: () -> Unit, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(block.title, style = MaterialTheme.typography.headlineSmall) + + block.items.forEach { item -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged) } + + OutlinedTextField( + value = block.freeText, + onValueChange = onFreeTextChanged, + label = { Text(stringResource(R.string.documentation_edit_free_text_label)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton(onClick = onPrevious, enabled = canGoBack) { + Text(stringResource(R.string.documentation_edit_previous)) + } + Button(onClick = onNext, enabled = canGoNext) { + Text(stringResource(R.string.documentation_edit_next)) + } + } + } +} + +@Composable +private fun CheckItemRow( + item: CheckItem, + onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = item.checked, + onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) }, + ) + Text(item.label) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt new file mode 100644 index 0000000..f62e5be --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt @@ -0,0 +1,167 @@ +package de.medapp.wunddoku.ui.documentation.edit + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation.toRoute +import de.medapp.wunddoku.data.repository.DocumentationRepository +import de.medapp.wunddoku.di.wunddokuApplication +import de.medapp.wunddoku.domain.model.Documentation +import de.medapp.wunddoku.ui.navigation.Screen +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class DocumentationEditUiState( + val documentation: Documentation? = null, + val currentBlockIndex: Int = 0, + val isLoading: Boolean = true, + val error: String? = null, +) { + val blockCount: Int get() = documentation?.blocks?.size ?: 0 +} + +/** + * Hält den kompletten Bearbeitungszustand einer Wunddokumentation und setzt die Autosave- + * Strategie aus dem Architektur-Dokument (Abschnitt 9) um: + * - Checkbox-Änderungen: sofort persistiert + * - Freitext-Änderungen: UI sofort aktualisiert, Persistierung über 300ms Debounce + * + * WICHTIG (siehe Architektur-Dokument Abschnitt 9.2): `onCleared()` wird bewusst NICHT als + * Garantie für das Flushen ausstehender Änderungen verwendet, da Android dessen Aufruf bei + * einem harten Prozessabbruch nicht garantiert. [flushPendingChanges] wird stattdessen vom + * Screen aus einem `Lifecycle.Event.ON_STOP`-Observer aufgerufen – das deckt den häufigen Fall + * (App gewechselt, Bildschirm gesperrt) ab, ist aber ausdrücklich keine Garantie für den + * seltenen Fall eines sofortigen Systemkills. + */ +class DocumentationEditViewModel( + private val documentationId: String, + private val repository: DocumentationRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(DocumentationEditUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val freeTextChanges = MutableSharedFlow>(extraBufferCapacity = 64) + + /** Letzter noch nicht persistierter Freitext-Wert je BlockState-ID, für [flushPendingChanges]. */ + private val pendingFreeText = mutableMapOf() + + init { + viewModelScope.launch { loadDocumentation() } + observeFreeTextChanges() + } + + private suspend fun loadDocumentation() { + _uiState.update { it.copy(isLoading = true, error = null) } + val documentation = repository.getFullDocumentation(documentationId) + _uiState.update { + if (documentation != null) { + it.copy(documentation = documentation, isLoading = false) + } else { + it.copy(isLoading = false, error = "Dokumentation nicht gefunden.") + } + } + } + + @OptIn(FlowPreview::class) + private fun observeFreeTextChanges() { + viewModelScope.launch { + freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (blockStateId, text) -> + persistFreeText(blockStateId, text) + } + } + } + + private suspend fun persistFreeText(blockStateId: String, text: String) { + repository.updateFreeText(blockStateId, text) + // Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist. + if (pendingFreeText[blockStateId] == text) pendingFreeText.remove(blockStateId) + } + + fun onFreeTextChanged(blockStateId: String, text: String) { + updateLocalFreeText(blockStateId, text) + pendingFreeText[blockStateId] = text + freeTextChanges.tryEmit(blockStateId to text) + } + + private fun updateLocalFreeText(blockStateId: String, text: String) { + _uiState.update { state -> + val documentation = state.documentation ?: return@update state + val updatedBlocks = documentation.blocks.map { block -> + if (block.blockStateId == blockStateId) block.copy(freeText = text) else block + } + state.copy(documentation = documentation.copy(blocks = updatedBlocks)) + } + } + + fun onCheckedChanged(checkItemStateId: String, checked: Boolean) { + // 1) UI sofort aktualisieren + _uiState.update { state -> + val documentation = state.documentation ?: return@update state + val updatedBlocks = documentation.blocks.map { block -> + val updatedItems = block.items.map { item -> + if (item.checkItemStateId == checkItemStateId) item.copy(checked = checked) else item + } + block.copy(items = updatedItems) + } + state.copy(documentation = documentation.copy(blocks = updatedBlocks)) + } + // 2) sofort persistieren (kein Debounce nötig, einzelnes günstiges UPDATE) + viewModelScope.launch { repository.setChecked(checkItemStateId, checked) } + } + + fun onNextBlock() { + _uiState.update { state -> + val lastIndex = state.blockCount - 1 + if (lastIndex < 0) return@update state + state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex)) + } + } + + fun onPreviousBlock() { + _uiState.update { state -> + state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0)) + } + } + + /** + * Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom + * Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei + * hartem Prozessabbruch. + */ + fun flushPendingChanges() { + if (pendingFreeText.isEmpty()) return + val toFlush = pendingFreeText.toMap() + pendingFreeText.clear() + viewModelScope.launch { + toFlush.forEach { (blockStateId, text) -> repository.updateFreeText(blockStateId, text) } + } + } + + companion object { + /** Debounce-Fenster für Freitext-Autosave. Siehe Architektur-Dokument Abschnitt 9.3: + * bewusst 300ms statt 500ms, da bei dieser kleinen lokalen SQLite-Datenbank Robustheit + * gegenüber reiner Schreibersparnis priorisiert werden darf. */ + private const val FREE_TEXT_DEBOUNCE_MS = 300L + + fun factory() = viewModelFactory { + initializer { + val container = wunddokuApplication().container + val savedStateHandle = createSavedStateHandle() + val route = savedStateHandle.toRoute() + DocumentationEditViewModel( + documentationId = route.documentationId, + repository = container.documentationRepository, + ) + } + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListScreen.kt new file mode 100644 index 0000000..ed68a57 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListScreen.kt @@ -0,0 +1,186 @@ +package de.medapp.wunddoku.ui.documentation.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.material.icons.filled.People +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import de.medapp.wunddoku.R +import de.medapp.wunddoku.domain.model.DocumentationStatus +import de.medapp.wunddoku.domain.model.DocumentationSummary +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId + +private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + +/** + * Startscreen der App (Aufgabenstellung Abschnitt 5): Liste aller Wunddokumentationen, + * durchsuchbar, mit den Aktionen Bearbeiten/Fortsetzen/Löschen (mit Bestätigung). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocumentationListScreen( + onOpenDocumentation: (documentationId: String) -> Unit, + onNewDocumentation: () -> Unit, + onOpenPatients: () -> Unit, + viewModel: DocumentationListViewModel = viewModel(factory = DocumentationListViewModel.factory()), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var pendingDeleteId by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.documentation_list_title)) }, + actions = { + IconButton(onClick = onOpenPatients) { + Icon(Icons.Filled.People, contentDescription = stringResource(R.string.patient_list_title)) + } + }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton(onClick = onNewDocumentation) { + Icon(Icons.Filled.Add, contentDescription = null) + Text(stringResource(R.string.documentation_list_new)) + } + }, + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + OutlinedTextField( + value = uiState.searchQuery, + onValueChange = viewModel::onSearchQueryChanged, + label = { Text(stringResource(R.string.documentation_list_search_hint)) }, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) + + if (uiState.items.isEmpty()) { + Text( + text = stringResource(R.string.documentation_list_empty), + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(uiState.items, key = { it.id }) { item -> + DocumentationListItemCard( + item = item, + onOpen = { onOpenDocumentation(item.id) }, + onContinue = { + viewModel.continueDocumentation(item.id) { newId -> onOpenDocumentation(newId) } + }, + onDeleteRequested = { pendingDeleteId = item.id }, + ) + } + } + } + } + } + + val deleteId = pendingDeleteId + if (deleteId != null) { + AlertDialog( + onDismissRequest = { pendingDeleteId = null }, + title = { Text(stringResource(R.string.documentation_delete_confirm_title)) }, + text = { Text(stringResource(R.string.documentation_delete_confirm_text)) }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteDocumentation(deleteId) + pendingDeleteId = null + }) { Text(stringResource(R.string.action_confirm)) } + }, + dismissButton = { + TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DocumentationListItemCard( + item: DocumentationSummary, + onOpen: () -> Unit, + onContinue: () -> Unit, + onDeleteRequested: () -> Unit, +) { + var menuExpanded by remember { mutableStateOf(false) } + + Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text(item.patientFullName, style = MaterialTheme.typography.titleMedium) + Text( + "Erstellt: ${item.createdAt.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}", + style = MaterialTheme.typography.bodySmall, + ) + if (item.updatedAt != item.createdAt) { + Text( + "Geändert: ${item.updatedAt.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}", + style = MaterialTheme.typography.bodySmall, + ) + } + if (item.status == DocumentationStatus.COMPLETED) { + Text("Abgeschlossen", style = MaterialTheme.typography.labelSmall) + } + } + + Column { + IconButton(onClick = { menuExpanded = true }) { + Text("⋮") + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.documentation_action_edit)) }, + onClick = { menuExpanded = false; onOpen() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.documentation_action_continue)) }, + onClick = { menuExpanded = false; onContinue() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.documentation_action_delete)) }, + onClick = { menuExpanded = false; onDeleteRequested() }, + ) + } + } + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListViewModel.kt new file mode 100644 index 0000000..03d4c47 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListViewModel.kt @@ -0,0 +1,74 @@ +package de.medapp.wunddoku.ui.documentation.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import de.medapp.wunddoku.data.repository.DocumentationRepository +import de.medapp.wunddoku.di.wunddokuApplication +import de.medapp.wunddoku.domain.model.DocumentationSummary +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class DocumentationListUiState( + val items: List = emptyList(), + val searchQuery: String = "", +) + +/** + * Kotlin-Hinweis zu `StateFlow`/`Flow`/`flatMapLatest`: [searchQuery] ist der Eingabetext, aus + * dem wir per `flatMapLatest` jeweils den passenden Datenbank-`Flow` ableiten (entweder "alle" + * oder "gefiltert nach Suchtext") – ändert sich der Suchtext erneut, während die vorherige + * Datenbankabfrage noch läuft, bricht `flatMapLatest` diese automatisch ab und wechselt zur + * neuen. Das verhindert veraltete Suchergebnisse ohne manuelles Abbrechen von Coroutines. + */ +class DocumentationListViewModel( + private val repository: DocumentationRepository, +) : ViewModel() { + + private val searchQuery = MutableStateFlow("") + + @OptIn(ExperimentalCoroutinesApi::class) + private val items = searchQuery.flatMapLatest { query -> + if (query.isBlank()) repository.observeAll() else repository.search(query) + } + + val uiState: StateFlow = combine(items, searchQuery) { list, query -> + DocumentationListUiState(items = list, searchQuery = query) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = DocumentationListUiState(), + ) + + fun onSearchQueryChanged(query: String) { + searchQuery.value = query + } + + fun deleteDocumentation(documentationId: String) { + viewModelScope.launch { repository.delete(documentationId) } + } + + fun continueDocumentation(documentationId: String, onCreated: (newDocumentationId: String) -> Unit) { + viewModelScope.launch { + val newDocumentation = repository.continueDocumentation(documentationId) + onCreated(newDocumentation.id) + } + } + + companion object { + fun factory() = viewModelFactory { + initializer { + val container = wunddokuApplication().container + DocumentationListViewModel(container.documentationRepository) + } + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/navigation/NavGraph.kt b/app/src/main/java/de/medapp/wunddoku/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..5b02e1c --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/navigation/NavGraph.kt @@ -0,0 +1,72 @@ +package de.medapp.wunddoku.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import de.medapp.wunddoku.ui.documentation.edit.DocumentationEditScreen +import de.medapp.wunddoku.ui.documentation.list.DocumentationListScreen +import de.medapp.wunddoku.ui.patients.form.PatientFormScreen +import de.medapp.wunddoku.ui.patients.list.PatientListScreen + +/** + * Navigations-Graph gemäß Architektur-Dokument Abschnitt 8: + * + * DocumentationList -> PatientForm(null) -> DocumentationEdit(neue Id) + * DocumentationList -> PatientList -> PatientForm(id) / DocumentationEdit (bearbeiten/fortsetzen) + * DocumentationEdit -> zurück zu DocumentationList (nach Abschluss/PDF) + */ +@Composable +fun WunddokuNavGraph() { + val navController = rememberNavController() + + NavHost(navController = navController, startDestination = Screen.DocumentationList) { + + composable { + DocumentationListScreen( + onOpenDocumentation = { id -> navController.navigate(Screen.DocumentationEdit(id)) }, + onNewDocumentation = { + navController.navigate(Screen.PatientForm(patientId = null, startsNewDocumentation = true)) + }, + onOpenPatients = { navController.navigate(Screen.PatientList) }, + ) + } + + composable { + PatientListScreen( + onEditPatient = { id -> + navController.navigate(Screen.PatientForm(patientId = id, startsNewDocumentation = false)) + }, + onNewPatient = { + navController.navigate(Screen.PatientForm(patientId = null, startsNewDocumentation = false)) + }, + onOpenDocumentation = { documentationId -> navController.navigate(Screen.DocumentationEdit(documentationId)) }, + onBack = { navController.popBackStack() }, + ) + } + + composable { + PatientFormScreen( + onPatientSavedOnly = { navController.popBackStack() }, + onDocumentationCreated = { documentationId -> + // Das Formular wird aus dem Back-Stack entfernt, damit "Zurück" aus der + // Dokumentation nicht wieder im Formular landet. + navController.navigate(Screen.DocumentationEdit(documentationId)) { + popUpTo { inclusive = false } + } + }, + onCancel = { navController.popBackStack() }, + ) + } + + composable { + DocumentationEditScreen( + onFinished = { + navController.navigate(Screen.DocumentationList) { + popUpTo { inclusive = true } + } + }, + ) + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/navigation/Screen.kt b/app/src/main/java/de/medapp/wunddoku/ui/navigation/Screen.kt new file mode 100644 index 0000000..4bcd252 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/navigation/Screen.kt @@ -0,0 +1,41 @@ +package de.medapp.wunddoku.ui.navigation + +import kotlinx.serialization.Serializable + +/** + * Typsichere Navigations-Routen (aktueller Standard von Navigation Compose seit 2.8, statt + * loser String-Routen mit manuellem Argument-Parsing). Tippfehler in Routen-Namen fallen damit + * bereits beim Kompilieren auf statt erst zur Laufzeit. + * + * Kotlin-Hinweis: `sealed interface` fasst die möglichen Bildschirme zu einer geschlossenen, + * dem Compiler bekannten Menge zusammen (siehe auch Erläuterung bei DocumentationStatus, wo + * bewusst KEIN sealed interface verwendet wird, weil dort keine zustandsspezifischen Daten + * nötig sind) – hier tragen die Varianten dagegen unterschiedliche, jeweils eigene Argumente + * (z. B. `documentationId`), was `sealed interface` hier zur passenden Wahl macht. + * `@Serializable` (kotlinx.serialization) ist die Voraussetzung dafür, dass Navigation Compose + * eine Route-Instanz automatisch in einen Navigations-Zustand kodieren/dekodieren kann. + */ +sealed interface Screen { + + @Serializable + data object DocumentationList : Screen + + @Serializable + data object PatientList : Screen + + /** + * [startsNewDocumentation] unterscheidet zwei fachlich unterschiedliche Abläufe, die sich + * dasselbe Formular teilen (Aufgabenstellung Abschnitt 7 Schritt 1 vs. Abschnitt 6 + * "Patient bearbeiten"): + * - `true`: Formular ist Schritt 1 einer neuen Wunddokumentation ("Neu" auf der + * Dokumentationsliste). Nach dem Speichern/Duplikat-Abgleich wird direkt eine neue, + * leere Dokumentation angelegt und geöffnet. + * - `false`: reines Bearbeiten der Patienten-Stammdaten (aus der Patientenliste). Nach dem + * Speichern geht es zurück zur Patientenliste, ohne eine Dokumentation anzulegen. + */ + @Serializable + data class PatientForm(val patientId: String? = null, val startsNewDocumentation: Boolean = false) : Screen + + @Serializable + data class DocumentationEdit(val documentationId: String) : Screen +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt new file mode 100644 index 0000000..727711a --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt @@ -0,0 +1,179 @@ +package de.medapp.wunddoku.ui.patients.form + +import androidx.compose.foundation.layout.Arrangement +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import de.medapp.wunddoku.R + +/** + * Formular für Schritt 1 einer neuen Wunddokumentation (Aufgabenstellung Abschnitt 7) UND für + * das reine Bearbeiten von Patienten-Stammdaten (Abschnitt 6) – unterschieden über + * [PatientFormViewModel.startsNewDocumentation], s. Kommentar dort und bei [de.medapp.wunddoku.ui.navigation.Screen.PatientForm]. + * + * Hinweis: Das Geburtsdatum wird in Phase 2 noch als einfaches Textfeld (Format JJJJ-MM-TT) + * erfasst. Ein Material-3-DatePicker-Dialog für bessere Eingabe-UX ist eine sinnvolle + * Verbesserung für eine spätere Phase, aber kein Blocker für das Grundgerüst. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PatientFormScreen( + onPatientSavedOnly: () -> Unit, + onDocumentationCreated: (documentationId: String) -> Unit, + onCancel: () -> Unit, + viewModel: PatientFormViewModel = viewModel(factory = PatientFormViewModel.factory()), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.patientSaved) { + if (uiState.patientSaved) onPatientSavedOnly() + } + LaunchedEffect(uiState.createdDocumentationId) { + uiState.createdDocumentationId?.let { onDocumentationCreated(it) } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + stringResource( + if (uiState.isEditing) R.string.patient_form_title_edit else R.string.patient_form_title_new + ) + ) + }, + navigationIcon = { + IconButton(onClick = onCancel) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + }, + ) { padding -> + if (uiState.isLoading) { + Column( + modifier = Modifier.fillMaxSize().padding(padding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } + return@Scaffold + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = uiState.firstName, + onValueChange = viewModel::onFirstNameChanged, + label = { Text(stringResource(R.string.patient_form_first_name)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = uiState.lastName, + onValueChange = viewModel::onLastNameChanged, + label = { Text(stringResource(R.string.patient_form_last_name)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = uiState.birthDateText, + onValueChange = viewModel::onBirthDateTextChanged, + label = { Text(stringResource(R.string.patient_form_birth_date)) }, + placeholder = { Text("JJJJ-MM-TT") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = uiState.address, + onValueChange = viewModel::onAddressChanged, + label = { Text(stringResource(R.string.patient_form_address)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = uiState.phone1, + onValueChange = viewModel::onPhone1Changed, + label = { Text(stringResource(R.string.patient_form_phone1)) }, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = uiState.phone2, + onValueChange = viewModel::onPhone2Changed, + label = { Text(stringResource(R.string.patient_form_phone2)) }, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = uiState.phone3, + onValueChange = viewModel::onPhone3Changed, + label = { Text(stringResource(R.string.patient_form_phone3)) }, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier.fillMaxWidth(), + ) + + uiState.validationError?.let { error -> + Text(error, color = androidx.compose.material3.MaterialTheme.colorScheme.error) + } + + Button(onClick = { viewModel.save() }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.patient_form_save)) + } + } + } + + val duplicate = uiState.duplicateWarning + if (duplicate != null) { + AlertDialog( + onDismissRequest = viewModel::dismissDuplicateWarning, + title = { Text("Patient existiert bereits") }, + text = { + Text( + "Es existiert bereits ein Patient mit diesem Namen und Geburtsdatum " + + "(${duplicate.fullName}, ${duplicate.birthDate}). Möchtest du diesen " + + "vorhandenen Patienten verwenden, oder trotzdem einen neuen Datensatz anlegen?" + ) + }, + confirmButton = { + TextButton(onClick = viewModel::useExistingPatient) { + Text("Vorhandenen verwenden") + } + }, + dismissButton = { + TextButton(onClick = viewModel::createAnyway) { + Text("Trotzdem neu anlegen") + } + }, + ) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt new file mode 100644 index 0000000..8f27c1d --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt @@ -0,0 +1,184 @@ +package de.medapp.wunddoku.ui.patients.form + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation.toRoute +import de.medapp.wunddoku.data.repository.DocumentationRepository +import de.medapp.wunddoku.data.repository.PatientRepository +import de.medapp.wunddoku.di.wunddokuApplication +import de.medapp.wunddoku.domain.model.Patient +import de.medapp.wunddoku.ui.navigation.Screen +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate + +data class PatientFormUiState( + val isEditing: Boolean = false, + val isLoading: Boolean = true, + val firstName: String = "", + val lastName: String = "", + /** Roh-Text des Eingabefelds; Format JJJJ-MM-TT. Parsen erfolgt beim Speichern. */ + val birthDateText: String = "", + val address: String = "", + val phone1: String = "", + val phone2: String = "", + val phone3: String = "", + val validationError: String? = null, + /** Vorhandener Patient mit identischen Stammdaten, sofern beim Speichern gefunden. */ + val duplicateWarning: Patient? = null, + /** != null, wenn reines Bearbeiten (startsNewDocumentation = false) abgeschlossen ist -> zurück zur Liste. */ + val patientSaved: Boolean = false, + /** != null, sobald eine neue Dokumentation angelegt wurde -> Navigation zu DocumentationEdit. */ + val createdDocumentationId: String? = null, +) + +/** + * Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab + * (Aufgabenstellung Abschnitt 7 Schritt 1, Abschnitt 6). + */ +class PatientFormViewModel( + private val patientId: String?, + private val startsNewDocumentation: Boolean, + private val repository: PatientRepository, + private val documentationRepository: DocumentationRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(PatientFormUiState(isEditing = patientId != null)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + if (patientId != null) { + viewModelScope.launch { + val patient = repository.getById(patientId) + _uiState.update { + if (patient != null) { + it.copy( + isLoading = false, + firstName = patient.firstName, + lastName = patient.lastName, + birthDateText = patient.birthDate.toString(), + address = patient.address, + phone1 = patient.phone1, + phone2 = patient.phone2.orEmpty(), + phone3 = patient.phone3.orEmpty(), + ) + } else { + it.copy(isLoading = false, validationError = "Patient nicht gefunden.") + } + } + } + } else { + _uiState.update { it.copy(isLoading = false) } + } + } + + fun onFirstNameChanged(value: String) = _uiState.update { it.copy(firstName = value) } + fun onLastNameChanged(value: String) = _uiState.update { it.copy(lastName = value) } + fun onBirthDateTextChanged(value: String) = _uiState.update { it.copy(birthDateText = value) } + fun onAddressChanged(value: String) = _uiState.update { it.copy(address = value) } + fun onPhone1Changed(value: String) = _uiState.update { it.copy(phone1 = value) } + fun onPhone2Changed(value: String) = _uiState.update { it.copy(phone2 = value) } + fun onPhone3Changed(value: String) = _uiState.update { it.copy(phone3 = value) } + + fun dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) } + + /** Der Nutzer entscheidet sich im Duplikat-Dialog, den bereits vorhandenen Patienten zu verwenden. */ + fun useExistingPatient() { + val existing = _uiState.value.duplicateWarning ?: return + _uiState.update { it.copy(duplicateWarning = null) } + viewModelScope.launch { finish(existing.id) } + } + + /** Der Nutzer entscheidet sich im Duplikat-Dialog, trotzdem einen neuen Patienten anzulegen. */ + fun createAnyway() { + _uiState.update { it.copy(duplicateWarning = null) } + save(skipDuplicateCheck = true) + } + + fun save(skipDuplicateCheck: Boolean = false) { + val state = _uiState.value + + val birthDate = try { + LocalDate.parse(state.birthDateText.trim()) + } catch (e: Exception) { + _uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum im Format JJJJ-MM-TT eingeben.") } + return + } + if (state.firstName.isBlank() || state.lastName.isBlank()) { + _uiState.update { it.copy(validationError = "Vor- und Nachname dürfen nicht leer sein.") } + return + } + if (state.address.isBlank() || state.phone1.isBlank()) { + _uiState.update { it.copy(validationError = "Adresse und Telefonnummer 1 dürfen nicht leer sein.") } + return + } + + viewModelScope.launch { + if (!state.isEditing && !skipDuplicateCheck) { + val existing = repository.findExisting(state.firstName.trim(), state.lastName.trim(), birthDate) + if (existing != null) { + _uiState.update { it.copy(duplicateWarning = existing, validationError = null) } + return@launch + } + } + + val patient = Patient( + id = patientId ?: repository.newId(), + firstName = state.firstName.trim(), + lastName = state.lastName.trim(), + birthDate = birthDate, + address = state.address.trim(), + phone1 = state.phone1.trim(), + phone2 = state.phone2.trim().ifBlank { null }, + phone3 = state.phone3.trim().ifBlank { null }, + createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt + updatedAt = Instant.EPOCH, + ) + val saved = repository.upsert(patient) + _uiState.update { it.copy(validationError = null) } + finish(saved.id) + } + } + + /** + * Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu + * gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine + * neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder + * nur signalisieren, dass gespeichert wurde (zurück zur Patientenliste). + */ + private suspend fun finish(resolvedPatientId: String) { + if (startsNewDocumentation) { + val documentation = documentationRepository.createEmpty(resolvedPatientId) + _uiState.update { it.copy(createdDocumentationId = documentation.id) } + } else { + _uiState.update { it.copy(patientSaved = true) } + } + } + + companion object { + fun factory() = viewModelFactory { + initializer { + val container = wunddokuApplication().container + // createSavedStateHandle() ist die von Google empfohlene Art, innerhalb eines + // viewModelFactory-Initializers an das SavedStateHandle zu kommen; toRoute() + // dekodiert daraus direkt die typsichere Navigations-Route (Screen.PatientForm), + // statt Argumente manuell als lose Strings auszulesen. + val savedStateHandle = createSavedStateHandle() + val route = savedStateHandle.toRoute() + PatientFormViewModel( + patientId = route.patientId, + startsNewDocumentation = route.startsNewDocumentation, + repository = container.patientRepository, + documentationRepository = container.documentationRepository, + ) + } + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt new file mode 100644 index 0000000..65617f9 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt @@ -0,0 +1,201 @@ +package de.medapp.wunddoku.ui.patients.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import de.medapp.wunddoku.R +import de.medapp.wunddoku.domain.model.PatientListItem +import kotlinx.coroutines.launch +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId + +private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + +/** Separate Patientenansicht (Aufgabenstellung Abschnitt 6). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PatientListScreen( + onEditPatient: (patientId: String) -> Unit, + onNewPatient: () -> Unit, + onOpenDocumentation: (documentationId: String) -> Unit, + onBack: () -> Unit, + viewModel: PatientListViewModel = viewModel(factory = PatientListViewModel.factory()), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.patient_list_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton(onClick = onNewPatient) { + Icon(Icons.Filled.Add, contentDescription = null) + Text(stringResource(R.string.patient_list_new)) + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + OutlinedTextField( + value = uiState.searchQuery, + onValueChange = viewModel::onSearchQueryChanged, + label = { Text(stringResource(R.string.patient_list_search_hint)) }, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) + + if (uiState.items.isEmpty()) { + Text( + text = stringResource(R.string.patient_list_empty), + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(uiState.items, key = { it.id }) { item -> + PatientListItemCard( + item = item, + onEdit = { onEditPatient(item.id) }, + onDeleteRequested = { viewModel.requestDelete(item.id) }, + onNewDocumentation = { + viewModel.createDocumentation(item.id) { docId -> onOpenDocumentation(docId) } + }, + onContinueDocumentation = { + viewModel.continueDocumentation(item.id) { docId -> + if (docId != null) { + onOpenDocumentation(docId) + } else { + coroutineScope.launch { + snackbarHostState.showSnackbar( + "Für diesen Patienten existiert noch keine Dokumentation zum Fortsetzen." + ) + } + } + } + }, + ) + } + } + } + } + } + + if (uiState.deleteBlockedForPatientId != null) { + AlertDialog( + onDismissRequest = viewModel::dismissDeleteBlockedDialog, + title = { Text(stringResource(R.string.patient_delete_blocked_title)) }, + text = { Text(stringResource(R.string.patient_delete_blocked_text)) }, + confirmButton = { + TextButton(onClick = viewModel::dismissDeleteBlockedDialog) { + Text(stringResource(R.string.action_confirm)) + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PatientListItemCard( + item: PatientListItem, + onEdit: () -> Unit, + onDeleteRequested: () -> Unit, + onNewDocumentation: () -> Unit, + onContinueDocumentation: () -> Unit, +) { + var menuExpanded by remember { mutableStateOf(false) } + + Card(onClick = onEdit, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text(item.fullName, style = MaterialTheme.typography.titleMedium) + Text( + "Geburtsdatum: ${item.birthDate.format(dateFormatter)}", + style = MaterialTheme.typography.bodySmall, + ) + Text(item.address, style = MaterialTheme.typography.bodySmall) + val lastDoc = item.lastDocumentationAt + if (lastDoc != null) { + Text( + "Letzte Dokumentation: ${lastDoc.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}", + style = MaterialTheme.typography.labelSmall, + ) + } + } + + Column { + IconButton(onClick = { menuExpanded = true }) { + Text("⋮") + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.documentation_action_edit)) }, + onClick = { menuExpanded = false; onEdit() }, + ) + DropdownMenuItem( + text = { Text("Neue Dokumentation") }, + onClick = { menuExpanded = false; onNewDocumentation() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.documentation_action_continue)) }, + onClick = { menuExpanded = false; onContinueDocumentation() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.documentation_action_delete)) }, + onClick = { menuExpanded = false; onDeleteRequested() }, + ) + } + } + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListViewModel.kt new file mode 100644 index 0000000..9d054ad --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListViewModel.kt @@ -0,0 +1,106 @@ +package de.medapp.wunddoku.ui.patients.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import de.medapp.wunddoku.data.repository.DocumentationRepository +import de.medapp.wunddoku.data.repository.PatientRepository +import de.medapp.wunddoku.di.wunddokuApplication +import de.medapp.wunddoku.domain.model.PatientListItem +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class PatientListUiState( + val items: List = emptyList(), + val searchQuery: String = "", + /** != null, solange der Bestätigungsdialog "Löschen nicht möglich" angezeigt werden soll. */ + val deleteBlockedForPatientId: String? = null, +) + +class PatientListViewModel( + private val repository: PatientRepository, + private val documentationRepository: DocumentationRepository, +) : ViewModel() { + + private val searchQuery = MutableStateFlow("") + private val deleteBlockedForPatientId = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + private val items = searchQuery.flatMapLatest { query -> + if (query.isBlank()) repository.observeAll() else repository.search(query) + } + + val uiState: StateFlow = + combine(items, searchQuery, deleteBlockedForPatientId) { list, query, blockedId -> + PatientListUiState(items = list, searchQuery = query, deleteBlockedForPatientId = blockedId) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = PatientListUiState(), + ) + + fun onSearchQueryChanged(query: String) { + searchQuery.value = query + } + + /** + * Löschversuch (Aufgabenstellung Abschnitt 6/20): Prüft zuerst, ob noch Dokumentationen zu + * diesem Patienten existieren, statt die Löschung blind zu versuchen und eine rohe + * Datenbank-Exception (ON DELETE RESTRICT) abfangen zu müssen. So kann eine verständliche + * Meldung angezeigt werden, statt eines technischen Fehlers. + */ + fun requestDelete(patientId: String) { + viewModelScope.launch { + if (repository.hasDocumentations(patientId)) { + deleteBlockedForPatientId.update { patientId } + } else { + repository.delete(patientId) + } + } + } + + fun dismissDeleteBlockedDialog() { + deleteBlockedForPatientId.value = null + } + + /** + * "neue Wunddokumentation für Patient erstellen" (Aufgabenstellung Abschnitt 6): Der + * Patient existiert bereits, es ist deshalb KEIN Stammdatenformular nötig – anders als beim + * Weg über die Dokumentationsliste ("Neu"), wo der Patient ggf. erst angelegt wird. + */ + fun createDocumentation(patientId: String, onCreated: (documentationId: String) -> Unit) { + viewModelScope.launch { + val documentation = documentationRepository.createEmpty(patientId) + onCreated(documentation.id) + } + } + + /** + * "Behandlung/Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 6). Liefert `null` an + * [onCreated], falls der Patient noch keine Dokumentation besitzt – die UI zeigt in diesem + * Fall einen verständlichen Hinweis statt eines technischen Fehlers. + */ + fun continueDocumentation(patientId: String, onCreated: (documentationId: String?) -> Unit) { + viewModelScope.launch { + val documentation = documentationRepository.continueLatestForPatient(patientId) + onCreated(documentation?.id) + } + } + + companion object { + fun factory() = viewModelFactory { + initializer { + val container = wunddokuApplication().container + PatientListViewModel(container.patientRepository, container.documentationRepository) + } + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/theme/Theme.kt b/app/src/main/java/de/medapp/wunddoku/ui/theme/Theme.kt new file mode 100644 index 0000000..f003920 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/theme/Theme.kt @@ -0,0 +1,40 @@ +package de.medapp.wunddoku.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val LightColors = lightColorScheme() +private val DarkColors = darkColorScheme() + +/** + * Bewusst schlichtes Material-3-Theme ohne individuelle Markenfarben – für dieses Projekt gab + * es keine gestalterische Vorgabe. Dynamic Color (aus dem Systemwallpaper abgeleitete Farben, + * ab Android 12) wird verwendet, wo verfügbar, sonst ein neutrales Standard-Farbschema. + */ +@Composable +fun WunddokuTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + + darkTheme -> DarkColors + else -> LightColors + } + + MaterialTheme( + colorScheme = colorScheme, + content = content, + ) +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..eb598ef --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..40fa44b --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #1B5E56 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..106d7de --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,40 @@ + + + VulneraDoc + + Wunddokumentationen + Patient oder Geburtsdatum suchen + Noch keine Wunddokumentationen vorhanden. + Neue Dokumentation + Bearbeiten + Fortsetzen + Löschen + Dokumentation löschen? + Diese Wunddokumentation inklusive aller Fotos und Unterschriften wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. + + Patienten + Name oder Geburtsdatum suchen + Noch keine Patienten vorhanden. + Neuer Patient + Löschen nicht möglich + Für diesen Patienten existieren noch Wunddokumentationen. Diese müssen zuerst gelöscht werden, bevor der Patient gelöscht werden kann. + + Neuer Patient + Patient bearbeiten + Vorname + Nachname + Geburtsdatum + Adresse + Telefonnummer 1 + Telefonnummer 2 (optional) + Telefonnummer 3 (optional) + Speichern + + %1$d / %2$d + Zurück + Weiter + Freitext + + Abbrechen + Bestätigen + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..013ce11 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + +