diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index ee415f9..baa6dfe 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -75,7 +75,7 @@ android { minSdk = 26 targetSdk = 36 versionCode = 14 - versionName = "0.4.5" + versionName = "0.5.0" } compileOptions { diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/data/ArcadeLocations.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/data/ArcadeLocations.kt index 44de1c1..22e5f7f 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/data/ArcadeLocations.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/data/ArcadeLocations.kt @@ -9,9 +9,33 @@ import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt -private const val ARCADE_LOCATIONS_URL = "https://location.am-all.net/alm/location?gm=98&lang=en&ct=1009" +private const val ARCADE_LOCATIONS_BASE_URL = "https://location.am-all.net/alm/location?gm=98&lang=en" private const val EARTH_RADIUS_KM = 6371.0 +data class ArcadeCountry( + val code: String, + val name: String, +) + +val northAmericaArcadeCountry = ArcadeCountry("1009", "North America") + +val arcadeLocationCountries = listOf( + ArcadeCountry("1001", "Taiwan"), + ArcadeCountry("1002", "Hong Kong"), + ArcadeCountry("1003", "Singapore"), + ArcadeCountry("1004", "Malaysia"), + ArcadeCountry("1005", "South Korea"), + ArcadeCountry("1006", "Thailand"), + ArcadeCountry("1007", "Indonesia"), + ArcadeCountry("1008", "Macau"), + northAmericaArcadeCountry, + ArcadeCountry("1010", "Philippines"), + ArcadeCountry("1011", "Vietnam"), + ArcadeCountry("1012", "Australia"), + ArcadeCountry("1013", "Myanmar"), + ArcadeCountry("1014", "New Zealand"), +) + @Serializable data class ArcadeLocation( val name: String, @@ -19,6 +43,8 @@ data class ArcadeLocation( val latitude: Double, val longitude: Double, val mapsUrl: String, + val countryCode: String = northAmericaArcadeCountry.code, + val countryName: String = northAmericaArcadeCountry.name, ) @Serializable @@ -35,14 +61,21 @@ class ArcadeLocationClient( suspend fun fetchLocations(forceRefresh: Boolean = false): List { if (!forceRefresh) cache.getArcadeLocations()?.let { return it } - val html: String = httpClient.get(ARCADE_LOCATIONS_URL).body() - return parseArcadeLocations(html).also { cache.putArcadeLocations(it) } + return fetchLocations(northAmericaArcadeCountry).also { cache.putArcadeLocations(it) } + } + + suspend fun fetchLocations(country: ArcadeCountry): List { + val html: String = httpClient.get("$ARCADE_LOCATIONS_BASE_URL&ct=${country.code}").body() + return parseArcadeLocations(html, country) } + fun cacheLocations(locations: List) = cache.putArcadeLocations(locations.deduplicatedArcadeLocations()) + + fun cachedFreshLocations(): List? = cache.getArcadeLocations() fun cachedLocations(): List? = cache.getArcadeLocationsForce() } -fun parseArcadeLocations(html: String): List { +fun parseArcadeLocations(html: String, country: ArcadeCountry = northAmericaArcadeCountry): List { val itemRegex = Regex("
  • ([\\s\\S]*?)
  • ", RegexOption.IGNORE_CASE) val nameRegex = Regex("([\\s\\S]*?)", RegexOption.IGNORE_CASE) val addressRegex = Regex("([\\s\\S]*?)", RegexOption.IGNORE_CASE) @@ -63,10 +96,49 @@ fun parseArcadeLocations(html: String): List { latitude = latitude, longitude = longitude, mapsUrl = map.groupValues[1].toAbsoluteMapsUrl(), + countryCode = country.code, + countryName = country.name, ) }.distinctBy { it.name to it.address }.toList() } +fun preferredArcadeCountryFor(location: UserLocation?): ArcadeCountry { + if (location == null) return northAmericaArcadeCountry + return arcadeCountryAreas.firstOrNull { it.contains(location.latitude, location.longitude) }?.country + ?: northAmericaArcadeCountry +} + +fun arcadeCountriesStartingWith(preferred: ArcadeCountry): List = + listOf(preferred) + arcadeLocationCountries.filterNot { it.code == preferred.code } + +fun List.deduplicatedArcadeLocations(): List = + distinctBy { listOf(it.name, it.address, it.latitude, it.longitude) } + +private data class ArcadeCountryArea( + val country: ArcadeCountry, + val latitudeRange: ClosedFloatingPointRange, + val longitudeRange: ClosedFloatingPointRange, +) { + fun contains(latitude: Double, longitude: Double): Boolean = latitude in latitudeRange && longitude in longitudeRange +} + +private val arcadeCountryAreas = listOf( + ArcadeCountryArea(ArcadeCountry("1008", "Macau"), 22.05..22.25, 113.50..113.70), + ArcadeCountryArea(ArcadeCountry("1002", "Hong Kong"), 22.10..22.60, 113.80..114.50), + ArcadeCountryArea(ArcadeCountry("1003", "Singapore"), 1.10..1.60, 103.50..104.10), + ArcadeCountryArea(ArcadeCountry("1001", "Taiwan"), 21.80..25.40, 119.30..122.10), + ArcadeCountryArea(ArcadeCountry("1010", "Philippines"), 4.50..21.50, 116.00..127.00), + ArcadeCountryArea(ArcadeCountry("1005", "South Korea"), 33.00..38.70, 124.50..132.00), + ArcadeCountryArea(ArcadeCountry("1006", "Thailand"), 5.50..20.80, 97.30..105.80), + ArcadeCountryArea(ArcadeCountry("1011", "Vietnam"), 8.00..23.50, 102.00..110.00), + ArcadeCountryArea(ArcadeCountry("1013", "Myanmar"), 9.50..28.80, 92.00..101.30), + ArcadeCountryArea(ArcadeCountry("1004", "Malaysia"), 0.80..7.50, 99.50..119.50), + ArcadeCountryArea(ArcadeCountry("1007", "Indonesia"), -11.20..6.30, 94.50..141.50), + ArcadeCountryArea(ArcadeCountry("1014", "New Zealand"), -48.00..-33.00, 165.00..179.90), + ArcadeCountryArea(ArcadeCountry("1012", "Australia"), -44.00..-10.00, 112.00..154.00), + ArcadeCountryArea(northAmericaArcadeCountry, 24.00..84.00, -170.00..-52.00), +) + fun distanceKm(from: UserLocation, to: ArcadeLocation): Double = distanceKm(from.latitude, from.longitude, to.latitude, to.longitude) fun distanceKm(fromLatitude: Double, fromLongitude: Double, toLatitude: Double, toLongitude: Double): Double { diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/data/DiscordCommunities.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/data/DiscordCommunities.kt new file mode 100644 index 0000000..a68f65b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/data/DiscordCommunities.kt @@ -0,0 +1,212 @@ +package com.derakkuma.data + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.coroutines.CancellationException +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +data class DiscordInvite( + val regionName: String, + val code: String, + val url: String, +) + +data class DiscordInvitePreview( + val serverName: String, + val iconUrl: String?, + val approximateMemberCount: Int?, + val approximatePresenceCount: Int?, +) + +private val southernCaliforniaDiscord = DiscordInvite( + regionName = "Southern California", + code = "PqRnZtgB3e", + url = "https://discord.gg/PqRnZtgB3e", +) + +private val northernCaliforniaDiscord = DiscordInvite( + regionName = "Northern California", + code = "pkry8GjH9U", + url = "https://discord.gg/pkry8GjH9U", +) + +private val pacificNorthwestDiscord = DiscordInvite( + regionName = "Pacific Northwest", + code = "Jp74Dbvgqs", + url = "https://discord.gg/Jp74Dbvgqs", +) + +private val texasDiscord = DiscordInvite( + regionName = "Texas", + code = "ra2zkj3k4b", + url = "https://discord.gg/ra2zkj3k4b", +) + +private val DiscordRegions = listOf( + DiscordRegion( + invite = southernCaliforniaDiscord, + polygon = listOf( + GeoPoint(-121.157227, 34.903953), + GeoPoint(-117.743618, 35.604556), + GeoPoint(-116.073696, 32.714222), + GeoPoint(-117.238247, 32.603229), + GeoPoint(-121.157227, 34.903953), + ), + ), + DiscordRegion( + invite = northernCaliforniaDiscord, + polygon = listOf( + GeoPoint(-125.134277, 41.983994), + GeoPoint(-119.926758, 41.95132), + GeoPoint(-118.564453, 36.297418), + GeoPoint(-122.958984, 36.297418), + GeoPoint(-125.134277, 41.983994), + ), + ), + DiscordRegion( + invite = pacificNorthwestDiscord, + polygon = listOf( + GeoPoint(-124.734846, 48.41496), + GeoPoint(-122.915039, 48.078079), + GeoPoint(-122.790266, 49.038206), + GeoPoint(-116.956526, 48.994974), + GeoPoint(-116.966728, 42.360066), + GeoPoint(-124.305595, 42.262574), + GeoPoint(-124.734846, 48.41496), + ), + ), + DiscordRegion( + invite = texasDiscord, + polygon = listOf( + GeoPoint(-106.655273, 31.970804), + GeoPoint(-103.051758, 32.008076), + GeoPoint(-103.065883, 36.651619), + GeoPoint(-100.033657, 36.651619), + GeoPoint(-100.033657, 34.742459), + GeoPoint(-94.07122, 33.981517), + GeoPoint(-94.01158, 30.300575), + GeoPoint(-96.877441, 25.977799), + GeoPoint(-99.030762, 26.39187), + GeoPoint(-99.923793, 27.975908), + GeoPoint(-101.395961, 29.746196), + GeoPoint(-102.670376, 29.860595), + GeoPoint(-103.139648, 28.960089), + GeoPoint(-104.567871, 29.61167), + GeoPoint(-106.655273, 31.970804), + ), + ), +) + +fun localDiscordFor(location: ArcadeLocation): DiscordInvite? { + val point = GeoPoint(location.longitude, location.latitude) + return DiscordRegions.firstOrNull { it.contains(point) }?.invite +} + +private data class GeoPoint(val longitude: Double, val latitude: Double) + +private data class DiscordRegion( + val invite: DiscordInvite, + val polygon: List, +) { + fun contains(point: GeoPoint): Boolean = polygon.containsPoint(point) +} + +private fun List.containsPoint(point: GeoPoint): Boolean { + if (size < 3) return false + + var inside = false + var previous = last() + + for (current in this) { + if (point.isOnSegment(previous, current)) return true + + val crossesLatitude = (current.latitude > point.latitude) != (previous.latitude > point.latitude) + if (crossesLatitude) { + val intersectionLongitude = (previous.longitude - current.longitude) * + (point.latitude - current.latitude) / + (previous.latitude - current.latitude) + + current.longitude + if (point.longitude < intersectionLongitude) inside = !inside + } + + previous = current + } + + return inside +} + +private fun GeoPoint.isOnSegment(start: GeoPoint, end: GeoPoint): Boolean { + val epsilon = 0.000000001 + val cross = (latitude - start.latitude) * (end.longitude - start.longitude) - + (longitude - start.longitude) * (end.latitude - start.latitude) + if (abs(cross) > epsilon) return false + + return longitude >= min(start.longitude, end.longitude) - epsilon && + longitude <= max(start.longitude, end.longitude) + epsilon && + latitude >= min(start.latitude, end.latitude) - epsilon && + latitude <= max(start.latitude, end.latitude) + epsilon +} + +class DiscordInviteClient( + private val httpClient: HttpClient = HttpClient(), +) { + private val json = Json { ignoreUnknownKeys = true } + + suspend fun fetchInvitePreview(invite: DiscordInvite): DiscordInvitePreview { + val fallback = DiscordInvitePreview( + serverName = invite.regionName, + iconUrl = null, + approximateMemberCount = null, + approximatePresenceCount = null, + ) + + return try { + val response = httpClient + .get("https://discord.com/api/v10/invites/${invite.code}?with_counts=true") { + header(HttpHeaders.Accept, "application/json") + header(HttpHeaders.UserAgent, "Derakkuma/1.0") + } + val body = response.bodyAsText() + println("[discord] invite ${invite.code} -> ${response.status.value} (${body.length} chars): ${body.take(240)}") + + val decoded = json.parseToJsonElement(body).jsonObject + val guild = decoded["guild"]?.jsonObject + val profile = decoded["profile"]?.jsonObject + val guildId = guild?.get("id")?.jsonPrimitive?.contentOrNull + val guildIcon = guild?.get("icon")?.jsonPrimitive?.contentOrNull + DiscordInvitePreview( + serverName = guild?.get("name")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: invite.regionName, + iconUrl = discordIconUrl(guildId, guildIcon), + approximateMemberCount = decoded["approximate_member_count"]?.jsonPrimitive?.intOrNull + ?: profile?.get("member_count")?.jsonPrimitive?.intOrNull, + approximatePresenceCount = decoded["approximate_presence_count"]?.jsonPrimitive?.intOrNull + ?: profile?.get("online_count")?.jsonPrimitive?.intOrNull, + ).also { preview -> + println( + "[discord] parsed ${invite.code}: name=${preview.serverName}, " + + "members=${preview.approximateMemberCount}, online=${preview.approximatePresenceCount}, icon=${preview.iconUrl}", + ) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + println("[discord] invite ${invite.code} failed: ${error.message}") + fallback + } + } +} + +private fun discordIconUrl(guildId: String?, guildIcon: String?): String? { + if (guildId.isNullOrBlank() || guildIcon.isNullOrBlank()) return null + return "https://cdn.discordapp.com/icons/$guildId/$guildIcon.png" +} diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/MapTab.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/MapTab.kt index 580bda7..021613e 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/MapTab.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/MapTab.kt @@ -1,28 +1,44 @@ package com.derakkuma.ui.tabs +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import com.derakkuma.atproto.openOAuthUrl import com.derakkuma.data.ArcadeLocation import com.derakkuma.data.ArcadeLocationClient import com.derakkuma.data.CacheStore +import com.derakkuma.data.DiscordInviteClient +import com.derakkuma.data.DiscordInvitePreview +import com.derakkuma.data.DiscordInvite import com.derakkuma.data.UserLocation +import com.derakkuma.data.arcadeCountriesStartingWith +import com.derakkuma.data.deduplicatedArcadeLocations import com.derakkuma.data.distanceKm +import com.derakkuma.data.localDiscordFor +import com.derakkuma.data.preferredArcadeCountryFor import com.derakkuma.location.UserLocationProvider import com.derakkuma.maps.openMapLocation +import com.derakkuma.ui.components.ExpressiveButton import com.derakkuma.ui.components.ExpressiveGlassCard import com.derakkuma.ui.components.ExpressiveTextButton -import com.derakkuma.ui.components.ExpressiveTopLoadingIndicator import com.swmansion.kmpmaps.core.CameraPosition import com.swmansion.kmpmaps.core.ClusterSettings import com.swmansion.kmpmaps.core.Coordinates @@ -37,59 +53,138 @@ import kotlinx.coroutines.launch fun MapTab(cacheStore: CacheStore) { val scope = rememberCoroutineScope() val locationClient = remember { ArcadeLocationClient(cacheStore) } + val discordInviteClient = remember { DiscordInviteClient() } val locationProvider = remember { UserLocationProvider() } + val listState = rememberLazyListState() var locations by remember { mutableStateOf(locationClient.cachedLocations().orEmpty()) } + val discordPreviews = remember { mutableStateMapOf() } var userLocation by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(locations.isEmpty()) } + var loadingMoreRegions by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } + var listResetRequest by remember { mutableStateOf(0) } + var consumedListResetRequest by remember { mutableStateOf(0) } + val requestedDiscordPreviews = remember { mutableStateMapOf() } fun refresh(force: Boolean = false) { scope.launch { loading = true + loadingMoreRegions = false error = null - try { - locations = locationClient.fetchLocations(forceRefresh = force) - } catch (e: Exception) { - if (locations.isEmpty()) error = e.message ?: "Could not load arcade locations" + + var cachedCountryCodes = if (force) emptySet() else locations.map { it.countryCode }.toSet() + + if (!force) { + locationClient.cachedFreshLocations()?.let { + locations = it + cachedCountryCodes = it.map { location -> location.countryCode }.toSet() + loading = false + } + } + + val currentLocation = userLocation ?: locationProvider.requestLocation().also { userLocation = it } + val preferredCountry = preferredArcadeCountryFor(currentLocation) + val countriesToFetch = arcadeCountriesStartingWith(preferredCountry).filter { force || it.code !in cachedCountryCodes } + + if (countriesToFetch.isEmpty()) { + loading = false + listResetRequest += 1 + return@launch + } + + if (locations.isNotEmpty()) { + loading = false + listResetRequest += 1 + } + + val remainingCountries = if (locations.isEmpty()) { + try { + val preferredLocations = locationClient.fetchLocations(countriesToFetch.first()) + locations = (preferredLocations + locations).deduplicatedArcadeLocations() + locationClient.cacheLocations(locations) + listResetRequest += 1 + } catch (e: Exception) { + if (locations.isEmpty()) error = e.message ?: "Could not load arcade locations" + } + loading = false + countriesToFetch.drop(1) + } else { + countriesToFetch + } + + loadingMoreRegions = true + remainingCountries.forEach { country -> + try { + val countryLocations = locationClient.fetchLocations(country) + locations = (locations + countryLocations).deduplicatedArcadeLocations() + locationClient.cacheLocations(locations) + } catch (_: Exception) { + // Keep showing the map/list as soon as the preferred region is available. + } } - loading = false + loadingMoreRegions = false } } LaunchedEffect(Unit) { - launch { refresh(force = false) } - launch { userLocation = locationProvider.requestLocation() } + refresh(force = false) } val sortedLocations = remember(locations, userLocation) { val current = userLocation if (current != null) locations.sortedBy { distanceKm(current, it) } else locations.sortedBy { it.name } } - val mapLocations = remember(sortedLocations) { sortedLocations.take(80) } + val mapLocations = sortedLocations + val listItems = remember(sortedLocations) { + val shownDiscords = mutableSetOf() + buildList { + sortedLocations.forEach { location -> + localDiscordFor(location)?.let { invite -> + if (shownDiscords.add(invite.code)) add(MapListItem.Discord(invite)) + } + add(MapListItem.Arcade(location)) + } + } + } + val discordInvites = remember(listItems) { + listItems.mapNotNull { (it as? MapListItem.Discord)?.invite }.distinctBy { it.code } + } + val discordInviteCodes = remember(discordInvites) { discordInvites.map { it.code } } val mapCenter = remember(mapLocations, userLocation) { userLocation?.let { Coordinates(it.latitude, it.longitude) } ?: mapLocations.firstOrNull()?.let { Coordinates(it.latitude, it.longitude) } ?: Coordinates(39.5, -98.35) } + LaunchedEffect(discordInviteCodes) { + discordInvites.forEach { invite -> + if (!discordPreviews.containsKey(invite.code) && requestedDiscordPreviews[invite.code] != true) { + requestedDiscordPreviews[invite.code] = true + scope.launch { discordPreviews[invite.code] = discordInviteClient.fetchInvitePreview(invite) } + } + } + } + + LaunchedEffect(listResetRequest, listItems.isNotEmpty()) { + if (listResetRequest > consumedListResetRequest && listItems.isNotEmpty()) { + listState.scrollToItem(0) + consumedListResetRequest = listResetRequest + } + } + Column(Modifier.fillMaxSize().padding(start = 12.dp, end = 12.dp, top = 12.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Column { Text("Cab Map", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) - Text(locationStatus(userLocation, locations.size), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(locationStatus(userLocation, locations.size, loadingMoreRegions), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } - ExpressiveTextButton(onClick = { refresh(force = true) }, enabled = !loading) { + ExpressiveTextButton(onClick = { refresh(force = true) }, enabled = !loading && !loadingMoreRegions) { Text("Refresh") } } Spacer(Modifier.height(8.dp)) - if (loading) { - ExpressiveTopLoadingIndicator(color = MaterialTheme.colorScheme.primary, trackColor = androidx.compose.ui.graphics.Color.Transparent) - Spacer(Modifier.height(6.dp)) - } - ExpressiveGlassCard(Modifier.fillMaxWidth().height(260.dp), cornerRadius = 28.dp) { if (locations.isEmpty() && loading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Loading arcade locations…", color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -122,10 +217,104 @@ fun MapTab(cacheStore: CacheStore) { Spacer(Modifier.height(8.dp)) - LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 88.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - items(sortedLocations) { location -> - ArcadeLocationRow(location, userLocation) + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(bottom = 88.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items( + items = listItems, + key = { item -> + when (item) { + is MapListItem.Discord -> "discord-${item.invite.code}" + is MapListItem.Arcade -> "arcade-${item.location.name}-${item.location.address}" + } + }, + ) { item -> + when (item) { + is MapListItem.Discord -> DiscordInviteCard(item.invite, discordPreviews[item.invite.code]) + is MapListItem.Arcade -> ArcadeLocationRow(item.location, userLocation) + } + } + } + } +} + +private sealed interface MapListItem { + data class Discord(val invite: DiscordInvite) : MapListItem + data class Arcade(val location: ArcadeLocation) : MapListItem +} + +@Composable +private fun DiscordInviteCard(invite: DiscordInvite, preview: DiscordInvitePreview?) { + ExpressiveGlassCard( + modifier = Modifier.fillMaxWidth(), + cornerRadius = 24.dp, + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.36f), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DiscordServerIcon(preview?.iconUrl, preview?.serverName ?: invite.regionName) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + "Join your local maimai Discord!", + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + lineHeight = 17.sp, + ) + Text( + "${preview?.serverName ?: invite.regionName} · not officially affiliated", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 15.sp, + ) + preview?.memberText()?.let { memberText -> + Text(memberText, fontSize = 11.sp, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + } } + ExpressiveButton( + onClick = { openOAuthUrl(invite.url) }, + modifier = Modifier.height(38.dp), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + ) { + Text("Join", fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +private fun DiscordServerIcon(iconUrl: String?, serverName: String) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + if (!iconUrl.isNullOrBlank()) { + val context = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(context) + .data(iconUrl) + .diskCacheKey(iconUrl) + .build(), + contentDescription = "$serverName Discord server icon", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + serverName.firstOrNull()?.uppercaseChar()?.toString() ?: "♪", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) } } } @@ -149,12 +338,29 @@ private fun ArcadeLocationRow(location: ArcadeLocation, userLocation: UserLocati } } -private fun locationStatus(userLocation: UserLocation?, count: Int): String = when { - userLocation == null -> "$count locations · location off" - userLocation.isPrecise -> "$count locations · sorted by precise location" - else -> "$count locations · sorted by approximate location" +private fun locationStatus(userLocation: UserLocation?, count: Int, loadingMoreRegions: Boolean): String { + val base = when { + userLocation == null -> "$count locations · location off" + userLocation.isPrecise -> "$count locations · sorted by precise location" + else -> "$count locations · sorted by approximate location" + } + return if (loadingMoreRegions) "$base · loading more regions" else base } private fun formatDistance(km: Double): String = "${(km * 0.621371).round1()} mi" +private fun DiscordInvitePreview.memberText(): String? { + val parts = listOfNotNull( + approximateMemberCount?.let { "${it.compactCount()} members" }, + approximatePresenceCount?.let { "${it.compactCount()} online" }, + ) + return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") +} + +private fun Int.compactCount(): String = when { + this >= 1_000_000 -> "${(this / 100_000) / 10.0}m" + this >= 1_000 -> "${(this / 100) / 10.0}k" + else -> toString() +} + private fun Double.round1(): String = (kotlin.math.round(this * 10) / 10).toString()