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