Skip to content

Commit

Permalink
favourites (wip)
Browse files Browse the repository at this point in the history
  • Loading branch information
avan1235 committed Apr 28, 2024
1 parent 9c29fd8 commit dd2b831
Show file tree
Hide file tree
Showing 26 changed files with 690 additions and 128 deletions.
5 changes: 4 additions & 1 deletion composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ kotlin {
}
}
binaries.executable()
applyBinaryen()
}

androidTarget {
Expand Down Expand Up @@ -105,6 +104,10 @@ kotlin {
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)

implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.no.arg)
}
desktopMain.dependencies {
implementation(compose.desktop.currentOs)
Expand Down
38 changes: 0 additions & 38 deletions composeApp/src/commonMain/kotlin/in/procyk/shin/App.kt

This file was deleted.

186 changes: 186 additions & 0 deletions composeApp/src/commonMain/kotlin/in/procyk/shin/ShinApp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package `in`.procyk.shin

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.outlined.Favorite
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import `in`.procyk.compose.camera.permission.rememberCameraPermissionState
import `in`.procyk.shin.component.ShinAppComponent
import `in`.procyk.shin.component.ShinAppComponent.Child
import `in`.procyk.shin.component.ShinAppComponent.MenuItem
import `in`.procyk.shin.ui.component.ShinBanner
import `in`.procyk.shin.ui.icons.Github
import `in`.procyk.shin.ui.icons.Html5
import `in`.procyk.shin.ui.icons.LinkedIn
import `in`.procyk.shin.ui.icons.ShinIcons
import `in`.procyk.shin.ui.screen.FavouritesScreen
import `in`.procyk.shin.ui.screen.MainScreen
import `in`.procyk.shin.ui.screen.ScanQRCodeScreen
import `in`.procyk.shin.ui.theme.ShinTheme
import `in`.procyk.shin.ui.util.isEscDown
import kotlinx.coroutines.launch

@Composable
fun ShinApp(component: ShinAppComponent) {
val permission = rememberCameraPermissionState()
ShinTheme {
Children(
stack = component.stack,
modifier = Modifier.fillMaxSize(),
animation = stackAnimation(slide(orientation = Orientation.Vertical))
) { child ->
NavigationDrawer(component) {
when (val instance = child.instance) {
is Child.Main -> MainScreen(instance.component, permission.isAvailable)
is Child.ScanQRCode -> ScanQRCodeScreen(instance.component, permission)
is Child.Favourites -> FavouritesScreen(instance.component)
}
}
}
}
}

@Composable
private inline fun NavigationDrawer(
component: ShinAppComponent,
crossinline content: @Composable BoxScope.() -> Unit,
) {
val keyboardController = LocalSoftwareKeyboardController.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val activeMenuItem by component.activeMenuItem.subscribeAsState()
ModalNavigationDrawer(
modifier = Modifier
.onKeyEvent { event ->
(drawerState.isOpen && event.isEscDown).also { isConsumed ->
if (isConsumed) scope.launch { drawerState.close() }
}
},
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = DrawerDefaults.shape
) {
ShinBanner(
modifier = Modifier.padding(bottom = 24.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))

MenuItem.entries.forEach { item ->
NavigationDrawerItem(
icon = {
Icon(
when (item) {
MenuItem.Main -> Icons.Outlined.Home
MenuItem.ScanQRCode -> Icons.Outlined.QrCodeScanner
MenuItem.Favourites -> Icons.Outlined.Favorite
},
contentDescription = "Menu item icon"
)
},
label = {
Text(
when (item) {
MenuItem.Main -> "Home"
MenuItem.ScanQRCode -> "Scan QR Code"
MenuItem.Favourites -> "Favourites"
}
)
},
selected = item == activeMenuItem,
onClick = {
scope
.launch { drawerState.close() }
.invokeOnCompletion { component.navigateTo(item) }
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
}
Spacer(
modifier = Modifier.weight(1f)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Find Me On")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally)
) {
FindMeOn(ShinIcons.Github, "https://github.com/avan1235/", "Github")
FindMeOn(ShinIcons.LinkedIn, "https://www.linkedin.com/in/maciej-procyk/", "LinkedIn")
FindMeOn(ShinIcons.Html5, "https://procyk.in", "web")
}
}
}
},
) {
Scaffold(
snackbarHost = { SnackbarHost(component.snackbarHostState) },
modifier = Modifier.fillMaxSize(),
) { contentPadding ->
Box(
modifier = Modifier.fillMaxSize().padding(contentPadding),
content = content,
)
Box(
modifier = Modifier.fillMaxSize()
) {
IconButton(
modifier = Modifier.align(Alignment.TopStart),
onClick = {
scope.launch {
drawerState.run {
if (isClosed) {
keyboardController?.hide()
open()
} else {
close()
}
}
}
}
) {
Icon(Icons.Default.Menu, contentDescription = "Menu")
}
}
}
}
}

@Composable
private inline fun FindMeOn(
icon: ImageVector,
url: String,
name: String,
) {
val uriHandler = LocalUriHandler.current
IconButton(onClick = { uriHandler.openUri(url) }) {
Icon(icon, "Find me on $name")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.arkivanov.essenty.lifecycle.Lifecycle
import `in`.procyk.shin.ui.util.coroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
Expand Down Expand Up @@ -108,4 +109,10 @@ abstract class AbstractComponent(
lifecycle: Lifecycle = [email protected],
context: CoroutineContext = Dispatchers.Main.immediate,
): Value<T> = asValueUtil(lifecycle, context)

protected fun <T : Any> Flow<T>.asValue(
initialValue: T,
lifecycle: Lifecycle = [email protected],
context: CoroutineContext = Dispatchers.Main.immediate,
): Value<T> = asValueUtil(initialValue, lifecycle, context)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package `in`.procyk.shin.component

import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.Value
import com.arkivanov.decompose.value.operator.map
import com.russhwolf.settings.ObservableSettings
import com.russhwolf.settings.Settings
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.milliseconds

interface FavouritesComponent : Component {

val favourites: Value<List<Favourite>>

fun overwriteFavourite(fullUrl: String, shortUrl: String)

fun deleteFavourite(fullUrl: String)

fun isFavourite(fullUrl: String, shortUrl: String): Value<Boolean>
}

@Serializable
data class Favourite(
val fullUrl: String,
val shortUrl: String,
val createdAt: Instant,
)

class FavouritesComponentImpl(
appContext: ShinAppComponentContext,
componentContext: ComponentContext,
) : AbstractComponent(appContext, componentContext), FavouritesComponent {

private val settings = Settings()

private val _favourites = callbackFlow {
if (settings is ObservableSettings) {
settings.addStringOrNullListener(FAVOURITES_KEY) {
trySend(settings.loadFavourites())
}
} else launch {
while (isActive) {
delay(100.milliseconds)
send(settings.loadFavourites())
}
}
awaitClose()
}
.asValue(settings.loadFavourites())

override val favourites: Value<List<Favourite>> =
_favourites.map { it.map { it.value } }

override fun overwriteFavourite(fullUrl: String, shortUrl: String) {
val favourites = _favourites.value
val new = Favourite(fullUrl, shortUrl, createdAt = Clock.System.now())
favourites[new.fullUrl] = new
settings.saveFavourites(favourites)
}

override fun deleteFavourite(fullUrl: String) {
val favourites = _favourites.value
favourites.remove(fullUrl)
settings.saveFavourites(favourites)
}

override fun isFavourite(fullUrl: String, shortUrl: String): Value<Boolean> {
return _favourites.map { it[fullUrl]?.shortUrl == shortUrl }
}
}

private const val FAVOURITES_KEY: String = "FAVOURITES"

private val FavouritesJson = Json {
ignoreUnknownKeys = true
}

private fun Settings.loadFavourites(): MutableMap<String, Favourite> {
val favourites = getStringOrNull(FAVOURITES_KEY) ?: return mutableMapOf()
return FavouritesJson.decodeFromString(favourites)
}

private fun Settings.saveFavourites(favourites: Map<String, Favourite>) {
val encoded = FavouritesJson.encodeToString(favourites)
putString(FAVOURITES_KEY, encoded)
}
Loading

0 comments on commit dd2b831

Please sign in to comment.