diff --git a/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/MainActivity.kt index 28869b2..1caf4a6 100644 --- a/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/MainActivity.kt @@ -19,6 +19,7 @@ import com.arkivanov.essenty.lifecycle.Lifecycle import com.arkivanov.essenty.lifecycle.LifecycleOwner import com.arkivanov.essenty.lifecycle.essentyLifecycle import com.arkivanov.essenty.statekeeper.stateKeeper +import dev.datlag.aniflow.other.DomainVerifier import dev.datlag.aniflow.other.UserHelper import dev.datlag.aniflow.ui.navigation.RootComponent import dev.datlag.tooling.compose.launchIO @@ -52,6 +53,8 @@ class MainActivity : AppCompatActivity() { di = di ) + DomainVerifier.verify(this) + setContent { CompositionLocalProvider( LocalLifecycleOwner provides lifecycleOwner, @@ -88,6 +91,42 @@ class MainActivity : AppCompatActivity() { ) } + override fun onDestroy() { + super.onDestroy() + + DomainVerifier.verify(this) + } + + override fun onStart() { + super.onStart() + + DomainVerifier.verify(this) + } + + override fun onResume() { + super.onResume() + + DomainVerifier.verify(this) + } + + override fun onPause() { + super.onPause() + + DomainVerifier.verify(this) + } + + override fun onStop() { + super.onStop() + + DomainVerifier.verify(this) + } + + override fun onRestart() { + super.onRestart() + + DomainVerifier.verify(this) + } + private fun Uri.getFragmentOrQueryParameter(param: String): String? { return this.fragment.getFragmentParameter(param) ?: getQueryParameter(param)?.ifBlank { null } } diff --git a/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/other/DomainVerifier.kt b/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/other/DomainVerifier.kt new file mode 100644 index 0000000..135ba12 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/other/DomainVerifier.kt @@ -0,0 +1,44 @@ +package dev.datlag.aniflow.other + +import android.content.Context +import android.content.Intent +import android.content.pm.verify.domain.DomainVerificationManager +import android.content.pm.verify.domain.DomainVerificationUserState +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.core.content.ContextCompat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +data object DomainVerifier { + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) + val supported: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val verified: MutableStateFlow = MutableStateFlow(false) + + @Suppress("NewApi") + fun verify(context: Context) { + if (supported) { + val manager = ContextCompat.getSystemService(context, DomainVerificationManager::class.java) + val userState = manager?.getDomainVerificationUserState(context.packageName) + val unapprovedDomains = userState?.hostToStateMap?.filterValues { it == DomainVerificationUserState.DOMAIN_STATE_NONE } + + unapprovedDomains?.let { count -> + verified.update { count.isEmpty() } + } + } + } + + @Suppress("NewApi") + fun enable(context: Context) { + if (supported) { + val intent = Intent( + Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, + Uri.parse("package:${context.packageName}") + ) + ContextCompat.startActivity(context, intent, null) + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/DomainSection.android.kt b/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/DomainSection.android.kt new file mode 100644 index 0000000..5ed8ed3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/DomainSection.android.kt @@ -0,0 +1,65 @@ +package dev.datlag.aniflow.ui.navigation.screen.initial.settings.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import dev.datlag.aniflow.other.DomainVerifier +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle + +@Composable +actual fun DomainSection(modifier: Modifier) { + if (DomainVerifier.supported) { + val context = LocalContext.current + val verified by DomainVerifier.verified.collectAsStateWithLifecycle() + + SideEffect { + DomainVerifier.verify(context) + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = null, + ) + Text( + text = "Open Links" + ) + Spacer(modifier = Modifier.weight(1F)) + Switch( + checked = verified, + onCheckedChange = { + DomainVerifier.enable(context) + }, + enabled = !verified, + thumbContent = { + if (verified) { + Icon( + modifier = Modifier.size(SwitchDefaults.IconSize), + imageVector = Icons.Default.Check, + contentDescription = null + ) + } + } + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/UserHelper.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/UserHelper.kt index f31a04c..0033eb2 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/UserHelper.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/UserHelper.kt @@ -11,12 +11,16 @@ import dev.datlag.aniflow.anilist.ViewerQuery import dev.datlag.aniflow.anilist.model.User import dev.datlag.aniflow.common.toMutation import dev.datlag.aniflow.common.toSettings +import dev.datlag.aniflow.model.emitNotNull +import dev.datlag.aniflow.model.mutableStateIn import dev.datlag.aniflow.model.safeFirstOrNull import dev.datlag.aniflow.settings.Settings import dev.datlag.tooling.async.suspendCatching import dev.datlag.tooling.compose.ioDispatcher import dev.datlag.tooling.compose.withIOContext import dev.datlag.tooling.compose.withMainContext +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.runBlocking import kotlinx.datetime.Clock @@ -36,30 +40,23 @@ class UserHelper( val isLoggedIn: Flow = userSettings.isAniListLoggedIn.distinctUntilChanged() val loginUrl: String = "https://anilist.co/api/v2/oauth/authorize?client_id=$clientId&response_type=token" - private val changedUser: MutableStateFlow = MutableStateFlow(null) - private val userQuery = client.query( - ViewerQuery() - ).toFlow().flowOn(ioDispatcher()) - private val defaultUser = isLoggedIn.transform { loggedIn -> + @OptIn(DelicateCoroutinesApi::class) + private val updatableUser = isLoggedIn.transform { loggedIn -> if (loggedIn) { emitAll( - userQuery.map { + client.query(ViewerQuery()).toFlow().map { it.data?.Viewer?.let(::User) } ) } else { emit(null) } - }.flowOn(ioDispatcher()) - private val latestUser = defaultUser.transform { default -> - emit(default) - emitAll(changedUser.filterNotNull().map { changed -> - changedUser.update { null } - changed - }) - }.flowOn(ioDispatcher()) + }.mutableStateIn( + scope = GlobalScope, + initialValue = null + ) - val user = latestUser.transform { user -> + val user = updatableUser.transform { user -> emit( user?.also { appSettings.setData( @@ -74,7 +71,7 @@ class UserHelper( suspend fun updateAdultSetting(value: Boolean) { appSettings.setAdultContent(value) - changedUser.emit( + updatableUser.emitNotNull( client.mutation( ViewerMutation( adult = Optional.present(value) @@ -87,7 +84,7 @@ class UserHelper( appSettings.setColor(value) if (value != null) { - changedUser.emit( + updatableUser.emitNotNull( client.mutation( ViewerMutation( color = Optional.present(value.label) @@ -101,7 +98,7 @@ class UserHelper( appSettings.setTitleLanguage(value) if (value != null) { - changedUser.emit( + updatableUser.emitNotNull( client.mutation( ViewerMutation( title = Optional.presentIfNotNull(value.toMutation()) @@ -115,7 +112,7 @@ class UserHelper( appSettings.setCharLanguage(value) if (value != null) { - changedUser.emit( + updatableUser.emitNotNull( client.mutation( ViewerMutation( char = Optional.presentIfNotNull(value.toMutation()) diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsScreen.kt index 5626904..91a57f5 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/SettingsScreen.kt @@ -42,6 +42,7 @@ import dev.datlag.aniflow.common.plus import dev.datlag.aniflow.common.toComposeColor import dev.datlag.aniflow.common.toComposeString import dev.datlag.aniflow.other.StateSaver +import dev.datlag.aniflow.ui.navigation.screen.initial.settings.component.* import dev.datlag.tooling.compose.onClick import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle import dev.icerock.moko.resources.compose.stringResource @@ -67,236 +68,43 @@ fun SettingsScreen(component: SettingsComponent) { verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { - val user by component.user.collectAsStateWithLifecycle(null) - - user?.let { u -> - Column( - modifier = Modifier.fillParentMaxWidth().padding(bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally - ) { - AsyncImage( - modifier = Modifier.size(96.dp).clip(CircleShape), - model = u.avatar.large, - contentDescription = null, - error = rememberAsyncImagePainter( - model = u.avatar.medium, - contentScale = ContentScale.Crop, - ), - contentScale = ContentScale.Crop, - alignment = Alignment.Center - ) - Text( - text = u.name, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) - u.description?.let { - Markdown( - modifier = Modifier.padding(bottom = 16.dp), - content = it - ) - } - } - } ?: Text( - modifier = Modifier.padding(bottom = 8.dp), - text = "Settings", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + UserSection( + userFlow = component.user, + modifier = Modifier.fillParentMaxWidth() ) } item { - val selectedColor by component.selectedColor.collectAsStateWithLifecycle(null) - val temporaryColor by StateSaver.temporaryColor.collectAsStateWithLifecycle() - val useCase = rememberUseCaseState( - onFinishedRequest = { - StateSaver.updateTemporaryColor(null) - }, - onCloseRequest = { - StateSaver.updateTemporaryColor(null) - }, - onDismissRequest = { - StateSaver.updateTemporaryColor(null) - } - ) - val colors = remember { SettingsColor.all.toList() } - - OptionDialog( - state = useCase, - selection = OptionSelection.Single( - options = colors.map { - Option( - icon = IconSource( - imageVector = Icons.Filled.Circle, - tint = it.toComposeColor() - ), - selected = if (temporaryColor != null) { - it == temporaryColor - } else { - it == selectedColor - }, - titleText = stringResource(it.toComposeString()), - onClick = { - StateSaver.updateTemporaryColor(it) - } - ) - }, - onSelectOption = { option, _ -> - component.changeProfileColor(colors[option]) - } - ), - config = OptionConfig( - mode = DisplayMode.GRID_VERTICAL, - gridColumns = 4 - ) - ) - - Row( + ColorSection( + selectedColorFlow = component.selectedColor, modifier = Modifier.fillParentMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Default.Palette, - contentDescription = null, - ) - Text( - text = "Profile Color" - ) - Spacer(modifier = Modifier.weight(1F)) - IconButton( - onClick = { useCase.show() } - ) { - Icon( - imageVector = Icons.Filled.Circle, - contentDescription = null, - tint = selectedColor?.toComposeColor() ?: LocalContentColor.current - ) - } - } + onChange = component::changeProfileColor + ) } item { - val selectedTitle by component.selectedTitleLanguage.collectAsStateWithLifecycle(null) - val useCase = rememberUseCaseState() - val languages = remember { SettingsTitle.all.toList() } - - OptionDialog( - state = useCase, - selection = OptionSelection.Single( - options = languages.map { - Option( - selected = it == selectedTitle, - titleText = stringResource(it.toComposeString()) - ) - }, - onSelectOption = { option, _ -> - component.changeTitleLanguage(languages[option]) - } - ), - config = OptionConfig( - mode = DisplayMode.LIST, - ) - ) - - Row( + TitleSection( + titleFlow = component.selectedTitleLanguage, modifier = Modifier.fillParentMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Rounded.Title, - contentDescription = null - ) - Text( - text = "Title Language" - ) - Spacer(modifier = Modifier.weight(1F)) - IconButton( - onClick = { useCase.show() } - ) { - Icon( - imageVector = Icons.Default.ExpandMore, - contentDescription = null - ) - } - } + onChange = component::changeTitleLanguage + ) } item { - val selectedChar by component.selectedCharLanguage.collectAsStateWithLifecycle(null) - val useCase = rememberUseCaseState() - val languages = remember { SettingsChar.all.toList() } - - OptionDialog( - state = useCase, - selection = OptionSelection.Single( - options = languages.map { - Option( - selected = it == selectedChar, - titleText = stringResource(it.toComposeString()) - ) - }, - onSelectOption = { option, _ -> - component.changeCharLanguage(languages[option]) - } - ), - config = OptionConfig( - mode = DisplayMode.LIST, - ) - ) - - Row( + CharacterSection( + characterFlow = component.selectedCharLanguage, modifier = Modifier.fillParentMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Filled.PersonPin, - contentDescription = null - ) - Text( - text = "Character Language" - ) - Spacer(modifier = Modifier.weight(1F)) - IconButton( - onClick = { useCase.show() } - ) { - Icon( - imageVector = Icons.Default.ExpandMore, - contentDescription = null - ) - } - } + onChanged = component::changeCharLanguage + ) } item { - val adultContent by component.adultContent.collectAsStateWithLifecycle(false) - - Row( + AdultSection( + adultFlow = component.adultContent, modifier = Modifier.fillParentMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Default.NoAdultContent, - contentDescription = null, - ) - Text( - text = stringResource(SharedRes.strings.adult_content_setting) - ) - Spacer(modifier = Modifier.weight(1F)) - Switch( - checked = adultContent, - onCheckedChange = component::changeAdultContent, - thumbContent = { - if (adultContent) { - Icon( - modifier = Modifier.size(SwitchDefaults.IconSize), - imageVector = Icons.Default.Check, - contentDescription = null - ) - } - } - ) - } + onChange = component::changeAdultContent + ) + } + item { + DomainSection( + modifier = Modifier.fillParentMaxWidth() + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/AdultSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/AdultSection.kt new file mode 100644 index 0000000..5b854d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/AdultSection.kt @@ -0,0 +1,59 @@ +package dev.datlag.aniflow.ui.navigation.screen.initial.settings.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.NoAdultContent +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.datlag.aniflow.SharedRes +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun AdultSection( + adultFlow: Flow, + modifier: Modifier = Modifier, + onChange: (Boolean) -> Unit +) { + val adultContent by adultFlow.collectAsStateWithLifecycle(false) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.NoAdultContent, + contentDescription = null, + ) + Text( + text = stringResource(SharedRes.strings.adult_content_setting) + ) + Spacer(modifier = Modifier.weight(1F)) + Switch( + checked = adultContent, + onCheckedChange = onChange, + thumbContent = { + if (adultContent) { + Icon( + modifier = Modifier.size(SwitchDefaults.IconSize), + imageVector = Icons.Default.Check, + contentDescription = null + ) + } + } + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/CharacterSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/CharacterSection.kt new file mode 100644 index 0000000..5f2308b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/CharacterSection.kt @@ -0,0 +1,82 @@ +package dev.datlag.aniflow.ui.navigation.screen.initial.settings.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.PersonPin +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState +import com.maxkeppeler.sheets.option.OptionDialog +import com.maxkeppeler.sheets.option.models.DisplayMode +import com.maxkeppeler.sheets.option.models.Option +import com.maxkeppeler.sheets.option.models.OptionConfig +import com.maxkeppeler.sheets.option.models.OptionSelection +import dev.datlag.aniflow.common.toComposeString +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.datlag.aniflow.settings.model.CharLanguage as SettingsChar +import kotlinx.coroutines.flow.Flow +import dev.icerock.moko.resources.compose.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CharacterSection( + characterFlow: Flow, + modifier: Modifier = Modifier, + onChanged: (SettingsChar?) -> Unit +) { + val selectedChar by characterFlow.collectAsStateWithLifecycle(null) + val useCase = rememberUseCaseState() + val languages = remember { SettingsChar.all.toList() } + + OptionDialog( + state = useCase, + selection = OptionSelection.Single( + options = languages.map { + Option( + selected = it == selectedChar, + titleText = stringResource(it.toComposeString()) + ) + }, + onSelectOption = { option, _ -> + onChanged(languages[option]) + } + ), + config = OptionConfig( + mode = DisplayMode.LIST, + ) + ) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Filled.PersonPin, + contentDescription = null + ) + Text( + text = "Character Language" + ) + Spacer(modifier = Modifier.weight(1F)) + IconButton( + onClick = { useCase.show() } + ) { + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = null + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/ColorSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/ColorSection.kt new file mode 100644 index 0000000..65730f2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/ColorSection.kt @@ -0,0 +1,106 @@ +package dev.datlag.aniflow.ui.navigation.screen.initial.settings.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.maxkeppeker.sheets.core.models.base.IconSource +import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState +import com.maxkeppeler.sheets.option.OptionDialog +import com.maxkeppeler.sheets.option.models.DisplayMode +import com.maxkeppeler.sheets.option.models.Option +import com.maxkeppeler.sheets.option.models.OptionConfig +import com.maxkeppeler.sheets.option.models.OptionSelection +import dev.datlag.aniflow.common.toComposeColor +import dev.datlag.aniflow.common.toComposeString +import dev.datlag.aniflow.other.StateSaver +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import dev.datlag.aniflow.settings.model.Color as SettingsColor +import dev.icerock.moko.resources.compose.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ColorSection( + selectedColorFlow: Flow, + modifier: Modifier = Modifier, + onChange: (SettingsColor?) -> Unit, +) { + val selectedColor by selectedColorFlow.collectAsStateWithLifecycle(null) + val temporaryColor by StateSaver.temporaryColor.collectAsStateWithLifecycle() + val useCase = rememberUseCaseState( + onFinishedRequest = { + StateSaver.updateTemporaryColor(null) + }, + onCloseRequest = { + StateSaver.updateTemporaryColor(null) + }, + onDismissRequest = { + StateSaver.updateTemporaryColor(null) + } + ) + val colors = remember { SettingsColor.all.toList() } + + OptionDialog( + state = useCase, + selection = OptionSelection.Single( + options = colors.map { + Option( + icon = IconSource( + imageVector = Icons.Filled.Circle, + tint = it.toComposeColor() + ), + selected = if (temporaryColor != null) { + it == temporaryColor + } else { + it == selectedColor + }, + titleText = stringResource(it.toComposeString()), + onClick = { + StateSaver.updateTemporaryColor(it) + } + ) + }, + onSelectOption = { option, _ -> + onChange(colors[option]) + } + ), + config = OptionConfig( + mode = DisplayMode.GRID_VERTICAL, + gridColumns = 4 + ) + ) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Palette, + contentDescription = null, + ) + Text( + text = "Profile Color" + ) + Spacer(modifier = Modifier.weight(1F)) + IconButton( + onClick = { useCase.show() } + ) { + Icon( + imageVector = Icons.Filled.Circle, + contentDescription = null, + tint = selectedColor?.toComposeColor() ?: LocalContentColor.current + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/DomainSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/DomainSection.kt new file mode 100644 index 0000000..deff88c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/DomainSection.kt @@ -0,0 +1,9 @@ +package dev.datlag.aniflow.ui.navigation.screen.initial.settings.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +expect fun DomainSection( + modifier: Modifier = Modifier +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/TitleSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/TitleSection.kt new file mode 100644 index 0000000..5ee8095 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/TitleSection.kt @@ -0,0 +1,82 @@ +package dev.datlag.aniflow.ui.navigation.screen.initial.settings.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.rounded.Title +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState +import com.maxkeppeler.sheets.option.OptionDialog +import com.maxkeppeler.sheets.option.models.DisplayMode +import com.maxkeppeler.sheets.option.models.Option +import com.maxkeppeler.sheets.option.models.OptionConfig +import com.maxkeppeler.sheets.option.models.OptionSelection +import dev.datlag.aniflow.common.toComposeString +import dev.datlag.aniflow.settings.model.TitleLanguage as SettingsTitle +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import dev.icerock.moko.resources.compose.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TitleSection( + titleFlow: Flow, + modifier: Modifier = Modifier, + onChange: (SettingsTitle?) -> Unit, +) { + val selectedTitle by titleFlow.collectAsStateWithLifecycle(null) + val useCase = rememberUseCaseState() + val languages = remember { SettingsTitle.all.toList() } + + OptionDialog( + state = useCase, + selection = OptionSelection.Single( + options = languages.map { + Option( + selected = it == selectedTitle, + titleText = stringResource(it.toComposeString()) + ) + }, + onSelectOption = { option, _ -> + onChange(languages[option]) + } + ), + config = OptionConfig( + mode = DisplayMode.LIST, + ) + ) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Rounded.Title, + contentDescription = null + ) + Text( + text = "Title Language" + ) + Spacer(modifier = Modifier.weight(1F)) + IconButton( + onClick = { useCase.show() } + ) { + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = null + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/UserSection.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/UserSection.kt new file mode 100644 index 0000000..0981f0f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/UserSection.kt @@ -0,0 +1,79 @@ +package dev.datlag.aniflow.ui.navigation.screen.initial.settings.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.rememberAsyncImagePainter +import com.mikepenz.markdown.m3.Markdown +import dev.datlag.aniflow.LocalDI +import dev.datlag.aniflow.SharedRes +import dev.datlag.aniflow.anilist.model.User +import dev.datlag.aniflow.other.UserHelper +import dev.datlag.tooling.compose.onClick +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import dev.icerock.moko.resources.compose.painterResource +import org.kodein.di.instance + +@Composable +fun UserSection( + userFlow: Flow, + modifier: Modifier = Modifier, +) { + val user by userFlow.collectAsStateWithLifecycle(null) + + Column( + modifier = modifier.padding(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val uriHandler = LocalUriHandler.current + val userHelper by LocalDI.current.instance() + + AsyncImage( + modifier = Modifier.size(96.dp).clip(CircleShape).onClick( + enabled = user == null, + ) { + uriHandler.openUri(userHelper.loginUrl) + }, + model = user?.avatar?.large, + contentDescription = null, + error = rememberAsyncImagePainter( + model = user?.avatar?.medium, + contentScale = ContentScale.Crop, + error = painterResource(SharedRes.images.anilist) + ), + contentScale = ContentScale.Crop, + alignment = Alignment.Center + ) + Text( + text = user?.name ?: "Settings", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + user?.description?.let { + Markdown( + modifier = Modifier.padding(bottom = 16.dp), + content = it + ) + } ?: run { + Text( + text = "Click the image above to login with AniList" + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/moko-resources/images/anilist.svg b/composeApp/src/commonMain/moko-resources/images/anilist.svg new file mode 100644 index 0000000..7ca1e5f --- /dev/null +++ b/composeApp/src/commonMain/moko-resources/images/anilist.svg @@ -0,0 +1 @@ +AniList logoAnime and manga tracking website \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/DomainSection.ios.kt b/composeApp/src/iosMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/DomainSection.ios.kt new file mode 100644 index 0000000..156c6ef --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/settings/component/DomainSection.ios.kt @@ -0,0 +1,8 @@ +package dev.datlag.aniflow.ui.navigation.screen.initial.settings.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +actual fun DomainSection(modifier: Modifier) { +} \ No newline at end of file diff --git a/model/src/commonMain/kotlin/dev/datlag/aniflow/model/ExtendFlow.kt b/model/src/commonMain/kotlin/dev/datlag/aniflow/model/ExtendFlow.kt index c9e4253..aad16a7 100644 --- a/model/src/commonMain/kotlin/dev/datlag/aniflow/model/ExtendFlow.kt +++ b/model/src/commonMain/kotlin/dev/datlag/aniflow/model/ExtendFlow.kt @@ -1,9 +1,28 @@ package dev.datlag.aniflow.model -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch suspend fun Flow.safeFirstOrNull(): T? { return this.firstOrNull() ?: (this as? StateFlow)?.value ?: this.firstOrNull() +} + +fun Flow.mutableStateIn( + scope: CoroutineScope, + initialValue: T +): MutableStateFlow { + val flow = MutableStateFlow(initialValue) + + scope.launch { + this@mutableStateIn.collect(flow) + } + + return flow +} + +suspend fun FlowCollector.emitNotNull(value: T?) { + if (value != null) { + emit(value) + } } \ No newline at end of file