1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.credentialmanager.common.ui
18 
19 import androidx.compose.foundation.Image
20 import androidx.compose.foundation.layout.Arrangement
21 import androidx.compose.foundation.layout.Box
22 import androidx.compose.foundation.layout.Column
23 import androidx.compose.foundation.layout.Row
24 import androidx.compose.foundation.layout.fillMaxWidth
25 import androidx.compose.foundation.layout.padding
26 import androidx.compose.foundation.layout.size
27 import androidx.compose.foundation.layout.wrapContentHeight
28 import androidx.compose.foundation.layout.wrapContentSize
29 import androidx.compose.material.icons.Icons
30 import androidx.compose.material.icons.filled.ArrowBack
31 import androidx.compose.material.icons.outlined.Lock
32 import androidx.compose.material3.ExperimentalMaterial3Api
33 import androidx.compose.material3.Icon
34 import androidx.compose.material3.IconButton
35 import androidx.compose.material3.SuggestionChip
36 import androidx.compose.material3.SuggestionChipDefaults
37 import androidx.compose.material3.TopAppBar
38 import androidx.compose.material3.TopAppBarDefaults
39 import androidx.compose.runtime.Composable
40 import androidx.compose.runtime.getValue
41 import androidx.compose.runtime.mutableStateOf
42 import androidx.compose.runtime.remember
43 import androidx.compose.ui.Alignment
44 import androidx.compose.ui.Modifier
45 import androidx.compose.ui.composed
46 import androidx.compose.ui.graphics.Color
47 import androidx.compose.ui.graphics.ImageBitmap
48 import androidx.compose.ui.graphics.graphicsLayer
49 import androidx.compose.ui.graphics.painter.Painter
50 import androidx.compose.ui.graphics.vector.ImageVector
51 import androidx.compose.ui.platform.LocalLayoutDirection
52 import androidx.compose.ui.res.stringResource
53 import androidx.compose.ui.text.AnnotatedString
54 import androidx.compose.ui.text.TextLayoutResult
55 import androidx.compose.ui.text.input.PasswordVisualTransformation
56 import androidx.compose.ui.unit.Dp
57 import androidx.compose.ui.unit.LayoutDirection
58 import androidx.compose.ui.unit.dp
59 import com.android.credentialmanager.R
60 import com.android.credentialmanager.ui.theme.EntryShape
61 import com.android.credentialmanager.ui.theme.LocalAndroidColorScheme
62 import com.android.credentialmanager.ui.theme.Shapes
63 
64 @Composable
65 fun Entry(
66     modifier: Modifier = Modifier,
67     onClick: () -> Unit,
68     entryHeadlineText: String,
69     entrySecondLineText: String? = null,
70     entryThirdLineText: String? = null,
71     /** Supply one and only one of the [iconImageBitmap], [iconImageVector], or [iconPainter] for
72      *  drawing the leading icon. */
73     iconImageBitmap: ImageBitmap? = null,
74     shouldApplyIconImageBitmapTint: Boolean = false,
75     iconImageVector: ImageVector? = null,
76     iconPainter: Painter? = null,
77     /** This will replace the [entrySecondLineText] value and render the text along with a
78      *  mask on / off toggle for hiding / displaying the password value. */
79     passwordValue: String? = null,
80     /** If true, draws a trailing lock icon. */
81     isLockedAuthEntry: Boolean = false,
82     enforceOneLine: Boolean = false,
83     onTextLayout: (TextLayoutResult) -> Unit = {},
84 ) {
85     val iconPadding = Modifier.wrapContentSize().padding(
86         // Horizontal padding should be 16dp, but the suggestion chip itself
87         // has 8dp horizontal elements padding
88         start = 8.dp, top = 16.dp, bottom = 16.dp
89     )
90     val iconSize = Modifier.size(24.dp)
91     SuggestionChip(
92         modifier = modifier.fillMaxWidth().wrapContentHeight(),
93         onClick = onClick,
94         shape = EntryShape.FullSmallRoundedCorner,
95         label = {
96             Row(
97                 horizontalArrangement = Arrangement.SpaceBetween,
98                 modifier = Modifier.fillMaxWidth().padding(
99                     // Total end padding should be 16dp, but the suggestion chip itself
100                     // has 8dp horizontal elements padding
101                     horizontal = 8.dp, vertical = 16.dp,
102                 ),
103                 // Make sure the trailing icon and text column are centered vertically.
104                 verticalAlignment = Alignment.CenterVertically,
105             ) {
106                 // Apply weight so that the trailing icon can always show.
107                 Column(modifier = Modifier.wrapContentHeight().fillMaxWidth().weight(1f)) {
108                     SmallTitleText(
109                         text = entryHeadlineText,
110                         enforceOneLine = enforceOneLine,
111                         onTextLayout = onTextLayout,
112                     )
113                     if (passwordValue != null) {
114                         Row(
115                             modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
116                             verticalAlignment = Alignment.CenterVertically,
117                             horizontalArrangement = Arrangement.Start,
118                         ) {
119                             val visualTransformation = remember { PasswordVisualTransformation() }
120                             val originalPassword by remember {
121                                 mutableStateOf(passwordValue)
122                             }
123                             val displayedPassword = remember {
124                                 mutableStateOf(
125                                     visualTransformation.filter(
126                                         AnnotatedString(originalPassword)
127                                     ).text.text
128                                 )
129                             }
130                             BodySmallText(
131                                 text = displayedPassword.value,
132                                 // Apply weight to allow visibility button to render first so that
133                                 // it doesn't get squeezed out by a super long password.
134                                 modifier = Modifier.wrapContentSize().weight(1f, fill = false),
135                             )
136                             ToggleVisibilityButton(
137                                 modifier = Modifier.padding(start = 12.dp).size(24.dp),
138                                 onToggle = {
139                                     if (it) {
140                                         displayedPassword.value = originalPassword
141                                     } else {
142                                         displayedPassword.value = visualTransformation.filter(
143                                             AnnotatedString(originalPassword)
144                                         ).text.text
145                                     }
146                                 },
147                             )
148                         }
149                     } else if (entrySecondLineText != null) {
150                         BodySmallText(
151                             text = entrySecondLineText,
152                             enforceOneLine = enforceOneLine,
153                             onTextLayout = onTextLayout,
154                         )
155                     }
156                     if (entryThirdLineText != null) {
157                         BodySmallText(
158                             text = entryThirdLineText,
159                             enforceOneLine = enforceOneLine,
160                             onTextLayout = onTextLayout,
161                         )
162                     }
163                 }
164                 if (isLockedAuthEntry) {
165                     Box(modifier = Modifier.wrapContentSize().padding(start = 16.dp)) {
166                         Icon(
167                             imageVector = Icons.Outlined.Lock,
168                             // Decorative purpose only.
169                             contentDescription = null,
170                             modifier = Modifier.size(24.dp),
171                             tint = LocalAndroidColorScheme.current.colorOnSurfaceVariant,
172                         )
173                     }
174                 }
175             }
176         },
177         icon =
178         if (iconImageBitmap != null) {
179             if (shouldApplyIconImageBitmapTint) {
180                 {
181                     Box(modifier = iconPadding) {
182                         Icon(
183                             modifier = iconSize,
184                             bitmap = iconImageBitmap,
185                             tint = LocalAndroidColorScheme.current.colorOnSurfaceVariant,
186                             // Decorative purpose only.
187                             contentDescription = null,
188                         )
189                     }
190                 }
191             } else {
192                 {
193                     Box(modifier = iconPadding) {
194                         Image(
195                             modifier = iconSize,
196                             bitmap = iconImageBitmap,
197                             // Decorative purpose only.
198                             contentDescription = null,
199                         )
200                     }
201                 }
202             }
203         } else if (iconImageVector != null) {
204             {
205                 Box(modifier = iconPadding) {
206                     Icon(
207                         modifier = iconSize,
208                         imageVector = iconImageVector,
209                         tint = LocalAndroidColorScheme.current.colorOnSurfaceVariant,
210                         // Decorative purpose only.
211                         contentDescription = null,
212                     )
213                 }
214             }
215         } else if (iconPainter != null) {
216             {
217                 Box(modifier = iconPadding) {
218                     Icon(
219                         modifier = iconSize,
220                         painter = iconPainter,
221                         tint = LocalAndroidColorScheme.current.colorOnSurfaceVariant,
222                         // Decorative purpose only.
223                         contentDescription = null,
224                     )
225                 }
226             }
227         } else {
228             null
229         },
230         border = null,
231         colors = SuggestionChipDefaults.suggestionChipColors(
232             containerColor = LocalAndroidColorScheme.current.colorSurfaceContainerHigh,
233             labelColor = LocalAndroidColorScheme.current.colorOnSurfaceVariant,
234             iconContentColor = LocalAndroidColorScheme.current.colorOnSurfaceVariant,
235         ),
236     )
237 }
238 
239 /**
240  * A variation of the normal entry in that its background is transparent and the paddings are
241  * different (no horizontal padding).
242  */
243 @Composable
244 fun ActionEntry(
245     onClick: () -> Unit,
246     entryHeadlineText: String,
247     entrySecondLineText: String? = null,
248     iconImageBitmap: ImageBitmap,
249 ) {
250     SuggestionChip(
251         modifier = Modifier.fillMaxWidth().wrapContentHeight(),
252         onClick = onClick,
253         shape = Shapes.large,
254         label = {
255             Column(modifier = Modifier.wrapContentSize()
256                 .padding(start = 16.dp, top = 16.dp, bottom = 16.dp)) {
257                 SmallTitleText(entryHeadlineText)
258                 if (entrySecondLineText != null && entrySecondLineText.isNotEmpty()) {
259                     BodySmallText(entrySecondLineText)
260                 }
261             }
262         },
263         icon = {
264             Box(modifier = Modifier.wrapContentSize().padding(vertical = 16.dp)) {
265                 Image(
266                     modifier = Modifier.size(24.dp),
267                     bitmap = iconImageBitmap,
268                     // Decorative purpose only.
269                     contentDescription = null,
270                 )
271             }
272         },
273         border = null,
274         colors = SuggestionChipDefaults.suggestionChipColors(
275             containerColor = Color.Transparent,
276         ),
277     )
278 }
279 
280 /**
281  * A single row of leading icon and text describing a benefit of passkeys, used by the
282  * [com.android.credentialmanager.createflow.PasskeyIntroCard].
283  */
284 @Composable
285 fun PasskeyBenefitRow(
286     leadingIconPainter: Painter,
287     text: String,
288 ) {
289     Row(
290         horizontalArrangement = Arrangement.spacedBy(16.dp),
291         verticalAlignment = Alignment.CenterVertically,
292         modifier = Modifier.fillMaxWidth()
293     ) {
294         Icon(
295             modifier = Modifier.size(24.dp),
296             painter = leadingIconPainter,
297             tint = LocalAndroidColorScheme.current.colorOnSurfaceVariant,
298             // Decorative purpose only.
299             contentDescription = null,
300         )
301         BodyMediumText(text = text)
302     }
303 }
304 
305 /**
306  * A single row of one or two CTA buttons for continuing or cancelling the current step.
307  */
308 @Composable
309 fun CtaButtonRow(
310     leftButton: (@Composable () -> Unit)? = null,
311     rightButton: (@Composable () -> Unit)? = null,
312 ) {
313     Row(
314         horizontalArrangement =
315         if (leftButton == null) Arrangement.End
316         else if (rightButton == null) Arrangement.Start
317         else Arrangement.SpaceBetween,
318         verticalAlignment = Alignment.CenterVertically,
319         modifier = Modifier.fillMaxWidth()
320     ) {
321         if (leftButton != null) {
322             leftButton()
323         }
324         if (rightButton != null) {
325             rightButton()
326         }
327     }
328 }
329 
330 @OptIn(ExperimentalMaterial3Api::class)
331 @Composable
332 fun MoreOptionTopAppBar(
333     text: String,
334     onNavigationIconClicked: () -> Unit,
335     bottomPadding: Dp,
336 ) {
337     TopAppBar(
338         title = {
339             LargeTitleText(text = text, modifier = Modifier.padding(horizontal = 4.dp))
340         },
341         navigationIcon = {
342             IconButton(
343                 modifier = Modifier.padding(top = 8.dp, bottom = 8.dp, start = 4.dp).size(48.dp),
344                 onClick = onNavigationIconClicked
345             ) {
346                 Box(
347                     modifier = Modifier.size(48.dp),
348                     contentAlignment = Alignment.Center,
349                 ) {
350                     Icon(
351                         imageVector = Icons.Filled.ArrowBack,
352                         contentDescription = stringResource(
353                             R.string.accessibility_back_arrow_button
354                         ),
355                         modifier = Modifier.size(24.dp).autoMirrored(),
356                         tint = LocalAndroidColorScheme.current.colorOnSurfaceVariant,
357                     )
358                 }
359             }
360         },
361         colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
362         modifier = Modifier.padding(top = 12.dp, bottom = bottomPadding)
363     )
364 }
365 
366 private fun Modifier.autoMirrored() = composed {
367     when (LocalLayoutDirection.current) {
368         LayoutDirection.Rtl -> graphicsLayer(scaleX = -1f)
369         else -> this
370     }
371 }