Pith - varnish
package com.vgmlr.varnish

import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.Assessment
import androidx.compose.material.icons.outlined.Brush
import androidx.compose.material.icons.outlined.Crop
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.MoveDown
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.outlined.StarOutline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil.size.Precision
import coil.size.Scale
import java.io.File
import kotlin.math.max
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.ui.Alignment

private const val FULL_PX = 4096
private const val ZOOM_EPS = 1.01f

@Composable
fun ImageScreen(vm: VarnishViewModel, screen: Screen.Image) {
    val context = LocalContext.current
    val settings by vm.settings.collectAsStateWithLifecycle()
    val favs by vm.favorites.collectAsStateWithLifecycle()
    val folderFiles by vm.folderFiles.collectAsStateWithLifecycle()
    val results by vm.searchResults.collectAsStateWithLifecycle()

    var removed by remember { mutableStateOf(emptySet<String>()) }
    val source = if (screen.folder != null) folderFiles else results
    val images = remember(source, removed) {
        source.filter { !it.video && it.path !in removed }
    }

    var current by rememberSaveable(screen.path) { mutableStateOf(screen.path) }
    var menu by remember { mutableStateOf(false) }
    var zoomed by remember { mutableStateOf(false) }
    var info by remember { mutableStateOf<MediaInfo?>(null) }
    var armDel by rememberArmed()
    var chrome by remember { mutableStateOf(true) }
    val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher

    LaunchedEffect(current, chrome) { armDel = false }
    
    fun leave() {
        if (!vm.back()) dispatcher?.onBackPressed()
    }

    fun advance(i: Int, gone: String) {
        removed = removed + gone
        val base = if (screen.folder != null) vm.folderFiles.value else vm.searchResults.value
        val fresh = base.filter { !it.video && it.path !in removed }
        if (i < 0 || i >= fresh.size) leave() else current = fresh[i].path
    }

    fun delete() {
        val path = current
        val i = images.indexOfFirst { it.path == path }
        vm.deleteFiles(listOf(path)) { gone -> if (path in gone) advance(i, path) }
    }

    val mover = rememberLauncherForActivityResult(
        ActivityResultContracts.OpenDocumentTree()
    ) { uri ->
        val dir = uri?.let(vm.repo::treePath) ?: return@rememberLauncherForActivityResult
        val path = current
        val i = images.indexOfFirst { it.path == path }
        vm.moveFile(path, dir) { to -> if (to != null) advance(i, path) }
    }

    BackHandler(enabled = menu) { menu = false }

    Box(Modifier.fillMaxSize().background(PageBg)) {
        if (images.isNotEmpty()) {
            key(screen.path) {
                val count by rememberUpdatedState(images.size)
                val pager = rememberPagerState(
                    initialPage = max(0, images.indexOfFirst { it.path == current })
                ) { count }

                LaunchedEffect(images, current) {
                    val idx = images.indexOfFirst { it.path == current }
                    if (idx >= 0 && idx != pager.currentPage) pager.scrollToPage(idx)
                }

                LaunchedEffect(pager, images) {
                    snapshotFlow { pager.settledPage }.collect { p ->
                        images.getOrNull(p)?.let {
                            if (it.path != current) {
                                current = it.path
                                zoomed = false
                            }
                        }
                    }
                }

                VerticalPager(
                    state = pager,
                    modifier = Modifier
                        .fillMaxSize()
                        .pointerInput(Unit) {
                            awaitEachGesture {
                                awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
                                menu = false
                            }
                        },
                    userScrollEnabled = !zoomed,
                    beyondViewportPageCount = 1,
                    key = { images.getOrNull(it)?.path ?: it }
                ) { page ->
                    val f = images.getOrNull(page) ?: return@VerticalPager
                    ZoomImage(
                        file = f,
                        active = page == pager.settledPage,
                        onZoomed = { zoomed = it },
                        onTap = { chrome = !chrome }
                    )
                }
            }
        }

        val file = File(current)
        val fav = current in favs
        AnimatedVisibility(chrome, enter = fadeIn(), exit = fadeOut()) {
            TopBar(
                onBack = { leave() },
                trailing = {
                    MenuStack(
                        open = menu,
                        onOpenChange = { menu = it },
                        items = listOf(
                            MenuItem(Icons.Outlined.Brush, "Edit") {
                                context.startOr(vm.repo.editIntent(current, settings.editorPkg)) {
                                    vm.open(Screen.Settings)
                                }
                            },
                            MenuItem(Icons.Outlined.MoveDown, "Move") { mover.launch(null) },
                            MenuItem(Icons.Outlined.Assessment, "Data") {
                                vm.info(current) { info = it }
                            }
                        )
                    )
                }
            ) {
                TitlePlate(
                    title = file.nameWithoutExtension,
                    modifier = Modifier.weight(1f),
                    editable = true,
                    suffix = if (file.extension.isEmpty()) "" else ".${file.extension}",
                    onCommit = { base ->
                        vm.renameFile(current, base) { to -> if (to != null) current = to }
                    }
                )
            }
        }
        AnimatedVisibility(
            chrome,
            modifier = Modifier.align(Alignment.BottomCenter),
            enter = fadeIn(),
            exit = fadeOut()
        ) {
            Box {
                Dock {
                    VButton(Icons.Outlined.Crop, "Crop") { vm.open(Screen.Crop(current)) }
                    VButton(Icons.Outlined.Share, "Share") {
                        context.startOr(vm.repo.shareIntent(listOf(current))) {}
                    }
                    VButton(
                        if (fav) Icons.Filled.Star else Icons.Outlined.StarOutline,
                        "Favorite"
                    ) {
                        val path = current
                        if (fav && screen.folder == VarnishConfig.FAV_PATH) {
                            val i = images.indexOfFirst { it.path == path }
                            vm.setFavorite(path, false)
                            advance(i, path)
                        } else {
                            vm.setFavorite(path, !fav)
                        }
                    }
                    VButton(
                        if (armDel) Icons.Outlined.WarningAmber else Icons.Outlined.Delete,
                        "Delete",
                        iconSize = DEL_ICON,
                        bg = if (armDel) WarnBg else BtnBg
                    ) {
                        if (settings.confirmDelete && !armDel) armDel = true else delete()
                    }
                }
            }
        }
    }
    info?.let { DataDialog(it) { info = null } }
}

@Composable
private fun ZoomImage(
    file: MediaFile,
    active: Boolean,
    onZoomed: (Boolean) -> Unit,
    onTap: () -> Unit
) {
    val context = LocalContext.current
    val report by rememberUpdatedState(onZoomed)
    val toggle by rememberUpdatedState(onTap)
    var scale by remember { mutableFloatStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    var box by remember { mutableStateOf(IntSize.Zero) }
    val isZoomed = scale > ZOOM_EPS

    val request = remember(file.path, file.modified) {
        ImageRequest.Builder(context)
            .data(File(file.path))
            .setParameter("m", file.modified)
            .size(FULL_PX)
            .precision(Precision.INEXACT)
            .scale(Scale.FIT)
            .build()
    }

    LaunchedEffect(isZoomed, active) { if (active) report(isZoomed) }
    LaunchedEffect(active) {
        if (!active) {
            scale = 1f
            offset = Offset.Zero
        }
    }

    fun clamp(o: Offset, s: Float): Offset {
        val mx = box.width * (s - 1f) / 2f
        val my = box.height * (s - 1f) / 2f
        return Offset(o.x.coerceIn(-mx, mx), o.y.coerceIn(-my, my))
    }

    Box(
        Modifier
            .fillMaxSize()
            .onSizeChanged { box = it }
            .pointerInput(Unit) {
                detectTapGestures(onTap = { toggle() }, onDoubleTap = { tap ->
                    if (scale > ZOOM_EPS) {
                        scale = 1f
                        offset = Offset.Zero
                    } else {
                        val s = VarnishConfig.ZOOM_TAP
                        val c = Offset(size.width / 2f, size.height / 2f)
                        scale = s
                        offset = clamp((tap - c) * (1f - s), s)
                    }
                })
            }
            .pointerInput(Unit) {
                awaitEachGesture {
                    awaitFirstDown(requireUnconsumed = false)
                    do {
                        val ev = awaitPointerEvent()
                        val multi = ev.changes.count { it.pressed } > 1
                        val q = ev.calculateCentroid(useCurrent = true)
                        if ((multi || scale > ZOOM_EPS) && q != Offset.Unspecified) {
                            val c = Offset(size.width / 2f, size.height / 2f)
                            val s2 = (scale * ev.calculateZoom()).coerceIn(1f, VarnishConfig.ZOOM_MAX)
                            val zr = s2 / scale
                            val t = (q - c) * (1f - zr) + offset * zr + ev.calculatePan()
                            scale = s2
                            offset = if (s2 <= ZOOM_EPS) Offset.Zero else clamp(t, s2)
                            ev.changes.forEach { if (it.positionChanged()) it.consume() }
                        }
                    } while (ev.changes.any { it.pressed })
                }
            }
    ) {
        AsyncImage(
            model = request,
            contentDescription = null,
            contentScale = ContentScale.Fit,
            modifier = Modifier
                .fillMaxSize()
                .graphicsLayer {
                    scaleX = scale
                    scaleY = scale
                    translationX = offset.x
                    translationY = offset.y
                }
        )
    }
}