Skip to content

Commit 11447c4

Browse files
committed
Migrate AttachmentViewHolderFactory to compose (wip)
1 parent ab0e1e6 commit 11447c4

File tree

5 files changed

+715
-9
lines changed

5 files changed

+715
-9
lines changed

datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ internal val QuestionnaireItemComponent.maxSizeInMiBs: BigDecimal?
579579
get() = maxSizeInBytes?.div(BYTES_PER_MIB)
580580

581581
/** The default maximum size of an attachment is 1 Mebibytes. */
582-
private val DEFAULT_SIZE = BigDecimal(1048576)
582+
internal val DEFAULT_SIZE = BigDecimal(1048576)
583583

584584
/** Returns true if given size is above maximum size allowed. */
585585
internal fun QuestionnaireItemComponent.isGivenSizeOverLimit(

datacapture/src/main/java/com/google/android/fhir/datacapture/views/attachment/CameraLauncherFragment.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2022-2023 Google LLC
2+
* Copyright 2022-2025 Google LLC
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.

datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AttachmentViewHolderFactory.kt

Lines changed: 259 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,58 @@
1616

1717
package com.google.android.fhir.datacapture.views.factories
1818

19+
import android.Manifest
1920
import android.content.Context
21+
import android.content.pm.PackageManager
2022
import android.net.Uri
2123
import android.provider.OpenableColumns
2224
import android.view.View
2325
import android.widget.Button
2426
import android.widget.ImageView
2527
import android.widget.TextView
28+
import androidx.activity.compose.rememberLauncherForActivityResult
29+
import androidx.activity.result.contract.ActivityResultContracts
2630
import androidx.annotation.DrawableRes
2731
import androidx.annotation.StringRes
2832
import 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
2957
import androidx.constraintlayout.widget.ConstraintLayout
58+
import androidx.core.content.ContextCompat
3059
import androidx.core.content.FileProvider
3160
import androidx.core.os.bundleOf
3261
import androidx.lifecycle.lifecycleScope
3362
import com.bumptech.glide.Glide
3463
import com.google.android.fhir.datacapture.R
64+
import com.google.android.fhir.datacapture.extensions.DEFAULT_SIZE
3565
import com.google.android.fhir.datacapture.extensions.MimeType
3666
import com.google.android.fhir.datacapture.extensions.hasMimeType
3767
import com.google.android.fhir.datacapture.extensions.hasMimeTypeOnly
3868
import 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
3971
import com.google.android.fhir.datacapture.extensions.maxSizeInMiBs
4072
import com.google.android.fhir.datacapture.extensions.mimeTypes
4173
import com.google.android.fhir.datacapture.extensions.tryUnwrapContext
@@ -47,20 +79,25 @@ import com.google.android.fhir.datacapture.views.HeaderView
4779
import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
4880
import com.google.android.fhir.datacapture.views.attachment.CameraLauncherFragment
4981
import 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
5085
import com.google.android.material.divider.MaterialDivider
5186
import com.google.android.material.snackbar.Snackbar
5287
import java.io.File
88+
import java.math.BigDecimal
5389
import java.util.Date
90+
import kotlinx.coroutines.Dispatchers
5491
import kotlinx.coroutines.launch
5592
import org.hl7.fhir.r4.model.Attachment
5693
import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent
5794
import 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

Comments
 (0)