-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
26 changed files
with
690 additions
and
128 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
186 changes: 186 additions & 0 deletions
186
composeApp/src/commonMain/kotlin/in/procyk/shin/ShinApp.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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) | ||
} |
95 changes: 95 additions & 0 deletions
95
composeApp/src/commonMain/kotlin/in/procyk/shin/component/FavouritesComponent.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Oops, something went wrong.