Add map feature
This commit is contained in:
		
							parent
							
								
									34d15c41e0
								
							
						
					
					
						commit
						e74bd7c097
					
				
					 13 changed files with 640 additions and 16 deletions
				
			
		| 
						 | 
				
			
			@ -22,8 +22,12 @@ class MarqueeParser @Inject constructor() {
 | 
			
		|||
                item is List<*> -> {
 | 
			
		||||
                    val map = item.getOrNull(structure.map) as? String
 | 
			
		||||
                    val name = item.getOrNull(structure.name) as? String
 | 
			
		||||
                    val x = (item.getOrNull(structure.x) as? String)?.toFloatOrNull()
 | 
			
		||||
                    val y = (item.getOrNull(structure.y) as? String)?.toFloatOrNull()
 | 
			
		||||
                    val x = (item.getOrNull(structure.x) as? String)
 | 
			
		||||
                        ?.replace(oldValue = ",", newValue = ".")
 | 
			
		||||
                        ?.toFloatOrNull()
 | 
			
		||||
                    val y = (item.getOrNull(structure.y) as? String)
 | 
			
		||||
                        ?.replace(oldValue = ",", newValue = ".")
 | 
			
		||||
                        ?.toFloatOrNull()
 | 
			
		||||
                    val description = item.getOrNull(structure.description) as? String?
 | 
			
		||||
 | 
			
		||||
                    if (map != null && name != null && x != null && y != null) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,7 +30,9 @@ import com.google.accompanist.navigation.animation.AnimatedNavHost
 | 
			
		|||
import com.google.accompanist.navigation.animation.rememberAnimatedNavController
 | 
			
		||||
import com.pixelized.rplexicon.LocalSnack
 | 
			
		||||
import com.pixelized.rplexicon.R
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.pages.LEXICON_ROUTE
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.pages.LEXICON_LIST_ROUTE
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.pages.LOCATION_LIST_ROUTE
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.pages.QUEST_LIST_ROUTE
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.pages.composableLexicon
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.pages.composableLocations
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.pages.composableQuests
 | 
			
		||||
| 
						 | 
				
			
			@ -50,7 +52,7 @@ fun HomeNavHost(
 | 
			
		|||
    lexiconListState: LazyListState,
 | 
			
		||||
    questListState: LazyListState,
 | 
			
		||||
    locationListState: LazyListState,
 | 
			
		||||
    startDestination: String = LEXICON_ROUTE
 | 
			
		||||
    startDestination: String = LOCATION_LIST_ROUTE
 | 
			
		||||
) {
 | 
			
		||||
    CompositionLocalProvider(
 | 
			
		||||
        LocalSnack provides remember { SnackbarHostState() },
 | 
			
		||||
| 
						 | 
				
			
			@ -69,15 +71,17 @@ fun HomeNavHost(
 | 
			
		|||
                )
 | 
			
		||||
            },
 | 
			
		||||
            bottomBar = {
 | 
			
		||||
                val selectedIndex = rememberSaveable { mutableStateOf(0) }
 | 
			
		||||
 | 
			
		||||
                val selectedIndex = rememberSaveable {
 | 
			
		||||
                    mutableStateOf(bottomBarItems.indexOfFirst { it.route == startDestination })
 | 
			
		||||
                }
 | 
			
		||||
                BottomAppBar(
 | 
			
		||||
                    containerColor = MaterialTheme.colorScheme.surface,
 | 
			
		||||
                    tonalElevation = 0.dp,
 | 
			
		||||
                ) {
 | 
			
		||||
                    bottomBarItems.forEachIndexed { index, item ->
 | 
			
		||||
                        val selectedItem =
 | 
			
		||||
                            remember { derivedStateOf { selectedIndex.value == index } }
 | 
			
		||||
                        val selectedItem = remember {
 | 
			
		||||
                            derivedStateOf { selectedIndex.value == index }
 | 
			
		||||
                        }
 | 
			
		||||
                        NavigationBarItem(
 | 
			
		||||
                            selected = selectedItem.value,
 | 
			
		||||
                            onClick = {
 | 
			
		||||
| 
						 | 
				
			
			@ -118,6 +122,7 @@ fun HomeNavHost(
 | 
			
		|||
 | 
			
		||||
@Stable
 | 
			
		||||
class BottomBarItem(
 | 
			
		||||
    val route: String,
 | 
			
		||||
    val icon: Int,
 | 
			
		||||
    val label: Int,
 | 
			
		||||
    val onClick: () -> Unit,
 | 
			
		||||
| 
						 | 
				
			
			@ -132,16 +137,19 @@ private fun rememberBottomBarItems(
 | 
			
		|||
        val option = navHostController.pageOption()
 | 
			
		||||
        listOf(
 | 
			
		||||
            BottomBarItem(
 | 
			
		||||
                route = LEXICON_LIST_ROUTE,
 | 
			
		||||
                icon = R.drawable.ic_outline_account_circle_24,
 | 
			
		||||
                label = R.string.home_lexicon,
 | 
			
		||||
                onClick = { navHostController.navigateToLexicon(option) }
 | 
			
		||||
            ),
 | 
			
		||||
            BottomBarItem(
 | 
			
		||||
                route = QUEST_LIST_ROUTE,
 | 
			
		||||
                icon = R.drawable.ic_outline_scroll_24,
 | 
			
		||||
                label = R.string.home_quest_log,
 | 
			
		||||
                onClick = { navHostController.navigateToQuestList(option) }
 | 
			
		||||
            ),
 | 
			
		||||
            BottomBarItem(
 | 
			
		||||
                route = LOCATION_LIST_ROUTE,
 | 
			
		||||
                icon = R.drawable.ic_outline_map_24,
 | 
			
		||||
                label = R.string.home_location,
 | 
			
		||||
                onClick = { navHostController.navigateToLocation(option) }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,6 @@
 | 
			
		|||
package com.pixelized.rplexicon.ui.navigation
 | 
			
		||||
 | 
			
		||||
import androidx.compose.animation.ExperimentalAnimationApi
 | 
			
		||||
import androidx.compose.foundation.lazy.LazyListState
 | 
			
		||||
import androidx.compose.foundation.lazy.rememberLazyListState
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.CompositionLocalProvider
 | 
			
		||||
| 
						 | 
				
			
			@ -16,6 +15,7 @@ import com.pixelized.rplexicon.ui.navigation.screens.composableAuthentication
 | 
			
		|||
import com.pixelized.rplexicon.ui.navigation.screens.composableHome
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.screens.composableLexiconDetail
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.screens.composableLexiconSearch
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.screens.composableLocationDetail
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.screens.composableQuestDetail
 | 
			
		||||
 | 
			
		||||
val LocalScreenNavHost = staticCompositionLocalOf<NavHostController> {
 | 
			
		||||
| 
						 | 
				
			
			@ -45,11 +45,10 @@ fun ScreenNavHost(
 | 
			
		|||
                questListState = questListState,
 | 
			
		||||
                locationListState = locationListState
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            composableLexiconDetail()
 | 
			
		||||
            composableLexiconSearch()
 | 
			
		||||
 | 
			
		||||
            composableQuestDetail()
 | 
			
		||||
            composableLocationDetail()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,13 +10,13 @@ import com.pixelized.rplexicon.ui.screens.lexicon.list.LexiconScreen
 | 
			
		|||
 | 
			
		||||
private const val ROUTE = "lexicon"
 | 
			
		||||
 | 
			
		||||
const val LEXICON_ROUTE = ROUTE
 | 
			
		||||
const val LEXICON_LIST_ROUTE = ROUTE
 | 
			
		||||
 | 
			
		||||
fun NavGraphBuilder.composableLexicon(
 | 
			
		||||
    lazyListState: LazyListState,
 | 
			
		||||
) {
 | 
			
		||||
    animatedComposable(
 | 
			
		||||
        route = LEXICON_ROUTE,
 | 
			
		||||
        route = LEXICON_LIST_ROUTE,
 | 
			
		||||
        animation = NavigationAnimation.NONE,
 | 
			
		||||
    ) {
 | 
			
		||||
        LexiconScreen(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,13 +10,13 @@ import com.pixelized.rplexicon.ui.screens.location.list.LocationScreen
 | 
			
		|||
 | 
			
		||||
private const val ROUTE = "locations"
 | 
			
		||||
 | 
			
		||||
const val LOCATION_ROUTE = ROUTE
 | 
			
		||||
const val LOCATION_LIST_ROUTE = ROUTE
 | 
			
		||||
 | 
			
		||||
fun NavGraphBuilder.composableLocations(
 | 
			
		||||
    lazyListState: LazyListState,
 | 
			
		||||
) {
 | 
			
		||||
    animatedComposable(
 | 
			
		||||
        route = LOCATION_ROUTE,
 | 
			
		||||
        route = LOCATION_LIST_ROUTE,
 | 
			
		||||
        animation = NavigationAnimation.NONE,
 | 
			
		||||
    ) {
 | 
			
		||||
        LocationScreen(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,55 @@
 | 
			
		|||
package com.pixelized.rplexicon.ui.navigation.screens
 | 
			
		||||
 | 
			
		||||
import androidx.compose.runtime.Immutable
 | 
			
		||||
import androidx.compose.runtime.Stable
 | 
			
		||||
import androidx.lifecycle.SavedStateHandle
 | 
			
		||||
import androidx.navigation.NavGraphBuilder
 | 
			
		||||
import androidx.navigation.NavHostController
 | 
			
		||||
import androidx.navigation.NavOptionsBuilder
 | 
			
		||||
import androidx.navigation.NavType
 | 
			
		||||
import androidx.navigation.navArgument
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.NavigationAnimation
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.animatedComposable
 | 
			
		||||
import com.pixelized.rplexicon.ui.screens.location.detail.LocationDetail
 | 
			
		||||
import com.pixelized.rplexicon.utilitary.extentions.ARG
 | 
			
		||||
 | 
			
		||||
private const val ROUTE = "LocationDetail"
 | 
			
		||||
private const val ARG_ID = "id"
 | 
			
		||||
 | 
			
		||||
val LOCATION_DETAIL_ROUTE = ROUTE +
 | 
			
		||||
        "?${ARG_ID.ARG}"
 | 
			
		||||
 | 
			
		||||
@Stable
 | 
			
		||||
@Immutable
 | 
			
		||||
data class LocationDetailArgument(
 | 
			
		||||
    val id: Int,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
val SavedStateHandle.locationDetailArgument: LocationDetailArgument
 | 
			
		||||
    get() = LocationDetailArgument(
 | 
			
		||||
        id = get(ARG_ID) ?: error("CharacterDetailArgument argument: $ARG_ID"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
fun NavGraphBuilder.composableLocationDetail() {
 | 
			
		||||
    animatedComposable(
 | 
			
		||||
        route = LOCATION_DETAIL_ROUTE,
 | 
			
		||||
        arguments = listOf(
 | 
			
		||||
            navArgument(name = ARG_ID) {
 | 
			
		||||
                type = NavType.IntType
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        animation = NavigationAnimation.Push,
 | 
			
		||||
    ) {
 | 
			
		||||
        LocationDetail()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun NavHostController.navigateToLocationDetail(
 | 
			
		||||
    id: Int,
 | 
			
		||||
    option: NavOptionsBuilder.() -> Unit = {},
 | 
			
		||||
) {
 | 
			
		||||
    val route = ROUTE +
 | 
			
		||||
            "?$ARG_ID=$id"
 | 
			
		||||
 | 
			
		||||
    navigate(route = route, builder = option)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,183 @@
 | 
			
		|||
package com.pixelized.rplexicon.ui.screens.location.detail
 | 
			
		||||
 | 
			
		||||
import androidx.annotation.DrawableRes
 | 
			
		||||
import androidx.compose.animation.core.animateFloatAsState
 | 
			
		||||
import androidx.compose.animation.core.animateOffsetAsState
 | 
			
		||||
import androidx.compose.foundation.gestures.detectTapGestures
 | 
			
		||||
import androidx.compose.foundation.gestures.detectTransformGestures
 | 
			
		||||
import androidx.compose.foundation.layout.Box
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.LaunchedEffect
 | 
			
		||||
import androidx.compose.runtime.MutableState
 | 
			
		||||
import androidx.compose.runtime.Stable
 | 
			
		||||
import androidx.compose.runtime.State
 | 
			
		||||
import androidx.compose.runtime.derivedStateOf
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.runtime.setValue
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.draw.drawWithContent
 | 
			
		||||
import androidx.compose.ui.geometry.Offset
 | 
			
		||||
import androidx.compose.ui.graphics.Color
 | 
			
		||||
import androidx.compose.ui.graphics.drawscope.Stroke
 | 
			
		||||
import androidx.compose.ui.graphics.graphicsLayer
 | 
			
		||||
import androidx.compose.ui.input.pointer.pointerInput
 | 
			
		||||
import androidx.compose.ui.layout.onSizeChanged
 | 
			
		||||
import androidx.compose.ui.unit.IntSize
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
import com.pixelized.rplexicon.ui.composable.AsyncImage
 | 
			
		||||
import com.skydoves.landscapist.ImageOptions
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun FantasyMap(
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    state: FantasyMapState,
 | 
			
		||||
    imageOptions: ImageOptions = ImageOptions(),
 | 
			
		||||
    @DrawableRes previewPlaceholder: Int,
 | 
			
		||||
    item: State<LocationDetailUio>,
 | 
			
		||||
    selectedItem: State<Int>,
 | 
			
		||||
    onMarquee: (LocationDetailUio.MarqueeUio) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val animatedScale = animateFloatAsState(targetValue = state.scale, label = "ScaleAnimation")
 | 
			
		||||
    val animatedOffset = animateOffsetAsState(targetValue = state.offset, label = "OffsetAnimation")
 | 
			
		||||
 | 
			
		||||
    LaunchedEffect(key1 = "CenterOnMarquee:${selectedItem.value}") {
 | 
			
		||||
        item.value.marquees.getOrNull(selectedItem.value)?.position
 | 
			
		||||
            ?.let { state.pan(state.computeMarqueeOffset(it)) }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Box(
 | 
			
		||||
        modifier = modifier
 | 
			
		||||
            .pointerInput("DetectTransformGestures") {
 | 
			
		||||
                detectTransformGestures(panZoomLock = true) { _, pan, zoom, _ ->
 | 
			
		||||
                    state.scale(
 | 
			
		||||
                        scale = state.scale * zoom,
 | 
			
		||||
                    )
 | 
			
		||||
                    state.pan(
 | 
			
		||||
                        offset = Offset(
 | 
			
		||||
                            x = state.offset.x + pan.x,
 | 
			
		||||
                            y = state.offset.y + pan.y,
 | 
			
		||||
                        ),
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .graphicsLayer {
 | 
			
		||||
                scaleX = animatedScale.value
 | 
			
		||||
                scaleY = animatedScale.value
 | 
			
		||||
                translationX = animatedOffset.value.x
 | 
			
		||||
                translationY = animatedOffset.value.y
 | 
			
		||||
            },
 | 
			
		||||
        contentAlignment = Alignment.Center,
 | 
			
		||||
    ) {
 | 
			
		||||
        AsyncImage(
 | 
			
		||||
            modifier = Modifier
 | 
			
		||||
                .onSizeChanged { state.imageSize = it }
 | 
			
		||||
                .drawWithContent {
 | 
			
		||||
                    drawContent()
 | 
			
		||||
                    item.value.marquees.forEachIndexed { index, item ->
 | 
			
		||||
                        drawCircle(
 | 
			
		||||
                            color = when (selectedItem.value) {
 | 
			
		||||
                                index -> Color.Cyan
 | 
			
		||||
                                else -> Color.White
 | 
			
		||||
                            },
 | 
			
		||||
                            radius = 12.dp.toPx() / animatedScale.value,
 | 
			
		||||
                            style = Stroke(
 | 
			
		||||
                                width = 2.dp.toPx() / animatedScale.value
 | 
			
		||||
                            ),
 | 
			
		||||
                            center = Offset(
 | 
			
		||||
                                x = size.width * item.position.x,
 | 
			
		||||
                                y = size.height * item.position.y
 | 
			
		||||
                            )
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .pointerInput("DetectTapGestures") {
 | 
			
		||||
                    detectTapGestures(
 | 
			
		||||
                        onTap = { tap ->
 | 
			
		||||
                            val marquee = item.value.marquees
 | 
			
		||||
                                .asReversed()
 | 
			
		||||
                                .firstOrNull { item ->
 | 
			
		||||
                                    val radius = 24.dp.toPx() * animatedScale.value
 | 
			
		||||
                                    (size.width * item.position.x).let { tap.x in (it - radius)..(it + radius) } &&
 | 
			
		||||
                                            (size.height * item.position.y).let { tap.y in (it - radius)..(it + radius) }
 | 
			
		||||
                                }
 | 
			
		||||
                            marquee?.let(onMarquee)
 | 
			
		||||
                        }
 | 
			
		||||
                    )
 | 
			
		||||
                },
 | 
			
		||||
            imageModel = { item.value.map },
 | 
			
		||||
            imageOptions = imageOptions,
 | 
			
		||||
            previewPlaceholder = previewPlaceholder,
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
@Stable
 | 
			
		||||
fun rememberFantasyMapState(
 | 
			
		||||
    initialScale: Float = 1f,
 | 
			
		||||
    initialOffset: Offset = Offset.Zero,
 | 
			
		||||
    minScale: Float = 1f,
 | 
			
		||||
    maxScale: Float = 5f,
 | 
			
		||||
): FantasyMapState {
 | 
			
		||||
    return remember {
 | 
			
		||||
        FantasyMapState(
 | 
			
		||||
            initialScale = initialScale,
 | 
			
		||||
            initialOffset = initialOffset,
 | 
			
		||||
            minScale = minScale,
 | 
			
		||||
            maxScale = maxScale,
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Stable
 | 
			
		||||
class FantasyMapState(
 | 
			
		||||
    initialScale: Float = 1f,
 | 
			
		||||
    initialOffset: Offset = Offset.Zero,
 | 
			
		||||
    val minScale: Float = 1f,
 | 
			
		||||
    val maxScale: Float = 5f,
 | 
			
		||||
) {
 | 
			
		||||
    private val maxX by derivedStateOf { imageSize.width * scale / 2f }
 | 
			
		||||
    private val minX by derivedStateOf { -maxX }
 | 
			
		||||
    private val maxY by derivedStateOf { imageSize.height * scale / 2f }
 | 
			
		||||
    private val minY by derivedStateOf { -maxY }
 | 
			
		||||
 | 
			
		||||
    private val _scale: MutableState<Float> = mutableStateOf(initialScale)
 | 
			
		||||
    val scale: Float by _scale
 | 
			
		||||
 | 
			
		||||
    private val _offset: MutableState<Offset> = mutableStateOf(initialOffset)
 | 
			
		||||
    val offset: Offset by _offset
 | 
			
		||||
 | 
			
		||||
    private val _imageSize: MutableState<IntSize> = mutableStateOf(IntSize.Zero)
 | 
			
		||||
    var imageSize: IntSize by _imageSize
 | 
			
		||||
 | 
			
		||||
    @Stable
 | 
			
		||||
    fun scale(
 | 
			
		||||
        scale: Float,
 | 
			
		||||
    ) {
 | 
			
		||||
        _scale.value = maxOf(minScale, minOf(scale, maxScale))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Stable
 | 
			
		||||
    fun pan(
 | 
			
		||||
        offset: Offset,
 | 
			
		||||
    ) {
 | 
			
		||||
        _offset.value = Offset(
 | 
			
		||||
            x = maxOf(minX, minOf(maxX, offset.x)),
 | 
			
		||||
            y = maxOf(minY, minOf(maxY, offset.y)),
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Stable
 | 
			
		||||
    fun computeMarqueeOffset(
 | 
			
		||||
        origin: Offset,
 | 
			
		||||
        size: IntSize = imageSize,
 | 
			
		||||
    ): Offset {
 | 
			
		||||
        return Offset(
 | 
			
		||||
            x = (size.width / 2f - origin.x * size.width) * scale,
 | 
			
		||||
            y = (size.height / 2f - origin.y * size.height) * scale,
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,321 @@
 | 
			
		|||
package com.pixelized.rplexicon.ui.screens.location.detail
 | 
			
		||||
 | 
			
		||||
import android.content.res.Configuration
 | 
			
		||||
import android.net.Uri
 | 
			
		||||
import androidx.compose.foundation.ExperimentalFoundationApi
 | 
			
		||||
import androidx.compose.foundation.layout.Arrangement
 | 
			
		||||
import androidx.compose.foundation.layout.Box
 | 
			
		||||
import androidx.compose.foundation.layout.Column
 | 
			
		||||
import androidx.compose.foundation.layout.PaddingValues
 | 
			
		||||
import androidx.compose.foundation.layout.fillMaxSize
 | 
			
		||||
import androidx.compose.foundation.layout.fillMaxWidth
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.foundation.pager.HorizontalPager
 | 
			
		||||
import androidx.compose.foundation.pager.PagerState
 | 
			
		||||
import androidx.compose.foundation.pager.rememberPagerState
 | 
			
		||||
import androidx.compose.material3.ExperimentalMaterial3Api
 | 
			
		||||
import androidx.compose.material3.FilledIconButton
 | 
			
		||||
import androidx.compose.material3.Icon
 | 
			
		||||
import androidx.compose.material3.IconButton
 | 
			
		||||
import androidx.compose.material3.IconButtonDefaults
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.Scaffold
 | 
			
		||||
import androidx.compose.material3.Surface
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.material3.TopAppBar
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.LaunchedEffect
 | 
			
		||||
import androidx.compose.runtime.Stable
 | 
			
		||||
import androidx.compose.runtime.State
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.runtime.rememberCoroutineScope
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.draw.clip
 | 
			
		||||
import androidx.compose.ui.geometry.Offset
 | 
			
		||||
import androidx.compose.ui.graphics.Color
 | 
			
		||||
import androidx.compose.ui.graphics.RectangleShape
 | 
			
		||||
import androidx.compose.ui.layout.ContentScale
 | 
			
		||||
import androidx.compose.ui.res.painterResource
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import androidx.compose.ui.text.style.TextAlign
 | 
			
		||||
import androidx.compose.ui.text.style.TextOverflow
 | 
			
		||||
import androidx.compose.ui.tooling.preview.Preview
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
import androidx.hilt.navigation.compose.hiltViewModel
 | 
			
		||||
import com.pixelized.rplexicon.R
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost
 | 
			
		||||
import com.pixelized.rplexicon.ui.theme.LexiconTheme
 | 
			
		||||
import com.skydoves.landscapist.ImageOptions
 | 
			
		||||
import kotlinx.coroutines.launch
 | 
			
		||||
import kotlin.math.max
 | 
			
		||||
 | 
			
		||||
@Stable
 | 
			
		||||
data class LocationDetailUio(
 | 
			
		||||
    val name: String,
 | 
			
		||||
    val map: Uri,
 | 
			
		||||
    val marquees: List<MarqueeUio>,
 | 
			
		||||
) {
 | 
			
		||||
    @Stable
 | 
			
		||||
    data class MarqueeUio(
 | 
			
		||||
        val name: String,
 | 
			
		||||
        val position: Offset,
 | 
			
		||||
        val description: String?,
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@OptIn(ExperimentalFoundationApi::class)
 | 
			
		||||
@Composable
 | 
			
		||||
fun LocationDetail(
 | 
			
		||||
    viewModel: LocationDetailViewModel = hiltViewModel()
 | 
			
		||||
) {
 | 
			
		||||
    val scope = rememberCoroutineScope()
 | 
			
		||||
    val pager = rememberPagerState()
 | 
			
		||||
    val fantasy = rememberFantasyMapState()
 | 
			
		||||
    val screen = LocalScreenNavHost.current
 | 
			
		||||
 | 
			
		||||
    val selectedIndex = remember { mutableStateOf(0) }
 | 
			
		||||
 | 
			
		||||
    Surface {
 | 
			
		||||
        LocationContent(
 | 
			
		||||
            modifier = Modifier.fillMaxSize(),
 | 
			
		||||
            pagerState = pager,
 | 
			
		||||
            fantasyMapState = fantasy,
 | 
			
		||||
            item = viewModel.location,
 | 
			
		||||
            selectedIndex = selectedIndex,
 | 
			
		||||
            onBack = {
 | 
			
		||||
                screen.popBackStack()
 | 
			
		||||
            },
 | 
			
		||||
            onMarquee = {
 | 
			
		||||
                scope.launch {
 | 
			
		||||
                    val index = max(viewModel.location.value.marquees.indexOf(it), 0)
 | 
			
		||||
                    selectedIndex.value = index
 | 
			
		||||
                    pager.animateScrollToPage(page = index)
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            onCenter = {
 | 
			
		||||
                fantasy.scale(scale = 1f)
 | 
			
		||||
                fantasy.pan(offset = Offset.Zero)
 | 
			
		||||
            },
 | 
			
		||||
            onZoomIn = {
 | 
			
		||||
                fantasy.scale(fantasy.scale + 1)
 | 
			
		||||
                fantasy.pan(offset = fantasy.offset)
 | 
			
		||||
            },
 | 
			
		||||
            onZoomOut = {
 | 
			
		||||
                fantasy.scale(fantasy.scale - 1)
 | 
			
		||||
                fantasy.pan(offset = fantasy.offset)
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        HandlePagerScroll(
 | 
			
		||||
            pagerState = pager,
 | 
			
		||||
            index = selectedIndex,
 | 
			
		||||
            onIndexChange = { page ->
 | 
			
		||||
                pager.animateScrollToPage(page = page)
 | 
			
		||||
            },
 | 
			
		||||
            onPageChange = { page ->
 | 
			
		||||
                selectedIndex.value = page
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
 | 
			
		||||
@Composable
 | 
			
		||||
private fun LocationContent(
 | 
			
		||||
    modifier: Modifier,
 | 
			
		||||
    pagerState: PagerState,
 | 
			
		||||
    fantasyMapState: FantasyMapState,
 | 
			
		||||
    item: State<LocationDetailUio>,
 | 
			
		||||
    selectedIndex: State<Int>,
 | 
			
		||||
    onBack: () -> Unit,
 | 
			
		||||
    onMarquee: (LocationDetailUio.MarqueeUio) -> Unit,
 | 
			
		||||
    onCenter: () -> Unit,
 | 
			
		||||
    onZoomIn: () -> Unit,
 | 
			
		||||
    onZoomOut: () -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    Scaffold(
 | 
			
		||||
        modifier = modifier,
 | 
			
		||||
        containerColor = Color.Transparent,
 | 
			
		||||
        topBar = {
 | 
			
		||||
            TopAppBar(
 | 
			
		||||
                navigationIcon = {
 | 
			
		||||
                    IconButton(onClick = onBack) {
 | 
			
		||||
                        Icon(
 | 
			
		||||
                            painter = painterResource(id = R.drawable.ic_arrow_back_ios_new_24),
 | 
			
		||||
                            contentDescription = null
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                title = {
 | 
			
		||||
                    Text(text = stringResource(id = R.string.detail_title))
 | 
			
		||||
                },
 | 
			
		||||
            )
 | 
			
		||||
        },
 | 
			
		||||
    ) { paddingValues ->
 | 
			
		||||
        Column(
 | 
			
		||||
            modifier = Modifier.padding(paddingValues = paddingValues),
 | 
			
		||||
        ) {
 | 
			
		||||
            Surface(
 | 
			
		||||
                modifier = Modifier
 | 
			
		||||
                    .fillMaxWidth()
 | 
			
		||||
                    .weight(weight = 2f),
 | 
			
		||||
                tonalElevation = 2.dp,
 | 
			
		||||
            ) {
 | 
			
		||||
                Box(
 | 
			
		||||
                    modifier = Modifier
 | 
			
		||||
                        .fillMaxSize()
 | 
			
		||||
                        .clip(shape = RectangleShape),
 | 
			
		||||
                ) {
 | 
			
		||||
                    FantasyMap(
 | 
			
		||||
                        modifier = Modifier.align(alignment = Alignment.Center),
 | 
			
		||||
                        state = fantasyMapState,
 | 
			
		||||
                        previewPlaceholder = R.drawable.im_brulkhai,
 | 
			
		||||
                        imageOptions = ImageOptions(contentScale = ContentScale.Fit),
 | 
			
		||||
                        item = item,
 | 
			
		||||
                        selectedItem = selectedIndex,
 | 
			
		||||
                        onMarquee = onMarquee,
 | 
			
		||||
                    )
 | 
			
		||||
                    Column(
 | 
			
		||||
                        modifier = Modifier
 | 
			
		||||
                            .align(alignment = Alignment.BottomEnd)
 | 
			
		||||
                            .padding(all = 16.dp),
 | 
			
		||||
                    ) {
 | 
			
		||||
                        val colors = IconButtonDefaults.filledIconButtonColors(
 | 
			
		||||
                            containerColor = MaterialTheme.colorScheme.surface,
 | 
			
		||||
                            contentColor = MaterialTheme.colorScheme.onSurface,
 | 
			
		||||
                        )
 | 
			
		||||
                        FilledIconButton(
 | 
			
		||||
                            onClick = onZoomOut,
 | 
			
		||||
                            colors = colors,
 | 
			
		||||
                        ) {
 | 
			
		||||
                            Icon(
 | 
			
		||||
                                painter = painterResource(id = R.drawable.ic_baseline_remove_24),
 | 
			
		||||
                                contentDescription = null
 | 
			
		||||
                            )
 | 
			
		||||
                        }
 | 
			
		||||
                        FilledIconButton(
 | 
			
		||||
                            onClick = onZoomIn,
 | 
			
		||||
                            colors = colors,
 | 
			
		||||
                        ) {
 | 
			
		||||
                            Icon(
 | 
			
		||||
                                painter = painterResource(id = R.drawable.ic_baseline_add_24),
 | 
			
		||||
                                contentDescription = null
 | 
			
		||||
                            )
 | 
			
		||||
                        }
 | 
			
		||||
                        FilledIconButton(
 | 
			
		||||
                            onClick = onCenter,
 | 
			
		||||
                            colors = colors,
 | 
			
		||||
                        ) {
 | 
			
		||||
                            Icon(
 | 
			
		||||
                                painter = painterResource(id = R.drawable.ic_baseline_zoom_in_map_24),
 | 
			
		||||
                                contentDescription = null
 | 
			
		||||
                            )
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            HorizontalPager(
 | 
			
		||||
                modifier = Modifier.weight(weight = 1f),
 | 
			
		||||
                state = pagerState,
 | 
			
		||||
                pageCount = item.value.marquees.size,
 | 
			
		||||
                contentPadding = PaddingValues(all = 16.dp),
 | 
			
		||||
                pageSpacing = 8.dp,
 | 
			
		||||
            ) {
 | 
			
		||||
                item.value.marquees.getOrNull(it)?.let { marquee ->
 | 
			
		||||
                    Marquee(
 | 
			
		||||
                        modifier = Modifier.fillMaxSize(),
 | 
			
		||||
                        marquee = marquee,
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
private fun Marquee(
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    marquee: LocationDetailUio.MarqueeUio,
 | 
			
		||||
) {
 | 
			
		||||
    Column(
 | 
			
		||||
        modifier = modifier,
 | 
			
		||||
        verticalArrangement = Arrangement.spacedBy(space = 8.dp)
 | 
			
		||||
    ) {
 | 
			
		||||
        Text(
 | 
			
		||||
            modifier = Modifier.fillMaxWidth(),
 | 
			
		||||
            textAlign = TextAlign.Center,
 | 
			
		||||
            overflow = TextOverflow.Ellipsis,
 | 
			
		||||
            maxLines = 1,
 | 
			
		||||
            style = MaterialTheme.typography.headlineSmall,
 | 
			
		||||
            text = marquee.name,
 | 
			
		||||
        )
 | 
			
		||||
        marquee.description?.let {
 | 
			
		||||
            Text(
 | 
			
		||||
                style = MaterialTheme.typography.bodyMedium,
 | 
			
		||||
                text = it,
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@OptIn(ExperimentalFoundationApi::class)
 | 
			
		||||
@Composable
 | 
			
		||||
private fun HandlePagerScroll(
 | 
			
		||||
    pagerState: PagerState,
 | 
			
		||||
    index: State<Int>,
 | 
			
		||||
    onIndexChange: suspend (page: Int) -> Unit,
 | 
			
		||||
    onPageChange: suspend (page: Int) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    if (!pagerState.isScrollInProgress) {
 | 
			
		||||
        LaunchedEffect(Unit) {
 | 
			
		||||
            onPageChange.invoke(pagerState.currentPage)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    LaunchedEffect(key1 = index.value) {
 | 
			
		||||
        onIndexChange.invoke(index.value)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@OptIn(ExperimentalFoundationApi::class)
 | 
			
		||||
@Composable
 | 
			
		||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
 | 
			
		||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
 | 
			
		||||
private fun LocationPreview() {
 | 
			
		||||
    LexiconTheme {
 | 
			
		||||
        Surface {
 | 
			
		||||
            LocationContent(
 | 
			
		||||
                modifier = Modifier.fillMaxSize(),
 | 
			
		||||
                pagerState = rememberPagerState(),
 | 
			
		||||
                fantasyMapState = rememberFantasyMapState(),
 | 
			
		||||
                item = remember {
 | 
			
		||||
                    mutableStateOf(
 | 
			
		||||
                        LocationDetailUio(
 | 
			
		||||
                            name = "Daggerfall",
 | 
			
		||||
                            map = Uri.parse("https://i.pinimg.com/originals/6d/56/cd/6d56cd9358cc94a7077157ea3c1b5842.jpg"),
 | 
			
		||||
                            marquees = listOf(
 | 
			
		||||
                                LocationDetailUio.MarqueeUio(
 | 
			
		||||
                                    name = "start",
 | 
			
		||||
                                    position = Offset.Zero,
 | 
			
		||||
                                    description = "Marquee en haut à gauche."
 | 
			
		||||
                                ),
 | 
			
		||||
                                LocationDetailUio.MarqueeUio(
 | 
			
		||||
                                    name = "end",
 | 
			
		||||
                                    position = Offset(1f, 1f),
 | 
			
		||||
                                    description = "Marquee en bas à droite."
 | 
			
		||||
                                ),
 | 
			
		||||
                            ),
 | 
			
		||||
                        )
 | 
			
		||||
                    )
 | 
			
		||||
                },
 | 
			
		||||
                selectedIndex = remember { mutableStateOf(0) },
 | 
			
		||||
                onBack = { },
 | 
			
		||||
                onMarquee = { },
 | 
			
		||||
                onCenter = { },
 | 
			
		||||
                onZoomIn = { },
 | 
			
		||||
                onZoomOut = { },
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
package com.pixelized.rplexicon.ui.screens.location.detail
 | 
			
		||||
 | 
			
		||||
import androidx.compose.runtime.State
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.ui.geometry.Offset
 | 
			
		||||
import androidx.lifecycle.SavedStateHandle
 | 
			
		||||
import androidx.lifecycle.ViewModel
 | 
			
		||||
import com.pixelized.rplexicon.repository.LocationRepository
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.screens.locationDetailArgument
 | 
			
		||||
import dagger.hilt.android.lifecycle.HiltViewModel
 | 
			
		||||
import javax.inject.Inject
 | 
			
		||||
 | 
			
		||||
@HiltViewModel
 | 
			
		||||
class LocationDetailViewModel @Inject constructor(
 | 
			
		||||
    savedStateHandle: SavedStateHandle,
 | 
			
		||||
    repository: LocationRepository,
 | 
			
		||||
) : ViewModel() {
 | 
			
		||||
    val location: State<LocationDetailUio>
 | 
			
		||||
 | 
			
		||||
    init {
 | 
			
		||||
        val argument = savedStateHandle.locationDetailArgument
 | 
			
		||||
        val source = repository.data.value[argument.id]
 | 
			
		||||
 | 
			
		||||
        location = mutableStateOf(
 | 
			
		||||
            LocationDetailUio(
 | 
			
		||||
                name = source.name,
 | 
			
		||||
                map = source.uri,
 | 
			
		||||
                marquees = source.marquees.map { marquee ->
 | 
			
		||||
                    LocationDetailUio.MarqueeUio(
 | 
			
		||||
                        name = marquee.name,
 | 
			
		||||
                        position = Offset(x = marquee.x, y = marquee.y),
 | 
			
		||||
                        description = marquee.description,
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -28,6 +28,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
 | 
			
		|||
import com.pixelized.rplexicon.ui.composable.Loader
 | 
			
		||||
import com.pixelized.rplexicon.ui.composable.error.HandleFetchError
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost
 | 
			
		||||
import com.pixelized.rplexicon.ui.navigation.screens.navigateToLocationDetail
 | 
			
		||||
import com.pixelized.rplexicon.ui.theme.LexiconTheme
 | 
			
		||||
import com.pixelized.rplexicon.utilitary.extentions.cell
 | 
			
		||||
import com.pixelized.rplexicon.utilitary.extentions.lexicon
 | 
			
		||||
| 
						 | 
				
			
			@ -59,7 +60,7 @@ fun LocationScreen(
 | 
			
		|||
            refreshState = refresh,
 | 
			
		||||
            refreshing = viewModel.isLoading,
 | 
			
		||||
            onItem = {
 | 
			
		||||
//                screen.navigateToQuestDetail(id = it.id)
 | 
			
		||||
                screen.navigateToLocationDetail(id = it.id)
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue