Skip to content

Commit

Permalink
cleanup client code
Browse files Browse the repository at this point in the history
  • Loading branch information
avan1235 committed Feb 10, 2024
1 parent a53b00b commit 38a1dc7
Show file tree
Hide file tree
Showing 11 changed files with 340 additions and 294 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package `in`.procyk.shin

import App
import ShinApp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
Expand All @@ -12,13 +12,13 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)

setContent {
App()
ShinApp()
}
}
}

@Preview
@Composable
fun AppAndroidPreview() {
App()
ShinApp()
}
303 changes: 15 additions & 288 deletions composeApp/src/commonMain/kotlin/in/procyk/shin/App.kt
Original file line number Diff line number Diff line change
@@ -1,303 +1,30 @@
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import `in`.procyk.compose.qrcode.QrData
import `in`.procyk.compose.qrcode.options.*
import `in`.procyk.compose.qrcode.rememberQrCodePainter
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.resources.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.launch
import `in`.procyk.shin.ShortenRequest
import `in`.procyk.shin.ShortenResponse
import `in`.procyk.shin.createHttpClient

@Composable
fun App() {
val client = HttpClient {
install(Resources)
defaultRequest {
host = "api-shorten-kotlin.koyeb.app"
port = 443
url { protocol = URLProtocol.HTTPS }
}
}
fun ShinApp() {
val client = remember { createHttpClient() }
MaterialTheme {
var response by remember<MutableState<String?>> { mutableStateOf(null) }
var shortenedUrl by remember<MutableState<String?>> { mutableStateOf(null) }
Column(
modifier = Modifier
.fillMaxSize()
.onKeyEvent { event ->
if (event.run { key == Key.Escape && type == KeyEventType.KeyDown }) {
response = null
true
} else false
},
.onKeyEvent { event -> event.isEscDown.also { if (it) shortenedUrl = null } },
verticalArrangement = Arrangement.Center,
) {
ShortenRequest(client, onResponse = { response = it })

AnimatedVisibility(
visible = response != null,
enter = expandVertically(expandFrom = Alignment.Top),
exit = shrinkVertically(shrinkTowards = Alignment.Top),
) {
response?.let {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterVertically)
) {
val uri by remember(it) { mutableStateOf(it.normalizeAsHttpUrl()) }
Spacer(Modifier.height(16.dp))
val color = MaterialTheme.colors.primary
val annotatedString = remember(uri, color) {
buildAnnotatedString {
append(uri)
addStyle(
style = SpanStyle(
color = color,
fontSize = 18.sp,
),
start = 0,
end = uri.length,
)
addStyle(
style = ParagraphStyle(textAlign = TextAlign.Center),
start = 0,
end = uri.length,
)
addStringAnnotation(
tag = URL_TAG,
annotation = uri,
start = 0,
end = uri.length
)
}
}
val clipboardManager = LocalClipboardManager.current
val localUriHandler = LocalUriHandler.current
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
ClickableText(
text = annotatedString,
modifier = Modifier
.fillMaxWidth()
.hoverable(interactionSource),
style = if (isHovered) TextStyle(textDecoration = TextDecoration.Underline) else TextStyle.Default,
onClick = { position ->
annotatedString
.getStringAnnotations(URL_TAG, position, position)
.single()
.let {
clipboardManager.setText(AnnotatedString(uri))
localUriHandler.openUri(uri)
}
}
)
Spacer(Modifier.height(16.dp))
QrCode(uri)
Button(
onClick = { clipboardManager.setText(AnnotatedString(uri)) },
) {
Text("Copy to Clipboard")
}
}
}
}
ShortenRequest(client, onResponse = { shortenedUrl = it })
ShortenResponse(shortenedUrl)
}
}
}

@Composable
private fun ShortenRequest(
client: HttpClient,
onResponse: (String) -> Unit,
space: Dp = 8.dp,
) {
BoxWithConstraints {
val maxWidth = maxWidth
if (maxHeight > maxWidth) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(
space = space,
alignment = Alignment.CenterVertically,
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ShortenRequestElements(client, onResponse, fillMaxWidth = true, maxTextFieldWidth = maxWidth / 2)
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(
space = space,
alignment = Alignment.CenterHorizontally,
),
verticalAlignment = Alignment.CenterVertically,
) {
ShortenRequestElements(client, onResponse, fillMaxWidth = false, maxTextFieldWidth = maxWidth / 2)
}
}
}
}

@Composable
private fun QrCode(url: String) {
val primaryColor = MaterialTheme.colors.primary
val secondaryColor = MaterialTheme.colors.secondary
val painter = rememberQrCodePainter(
data = QrData.text(url),
) {
shapes {
ball = QrBallShape.circle()
darkPixel = QrPixelShape.roundCorners()
frame = QrFrameShape.roundCorners(.25f)
}
colors {
dark = QrBrush.brush {
Brush.linearGradient(
0f to secondaryColor,
1f to primaryColor,
end = Offset(it, it)
)
}
}
}
Image(painter, "QR code")
}

@Composable
private fun ShortenRequestElements(
client: HttpClient,
onResponse: (String) -> Unit,
fillMaxWidth: Boolean,
maxTextFieldWidth: Dp,
) {
var url by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
var shortenedProtocol by remember { mutableStateOf(ShortenedProtocol.entries.first()) }

ProtocolChooser(
protocol = shortenedProtocol,
onChange = { shortenedProtocol = it },
fillMaxWidth = fillMaxWidth
)
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = url,
onValueChange = { url = it },
modifier = Modifier
.focusRequester(focusRequester)
.height(50.dp)
.applyIf(fillMaxWidth) { fillMaxWidth() }
.applyIf(!fillMaxWidth) { widthIn(max = maxTextFieldWidth) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
scope.launch {
client.post<Shorten>(Shorten(normalizeUrl(url, shortenedProtocol)))
.takeIf { it.status == HttpStatusCode.OK }
?.bodyAsText()
?.let(onResponse)
}
}
),
singleLine = true,
)
Button(
modifier = Modifier.height(50.dp).applyIf(fillMaxWidth) { fillMaxWidth() },
onClick = {
scope.launch {
client.post<Shorten>(Shorten(normalizeUrl(url, shortenedProtocol)))
.takeIf { it.status == HttpStatusCode.OK }
?.bodyAsText()
?.let(onResponse)
}
}
) {
Text(text = "Shorten")
}
LaunchedEffect(focusRequester) {
focusRequester.requestFocus()
}
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun ProtocolChooser(
protocol: ShortenedProtocol,
onChange: (ShortenedProtocol) -> Unit,
fillMaxWidth: Boolean,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
value = protocol.presentableName,
onValueChange = {},
readOnly = true,
modifier = Modifier.height(50.dp).applyIf(fillMaxWidth) { fillMaxWidth() }.width(128.dp),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
ShortenedProtocol.entries.forEach { protocol ->
DropdownMenuItem(
content = { Text(protocol.presentableName) },
onClick = {
onChange(protocol)
expanded = false
})
}
}
}
}

private fun normalizeUrl(url: String, protocol: ShortenedProtocol): String {
val protocolDelimiter = "://"
val idx = url.indexOf(protocolDelimiter)
val noProtocolUrl = if (idx != -1) url.drop(idx + protocolDelimiter.length) else url
return protocol.presentableName + protocolDelimiter + noProtocolUrl
}

private enum class ShortenedProtocol(val presentableName: String) {
HTTPS("https"),
HTTP("http"),
;
}

private fun String.normalizeAsHttpUrl() = applyIf(!contains("://")) { "http://$this" }

private const val URL_TAG: String = "SHORT_URL_TAG"

private inline fun <T : Any> T.applyIf(condition: Boolean, action: T.() -> T): T = if (condition) action(this) else this
private val KeyEvent.isEscDown: Boolean
get() = key == Key.Escape && type == KeyEventType.KeyDown
15 changes: 15 additions & 0 deletions composeApp/src/commonMain/kotlin/in/procyk/shin/HttpClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package `in`.procyk.shin

import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.resources.*
import io.ktor.http.*

internal fun createHttpClient(): HttpClient = HttpClient {
install(Resources)
defaultRequest {
host = "api-shorten-kotlin.koyeb.app"
port = 443
url { protocol = URLProtocol.HTTPS }
}
}
35 changes: 35 additions & 0 deletions composeApp/src/commonMain/kotlin/in/procyk/shin/QrCode.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package `in`.procyk.shin

import androidx.compose.foundation.Image
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import `in`.procyk.compose.qrcode.QrData
import `in`.procyk.compose.qrcode.options.*
import `in`.procyk.compose.qrcode.rememberQrCodePainter

@Composable
internal fun QrCode(url: String) {
val primaryColor = MaterialTheme.colors.primary
val secondaryColor = MaterialTheme.colors.secondary
val painter = rememberQrCodePainter(
data = QrData.text(url),
) {
shapes {
ball = QrBallShape.circle()
darkPixel = QrPixelShape.roundCorners()
frame = QrFrameShape.roundCorners(.25f)
}
colors {
dark = QrBrush.brush {
Brush.linearGradient(
0f to secondaryColor,
1f to primaryColor,
end = Offset(it, it)
)
}
}
}
Image(painter, "QR code")
}
Loading

0 comments on commit 38a1dc7

Please sign in to comment.