AlertDialogs.kt (28368B) - raw
1 package me.rhunk.snapenhance.ui.util 2 3 import android.content.Context 4 import android.view.MotionEvent 5 import android.widget.Toast 6 import androidx.compose.foundation.ScrollState 7 import androidx.compose.foundation.background 8 import androidx.compose.foundation.clickable 9 import androidx.compose.foundation.layout.* 10 import androidx.compose.foundation.shape.RoundedCornerShape 11 import androidx.compose.foundation.text.KeyboardOptions 12 import androidx.compose.foundation.verticalScroll 13 import androidx.compose.material.icons.Icons 14 import androidx.compose.material.icons.filled.Check 15 import androidx.compose.material.icons.filled.DeleteOutline 16 import androidx.compose.material.icons.filled.Edit 17 import androidx.compose.material.icons.filled.Save 18 import androidx.compose.material3.* 19 import androidx.compose.runtime.* 20 import androidx.compose.ui.Alignment 21 import androidx.compose.ui.Modifier 22 import androidx.compose.ui.draw.clip 23 import androidx.compose.ui.draw.clipToBounds 24 import androidx.compose.ui.focus.FocusRequester 25 import androidx.compose.ui.focus.focusRequester 26 import androidx.compose.ui.graphics.Color 27 import androidx.compose.ui.graphics.toArgb 28 import androidx.compose.ui.layout.onGloballyPositioned 29 import androidx.compose.ui.platform.LocalContext 30 import androidx.compose.ui.text.TextRange 31 import androidx.compose.ui.text.font.FontWeight 32 import androidx.compose.ui.text.input.ImeAction 33 import androidx.compose.ui.text.input.KeyboardType 34 import androidx.compose.ui.text.input.TextFieldValue 35 import androidx.compose.ui.unit.dp 36 import androidx.compose.ui.unit.sp 37 import androidx.compose.ui.viewinterop.AndroidView 38 import androidx.core.net.toUri 39 import com.github.skydoves.colorpicker.compose.* 40 import com.google.gson.JsonParser 41 import kotlinx.coroutines.Dispatchers 42 import kotlinx.coroutines.delay 43 import kotlinx.coroutines.launch 44 import me.rhunk.snapenhance.common.Constants 45 import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper 46 import me.rhunk.snapenhance.common.config.DataProcessors 47 import me.rhunk.snapenhance.common.config.PropertyPair 48 import me.rhunk.snapenhance.common.ui.AutoClearKeyboardFocus 49 import me.rhunk.snapenhance.common.util.ktx.await 50 import okhttp3.OkHttpClient 51 import okhttp3.Request 52 import org.osmdroid.config.Configuration 53 import org.osmdroid.tileprovider.tilesource.TileSourceFactory 54 import org.osmdroid.util.GeoPoint 55 import org.osmdroid.views.CustomZoomButtonsController 56 import org.osmdroid.views.MapView 57 import org.osmdroid.views.overlay.Marker 58 import org.osmdroid.views.overlay.Overlay 59 import java.io.File 60 61 62 class AlertDialogs( 63 private val translation: LocaleWrapper, 64 ){ 65 @Composable 66 fun DefaultDialogCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { 67 Card( 68 shape = MaterialTheme.shapes.large, 69 modifier = Modifier 70 .padding(16.dp) 71 .then(modifier), 72 ) { 73 Column( 74 modifier = Modifier 75 .padding(10.dp, 10.dp, 10.dp, 10.dp) 76 .verticalScroll(ScrollState(0)), 77 ) { content() } 78 } 79 } 80 81 @Composable 82 fun ConfirmDialog( 83 title: String, 84 message: String? = null, 85 onConfirm: () -> Unit, 86 onDismiss: () -> Unit, 87 ) { 88 DefaultDialogCard { 89 Text( 90 text = title, 91 fontSize = 20.sp, 92 fontWeight = FontWeight.Bold, 93 modifier = Modifier.padding(start = 5.dp, bottom = 10.dp) 94 ) 95 if (message != null) { 96 Text( 97 text = message, 98 style = MaterialTheme.typography.bodyMedium, 99 modifier = Modifier.padding(bottom = 15.dp) 100 ) 101 } 102 Row( 103 modifier = Modifier.fillMaxWidth(), 104 horizontalArrangement = Arrangement.SpaceEvenly, 105 ) { 106 Button(onClick = { onDismiss() }) { 107 Text(text = translation["button.cancel"]) 108 } 109 Button(onClick = { onConfirm() }) { 110 Text(text = translation["button.ok"]) 111 } 112 } 113 } 114 } 115 116 @Composable 117 fun InfoDialog( 118 title: String, 119 message: String? = null, 120 onDismiss: () -> Unit, 121 ) { 122 DefaultDialogCard { 123 Text( 124 text = title, 125 fontSize = 20.sp, 126 modifier = Modifier.padding(start = 5.dp, bottom = 10.dp) 127 ) 128 if (message != null) { 129 Text( 130 text = message, 131 style = MaterialTheme.typography.bodySmall, 132 modifier = Modifier.padding(bottom = 15.dp) 133 ) 134 } 135 Row( 136 modifier = Modifier.fillMaxWidth(), 137 horizontalArrangement = Arrangement.SpaceEvenly, 138 ) { 139 Button(onClick = { onDismiss() }) { 140 Text(text = translation["button.ok"]) 141 } 142 } 143 } 144 } 145 146 @Composable 147 fun TranslatedText(property: PropertyPair<*>, key: String, modifier: Modifier = Modifier) { 148 Text( 149 text = property.key.propertyOption(translation, key), 150 modifier = Modifier 151 .padding(10.dp, 10.dp, 10.dp, 10.dp) 152 .then(modifier) 153 ) 154 } 155 156 @Composable 157 @Suppress("UNCHECKED_CAST") 158 fun UniqueSelectionDialog(property: PropertyPair<*>) { 159 val keys = (property.value.defaultValues as List<String>).toMutableList().apply { 160 add(0, "null") 161 } 162 163 var selectedValue by remember { 164 mutableStateOf(property.value.getNullable()?.toString() ?: "null") 165 } 166 167 DefaultDialogCard { 168 keys.forEachIndexed { index, item -> 169 fun select() { 170 selectedValue = item 171 property.value.setAny(if (index == 0) { 172 null 173 } else { 174 item 175 }) 176 } 177 178 Row( 179 modifier = Modifier.clickable { select() }, 180 verticalAlignment = Alignment.CenterVertically 181 ) { 182 TranslatedText( 183 property = property, 184 key = item, 185 modifier = Modifier.weight(1f) 186 ) 187 RadioButton( 188 selected = selectedValue == item, 189 onClick = { select() } 190 ) 191 } 192 } 193 } 194 } 195 196 @Composable 197 fun KeyboardInputDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) { 198 val focusRequester = remember { FocusRequester() } 199 val context = LocalContext.current 200 201 DefaultDialogCard { 202 var fieldValue by remember { 203 mutableStateOf(property.value.get().toString().let { 204 TextFieldValue( 205 text = it, 206 selection = TextRange(it.length) 207 ) 208 }) 209 } 210 211 TextField( 212 modifier = Modifier 213 .fillMaxWidth() 214 .padding(all = 10.dp) 215 .onGloballyPositioned { 216 focusRequester.requestFocus() 217 } 218 .focusRequester(focusRequester), 219 value = fieldValue, 220 onValueChange = { fieldValue = it }, 221 keyboardOptions = when (property.key.dataType.type) { 222 DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number) 223 DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal) 224 else -> KeyboardOptions(keyboardType = KeyboardType.Text) 225 }, 226 singleLine = true 227 ) 228 229 Row( 230 modifier = Modifier 231 .padding(top = 10.dp) 232 .fillMaxWidth(), 233 horizontalArrangement = Arrangement.SpaceEvenly, 234 ) { 235 Button(onClick = { dismiss() }) { 236 Text(text = translation["button.cancel"]) 237 } 238 Button(onClick = { 239 if (fieldValue.text.isNotEmpty() && property.key.params.inputCheck?.invoke(fieldValue.text) == false) { 240 Toast.makeText(context, "Invalid input! Make sure you entered a valid value.", Toast.LENGTH_SHORT).show() //TODO: i18n 241 return@Button 242 } 243 244 when (property.key.dataType.type) { 245 DataProcessors.Type.INTEGER -> { 246 runCatching { 247 property.value.setAny(fieldValue.text.toInt()) 248 }.onFailure { 249 property.value.setAny(0) 250 } 251 } 252 DataProcessors.Type.FLOAT -> { 253 runCatching { 254 property.value.setAny(fieldValue.text.toFloat()) 255 }.onFailure { 256 property.value.setAny(0f) 257 } 258 } 259 else -> property.value.setAny(fieldValue.text) 260 } 261 dismiss() 262 }) { 263 Text(text = translation["button.ok"]) 264 } 265 } 266 } 267 } 268 269 @Composable 270 fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) { 271 val focusRequester = remember { FocusRequester() } 272 273 DefaultDialogCard { 274 val fieldValue = remember { 275 mutableStateOf(TextFieldValue()) 276 } 277 278 TextField( 279 modifier = Modifier 280 .fillMaxWidth() 281 .padding(all = 10.dp) 282 .onGloballyPositioned { 283 focusRequester.requestFocus() 284 } 285 .focusRequester(focusRequester), 286 value = fieldValue.value, 287 onValueChange = { 288 fieldValue.value = it 289 }, 290 singleLine = true 291 ) 292 293 Row( 294 modifier = Modifier 295 .padding(top = 10.dp) 296 .fillMaxWidth(), 297 horizontalArrangement = Arrangement.SpaceEvenly, 298 ) { 299 Button(onClick = { onDismiss() }) { 300 Text(text = translation["button.cancel"]) 301 } 302 Button(onClick = { 303 onConfirm(fieldValue.value.text) 304 }) { 305 Text(text = translation["button.ok"]) 306 } 307 } 308 } 309 } 310 311 @Composable 312 @Suppress("UNCHECKED_CAST") 313 fun MultipleSelectionDialog(property: PropertyPair<*>) { 314 val defaultItems = property.value.defaultValues as List<String> 315 val toggledStates = property.value.get() as MutableList<String> 316 DefaultDialogCard { 317 defaultItems.forEach { key -> 318 var state by remember { mutableStateOf(toggledStates.contains(key)) } 319 320 fun toggle(value: Boolean? = null) { 321 state = value ?: !state 322 if (state) { 323 toggledStates.add(key) 324 } else { 325 toggledStates.remove(key) 326 } 327 } 328 329 Row( 330 modifier = Modifier.clickable { toggle() }, 331 verticalAlignment = Alignment.CenterVertically 332 ) { 333 TranslatedText( 334 property = property, 335 key = key, 336 modifier = Modifier 337 .weight(1f) 338 ) 339 Switch( 340 checked = state, 341 onCheckedChange = { 342 toggle(it) 343 } 344 ) 345 } 346 } 347 } 348 } 349 350 @Composable 351 fun ColorPickerDialog( 352 initialColor: Color?, 353 setProperty: (Color?) -> Unit, 354 dismiss: () -> Unit 355 ) { 356 var currentColor by remember { mutableStateOf(initialColor) } 357 358 DefaultDialogCard { 359 val controller = remember { ColorPickerController().apply { 360 if (currentColor == null) { 361 setWheelAlpha(1f) 362 setBrightness(1f, false) 363 } 364 } } 365 var colorHexValue by remember { 366 mutableStateOf(currentColor?.toArgb()?.let { Integer.toHexString(it) } ?: "") 367 } 368 369 Box( 370 modifier = Modifier.fillMaxWidth(), 371 contentAlignment = Alignment.Center, 372 ) { 373 TextField( 374 value = colorHexValue, 375 onValueChange = { value -> 376 colorHexValue = value 377 runCatching { 378 currentColor = Color(android.graphics.Color.parseColor("#$value")).also { 379 controller.selectByColor(it, true) 380 setProperty(it) 381 } 382 }.onFailure { 383 currentColor = null 384 } 385 }, 386 label = { Text(text = "Hex Color") }, 387 modifier = Modifier 388 .fillMaxWidth() 389 .padding(10.dp), 390 singleLine = true, 391 colors = TextFieldDefaults.colors( 392 unfocusedContainerColor = Color.Transparent, 393 focusedContainerColor = Color.Transparent, 394 ) 395 ) 396 } 397 HsvColorPicker( 398 modifier = Modifier 399 .fillMaxWidth() 400 .height(300.dp) 401 .padding(10.dp), 402 initialColor = remember { currentColor }, 403 controller = controller, 404 onColorChanged = { 405 if (!it.fromUser) return@HsvColorPicker 406 currentColor = it.color 407 colorHexValue = Integer.toHexString(it.color.toArgb()) 408 setProperty(it.color) 409 } 410 ) 411 AlphaSlider( 412 modifier = Modifier 413 .fillMaxWidth() 414 .padding(10.dp) 415 .height(35.dp), 416 initialColor = remember { currentColor }, 417 controller = controller, 418 ) 419 BrightnessSlider( 420 modifier = Modifier 421 .fillMaxWidth() 422 .padding(10.dp) 423 .height(35.dp), 424 initialColor = remember { currentColor }, 425 controller = controller, 426 ) 427 Row( 428 modifier = Modifier 429 .fillMaxWidth() 430 .padding(5.dp), 431 horizontalArrangement = Arrangement.SpaceEvenly, 432 verticalAlignment = Alignment.CenterVertically, 433 ) { 434 AlphaTile( 435 modifier = Modifier 436 .size(80.dp) 437 .clip(RoundedCornerShape(6.dp)), 438 controller = controller 439 ) 440 IconButton(onClick = { 441 setProperty(null) 442 dismiss() 443 }) { 444 Icon( 445 modifier = Modifier.size(60.dp), 446 imageVector = Icons.Filled.DeleteOutline, 447 contentDescription = null 448 ) 449 } 450 } 451 } 452 } 453 454 @Composable 455 fun ColorPickerPropertyDialog( 456 property: PropertyPair<*>, 457 dismiss: () -> Unit = {}, 458 ) { 459 var currentColor by remember { 460 mutableStateOf((property.value.getNullable() as? Int)?.let { Color(it) }) 461 } 462 463 ColorPickerDialog( 464 initialColor = currentColor, 465 setProperty = setProperty@{ 466 currentColor = it 467 property.value.setAny(it?.toArgb()) 468 if (it == null) { 469 property.value.setAny(property.value.defaultValues?.firstOrNull() ?: return@setProperty) 470 } 471 }, 472 dismiss = dismiss 473 ) 474 } 475 476 @Composable 477 fun ChooseLocationDialog( 478 property: PropertyPair<*>, 479 marker: MutableState<Marker?> = remember { mutableStateOf(null) }, 480 mapView: MutableState<MapView?> = remember { mutableStateOf(null) }, 481 saveCoordinates: (() -> Unit)? = null, 482 dismiss: () -> Unit = {} 483 ) { 484 val coordinates = remember { 485 (property.value.get() as Pair<*, *>).let { 486 it.first.toString().toDouble() to it.second.toString().toDouble() 487 } 488 } 489 val context = LocalContext.current 490 491 mapView.value = remember { 492 Configuration.getInstance().apply { 493 osmdroidBasePath = File(context.cacheDir, "osmdroid") 494 load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) 495 } 496 MapView(context).apply { 497 setMultiTouchControls(true) 498 zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) 499 setTileSource(TileSourceFactory.MAPNIK) 500 501 val startPoint = GeoPoint(coordinates.first, coordinates.second) 502 controller.setZoom(10.0) 503 controller.setCenter(startPoint) 504 505 marker.value = Marker(this).apply { 506 isDraggable = true 507 position = startPoint 508 setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) 509 } 510 511 overlays.add(object: Overlay() { 512 override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean { 513 marker.value?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint 514 mapView.invalidate() 515 return true 516 } 517 }) 518 519 overlays.add(marker.value) 520 } 521 } 522 523 DisposableEffect(Unit) { 524 onDispose { 525 mapView.value?.onDetach() 526 } 527 } 528 529 var customCoordinatesDialog by remember { mutableStateOf(false) } 530 531 532 val coroutineScope = rememberCoroutineScope { Dispatchers.IO } 533 val okHttpClient by lazy { OkHttpClient() } 534 535 Box( 536 modifier = Modifier 537 .fillMaxWidth() 538 .clipToBounds() 539 .fillMaxHeight(fraction = 0.9f), 540 ) { 541 AndroidView( 542 factory = { mapView.value!! }, 543 ) 544 Column( 545 modifier = Modifier 546 .align(Alignment.TopCenter) 547 .fillMaxWidth() 548 ) { 549 var locationName by remember { mutableStateOf<String>("") } 550 var addressResults by remember { mutableStateOf<List<Triple<String, String, String>>>(emptyList()) } 551 var searchJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) } 552 553 suspend fun search() { 554 okHttpClient.newCall(Request.Builder() 555 .url("https://nominatim.openstreetmap.org/search".toUri().buildUpon().appendQueryParameter("q", locationName).appendQueryParameter("format", "jsonv2").build().toString()) 556 .header("User-Agent", Constants.USER_AGENT) 557 .build() 558 ).await().use { response -> 559 if (!response.isSuccessful) { 560 return@use 561 } 562 563 runCatching { 564 val body = JsonParser.parseString(response.body.string()).asJsonArray 565 addressResults = body.take(5).map { jsonElement -> 566 val jsonObject = jsonElement.asJsonObject 567 Triple( 568 jsonObject.get("display_name").asString, 569 jsonObject.get("lat").asString, 570 jsonObject.get("lon").asString 571 ) 572 } 573 } 574 } 575 576 searchJob = null 577 } 578 579 TextField( 580 modifier = Modifier 581 .fillMaxWidth(), 582 value = locationName, 583 onValueChange = { 584 locationName = it.replace("\n", "") 585 if (locationName == "") { 586 addressResults = emptyList() 587 searchJob?.cancel() 588 searchJob = null 589 return@TextField 590 } 591 searchJob?.cancel() 592 searchJob = coroutineScope.launch { 593 delay(500) 594 search() 595 } 596 }, 597 label = { Text(text = "Search") }, 598 singleLine = true, 599 keyboardOptions = KeyboardOptions(imeAction = ImeAction.None) 600 ) 601 602 AutoClearKeyboardFocus(onFocusClear = { 603 locationName = "" 604 addressResults = emptyList() 605 searchJob?.cancel() 606 searchJob = null 607 }) 608 609 Column( 610 modifier = Modifier 611 .fillMaxWidth() 612 .background(MaterialTheme.colorScheme.background) 613 .verticalScroll(ScrollState(0)), 614 ) { 615 if (addressResults.isNotEmpty()) { 616 addressResults.forEach { address -> 617 Row( 618 modifier = Modifier 619 .fillMaxWidth() 620 .clickable { 621 marker.value?.position = GeoPoint(address.second.toDouble(), address.third.toDouble()) 622 mapView.value?.controller?.setCenter(marker.value?.position) 623 mapView.value?.invalidate() 624 } 625 ) { 626 Text( 627 text = address.first, 628 modifier = Modifier 629 .padding(10.dp) 630 .fillMaxWidth(), 631 ) 632 } 633 } 634 } else { 635 if (searchJob?.isActive == true) { 636 Row( 637 modifier = Modifier.fillMaxWidth().padding(10.dp), 638 horizontalArrangement = Arrangement.Center 639 ) { 640 CircularProgressIndicator() 641 } 642 } 643 } 644 } 645 } 646 647 648 Row( 649 modifier = Modifier 650 .align(Alignment.BottomEnd) 651 .padding(10.dp), 652 horizontalArrangement = Arrangement.spacedBy(10.dp), 653 ) { 654 FilledIconButton( 655 onClick = { 656 val lat = marker.value?.position?.latitude ?: coordinates.first 657 val lon = marker.value?.position?.longitude ?: coordinates.second 658 property.value.setAny(lat to lon) 659 dismiss() 660 }) { 661 Icon( 662 modifier = Modifier 663 .size(60.dp) 664 .padding(5.dp), 665 imageVector = Icons.Filled.Check, 666 contentDescription = null 667 ) 668 } 669 saveCoordinates?.let { 670 FilledIconButton( 671 onClick = { it() }) { 672 Icon( 673 modifier = Modifier 674 .size(60.dp) 675 .padding(5.dp), 676 imageVector = Icons.Filled.Save, 677 contentDescription = null 678 ) 679 } 680 } 681 682 FilledIconButton( 683 onClick = { 684 customCoordinatesDialog = true 685 }) { 686 Icon( 687 modifier = Modifier 688 .size(60.dp) 689 .padding(5.dp), 690 imageVector = Icons.Filled.Edit, 691 contentDescription = null 692 ) 693 } 694 } 695 696 if (customCoordinatesDialog) { 697 val lat = remember { mutableStateOf(coordinates.first.toString()) } 698 val lon = remember { mutableStateOf(coordinates.second.toString()) } 699 700 Dialog(onDismissRequest = { 701 customCoordinatesDialog = false 702 }) { 703 DefaultDialogCard( 704 modifier = Modifier.align(Alignment.Center) 705 ) { 706 TextField( 707 modifier = Modifier 708 .fillMaxWidth() 709 .padding(all = 10.dp), 710 value = lat.value, 711 onValueChange = { lat.value = it }, 712 label = { Text(text = "Latitude") }, 713 singleLine = true 714 ) 715 TextField( 716 modifier = Modifier 717 .fillMaxWidth() 718 .padding(all = 10.dp), 719 value = lon.value, 720 onValueChange = { lon.value = it }, 721 label = { Text(text = "Longitude") }, 722 singleLine = true 723 ) 724 Row( 725 modifier = Modifier.fillMaxWidth(), 726 horizontalArrangement = Arrangement.SpaceEvenly, 727 ) { 728 Button(onClick = { 729 customCoordinatesDialog = false 730 }) { 731 Text(text = translation["button.cancel"]) 732 } 733 734 Button(onClick = { 735 marker.value?.position = GeoPoint(lat.value.toDouble(), lon.value.toDouble()) 736 mapView.value?.controller?.setCenter(marker.value?.position) 737 mapView.value?.invalidate() 738 customCoordinatesDialog = false 739 }) { 740 Text(text = translation["button.ok"]) 741 } 742 } 743 } 744 } 745 } 746 } 747 } 748 }