package com.vgmlr.stave
import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color as AndroidColor
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.graphics.Point
import android.graphics.RectF
import android.graphics.Typeface
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.util.TypedValue
import android.view.MotionEvent
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.material.icons.outlined.Receipt
import androidx.compose.material.icons.outlined.Signpost
import androidx.compose.material.icons.outlined.Terrain
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.edit
import androidx.core.graphics.withClip
import androidx.core.view.drawToBitmap
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Overlay
import org.osmdroid.views.overlay.Polyline
private const val MIN_ZOOM = 3.0
private const val FOCUS_ZOOM = 15.0
private const val FIT_PAD = 96f
private const val LINE_W = 3f
private const val DOT_R = 5f
private const val TAG_FS = 13f
private const val TAG_PAD = 6f
private const val TAG_BAR = 3f
private const val TAG_RADIUS = 6f
private const val TAG_LIFT = 10f
private const val TAG_STACK = 4f
private const val TAG_MAX = 28
private val TAG_BG = 0xE6151515.toInt()
private const val KEY_STYLE = "map_style"
private const val ATTRIBUTION = "\u00A9 OpenStreetMap contributors"
private const val OSM_COPYRIGHT = "https://www.openstreetmap.org/copyright"
private const val ATTR_ALPHA = 0.6f
private const val ATTR_FS = 12f
private const val ATTR_PAD = 8f
// invert, hue 180
private val DARK_FILTER = ColorMatrixColorFilter(
ColorMatrix(
floatArrayOf(
-1f, 0f, 0f, 0f, 255f,
0f, -1f, 0f, 0f, 255f,
0f, 0f, -1f, 0f, 255f,
0f, 0f, 0f, 1f, 0f
)
).apply {
postConcat(
ColorMatrix(
floatArrayOf(
-0.574f, 1.430f, 0.144f, 0f, 0f,
0.426f, 0.430f, 0.144f, 0f, 0f,
0.426f, 1.430f, -0.856f, 0f, 0f,
0f, 0f, 0f, 1f, 0f
)
)
)
}
)
internal enum class MapStyle { STANDARD, DARK }
internal data class MapPin(val entry: StaveEntry, val point: GeoPoint)
internal class MapHolder(val map: MapView, val line: Polyline, val tags: TagOverlay)
class StaveMap : ComponentActivity() {
private lateinit var db: StaveDatabase
private lateinit var themeManager: StaveThemeManager
private val prefs by lazy { getSharedPreferences("stave_prefs", MODE_PRIVATE) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(AndroidColor.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(AndroidColor.TRANSPARENT)
)
window.isNavigationBarContrastEnforced = false
Configuration.getInstance().apply {
userAgentValue = packageName
osmdroidBasePath = File(cacheDir, "osmdroid")
osmdroidTileCache = File(osmdroidBasePath, "tiles")
}
db = StaveDatabase(this)
themeManager = StaveThemeManager(this)
val trip = db.getTrip(intent.getIntExtra(EXTRA_TRIP, -1))
if (trip == null) {
finish()
return
}
val focus = intent.getIntExtra(EXTRA_FOCUS, -1).takeIf { it > 0 }
setContent {
StaveTheme(themeManager) {
MapScreen(trip, focus)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun MapScreen(trip: StaveTrip, focus: Int?) {
val lifecycleOwner = LocalLifecycleOwner.current
val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
var style by remember { mutableStateOf(loadStyle()) }
val holder = remember {
buildMap(
ink = themeManager.textColor.value.toArgb(),
accent = themeManager.accentColor.value.toArgb()
).also { applyStyle(it.map, style) }
}
var pins by remember { mutableStateOf<List<MapPin>?>(null) }
val ink = if (style == MapStyle.DARK) Color.White else Color.Black
val pick: (MapStyle) -> Unit = { s ->
style = s
applyStyle(holder.map, s)
saveStyle(s)
}
LaunchedEffect(Unit) {
val resolved = resolve(db.getEntries(trip.id))
holder.line.setPoints(resolved.map { it.point })
holder.tags.pins = resolved
holder.tags.expanded = focus?.takeIf { f -> resolved.any { it.entry.id == f } }
holder.map.invalidate()
fit(holder.map, resolved, focus)
pins = resolved
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> holder.map.onResume()
Lifecycle.Event.ON_PAUSE -> holder.map.onPause()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
holder.map.onDetach()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
actions = {
Spacer(Modifier.width(BAR_EDGE))
StaveButton(
Icons.AutoMirrored.Filled.ArrowBack,
"Back"
) { finish() }
Spacer(Modifier.width(BAR_GAP))
StaveBlankButton(
Modifier.weight(1f),
label = trip.name.ifBlank { UNNAMED },
labelAlignment = Alignment.CenterStart
)
Spacer(Modifier.width(BAR_GAP))
Box {
var expanded by remember { mutableStateOf(false) }
StaveButton(Icons.Outlined.Receipt, "Menu") { expanded = true }
StaveMenu(expanded = expanded, onDismiss = { expanded = false }) {
StaveMenuItem("Standard", Icons.Outlined.Signpost) {
pick(MapStyle.STANDARD); expanded = false
}
StaveMenuItem("Dark", Icons.Outlined.Terrain) {
pick(MapStyle.DARK); expanded = false
}
StaveMenuItem("Capture", Icons.Outlined.PhotoCamera) {
expanded = false
scope.launch { capture(holder.map, ink.toArgb()) }
}
}
}
Spacer(Modifier.width(BAR_EDGE_ACTIONS))
}
)
},
contentWindowInsets = WindowInsets(0, 0, 0, 0),
containerColor = themeManager.backgroundColor.value
) { _ ->
Box(Modifier.fillMaxSize()) {
AndroidView(factory = { holder.map }, modifier = Modifier.fillMaxSize())
val state = pins
if (state.isNullOrEmpty()) {
Text(
if (state == null) "Locating" else "No locations",
color = ink.copy(alpha = 0.6f),
fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
modifier = Modifier.align(Alignment.Center)
)
}
Text(
ATTRIBUTION,
color = ink.copy(alpha = ATTR_ALPHA),
fontSize = ATTR_FS.sp,
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = ATTR_PAD.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { uriHandler.openUri(OSM_COPYRIGHT) }
)
}
}
}
private fun loadStyle(): MapStyle =
runCatching { MapStyle.valueOf(prefs.getString(KEY_STYLE, null).orEmpty()) }
.getOrDefault(MapStyle.STANDARD)
private fun saveStyle(s: MapStyle) = prefs.edit { putString(KEY_STYLE, s.name) }
private fun applyStyle(map: MapView, s: MapStyle) {
map.overlayManager.tilesOverlay.setColorFilter(if (s == MapStyle.DARK) DARK_FILTER else null)
map.invalidate()
}
// map only
private suspend fun capture(map: MapView, ink: Int) {
val bmp = map.drawToBitmap()
val m = resources.displayMetrics
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ink
alpha = (ATTR_ALPHA * 255).toInt()
textAlign = Paint.Align.CENTER
textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, ATTR_FS, m)
}
Canvas(bmp).drawText(ATTRIBUTION, bmp.width / 2f, bmp.height - ATTR_PAD * m.density, paint)
val ok = save(bmp)
Toast.makeText(this, if (ok) "Saved" else "Failed", Toast.LENGTH_SHORT).show()
}
private suspend fun save(bmp: Bitmap): Boolean = withContext(Dispatchers.IO) {
val values = ContentValues().apply {
put(
MediaStore.Images.Media.DISPLAY_NAME,
"stave_map_${StaveOTCClock.getFileNameTimestamp()}.png"
)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Stave")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: return@withContext false
val ok = runCatching {
contentResolver.openOutputStream(uri)
?.use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) } == true
}.getOrDefault(false)
if (ok) {
contentResolver.update(
uri,
ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) },
null,
null
)
} else {
contentResolver.delete(uri, null, null)
}
ok
}
private fun buildMap(ink: Int, accent: Int): MapHolder {
val dp = resources.displayMetrics.density
val map = MapView(this).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
isTilesScaledToDpi = true
minZoomLevel = MIN_ZOOM
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
controller.setZoom(MIN_ZOOM)
}
val line = Polyline(map).apply {
outlinePaint.color = accent
outlinePaint.strokeWidth = LINE_W * dp
infoWindow = null
}
val tags = TagOverlay(dp, ink, accent)
map.overlays.add(line)
map.overlays.add(tags)
return MapHolder(map, line, tags)
}
// oldest first
private suspend fun resolve(entries: List<StaveEntry>): List<MapPin> {
val cache = HashMap<String, GeoPoint?>()
return entries.asReversed().mapNotNull { e ->
val lat = e.lat
val lng = e.lng
val point = if (lat != null && lng != null) {
GeoPoint(lat, lng)
} else {
val key = e.location.trim()
if (key.isEmpty()) null
else (
if (key in cache) cache[key]
else StaveLocator.lookup(this, key)
?.let { GeoPoint(it.lat, it.lng) }
.also { cache[key] = it }
)?.also { db.updateEntryLatLng(e.id, it.latitude, it.longitude) }
}
point?.let { MapPin(e, it) }
}
}
private fun fit(map: MapView, pins: List<MapPin>, focus: Int?) {
if (pins.isEmpty()) return
val go = {
val single = pins.firstOrNull { it.entry.id == focus }
?: pins.distinctBy { it.point.latitude to it.point.longitude }.singleOrNull()
if (single != null) {
map.controller.setZoom(FOCUS_ZOOM)
map.controller.setCenter(single.point)
} else {
map.zoomToBoundingBox(
BoundingBox.fromGeoPointsSafe(pins.map { it.point }),
false,
(FIT_PAD * resources.displayMetrics.density).toInt()
)
}
}
if (map.isLayoutOccurred) go() else map.addOnFirstLayoutListener { _, _, _, _, _ -> go() }
}
companion object {
private const val EXTRA_TRIP = "trip"
private const val EXTRA_FOCUS = "focus"
fun open(context: Context, tripId: Int, focus: Int? = null) {
context.startActivity(
Intent(context, StaveMap::class.java)
.putExtra(EXTRA_TRIP, tripId)
.apply { focus?.let { putExtra(EXTRA_FOCUS, it) } }
)
}
}
}
internal class TagOverlay(
private val dp: Float,
private val ink: Int,
private val loc: Int
) : Overlay() {
var pins: List<MapPin> = emptyList()
var expanded: Int? = null
private class Tag(val pin: MapPin, val rect: RectF, val lines: List<Pair<String, Int>>)
private val tags = ArrayList<Tag>()
private val pt = Point()
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = TAG_FS * dp
typeface = Typeface.MONOSPACE
}
private fun String.cap() = if (length > TAG_MAX) take(TAG_MAX - 1) + "\u2026" else this
private fun MapPin.stackKey(): String =
entry.location.trim().lowercase().ifEmpty { "${point.latitude},${point.longitude}" }
private fun lines(pin: MapPin): List<Pair<String, Int>> {
val cost = money(pin.entry.cost) to ink
if (pin.entry.id != expanded) return listOf(cost)
return listOfNotNull(
cost,
pin.entry.label.takeIf { it.isNotBlank() }?.let { it.cap() to ink },
pin.entry.location.takeIf { it.isNotBlank() }?.let { it.cap() to loc }
)
}
override fun draw(c: Canvas, map: MapView, shadow: Boolean) {
if (shadow) return
tags.clear()
val pj = map.projection
val lineH = text.fontSpacing
val pad = TAG_PAD * dp
val bar = TAG_BAR * dp
pins.forEach { pin ->
pj.toPixels(pin.point, pt)
fill.color = StaveCategory.color(pin.entry.category).toArgb()
c.drawCircle(pt.x.toFloat(), pt.y.toFloat(), DOT_R * dp, fill)
}
// location stacks
pins.groupBy { it.stackKey() }.values.forEach { group ->
pj.toPixels(group.first().point, pt)
val x = pt.x.toFloat()
var bottom = pt.y - TAG_LIFT * dp
group.forEach { pin ->
val ls = lines(pin)
val w = ls.maxOf { text.measureText(it.first) } + pad * 2 + bar
val h = lineH * ls.size + pad * 2
val r = RectF(x - w / 2f, bottom - h, x + w / 2f, bottom)
tags.add(Tag(pin, r, ls))
bottom = r.top - TAG_STACK * dp
}
}
tags.sortBy { it.pin.entry.id == expanded }
tags.forEach { drawTag(c, it, lineH, pad, bar) }
}
private fun drawTag(c: Canvas, t: Tag, lineH: Float, pad: Float, bar: Float) {
val rad = TAG_RADIUS * dp
fill.color = TAG_BG
c.drawRoundRect(t.rect, rad, rad, fill)
fill.color = StaveCategory.color(t.pin.entry.category).toArgb()
c.withClip(t.rect.left, t.rect.top, t.rect.left + bar, t.rect.bottom) {
drawRoundRect(t.rect, rad, rad, fill)
}
var y = t.rect.top + pad - text.fontMetrics.ascent
t.lines.forEach { (s, color) ->
text.color = color
c.drawText(s, t.rect.left + bar + pad, y, text)
y += lineH
}
}
override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean {
val hit = tags.lastOrNull { it.rect.contains(e.x, e.y) }?.pin?.entry?.id
if (hit == null && expanded == null) return false
expanded = if (hit == expanded) null else hit
mapView.invalidate()
return true
}
}