1616
1717package  com.google.android.fhir.datacapture.views.factories 
1818
19+ import  android.Manifest 
1920import  android.content.Context 
21+ import  android.content.pm.PackageManager 
2022import  android.net.Uri 
2123import  android.provider.OpenableColumns 
2224import  android.view.View 
2325import  android.widget.Button 
2426import  android.widget.ImageView 
2527import  android.widget.TextView 
28+ import  androidx.activity.compose.rememberLauncherForActivityResult 
29+ import  androidx.activity.result.contract.ActivityResultContracts 
2630import  androidx.annotation.DrawableRes 
2731import  androidx.annotation.StringRes 
2832import  androidx.appcompat.app.AppCompatActivity 
33+ import  androidx.compose.foundation.layout.Arrangement 
34+ import  androidx.compose.foundation.layout.Column 
35+ import  androidx.compose.foundation.layout.Row 
36+ import  androidx.compose.foundation.layout.Spacer 
37+ import  androidx.compose.foundation.layout.height 
38+ import  androidx.compose.foundation.layout.padding 
39+ import  androidx.compose.foundation.layout.size 
40+ import  androidx.compose.material3.HorizontalDivider 
41+ import  androidx.compose.material3.Icon 
42+ import  androidx.compose.material3.MaterialTheme 
43+ import  androidx.compose.material3.OutlinedButton 
44+ import  androidx.compose.material3.Text 
45+ import  androidx.compose.runtime.Composable 
46+ import  androidx.compose.runtime.getValue 
47+ import  androidx.compose.runtime.mutableStateOf 
48+ import  androidx.compose.runtime.remember 
49+ import  androidx.compose.runtime.rememberCoroutineScope 
50+ import  androidx.compose.runtime.setValue 
51+ import  androidx.compose.ui.Modifier 
52+ import  androidx.compose.ui.platform.LocalContext 
53+ import  androidx.compose.ui.res.dimensionResource 
54+ import  androidx.compose.ui.res.painterResource 
55+ import  androidx.compose.ui.res.stringResource 
56+ import  androidx.compose.ui.unit.dp 
2957import  androidx.constraintlayout.widget.ConstraintLayout 
58+ import  androidx.core.content.ContextCompat 
3059import  androidx.core.content.FileProvider 
3160import  androidx.core.os.bundleOf 
3261import  androidx.lifecycle.lifecycleScope 
3362import  com.bumptech.glide.Glide 
3463import  com.google.android.fhir.datacapture.R 
64+ import  com.google.android.fhir.datacapture.extensions.DEFAULT_SIZE 
3565import  com.google.android.fhir.datacapture.extensions.MimeType 
3666import  com.google.android.fhir.datacapture.extensions.hasMimeType 
3767import  com.google.android.fhir.datacapture.extensions.hasMimeTypeOnly 
3868import  com.google.android.fhir.datacapture.extensions.isGivenSizeOverLimit 
69+ import  com.google.android.fhir.datacapture.extensions.itemMedia 
70+ import  com.google.android.fhir.datacapture.extensions.maxSizeInBytes 
3971import  com.google.android.fhir.datacapture.extensions.maxSizeInMiBs 
4072import  com.google.android.fhir.datacapture.extensions.mimeTypes 
4173import  com.google.android.fhir.datacapture.extensions.tryUnwrapContext 
@@ -47,20 +79,25 @@ import com.google.android.fhir.datacapture.views.HeaderView
4779import  com.google.android.fhir.datacapture.views.QuestionnaireViewItem 
4880import  com.google.android.fhir.datacapture.views.attachment.CameraLauncherFragment 
4981import  com.google.android.fhir.datacapture.views.attachment.OpenDocumentLauncherFragment 
82+ import  com.google.android.fhir.datacapture.views.compose.ErrorText 
83+ import  com.google.android.fhir.datacapture.views.compose.Header 
84+ import  com.google.android.fhir.datacapture.views.compose.MediaItem 
5085import  com.google.android.material.divider.MaterialDivider 
5186import  com.google.android.material.snackbar.Snackbar 
5287import  java.io.File 
88+ import  java.math.BigDecimal 
5389import  java.util.Date 
90+ import  kotlinx.coroutines.Dispatchers 
5491import  kotlinx.coroutines.launch 
5592import  org.hl7.fhir.r4.model.Attachment 
5693import  org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent 
5794import  org.hl7.fhir.r4.model.QuestionnaireResponse 
95+ import  timber.log.Timber 
5896
59- internal  object  AttachmentViewHolderFactory :
60-   QuestionnaireItemAndroidViewHolderFactory (R .layout.attachment_view) {
97+ internal  object  AttachmentViewHolderFactory : QuestionnaireItemComposeViewHolderFactory {
6198  override  fun  getQuestionnaireItemViewHolderDelegate () = 
62-     object  :  QuestionnaireItemAndroidViewHolderDelegate  {
63-       override   lateinit  var  questionnaireViewItem:  QuestionnaireViewItem 
99+     object  :  QuestionnaireItemComposeViewHolderDelegate  {
100+       lateinit  var  questionnaireViewItem:  QuestionnaireViewItem 
64101      private  lateinit  var  header:  HeaderView 
65102      private  lateinit  var  errorTextView:  TextView 
66103      private  lateinit  var  takePhotoButton:  Button 
@@ -81,7 +118,136 @@ internal object AttachmentViewHolderFactory :
81118      private  lateinit  var  fileDeleteButton:  Button 
82119      private  lateinit  var  context:  AppCompatActivity 
83120
84-       override  fun  init (itemView :  View ) {
121+       @Composable
122+       override  fun  Content (questionnaireViewItem :  QuestionnaireViewItem ) {
123+         val  context =  LocalContext .current
124+         val  coroutineScope =  rememberCoroutineScope { Dispatchers .Main  }
125+         val  validationResult = 
126+           remember(questionnaireViewItem.validationResult) {
127+             questionnaireViewItem.validationResult
128+           }
129+         var  errorMessage by
130+           remember(validationResult) {
131+             mutableStateOf((validationResult as ?  Invalid )?.getSingleStringValidationMessage())
132+           }
133+         val  questionnaireItem = 
134+           remember(questionnaireViewItem.questionnaireItem) {
135+             questionnaireViewItem.questionnaireItem
136+           }
137+         val  readOnly =  remember(questionnaireItem) { questionnaireItem.readOnly }
138+         var  currentAttachment by remember(questionnaireViewItem.answers) {
139+           mutableStateOf(questionnaireViewItem.answers.singleOrNull()?.valueAttachment)
140+         }
141+         val  displayTakePhoto = 
142+           remember(questionnaireItem) { questionnaireItem.hasMimeType(MimeType .IMAGE .value) }
143+         val  uploadButtonTextResId = 
144+           remember(questionnaireItem) {
145+             when  {
146+               questionnaireItem.hasMimeTypeOnly(MimeType .AUDIO .value) ->  R .string.upload_audio
147+               questionnaireItem.hasMimeTypeOnly(MimeType .DOCUMENT .value) ->  R .string.upload_document
148+               questionnaireItem.hasMimeTypeOnly(MimeType .IMAGE .value) ->  R .string.upload_photo
149+               questionnaireItem.hasMimeTypeOnly(MimeType .VIDEO .value) ->  R .string.upload_video
150+               else  ->  R .string.upload_file
151+             }
152+           }
153+         val  uploadButtonIconResId = 
154+           remember(questionnaireItem) {
155+             when  {
156+               questionnaireItem.hasMimeTypeOnly(MimeType .AUDIO .value) ->  R .drawable.ic_audio_file
157+               questionnaireItem.hasMimeTypeOnly(MimeType .DOCUMENT .value) -> 
158+                 R .drawable.ic_document_file
159+               questionnaireItem.hasMimeTypeOnly(MimeType .IMAGE .value) ->  R .drawable.ic_image_file
160+               questionnaireItem.hasMimeTypeOnly(MimeType .VIDEO .value) ->  R .drawable.ic_video_file
161+               else  ->  R .drawable.ic_file
162+             }
163+           }
164+         var  displayUploadedText by remember(questionnaireViewItem) { mutableStateOf(false ) }
165+ 
166+         Column (
167+           modifier = 
168+             Modifier .padding(
169+               horizontal =  dimensionResource(R .dimen.item_margin_horizontal),
170+               vertical =  dimensionResource(R .dimen.item_margin_vertical),
171+             ),
172+         ) {
173+           Header (questionnaireViewItem, showRequiredOrOptionalText =  true )
174+           questionnaireViewItem.questionnaireItem.itemMedia?.let  { MediaItem (it) }
175+ 
176+           errorMessage?.takeIf  { it.isNotBlank() }?.let  { ErrorText (it) }
177+ 
178+           Row (
179+             modifier =  Modifier .padding(top =  dimensionResource(R .dimen.header_margin_bottom)),
180+             horizontalArrangement = 
181+               Arrangement .spacedBy(dimensionResource(R .dimen.attachment_action_button_margin_end)),
182+           ) {
183+             if  (displayTakePhoto) {
184+               TakePhotoButton (
185+                 context,
186+                 enabled =  ! readOnly,
187+                 maxFileSizeLimitInBytes =  questionnaireItem.maxSizeInBytes ? :  DEFAULT_SIZE ,
188+                 supportedMimeType =  questionnaireItem::hasMimeType,
189+                 onFailure =  {
190+                   errorMessage =  it
191+                 },
192+               ) { uri, file -> 
193+                 coroutineScope.launch {
194+                   val  attachmentMimeTypeWithSubType =  context.getMimeTypeFromUri(uri)
195+                   val  attachmentByteArray =  context.readBytesFromUri(uri)
196+                   currentAttachment =  Attachment ().apply  {
197+                     contentType =  attachmentMimeTypeWithSubType
198+                     data =  attachmentByteArray
199+                     title =  file.name
200+                     creation =  Date ()
201+                   }
202+ 
203+                   val  answer = 
204+                     QuestionnaireResponse .QuestionnaireResponseItemAnswerComponent ().apply  {
205+                       value =  currentAttachment
206+                     }
207+                   questionnaireViewItem.setAnswer(answer)
208+ 
209+                   displayUploadedText =  true 
210+                   //                       todo:
211+                   //                       divider.visibility = View.VISIBLE
212+                   //                       labelUploaded.visibility = View.VISIBLE
213+                   //                       displayPreview(
214+                   //                           attachmentType = attachmentMimeType,
215+                   //                           attachmentTitle = file.name,
216+                   //                           attachmentUri = attachmentUri,
217+                   //                       )
218+                   //                       displaySnackbarOnUpload(view, attachmentMimeType)
219+                   file.delete()
220+                 }
221+               }
222+             }
223+ 
224+             OutlinedButton (onClick =  {}, enabled =  ! readOnly) {
225+               Icon (
226+                 painterResource(uploadButtonIconResId),
227+                 contentDescription =  stringResource(uploadButtonTextResId),
228+                 modifier = 
229+                   Modifier .size(dimensionResource(R .dimen.attachment_action_button_icon_size)),
230+               )
231+               Spacer (modifier =  Modifier )
232+               Text (stringResource(uploadButtonTextResId))
233+             }
234+           }
235+ 
236+           if  (displayUploadedText) {
237+             Spacer (modifier =  Modifier .height(dimensionResource(R .dimen.attachment_divider_margin_top)))
238+             HorizontalDivider ()
239+             Spacer (modifier =  Modifier .height(dimensionResource(R .dimen.attachment_uploaded_label_margin_top)))
240+             Text (stringResource(R .string.uploaded),  style =  MaterialTheme .typography.titleSmall)
241+           }
242+ 
243+           currentAttachment?.let  {
244+             Spacer (modifier =  Modifier .height(8 .dp))
245+           }
246+ 
247+         }
248+       }
249+ 
250+       fun  init (itemView :  View ) {
85251        header =  itemView.findViewById(R .id.header)
86252        errorTextView =  itemView.findViewById(R .id.error)
87253        takePhotoButton =  itemView.findViewById(R .id.take_photo)
@@ -103,7 +269,7 @@ internal object AttachmentViewHolderFactory :
103269        context =  itemView.context.tryUnwrapContext()!! 
104270      }
105271
106-       override   fun  bind (questionnaireViewItem :  QuestionnaireViewItem ) {
272+       fun  bind (questionnaireViewItem :  QuestionnaireViewItem ) {
107273        this .questionnaireViewItem =  questionnaireViewItem
108274        header.bind(questionnaireViewItem, showRequiredOrOptionalText =  true )
109275        val  questionnaireItem =  questionnaireViewItem.questionnaireItem
@@ -132,7 +298,7 @@ internal object AttachmentViewHolderFactory :
132298        }
133299      }
134300
135-       override   fun  setReadOnly (isReadOnly :  Boolean ) {
301+       fun  setReadOnly (isReadOnly :  Boolean ) {
136302        takePhotoButton.isEnabled =  ! isReadOnly
137303        uploadPhotoButton.isEnabled =  ! isReadOnly
138304        uploadAudioButton.isEnabled =  ! isReadOnly
@@ -189,6 +355,92 @@ internal object AttachmentViewHolderFactory :
189355        }
190356      }
191357
358+       @Composable
359+       private  fun  TakePhotoButton (
360+         context :  Context ,
361+         enabled :  Boolean ,
362+         maxFileSizeLimitInBytes :  BigDecimal ,
363+         supportedMimeType :  (String ) ->  Boolean ,
364+         onFailure :  (String ) ->  Unit ,
365+         onSuccess :  (Uri , File ) ->  Unit ,
366+       ) {
367+         val  file =  File .createTempFile(" IMG_"  , " .jpeg"  , context.cacheDir)
368+         val  attachmentUri = 
369+           FileProvider .getUriForFile(context, " ${context.packageName} .fileprovider"  , file)
370+ 
371+         val  bytesInMB =  remember { BigDecimal (1048576 ) }
372+         val  mediaNotSavedError =  stringResource(R .string.media_not_saved_validation_error_msg)
373+         val  maxSizeImageLimitErrorMessage = 
374+           stringResource(
375+             R .string.max_size_image_above_limit_validation_error_msg,
376+             maxFileSizeLimitInBytes.div(bytesInMB),
377+           )
378+         val  wrongMediaFormatErrorMessage = 
379+           stringResource(R .string.mime_type_wrong_media_format_validation_error_msg)
380+ 
381+         val  takePictureLauncher = 
382+           rememberLauncherForActivityResult(
383+             contract =  ActivityResultContracts .TakePicture (),
384+             onResult =  { success -> 
385+               when  {
386+                 ! success ->  {
387+                   onFailure(mediaNotSavedError)
388+                   file.delete()
389+                 }
390+                 file.length().toBigDecimal() >  maxFileSizeLimitInBytes ->  {
391+                   onFailure(maxSizeImageLimitErrorMessage)
392+                   file.delete()
393+                 }
394+                 ! supportedMimeType(getMimeType(context.getMimeTypeFromUri(attachmentUri))) ->  {
395+                   onFailure(wrongMediaFormatErrorMessage)
396+                   file.delete()
397+                 }
398+                 else  ->  {
399+                   onSuccess(attachmentUri, file)
400+                 }
401+               }
402+             },
403+           )
404+ 
405+         val  requestPermissionLauncher = 
406+           rememberLauncherForActivityResult(
407+             ActivityResultContracts .RequestPermission (),
408+           ) { isGranted:  Boolean  -> 
409+             if  (isGranted) {
410+               Timber .d(" Camera permission granted"  )
411+               takePictureLauncher.launch(attachmentUri)
412+             } else  {
413+               Timber .d(" Camera permission not granted"  )
414+               onFailure(" Camera permission not granted"  )
415+             }
416+           }
417+ 
418+         val  launcherAction =  {
419+           if  (
420+             ContextCompat .checkSelfPermission(context, Manifest .permission.CAMERA ) == 
421+               PackageManager .PERMISSION_GRANTED 
422+           ) {
423+             takePictureLauncher.launch(attachmentUri)
424+           } else  {
425+             requestPermissionLauncher.launch(Manifest .permission.CAMERA )
426+           }
427+         }
428+ 
429+         OutlinedButton (
430+           onClick =  { launcherAction.invoke() },
431+           enabled =  enabled,
432+         ) {
433+           val  takePhotoText =  stringResource(R .string.take_photo)
434+           Icon (
435+             painterResource(R .drawable.ic_camera),
436+             contentDescription =  takePhotoText,
437+             modifier =  Modifier .size(dimensionResource(R .dimen.attachment_action_button_icon_size)),
438+           )
439+           Spacer (modifier =  Modifier )
440+           Text (takePhotoText)
441+         }
442+       }
443+ 
192444      private  fun  onTakePhotoClicked (view :  View , questionnaireItem :  QuestionnaireItemComponent ) {
193445        val  file =  File .createTempFile(" IMG_"  , " .jpeg"  , context.cacheDir)
194446        val  attachmentUri = 
0 commit comments