Add map feature

This commit is contained in:
Thomas Andres Gomez 2023-08-08 15:59:30 +02:00
parent 34d15c41e0
commit e74bd7c097
13 changed files with 640 additions and 16 deletions

View file

@ -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) {

View file

@ -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) }

View file

@ -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()
}
}
}

View file

@ -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(

View file

@ -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(

View file

@ -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)
}

View file

@ -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,
)
}
}

View file

@ -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 = { },
)
}
}
}

View file

@ -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,
)
}
)
)
}
}

View file

@ -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)
},
)

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,13H5v-2h14v2z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M9,9l0,-6L7,3l0,2.59L3.91,2.5L2.5,3.91L5.59,7L3,7l0,2L9,9zM21,9V7l-2.59,0l3.09,-3.09L20.09,2.5L17,5.59V3l-2,0l0,6L21,9zM3,15l0,2h2.59L2.5,20.09l1.41,1.41L7,18.41L7,21h2l0,-6L3,15zM15,15l0,6h2v-2.59l3.09,3.09l1.41,-1.41L18.41,17H21v-2L15,15z"/>
</vector>