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.ui.ViewModelComponent
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.Provides
@AppScope
@Component
abstract class AppComponent : ViewModelComponent, DataComponent, DomainComponent, UiComponent {
abstract class AppComponent(
@get:Provides
val context: ContextWrapper
) : ViewModelComponent, DataComponent, DomainComponent, UiComponent {
abstract val appMigrations: AppMigrations
@@ -31,7 +35,7 @@ abstract class AppComponent : ViewModelComponent, DataComponent, DomainComponent
companion object {
private var appComponentInstance: AppComponent? = null
fun getInstance() = appComponentInstance ?: create()
fun getInstance(context: ContextWrapper) = appComponentInstance ?: create(context)
.also { appComponentInstance = it }
}
}

View File

@@ -8,21 +8,35 @@ package ca.gosyer.jui.desktop
import androidx.compose.animation.Crossfade
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.configureSwingGlobalsForCompose
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPainter
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
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.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.awaitApplication
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.prefs.asStateIn
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.theme.DarculaTheme
import com.github.weisj.darklaf.theme.IntelliJTheme
@@ -53,6 +69,7 @@ import com.vanpra.composematerialdialogs.rememberMaterialDialogState
import com.vanpra.composematerialdialogs.title
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
@@ -61,6 +78,8 @@ import org.jetbrains.skiko.SystemTheme
import org.jetbrains.skiko.currentSystemTheme
import java.util.Locale
import kotlin.system.exitProcess
import kotlin.time.Duration.Companion.ZERO
import kotlin.time.Duration.Companion.seconds
@OptIn(DelicateCoroutinesApi::class)
suspend fun main() {
@@ -70,7 +89,9 @@ suspend fun main() {
System.setProperty("kotlinx.coroutines.debug", "on")
}
val appComponent = AppComponent.getInstance()
val context = ContextWrapper()
val appComponent = AppComponent.getInstance(context)
appComponent.migrations.runMigrations()
appComponent.appMigrations.runMigrations()
@@ -191,6 +212,12 @@ suspend fun main() {
if (displayDebugInfo) {
DebugOverlay()
}
ToastOverlay(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 64.dp),
context = context
)
}
}
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) {
suspend fun await(mangaId: Long) = asFlow(mangaId)
.catch { log.warn(it) { "Failed to get categories for $mangaId" } }
suspend fun await(mangaId: Long, onError: suspend (Throwable) -> Unit = {}) = asFlow(mangaId)
.catch {
onError(it)
log.warn(it) { "Failed to get categories for $mangaId" }
}
.singleOrNull()
suspend fun await(manga: Manga) = asFlow(manga)
.catch { log.warn(it) { "Failed to get categories for ${manga.title}(${manga.id})" } }
suspend fun await(manga: Manga, onError: suspend (Throwable) -> Unit = {}) = asFlow(manga)
.catch {
onError(it)
log.warn(it) { "Failed to get categories for ${manga.title}(${manga.id})" }
}
.singleOrNull()
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) {
suspend fun await(mangaId: Long) = asFlow(mangaId)
.catch { log.warn(it) { "Failed to get manga $mangaId" } }
suspend fun await(mangaId: Long, onError: suspend (Throwable) -> Unit = {}) = asFlow(mangaId)
.catch {
onError(it)
log.warn(it) { "Failed to get manga $mangaId" }
}
.singleOrNull()
suspend fun await(manga: Manga) = asFlow(manga)
.catch { log.warn(it) { "Failed to get manga ${manga.title}(${manga.id})" } }
suspend fun await(manga: Manga, onError: suspend (Throwable) -> Unit = {}) = asFlow(manga)
.catch {
onError(it)
log.warn(it) { "Failed to get manga ${manga.title}(${manga.id})" }
}
.singleOrNull()
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) {
suspend fun await(mangaId: Long) = asFlow(mangaId)
.catch { log.warn(it) { "Failed to refresh manga $mangaId" } }
suspend fun await(mangaId: Long, onError: suspend (Throwable) -> Unit = {}) = asFlow(mangaId)
.catch {
onError(it)
log.warn(it) { "Failed to refresh manga $mangaId" }
}
.singleOrNull()
suspend fun await(manga: Manga) = asFlow(manga)
.catch { log.warn(it) { "Failed to refresh manga ${manga.title}(${manga.id})" } }
suspend fun await(manga: Manga, onError: suspend (Throwable) -> Unit = {}) = asFlow(manga)
.catch {
onError(it)
log.warn(it) { "Failed to refresh manga ${manga.title}(${manga.id})" }
}
.singleOrNull()
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) {
suspend fun await(source: Source, page: Int) = asFlow(source.id, page)
.catch { log.warn(it) { "Failed to get latest manga from ${source.displayName} on page $page" } }
suspend fun await(source: Source, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(source.id, page)
.catch {
onError(it)
log.warn(it) { "Failed to get latest manga from ${source.displayName} on page $page" }
}
.singleOrNull()
suspend fun await(sourceId: Long, page: Int) = asFlow(sourceId, page)
.catch { log.warn(it) { "Failed to get latest manga from $sourceId on page $page" } }
suspend fun await(sourceId: Long, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(sourceId, page)
.catch {
onError(it)
log.warn(it) { "Failed to get latest manga from $sourceId on page $page" }
}
.singleOrNull()
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) {
suspend fun await(source: Source, page: Int) = asFlow(source.id, page)
.catch { log.warn(it) { "Failed to get popular manga from ${source.displayName} on page $page" } }
suspend fun await(source: Source, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(source.id, page)
.catch {
onError(it)
log.warn(it) { "Failed to get popular manga from ${source.displayName} on page $page" }
}
.singleOrNull()
suspend fun await(sourceId: Long, page: Int) = asFlow(sourceId, page)
.catch { log.warn(it) { "Failed to get popular manga from $sourceId on page $page" } }
suspend fun await(sourceId: Long, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(sourceId, page)
.catch {
onError(it)
log.warn(it) { "Failed to get popular manga from $sourceId on page $page" }
}
.singleOrNull()
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) {
suspend fun await(source: Source, searchTerm: String?, page: Int) = asFlow(source.id, searchTerm, page)
.catch { log.warn(it) { "Failed to get search results from ${source.displayName} on page $page with query '$searchTerm'" } }
suspend fun await(source: Source, searchTerm: String?, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(source.id, searchTerm, page)
.catch {
onError(it)
log.warn(it) { "Failed to get search results from ${source.displayName} on page $page with query '$searchTerm'" }
}
.singleOrNull()
suspend fun await(sourceId: Long, searchTerm: String?, page: Int) = asFlow(sourceId, searchTerm, page)
.catch { log.warn(it) { "Failed to get search results from $sourceId on page $page with query '$searchTerm'" } }
suspend fun await(sourceId: Long, searchTerm: String?, page: Int, onError: suspend (Throwable) -> Unit = {}) = asFlow(sourceId, searchTerm, page)
.catch {
onError(it)
log.warn(it) { "Failed to get search results from $sourceId on page $page with query '$searchTerm'" }
}
.singleOrNull()
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 {
async {
val manga = if (refresh) {
refreshManga.await(mangaId)
refreshManga.await(mangaId, onError = { toast(it.message.orEmpty()) })
} else {
getManga.await(mangaId)
getManga.await(mangaId, onError = { toast(it.message.orEmpty()) })
}
if (manga != null) {
_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) {
_mangaCategories.value = mangaCategories.toImmutableList()
} else {
// TODO: 2022-07-01 Error toast
}
}
}

View File

@@ -134,13 +134,14 @@ class SourceScreenViewModel(
private suspend fun getPage(): MangaPage? {
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(
source.id,
_query.value,
pageNum.value
sourceId = source.id,
searchTerm = _query.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 {
return contextWrapper.toPlatformString(this, *args)
}
fun toast(string: String, length: Length) {
fun toast(string: String, length: Length = Length.SHORT) {
scope.launchUI {
contextWrapper.toast(string, length)
}

View File

@@ -6,11 +6,19 @@
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.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 {
return stringResource.localized()
}
@@ -18,5 +26,8 @@ actual class ContextWrapper @Inject constructor() {
return stringResource.format(*args).localized()
}
actual fun toast(string: String, length: Length) {
GlobalScope.launchDefault {
_toasts.emit(string to length)
}
}
}