Skip to content

Commit 1d580a1

Browse files
committed
feat(feature:send-money): implement screen shot in payment details screen
1 parent c9fb26b commit 1d580a1

File tree

10 files changed

+540
-13
lines changed

10 files changed

+540
-13
lines changed

cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,11 @@ internal fun MifosNavHost(
354354
onRetryClick = {
355355
navController.popBackStack()
356356
},
357+
onShareScreenshot = {
358+
// Screenshot functionality is handled by the Android-specific PaymentDetailsScreen implementation
359+
// The actual screenshot and sharing is done within the screen itself
360+
// This callback is used by the Android-specific implementation to trigger the screenshot
361+
},
357362
)
358363

359364
payeeDetailsScreen(
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright 2024 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md
9+
*/
10+
package org.mifospay.feature.send.money
11+
12+
import android.app.Activity
13+
import androidx.compose.runtime.Composable
14+
import androidx.compose.runtime.DisposableEffect
15+
import androidx.compose.runtime.remember
16+
import androidx.compose.runtime.rememberCoroutineScope
17+
import androidx.compose.ui.Modifier
18+
import androidx.compose.ui.platform.LocalContext
19+
import co.touchlab.kermit.Logger
20+
import kotlinx.coroutines.launch
21+
import org.mifospay.core.ui.utils.ScreenshotContextDetails
22+
import org.mifospay.core.ui.utils.ShareUtils
23+
24+
/**
25+
* Android-specific implementation of PaymentDetailsScreen with screenshot functionality.
26+
*
27+
* This composable sets up the necessary providers for screenshot and sharing functionality
28+
* and delegates the UI rendering to the common implementation.
29+
*/
30+
@Composable
31+
actual fun PaymentDetailsScreen(
32+
onBackClick: () -> Unit,
33+
onPayAgainClick: () -> Unit,
34+
onRetryClick: () -> Unit,
35+
onShareScreenshot: () -> Unit,
36+
modifier: Modifier,
37+
viewModel: PaymentDetailsViewModel,
38+
transactionId: String,
39+
) {
40+
val context = LocalContext.current
41+
val activity = remember { context as? Activity }
42+
val coroutineScope = rememberCoroutineScope()
43+
44+
DisposableEffect(activity) {
45+
if (activity != null) {
46+
ShareUtils.setActivityProvider { activity }
47+
PaymentDetailsScreenshotUtils.setActivityProvider { activity }
48+
}
49+
onDispose { }
50+
}
51+
52+
val handleShareScreenshot = remember {
53+
{
54+
coroutineScope.launch {
55+
try {
56+
Logger.d("Taking payment details screenshot and sharing...")
57+
58+
val state = viewModel.stateFlow.value
59+
val contextDetails = ScreenshotContextDetails(
60+
screenType = "Payment Details",
61+
amount = state.formattedAmount,
62+
recipientName = state.payeeName,
63+
timestamp = state.transactionDate,
64+
customSuffix = "UPI",
65+
)
66+
67+
PaymentDetailsScreenshotUtils.takePaymentDetailsScreenshotAndShare(
68+
contextDetails = contextDetails,
69+
onError = { errorMessage ->
70+
Logger.e("Screenshot failed: $errorMessage")
71+
},
72+
)
73+
} catch (e: Exception) {
74+
Logger.e(e) { "Failed to take screenshot: ${e.message}" }
75+
}
76+
}
77+
Unit
78+
}
79+
}
80+
81+
PaymentDetailsScreenDefault(
82+
onBackClick = onBackClick,
83+
onPayAgainClick = onPayAgainClick,
84+
onRetryClick = onRetryClick,
85+
onShareScreenshot = handleShareScreenshot,
86+
modifier = modifier,
87+
viewModel = viewModel,
88+
transactionId = transactionId,
89+
)
90+
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/*
2+
* Copyright 2024 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md
9+
*/
10+
package org.mifospay.feature.send.money
11+
12+
import android.app.Activity
13+
import android.graphics.Bitmap
14+
import android.view.View
15+
import android.view.ViewGroup
16+
import androidx.core.view.drawToBitmap
17+
import co.touchlab.kermit.Logger
18+
import kotlinx.coroutines.Dispatchers
19+
import kotlinx.coroutines.withContext
20+
import org.mifospay.core.ui.utils.MimeType
21+
import org.mifospay.core.ui.utils.ScreenshotContextDetails
22+
import org.mifospay.core.ui.utils.ShareFileModel
23+
import org.mifospay.core.ui.utils.ShareUtils
24+
import java.io.ByteArrayOutputStream
25+
26+
// TODO Capture the whole content of this page
27+
// currently this captures only visible screen between topbar and share screenshot button
28+
/**
29+
* Specialized screenshot utility for PaymentDetailsScreen.
30+
*
31+
* This utility is designed specifically for taking screenshots of the payment details screen
32+
* while excluding the top bar and bottom button areas. It captures only the scrollable content
33+
* area for clean, professional screenshots.
34+
*/
35+
object PaymentDetailsScreenshotUtils {
36+
37+
private const val CONTENT_PADDING_DP = 16
38+
private const val COMPRESSION_QUALITY = 90
39+
private const val TEST_TAG_PAYMENT_DETAILS_CONTENT = "payment-details-content"
40+
41+
/**
42+
* Provider function to retrieve the current [Activity].
43+
* This must be set before using screenshot functionality.
44+
*/
45+
private var activityProvider: () -> Activity = {
46+
throw IllegalArgumentException(
47+
"You need to implement the 'activityProvider' to provide the required Activity. " +
48+
"Just make sure to set a valid activity using " +
49+
"the 'setActivityProvider()' method.",
50+
)
51+
}
52+
53+
/**
54+
* Sets the activity provider function to be used internally for context retrieval.
55+
*
56+
* This is required to initialize before calling any screenshot methods.
57+
*
58+
* @param provider A lambda that returns the current [Activity].
59+
*/
60+
fun setActivityProvider(provider: () -> Activity) {
61+
activityProvider = provider
62+
}
63+
64+
/**
65+
* Takes a screenshot of the PaymentDetailsScreen and shares it.
66+
*
67+
* @param contextDetails Payment context details for better file naming
68+
* @param onError Callback when screenshot or sharing fails
69+
*/
70+
suspend fun takePaymentDetailsScreenshotAndShare(
71+
contextDetails: ScreenshotContextDetails,
72+
onError: (String) -> Unit,
73+
) {
74+
try {
75+
Logger.d("Taking payment details screenshot and sharing...")
76+
val screenshotBytes = takePaymentDetailsScreenshotSync()
77+
78+
withContext(Dispatchers.Main) {
79+
val fileName = contextDetails.generateFileName()
80+
val shareFile = ShareFileModel(
81+
mime = MimeType.IMAGE,
82+
fileName = fileName,
83+
bytes = screenshotBytes,
84+
)
85+
ShareUtils.shareFile(shareFile)
86+
}
87+
} catch (e: Exception) {
88+
Logger.e(e) { "Failed to take payment details screenshot: ${e.message}" }
89+
onError("Failed to take screenshot: ${e.message}")
90+
}
91+
}
92+
93+
/**
94+
* Takes a screenshot of the PaymentDetailsScreen synchronously and returns the bytes.
95+
*
96+
* @return Byte array of the compressed screenshot
97+
*/
98+
private suspend fun takePaymentDetailsScreenshotSync(): ByteArray {
99+
return withContext(Dispatchers.Main) {
100+
val activity = activityProvider.invoke()
101+
val rootView = activity.findViewById<ViewGroup>(android.R.id.content)
102+
103+
val screenshot = capturePaymentDetailsScreenshot(rootView)
104+
compressScreenshot(screenshot)
105+
}
106+
}
107+
108+
/**
109+
* Captures a screenshot of the PaymentDetailsScreen while excluding the top bar and bottom button areas.
110+
*
111+
* @param rootView The root view to capture
112+
* @return Bitmap of the screenshot with only the content area
113+
*/
114+
private fun capturePaymentDetailsScreenshot(rootView: ViewGroup): Bitmap {
115+
val bitmap = rootView.drawToBitmap()
116+
val contentArea = findContentArea(rootView)
117+
118+
return if (contentArea != null && contentArea.width() > 0 && contentArea.height() > 0) {
119+
captureContentAreaScreenshot(bitmap, contentArea, rootView.resources.displayMetrics.density)
120+
} else {
121+
captureFallbackScreenshot(bitmap)
122+
}
123+
}
124+
125+
/**
126+
* Captures screenshot using the identified content area with proper padding.
127+
*/
128+
private fun captureContentAreaScreenshot(
129+
bitmap: Bitmap,
130+
contentArea: android.graphics.Rect,
131+
density: Float,
132+
): Bitmap {
133+
val paddingPx = (CONTENT_PADDING_DP * density).toInt()
134+
135+
val paddedLeft = maxOf(0, contentArea.left - paddingPx)
136+
val paddedTop = maxOf(0, contentArea.top - paddingPx)
137+
val paddedRight = minOf(bitmap.width, contentArea.right + paddingPx)
138+
val paddedBottom = minOf(bitmap.height, contentArea.bottom + paddingPx)
139+
140+
val paddedWidth = paddedRight - paddedLeft
141+
val paddedHeight = paddedBottom - paddedTop
142+
143+
val contentBitmap = Bitmap.createBitmap(
144+
bitmap,
145+
paddedLeft,
146+
paddedTop,
147+
paddedWidth,
148+
paddedHeight,
149+
)
150+
bitmap.recycle()
151+
return contentBitmap
152+
}
153+
154+
/**
155+
* Captures screenshot using fallback method when content area is not found.
156+
* Excludes top bar and bottom button areas using estimated positions.
157+
*/
158+
private fun captureFallbackScreenshot(bitmap: Bitmap): Bitmap {
159+
val screenHeight = bitmap.height
160+
val screenWidth = bitmap.width
161+
162+
// Estimate top bar height (status bar + app bar)
163+
val topBarHeight = (screenHeight * 0.12f).toInt()
164+
165+
// Estimate bottom button area height
166+
val bottomButtonAreaHeight = (screenHeight * 0.15f).toInt()
167+
168+
// Calculate the area to capture (between top bar and bottom button)
169+
val captureTop = topBarHeight
170+
val captureHeight = screenHeight - topBarHeight - bottomButtonAreaHeight
171+
172+
Logger.d("Fallback screenshot: excluding top ${topBarHeight}px and bottom ${bottomButtonAreaHeight}px")
173+
174+
if (captureHeight > 0) {
175+
val croppedBitmap = Bitmap.createBitmap(
176+
bitmap,
177+
0,
178+
captureTop,
179+
screenWidth,
180+
captureHeight,
181+
)
182+
bitmap.recycle()
183+
return croppedBitmap
184+
} else {
185+
Logger.w("Invalid capture dimensions, returning original bitmap")
186+
return bitmap
187+
}
188+
}
189+
190+
/**
191+
* Compresses the screenshot bitmap to reduce file size.
192+
*
193+
* @param bitmap The screenshot bitmap to compress
194+
* @return Compressed image as byte array
195+
*/
196+
private suspend fun compressScreenshot(bitmap: Bitmap): ByteArray {
197+
return withContext(Dispatchers.IO) {
198+
val outputStream = ByteArrayOutputStream()
199+
bitmap.compress(Bitmap.CompressFormat.PNG, COMPRESSION_QUALITY, outputStream)
200+
val bytes = outputStream.toByteArray()
201+
outputStream.close()
202+
203+
bitmap.recycle()
204+
bytes
205+
}
206+
}
207+
208+
/**
209+
* Finds the content area using the testTag.
210+
*
211+
* @param rootView The root view to search in
212+
* @return Rect representing the content area bounds, or null if not found
213+
*/
214+
private fun findContentArea(rootView: ViewGroup): android.graphics.Rect? {
215+
return findViewWithTestTag(rootView, TEST_TAG_PAYMENT_DETAILS_CONTENT)?.let { contentView ->
216+
val location = IntArray(2)
217+
contentView.getLocationInWindow(location)
218+
219+
var viewWidth = contentView.width
220+
var viewHeight = contentView.height
221+
222+
if (viewWidth <= 0 || viewHeight <= 0) {
223+
Logger.w("Content view has invalid dimensions: ${viewWidth}x$viewHeight")
224+
return null
225+
}
226+
227+
android.graphics.Rect(
228+
location[0],
229+
location[1],
230+
location[0] + viewWidth,
231+
location[1] + viewHeight,
232+
)
233+
}
234+
}
235+
236+
/**
237+
* Recursively searches for a view with the specified test tag.
238+
*
239+
* @param root The root view to search in
240+
* @param testTag The test tag to search for
241+
* @return The view with the matching test tag, or null if not found
242+
*/
243+
private fun findViewWithTestTag(root: View, testTag: String): View? {
244+
if (root.tag == testTag) {
245+
return root
246+
}
247+
248+
if (root is ViewGroup) {
249+
for (i in 0 until root.childCount) {
250+
val child = root.getChildAt(i)
251+
val result = findViewWithTestTag(child, testTag)
252+
if (result != null) {
253+
return result
254+
}
255+
}
256+
}
257+
258+
return null
259+
}
260+
}

feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PaymentChatHistoryScreen.kt

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -297,16 +297,12 @@ private fun PaymentCard(
297297
) {
298298
Icon(
299299
imageVector = MifosIcons.CheckCircle,
300-
contentDescription = if (transaction.isSent) "Sent" else "Received",
301-
tint = if (transaction.isSent) {
302-
Color(0xFF4CAF50)
303-
} else {
304-
KptTheme.colorScheme.primary
305-
},
300+
contentDescription = "Paid",
301+
tint = Color(0xFF4CAF50),
306302
modifier = Modifier.size(16.dp),
307303
)
308304
Text(
309-
text = "${if (transaction.isSent) "Sent" else "Received"} - ${formatPaymentDate(transaction.paymentDate)}",
305+
text = "Paid - ${formatPaymentDate(transaction.paymentDate)}",
310306
style = KptTheme.typography.bodyMedium,
311307
fontWeight = FontWeight.SemiBold,
312308
color = KptTheme.colorScheme.onSurfaceVariant,

0 commit comments

Comments
 (0)