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 }