Support toasts on desktop, start working on toasting errors

This commit is contained in:
Syer10
2022-11-04 23:50:24 -04:00
parent c0bca44d18
commit c5e0cf119e
12 changed files with 164 additions and 42 deletions

View File

@@ -11,12 +11,16 @@ import ca.gosyer.jui.data.DataComponent
import ca.gosyer.jui.domain.DomainComponent import ca.gosyer.jui.domain.DomainComponent
import ca.gosyer.jui.ui.ViewModelComponent import ca.gosyer.jui.ui.ViewModelComponent
import ca.gosyer.jui.ui.base.UiComponent import ca.gosyer.jui.ui.base.UiComponent
import ca.gosyer.jui.uicore.vm.ContextWrapper
import me.tatarka.inject.annotations.Component import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides import me.tatarka.inject.annotations.Provides
@AppScope @AppScope
@Component @Component
abstract class AppComponent : ViewModelComponent, DataComponent, DomainComponent, UiComponent { abstract class AppComponent(
@get:Provides
val context: ContextWrapper
) : ViewModelComponent, DataComponent, DomainComponent, UiComponent {
abstract val appMigrations: AppMigrations abstract val appMigrations: AppMigrations
@@ -31,7 +35,7 @@ abstract class AppComponent : ViewModelComponent, DataComponent, DomainComponent
companion object { companion object {
private var appComponentInstance: AppComponent? = null private var appComponentInstance: AppComponent? = null
fun getInstance() = appComponentInstance ?: create() fun getInstance(context: ContextWrapper) = appComponentInstance ?: create(context)
.also { appComponentInstance = it } .also { appComponentInstance = it }
} }
} }

View File

@@ -8,21 +8,35 @@ package ca.gosyer.jui.desktop
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Card
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.configureSwingGlobalsForCompose import androidx.compose.ui.configureSwingGlobalsForCompose
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPainter import androidx.compose.ui.graphics.toPainter
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.awaitApplication import androidx.compose.ui.window.awaitApplication
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
@@ -44,6 +58,8 @@ import ca.gosyer.jui.ui.util.compose.WindowGet
import ca.gosyer.jui.uicore.components.LoadingScreen import ca.gosyer.jui.uicore.components.LoadingScreen
import ca.gosyer.jui.uicore.prefs.asStateIn import ca.gosyer.jui.uicore.prefs.asStateIn
import ca.gosyer.jui.uicore.resources.stringResource import ca.gosyer.jui.uicore.resources.stringResource
import ca.gosyer.jui.uicore.vm.ContextWrapper
import ca.gosyer.jui.uicore.vm.Length
import com.github.weisj.darklaf.LafManager import com.github.weisj.darklaf.LafManager
import com.github.weisj.darklaf.theme.DarculaTheme import com.github.weisj.darklaf.theme.DarculaTheme
import com.github.weisj.darklaf.theme.IntelliJTheme import com.github.weisj.darklaf.theme.IntelliJTheme
@@ -53,6 +69,7 @@ import com.vanpra.composematerialdialogs.rememberMaterialDialogState
import com.vanpra.composematerialdialogs.title import com.vanpra.composematerialdialogs.title
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@@ -61,6 +78,8 @@ import org.jetbrains.skiko.SystemTheme
import org.jetbrains.skiko.currentSystemTheme import org.jetbrains.skiko.currentSystemTheme
import java.util.Locale import java.util.Locale
import kotlin.system.exitProcess import kotlin.system.exitProcess
import kotlin.time.Duration.Companion.ZERO
import kotlin.time.Duration.Companion.seconds
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
suspend fun main() { suspend fun main() {
@@ -70,7 +89,9 @@ suspend fun main() {
System.setProperty("kotlinx.coroutines.debug", "on") System.setProperty("kotlinx.coroutines.debug", "on")
} }
val appComponent = AppComponent.getInstance() val context = ContextWrapper()
val appComponent = AppComponent.getInstance(context)
appComponent.migrations.runMigrations() appComponent.migrations.runMigrations()
appComponent.appMigrations.runMigrations() appComponent.appMigrations.runMigrations()
@@ -191,6 +212,12 @@ suspend fun main() {
if (displayDebugInfo) { if (displayDebugInfo) {
DebugOverlay() DebugOverlay()
} }
ToastOverlay(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 64.dp),
context = context
)
} }
} }
ServerResult.STARTING, ServerResult.FAILED -> { ServerResult.STARTING, ServerResult.FAILED -> {
@@ -224,3 +251,50 @@ suspend fun main() {
} }
} }
} }
@Composable
fun ToastOverlay(modifier: Modifier, context: ContextWrapper) {
var toast by remember { mutableStateOf<Pair<String, Length>?>(null) }
LaunchedEffect(Unit) {
context.toasts
.onEach {
toast = it
}
.launchIn(this)
}
LaunchedEffect(toast) {
if (toast != null) {
delay(
when (toast?.second) {
Length.SHORT -> 2.seconds
Length.LONG -> 5.seconds
else -> ZERO
}
)
toast = null
}
}
@Suppress("NAME_SHADOWING")
Crossfade(
toast?.first,
modifier = modifier
) { toast ->
if (toast != null) {
Card(
Modifier.sizeIn(maxWidth = 200.dp),
shape = CircleShape,
backgroundColor = Color.DarkGray,
) {
Text(
toast,
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp,
textAlign = TextAlign.Center
)
}
}
}
}

View File

@@ -15,12 +15,18 @@ import org.lighthousegames.logging.logging
class GetMangaCategories @Inject constructor(private val categoryRepository: CategoryRepository) { class GetMangaCategories @Inject constructor(private val categoryRepository: CategoryRepository) {
suspend fun await(mangaId: Long) = asFlow(mangaId) suspend fun await(mangaId: Long, onError: suspend (Throwable) -> Unit = {}) = asFlow(mangaId)
.catch { log.warn(it) { "Failed to get categories for $mangaId" } } .catch {
onError(it)
log.warn(it) { "Failed to get categories for $mangaId" }
}
.singleOrNull() .singleOrNull()
suspend fun await(manga: Manga) = asFlow(manga) suspend fun await(manga: Manga, onError: suspend (Throwable) -> Unit = {}) = asFlow(manga)
.catch { log.warn(it) { "Failed to get categories for ${manga.title}(${manga.id})" } } .catch {
onError(it)
log.warn(it) { "Failed to get categories for ${manga.title}(${manga.id})" }
}
.singleOrNull() .singleOrNull()
fun asFlow(mangaId: Long) = categoryRepository.getMangaCategories(mangaId) fun asFlow(mangaId: Long) = categoryRepository.getMangaCategories(mangaId)

View File

@@ -15,12 +15,18 @@ import org.lighthousegames.logging.logging
class GetManga @Inject constructor(private val mangaRepository: MangaRepository) { class GetManga @Inject constructor(private val mangaRepository: MangaRepository) {
suspend fun await(mangaId: Long) = asFlow(mangaId) suspend fun await(mangaId: Long, onError: suspend (Throwable) -> Unit = {}) = asFlow(mangaId)
.catch { log.warn(it) { "Failed to get manga $mangaId" } } .catch {
onError(it)
log.warn(it) { "Failed to get manga $mangaId" }
}
.singleOrNull() .singleOrNull()
suspend fun await(manga: Manga) = asFlow(manga) suspend fun await(manga: Manga, onError: suspend (Throwable) -> Unit = {}) = asFlow(manga)
.catch { log.warn(it) { "Failed to get manga ${manga.title}(${manga.id})" } } .catch {
onError(it)
log.warn(it) { "Failed to get manga ${manga.title}(${manga.id})" }
}
.singleOrNull() .singleOrNull()
fun asFlow(mangaId: Long) = mangaRepository.getManga(mangaId) fun asFlow(mangaId: Long) = mangaRepository.getManga(mangaId)

View File

@@ -15,12 +15,18 @@ import org.lighthousegames.logging.logging
class RefreshManga @Inject constructor(private val mangaRepository: MangaRepository) { class RefreshManga @Inject constructor(private val mangaRepository: MangaRepository) {
suspend fun await(mangaId: Long) = asFlow(mangaId) suspend fun await(mangaId: Long, onError: suspend (Throwable) -> Unit = {}) = asFlow(mangaId)
.catch { log.warn(it) { "Failed to refresh manga $mangaId" } } .catch {
onError(it)
log.warn(it) { "Failed to refresh manga $mangaId" }
}
.singleOrNull() .singleOrNull()
suspend fun await(manga: Manga) = asFlow(manga) suspend fun await(manga: Manga, onError: suspend (Throwable) -> Unit = {}) = asFlow(manga)
.catch { log.warn(it) { "Failed to refresh manga ${manga.title}(${manga.id})" } } .catch {
onError(it)
log.warn(it) { "Failed to refresh manga ${manga.title}(${manga.id})" }
}
.singleOrNull() .singleOrNull()
fun asFlow(mangaId: Long) = mangaRepository.getManga(mangaId, true) fun asFlow(mangaId: Long) = mangaRepository.getManga(mangaId, true)

View File

@@ -15,12 +15,18 @@ import org.lighthousegames.logging.logging
class GetLatestManga @Inject constructor(private val sourceRepository: SourceRepository) { class GetLatestManga @Inject constructor(private val sourceRepository: SourceRepository) {
suspend fun await(source: Source, page: Int) = asFlow(source.id, page) suspend fun await(source: Source, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(source.id, page)
.catch { log.warn(it) { "Failed to get latest manga from ${source.displayName} on page $page" } } .catch {
onError(it)
log.warn(it) { "Failed to get latest manga from ${source.displayName} on page $page" }
}
.singleOrNull() .singleOrNull()
suspend fun await(sourceId: Long, page: Int) = asFlow(sourceId, page) suspend fun await(sourceId: Long, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(sourceId, page)
.catch { log.warn(it) { "Failed to get latest manga from $sourceId on page $page" } } .catch {
onError(it)
log.warn(it) { "Failed to get latest manga from $sourceId on page $page" }
}
.singleOrNull() .singleOrNull()
fun asFlow(source: Source, page: Int) = sourceRepository.getLatestManga(source.id, page) fun asFlow(source: Source, page: Int) = sourceRepository.getLatestManga(source.id, page)

View File

@@ -15,12 +15,18 @@ import org.lighthousegames.logging.logging
class GetPopularManga @Inject constructor(private val sourceRepository: SourceRepository) { class GetPopularManga @Inject constructor(private val sourceRepository: SourceRepository) {
suspend fun await(source: Source, page: Int) = asFlow(source.id, page) suspend fun await(source: Source, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(source.id, page)
.catch { log.warn(it) { "Failed to get popular manga from ${source.displayName} on page $page" } } .catch {
onError(it)
log.warn(it) { "Failed to get popular manga from ${source.displayName} on page $page" }
}
.singleOrNull() .singleOrNull()
suspend fun await(sourceId: Long, page: Int) = asFlow(sourceId, page) suspend fun await(sourceId: Long, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(sourceId, page)
.catch { log.warn(it) { "Failed to get popular manga from $sourceId on page $page" } } .catch {
onError(it)
log.warn(it) { "Failed to get popular manga from $sourceId on page $page" }
}
.singleOrNull() .singleOrNull()
fun asFlow(source: Source, page: Int) = sourceRepository.getPopularManga(source.id, page) fun asFlow(source: Source, page: Int) = sourceRepository.getPopularManga(source.id, page)

View File

@@ -15,12 +15,18 @@ import org.lighthousegames.logging.logging
class GetSearchManga @Inject constructor(private val sourceRepository: SourceRepository) { class GetSearchManga @Inject constructor(private val sourceRepository: SourceRepository) {
suspend fun await(source: Source, searchTerm: String?, page: Int) = asFlow(source.id, searchTerm, page) suspend fun await(source: Source, searchTerm: String?, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(source.id, searchTerm, page)
.catch { log.warn(it) { "Failed to get search results from ${source.displayName} on page $page with query '$searchTerm'" } } .catch {
onError(it)
log.warn(it) { "Failed to get search results from ${source.displayName} on page $page with query '$searchTerm'" }
}
.singleOrNull() .singleOrNull()
suspend fun await(sourceId: Long, searchTerm: String?, page: Int) = asFlow(sourceId, searchTerm, page) suspend fun await(sourceId: Long, searchTerm: String?, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(sourceId, searchTerm, page)
.catch { log.warn(it) { "Failed to get search results from $sourceId on page $page with query '$searchTerm'" } } .catch {
onError(it)
log.warn(it) { "Failed to get search results from $sourceId on page $page with query '$searchTerm'" }
}
.singleOrNull() .singleOrNull()
fun asFlow(source: Source, searchTerm: String?, page: Int) = sourceRepository.getSearchResults( fun asFlow(source: Source, searchTerm: String?, page: Int) = sourceRepository.getSearchResults(

View File

@@ -151,21 +151,17 @@ class MangaScreenViewModel @Inject constructor(
private suspend fun refreshMangaAsync(mangaId: Long, refresh: Boolean = false) = withIOContext { private suspend fun refreshMangaAsync(mangaId: Long, refresh: Boolean = false) = withIOContext {
async { async {
val manga = if (refresh) { val manga = if (refresh) {
refreshManga.await(mangaId) refreshManga.await(mangaId, onError = { toast(it.message.orEmpty()) })
} else { } else {
getManga.await(mangaId) getManga.await(mangaId, onError = { toast(it.message.orEmpty()) })
} }
if (manga != null) { if (manga != null) {
_manga.value = manga _manga.value = manga
} else {
// TODO: 2022-07-01 Error toast
} }
val mangaCategories = getMangaCategories.await(mangaId) val mangaCategories = getMangaCategories.await(mangaId, onError = { toast(it.message.orEmpty()) })
if (mangaCategories != null) { if (mangaCategories != null) {
_mangaCategories.value = mangaCategories.toImmutableList() _mangaCategories.value = mangaCategories.toImmutableList()
} else {
// TODO: 2022-07-01 Error toast
} }
} }
} }

View File

@@ -134,13 +134,14 @@ class SourceScreenViewModel(
private suspend fun getPage(): MangaPage? { private suspend fun getPage(): MangaPage? {
return when { return when {
isLatest.value -> getLatestManga.await(source, pageNum.value) isLatest.value -> getLatestManga.await(source, pageNum.value, onError = { toast(it.message.orEmpty()) })
_query.value != null || _usingFilters.value -> getSearchManga.await( _query.value != null || _usingFilters.value -> getSearchManga.await(
source.id, sourceId = source.id,
_query.value, searchTerm = _query.value,
pageNum.value page = pageNum.value,
onError = { toast(it.message.orEmpty()) }
) )
else -> getPopularManga.await(source.id, pageNum.value) else -> getPopularManga.await(source.id, pageNum.value, onError = { toast(it.message.orEmpty()) })
} }
} }

View File

@@ -39,7 +39,7 @@ abstract class ViewModel(private val contextWrapper: ContextWrapper) : ScreenMod
fun StringResource.toPlatformString(vararg args: Any): String { fun StringResource.toPlatformString(vararg args: Any): String {
return contextWrapper.toPlatformString(this, *args) return contextWrapper.toPlatformString(this, *args)
} }
fun toast(string: String, length: Length) { fun toast(string: String, length: Length = Length.SHORT) {
scope.launchUI { scope.launchUI {
contextWrapper.toast(string, length) contextWrapper.toast(string, length)
} }

View File

@@ -6,11 +6,19 @@
package ca.gosyer.jui.uicore.vm package ca.gosyer.jui.uicore.vm
import androidx.compose.runtime.Stable
import ca.gosyer.jui.core.lang.launchDefault
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.format import dev.icerock.moko.resources.format
import me.tatarka.inject.annotations.Inject import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@Stable
actual class ContextWrapper {
private val _toasts = MutableSharedFlow<Pair<String, Length>>()
val toasts = _toasts.asSharedFlow()
actual class ContextWrapper @Inject constructor() {
actual fun toPlatformString(stringResource: StringResource): String { actual fun toPlatformString(stringResource: StringResource): String {
return stringResource.localized() return stringResource.localized()
} }
@@ -18,5 +26,8 @@ actual class ContextWrapper @Inject constructor() {
return stringResource.format(*args).localized() return stringResource.format(*args).localized()
} }
actual fun toast(string: String, length: Length) { actual fun toast(string: String, length: Length) {
GlobalScope.launchDefault {
_toasts.emit(string to length)
}
} }
} }