diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt index 50e808b1f..37f459163 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt @@ -63,7 +63,9 @@ class FrostApp : Application() { ) } - MainScope().launch { setup() } + MainScope().launch { + // setup() + } } private suspend fun setup() { diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/FrostTheme.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/FrostTheme.kt index 216457092..737042453 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/FrostTheme.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/FrostTheme.kt @@ -18,6 +18,7 @@ package com.pitchedapps.frost.compose import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.darkColorScheme @@ -27,16 +28,15 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext /** Main Frost compose theme. */ @Composable fun FrostTheme( + modifier: Modifier = Modifier, isDarkTheme: Boolean = isSystemInDarkTheme(), isDynamicColor: Boolean = true, transparent: Boolean = true, - modifier: Modifier = Modifier, content: @Composable () -> Unit ) { val context = LocalContext.current @@ -57,8 +57,8 @@ fun FrostTheme( MaterialTheme(colorScheme = colorScheme) { Surface( - modifier = modifier, - color = if (transparent) Color.Transparent else MaterialTheme.colorScheme.surface, + modifier = modifier.fillMaxSize(), + // color = if (transparent) Color.Transparent else MaterialTheme.colorScheme.surface, content = content, ) } diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/draggable/DragContainer.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/draggable/DragContainer.kt new file mode 100644 index 000000000..7c2aafc53 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/draggable/DragContainer.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2023 Allan Wang + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.pitchedapps.frost.compose.draggable + +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import com.pitchedapps.frost.ext.toDpSize +import com.pitchedapps.frost.ext.toIntOffset + +/* + * Resources: + * + * https://blog.canopas.com/android-drag-and-drop-ui-element-in-jetpack-compose-14922073b3f1 + */ + +/** + * Container for drag interactions. + * + * This must hold all drag and drop targets. + * + * If a composable cannot be used, any container can add the [Modifier.dragContainer] call, and have + * a [DraggingContents] positioned in the same window space (ie in a Box, or with the appropriate + * offsets to match). + */ +@Composable +fun DragContainer( + modifier: Modifier = Modifier, + draggableState: DraggableState, + content: @Composable () -> Unit +) { + Box(modifier = modifier.dragContainer(draggableState)) { + content() + + DraggingContents(draggableState = draggableState) + } +} + +/** + * Drag container modifier. + * + * Containers must hold all drag and drop targets, along with [DraggingContents]. + */ +fun Modifier.dragContainer(draggableState: DraggableState<*>): Modifier { + return onGloballyPositioned { draggableState.windowPosition = it.positionInWindow() } +} + +/** + * Drag target modifier. + * + * This should be applied to the composable that will be dragged. The modifier will capture + * positions and hide (alpha 0) the target when dragging. + */ +fun Modifier.dragTarget(dragTargetState: DragTargetState<*>): Modifier { + return onGloballyPositioned { + dragTargetState.windowPosition = it.positionInWindow() + dragTargetState.size = it.size + } + .pointerInput(dragTargetState) { + detectDragGesturesAfterLongPress( + onDragStart = { dragTargetState.onDragStart() }, + onDrag = { _, offset -> dragTargetState.onDrag(offset) }, + onDragEnd = { dragTargetState.onDragEnd() }, + onDragCancel = { dragTargetState.onDragEnd() }, + ) + } + // We still need to draw to track size changes + .alpha(if (dragTargetState.isDragging) 0f else 1f) +} + +/** + * Drop target modifier. + * + * This should be applied to targets that capture drag targets. The modifier will listen to + * composable bounds. + */ +fun Modifier.dropTarget(dropTargetState: DropTargetState): Modifier { + return onGloballyPositioned { dropTargetState.bounds = it.boundsInWindow() } +} + +/** + * Draggable content. + * + * Provides composition for all dragging targets. + * + * For composing, we take the position provided by the dragging target. We also account for the + * target size, as we want to maintain the same bounds here. As an example, the target can have + * fillMaxWidth in a grid, but would have a full parent width here without the sizing constraints. + */ +@Composable +fun DraggingContents(draggableState: DraggableState) { + for (target in draggableState.targets) { + DraggingContent(draggableState = draggableState, target = target) + } +} + +@Composable +private fun DraggingContent(draggableState: DraggableState, target: DragTargetState) { + val density = LocalDensity.current + Box( + modifier = + Modifier.size(target.size.toDpSize(density)).offset { + (target.dragPosition - draggableState.windowPosition).toIntOffset() + }, + ) { + target.dragComposable() + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/draggable/DraggableState.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/draggable/DraggableState.kt new file mode 100644 index 000000000..6e1106c43 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/draggable/DraggableState.kt @@ -0,0 +1,268 @@ +/* + * Copyright 2023 Allan Wang + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.pitchedapps.frost.compose.draggable + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.IntSize + +/** Callback when a drag completes on top of a target. */ +fun interface OnDrop { + fun onDrop(dragTarget: String, dragData: T, dropTarget: String) +} + +/** Create draggable state, which will store and create all target states. */ +@Composable +fun rememberDraggableState(onDrop: OnDrop): DraggableState { + // State must be remembered without keys, or else updates will clear draggable data + val state = remember { DraggableStateImpl(onDrop) } + + LaunchedEffect(onDrop) { state.onDrop = onDrop } + + return state +} + +/** + * Parent draggable state. + * + * Allows for drag and drop target state creation via [dragTarget] and [dragTarget]. + * + * All public getters are states, and will trigger recomposition automatically. + */ +interface DraggableState { + + var windowPosition: Offset + + val targets: Collection> + + @Composable + fun rememberDragTarget(key: String, data: T, content: @Composable () -> Unit): DragTargetState + + @Composable fun rememberDropTarget(key: String): DropTargetState +} + +interface DragTargetState { + /** + * Identifier for drag target. + * + * This is not necessarily unique, but only unique drag target keys may be dragged at a time. Ie, + * if a group of drag targets with the same key is dragging, only the first one will activate. + */ + val key: String + + /** Data associated with this drag target. */ + val data: T + + /** True if target is actively being dragged. */ + val isDragging: Boolean + + /** Window position for target. */ + var windowPosition: Offset + + /** Size bounds for target. */ + var size: IntSize + + /** Drag position relative to window. This is only valid while [isDragging]. */ + val dragPosition: Offset + + /** Composable to use when dragging. */ + val dragComposable: @Composable () -> Unit + + /** + * Begin drag for target. + * + * If there is another target with the same [key] being dragged, we will ignore this request. + * + * Returns true if the request is accepted. It is the caller's responsibility to not propagate + * drag events if the request is denied. + */ + fun onDragStart() + + fun onDrag(offset: Offset) + + fun onDragEnd() +} + +interface DropTargetState { + /** + * True if there is a drag target above this target. + * + * [hoverData] will be populated when true. + */ + val isHovered: Boolean + + /** Associated [DragTargetState.data], or null when not [isHovered] */ + val hoverData: T? + + /** Bounds of the drop target composable */ + var bounds: Rect +} + +private class DraggableStateImpl(var onDrop: OnDrop) : DraggableState { + + override var windowPosition: Offset by mutableStateOf(Offset.Zero) + + val activeDragTargets = mutableStateMapOf>() + + private val dropTargets = mutableStateMapOf>() + + override val targets: Collection> + get() = activeDragTargets.values + + fun cleanUpDrag(dragTarget: DragTargetStateImpl) { + val dragKey = dragTarget.key + for ((dropKey, dropTarget) in dropTargets) { + if (dropTarget.hoverKey == dragKey) { + onDrop.onDrop(dragTarget = dragKey, dragData = dragTarget.data, dropTarget = dropKey) + + setHover(dragKey = null, dropKey) + // Check other drag targets in case one meets drag requirements + checkForDrop(dropKey) + } + } + } + + @Composable + override fun rememberDragTarget( + key: String, + data: T, + content: @Composable () -> Unit, + ): DragTargetState { + val target = + remember(key, data, content, this) { + DragTargetStateImpl(key = key, data = data, draggableState = this, dragComposable = content) + } + DisposableEffect(target) { onDispose { activeDragTargets.remove(key) } } + return target + } + + @Composable + override fun rememberDropTarget(key: String): DropTargetState { + val target = remember(key, this) { DropTargetStateImpl(key, this) } + DisposableEffect(target) { + dropTargets[key] = target + + onDispose { dropTargets.remove(key) } + } + return target + } + + private fun setHover(dragKey: String?, dropKey: String) { + val dropTarget = dropTargets[dropKey] ?: return + // Safety check; we only want to register active keys + val dragTarget = if (dragKey != null) activeDragTargets[dragKey] else null + dropTarget.hoverKey = dragTarget?.key + dropTarget.hoverData = dragTarget?.data + } + + /** Returns true if drag target exists and is within bounds */ + private fun DropTargetStateImpl.hasValidDragTarget(): Boolean { + val currentKey = hoverKey ?: return false // no target + val dragTarget = activeDragTargets[currentKey] ?: return false // target not valid + return dragTarget.within(bounds) + } + + /** Check if drag target fits in drop */ + fun checkForDrop(dropKey: String) { + val dropTarget = dropTargets[dropKey] ?: return + val bounds = dropTarget.bounds + if (dropTarget.hasValidDragTarget()) return + + // Find first target that matches + val dragKey = activeDragTargets.entries.firstOrNull { it.value.within(bounds) }?.key + setHover(dragKey = dragKey, dropKey = dropKey) + } + + /** Check drops for drag target fit */ + fun checkForDrag(dragKey: String) { + val dragTarget = activeDragTargets[dragKey] ?: return + for ((dropKey, dropTarget) in dropTargets) { + // Do not override targets that are valid + if (dropTarget.hasValidDragTarget()) continue + if (dragTarget.within(dropTarget.bounds)) { + setHover(dragKey = dragKey, dropKey = dropKey) + } else if (dropTarget.hoverKey == dragKey) { + setHover(dragKey = null, dropKey = dropKey) + } + } + } +} + +private fun DragTargetStateImpl<*>?.within(bounds: Rect): Boolean { + if (this == null) return false + val center = dragPosition + Offset(size.width * 0.5f, size.height * 0.5f) + return bounds.contains(center) +} + +/** State for individual dragging target. */ +private class DragTargetStateImpl( + override val key: String, + override val data: T, + val draggableState: DraggableStateImpl, + override val dragComposable: @Composable () -> Unit, +) : DragTargetState { + override var isDragging by mutableStateOf(false) + override var windowPosition = Offset.Zero + override var dragPosition by mutableStateOf(Offset.Zero) + override var size: IntSize by mutableStateOf(IntSize.Zero) + + override fun onDragStart() { + // Another drag target is being used; ignore + dragPosition = windowPosition + if (key in draggableState.activeDragTargets) return + draggableState.activeDragTargets[key] = this + isDragging = true + } + + override fun onDrag(offset: Offset) { + if (isDragging) { + dragPosition += offset + draggableState.checkForDrag(key) + } + } + + override fun onDragEnd() { + if (!isDragging) return + draggableState.activeDragTargets.remove(key) + draggableState.cleanUpDrag(this) + isDragging = false + } +} + +private class DropTargetStateImpl( + private val key: String, + private val draggableState: DraggableStateImpl +) : DropTargetState { + var hoverKey: String? by mutableStateOf(null) + override var hoverData: T? by mutableStateOf(null) + override var bounds: Rect = Rect.Zero + set(value) { + field = value + draggableState.checkForDrop(key) + } + + override val isHovered + get() = hoverKey != null +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/effects/Shake.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/effects/Shake.kt new file mode 100644 index 000000000..7e6a91eaf --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/effects/Shake.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2023 Allan Wang + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.pitchedapps.frost.compose.effects + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.platform.inspectable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * State for tracking shaking animation + * + * Note: Used some other material states as reference. This however will recompose the function + * holding the state during shakes. + */ +@Composable +fun rememberShakeState(): ShakeState { + val scope = rememberCoroutineScope() + val state = remember(scope) { ShakeState(scope) } + + return state +} + +class ShakeState +internal constructor( + private val animationScope: CoroutineScope, +) { + + private val rotationAnimatable = Animatable(0f) + + internal val rotation + get() = rotationAnimatable.value + + fun shake() { + animationScope.launch { + rotationAnimatable.stop() + rotationAnimatable.animateTo( + 0f, + initialVelocity = 200f, + animationSpec = + spring( + dampingRatio = 0.3f, + stiffness = 200f, + ), + ) + } + } +} + +fun Modifier.shake(state: ShakeState, enabled: Boolean = true) = + inspectable( + inspectorInfo = + debugInspectorInfo { + name = "shake" + properties["enabled"] = enabled + }, + ) { + Modifier.rotate(state.rotation) + } diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/ext/ComposeExt.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/ext/ComposeExt.kt new file mode 100644 index 000000000..d6fa670fc --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/ext/ComposeExt.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2023 Allan Wang + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.pitchedapps.frost.ext + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlin.math.roundToInt + +@Composable +fun Modifier.thenIf(condition: Boolean, action: @Composable () -> Modifier): Modifier = + if (condition) then(action()) else this + +fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt()) + +fun IntSize.toDpSize(density: Density): DpSize { + return with(density) { DpSize(width.toDp(), height.toDp()) } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt index 28af0532d..56937cc12 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import com.pitchedapps.frost.R import com.pitchedapps.frost.ext.WebTargetId import com.pitchedapps.frost.main.MainTabItem +import com.pitchedapps.frost.tabselector.TabData import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.GlobeAmericas @@ -127,6 +128,13 @@ fun FbItem.tab(context: Context, id: WebTargetId): MainTabItem = url = url, ) +fun FbItem.tab(context: Context): TabData = + TabData( + key = key, + title = context.getString(titleId), + icon = icon, + ) + /// ** Note that this url only works if a query (?q=) is provided */ // _SEARCH("search", R.string.kau_search, GoogleMaterial.Icon.gmd_search, "search/top"), diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainActivity.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainActivity.kt index a64f6053d..3cd228147 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainActivity.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainActivity.kt @@ -23,10 +23,10 @@ import androidx.core.view.WindowCompat import com.google.common.flogger.FluentLogger import com.pitchedapps.frost.R import com.pitchedapps.frost.compose.FrostTheme +import com.pitchedapps.frost.tabselector.TabSelectorScreen import com.pitchedapps.frost.web.state.FrostWebStore import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import mozilla.components.lib.state.ext.observeAsState /** * Main activity. @@ -52,11 +52,9 @@ class MainActivity : ComponentActivity() { // tabs = tabs, // ) - val tabs = - store.observeAsState(initialValue = null) { it.homeTabs.map { it.tab } }.value - ?: return@FrostTheme + TabSelectorScreen() - MainScreenWebView(homeTabs = tabs) + // MainScreenWebView() } } } diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreenWebView.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreenWebView.kt index e38f820fd..22b4cc131 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreenWebView.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreenWebView.kt @@ -19,6 +19,7 @@ package com.pitchedapps.frost.main import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.ExperimentalMaterialApi @@ -42,6 +43,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.pitchedapps.frost.compose.webview.FrostWebCompose import com.pitchedapps.frost.ext.WebTargetId @@ -52,9 +54,12 @@ import kotlinx.coroutines.launch import mozilla.components.lib.state.ext.observeAsState @Composable -fun MainScreenWebView(modifier: Modifier = Modifier, homeTabs: List) { +fun MainScreenWebView(modifier: Modifier = Modifier) { val vm: MainScreenViewModel = viewModel() + val homeTabs = + vm.store.observeAsState(initialValue = null) { it.homeTabs.map { it.tab } }.value ?: return + val selectedHomeTab by vm.store.observeAsState(initialValue = null) { it.selectedHomeTab } Scaffold( @@ -95,7 +100,13 @@ fun MainBottomBar( NavigationBar(modifier = modifier) { items.forEach { item -> NavigationBarItem( - icon = { Icon(item.icon, contentDescription = item.title) }, + icon = { + Icon( + modifier = Modifier.size(24.dp), + imageVector = item.icon, + contentDescription = item.title, + ) + }, selected = selectedTab == item.id, onClick = { onSelect(item.id) }, ) diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/tabselector/TabData.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/tabselector/TabData.kt new file mode 100644 index 000000000..65ccf74c4 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/tabselector/TabData.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2023 Allan Wang + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.pitchedapps.frost.tabselector + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.vector.ImageVector + +/** Option for main screen tabs */ +@Immutable +data class TabData( + val key: String, + val title: String, + val icon: ImageVector, +) diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/tabselector/TabSelectorScreen.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/tabselector/TabSelectorScreen.kt new file mode 100644 index 000000000..e502ebf8f --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/tabselector/TabSelectorScreen.kt @@ -0,0 +1,263 @@ +/* + * Copyright 2023 Allan Wang + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.pitchedapps.frost.tabselector + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ripple.LocalRippleTheme +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pitchedapps.frost.compose.draggable.DragContainer +import com.pitchedapps.frost.compose.draggable.DraggableState +import com.pitchedapps.frost.compose.draggable.dragTarget +import com.pitchedapps.frost.compose.draggable.dropTarget +import com.pitchedapps.frost.compose.draggable.rememberDraggableState +import com.pitchedapps.frost.compose.effects.rememberShakeState +import com.pitchedapps.frost.compose.effects.shake +import com.pitchedapps.frost.facebook.FbItem +import com.pitchedapps.frost.facebook.tab + +@Composable +fun TabSelectorScreen(modifier: Modifier = Modifier) { + + val context = LocalContext.current + + var selected: List by remember { + mutableStateOf(FbItem.defaults().map { it.tab(context) }) + } + + val options: Map = remember { + FbItem.values().associateBy({ it.key }, { it.tab(context) }) + } + + val unselected = remember(selected) { options.values - selected.toSet() } + + TabSelector( + modifier = modifier, + selected = selected, + unselected = unselected, + onSelect = { selected = it }, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TabSelector( + modifier: Modifier, + selected: List, + unselected: List, + onSelect: (List) -> Unit +) { + val draggableState = + rememberDraggableState( + onDrop = { _, dragData, dropTarget -> + onSelect( + selected.map { if (it.key == dropTarget) dragData else it }, + ) + }, + ) + + DragContainer(modifier = modifier, draggableState = draggableState) { + Column(modifier = Modifier.statusBarsPadding()) { + LazyVerticalGrid( + modifier = Modifier.weight(1f), + columns = GridCells.Fixed(4), + ) { + items(unselected, key = { it.key }) { data -> + val dragTargetState = + draggableState.rememberDragTarget(key = data.key, data = data) { + DraggingTabItem(data = data) + } + + val shakeState = rememberShakeState() + + TabItem( + modifier = + Modifier.animateItemPlacement().dragTarget(dragTargetState).shake(shakeState), + data = data, + onClick = { shakeState.shake() }, + ) + } + } + + TabBottomBar( + modifier = Modifier.navigationBarsPadding(), + draggableState = draggableState, + items = selected, + ) + } + } +} + +@Composable +fun TabBottomBar( + modifier: Modifier = Modifier, + draggableState: DraggableState, + items: List +) { + NavigationBar(modifier = modifier) { + items.forEach { item -> + val dropTargetState = draggableState.rememberDropTarget(item.key) + + val alpha by + animateFloatAsState( + targetValue = if (!dropTargetState.isHovered) 1f else 0.3f, + label = "Nav Item Alpha", + ) + + NavigationBarItem( + modifier = Modifier.dropTarget(dropTargetState), + icon = { + val iconItem = dropTargetState.hoverData ?: item + + Icon( + modifier = Modifier.size(24.dp).alpha(alpha), + imageVector = iconItem.icon, + contentDescription = iconItem.title, + ) + }, + selected = false, + onClick = {}, + ) + } + } +} + +/** + * Our dragging tab item is fairly different from the original, so we'll just make a copy to add the + * animations. + * + * Animations are done as one shot events from a default + */ +@Composable +fun DraggingTabItem( + data: TabData, + modifier: Modifier = Modifier, +) { + + var startState by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { startState = false } + + val transition = updateTransition(targetState = startState, label = "One Shot") + + val scale by transition.animateFloat(label = "Scale") { if (it) 1f else 1.3f } + + // Same color as ripple values + val color by + transition.animateColor(label = "Background") { + if (it) + MaterialTheme.colorScheme.surfaceVariant.copy( + LocalRippleTheme.current.rippleAlpha().pressedAlpha, + ) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f) + } + + TabItem( + modifier = + modifier.graphicsLayer { + scaleX = scale + scaleY = scale + }, + iconBackground = color, + labelAlpha = 0f, + data = data, + ) +} + +@Composable +fun TabItem( + data: TabData, + modifier: Modifier = Modifier, + iconBackground: Color = Color.Unspecified, + labelAlpha: Float = 1f, + onClick: () -> Unit = {} +) { + + val interactionSource = remember { MutableInteractionSource() } + + Column( + modifier + .clickable(onClick = onClick, interactionSource = interactionSource, indication = null) + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = + Modifier.clip(CircleShape) + .background(iconBackground) + .indication( + interactionSource, + rememberRipple(color = MaterialTheme.colorScheme.surfaceVariant), + ) + .padding(12.dp) + .size(24.dp), + imageVector = data.icon, + contentDescription = data.title, + ) + Text( + // Weird offset is to accommodate the icon background, which is only used when the label is + // hidden + modifier = Modifier.offset(y = (-4).dp).alpha(labelAlpha), + text = data.title, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } +}