From dd4251538dc6bc3b74c230f7832ff2c2fe7b14b5 Mon Sep 17 00:00:00 2001 From: Daniel Yrovas Date: Sun, 21 Apr 2024 18:44:01 +1000 Subject: [PATCH] fetch all bookmarks button --- .../java/org/yrovas/linklater/AppComponent.kt | 14 ++++ .../main/java/org/yrovas/linklater/Utils.kt | 25 +++++--- .../linklater/ui/activity/AppActivity.kt | 7 +- .../yrovas/linklater/ui/screens/HomeScreen.kt | 10 +-- .../linklater/ui/screens/PreferencesScreen.kt | 56 +++++++++++----- .../linklater/ui/state/HomeScreenState.kt | 55 ++++++---------- .../ui/state/PreferencesScreenState.kt | 64 ++++++++++++++++++- .../yrovas/linklater/ui/state/ScreenState.kt | 10 +-- gradle/libs.versions.toml | 4 +- 9 files changed, 165 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/org/yrovas/linklater/AppComponent.kt b/app/src/main/java/org/yrovas/linklater/AppComponent.kt index c1bddfa..b0b83a5 100644 --- a/app/src/main/java/org/yrovas/linklater/AppComponent.kt +++ b/app/src/main/java/org/yrovas/linklater/AppComponent.kt @@ -19,8 +19,11 @@ import io.ktor.client.request.header import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.serialization.json.Json import me.tatarka.inject.annotations.Component +import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Provides import me.tatarka.inject.annotations.Scope import org.yrovas.linklater.data.local.BookmarkDataSourceImpl @@ -44,6 +47,16 @@ val Context.dataStore: DataStore by preferencesDataStore(name = "pr ) annotation class AppScope +@Inject +class ApplicationScope(private val context: Context) { + fun launch( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + job: suspend () -> Unit, + ) { + context.launch(dispatcher, job) + } +} + @Component @AppScope abstract class AppComponent( @@ -51,6 +64,7 @@ abstract class AppComponent( ) { abstract val destinationHost: DestinationHost abstract val prefStore: PrefDataStore + abstract val appScope: ApplicationScope val store: DataStore @AppScope @Provides get() = context.dataStore diff --git a/app/src/main/java/org/yrovas/linklater/Utils.kt b/app/src/main/java/org/yrovas/linklater/Utils.kt index fac2fff..53b33f3 100644 --- a/app/src/main/java/org/yrovas/linklater/Utils.kt +++ b/app/src/main/java/org/yrovas/linklater/Utils.kt @@ -5,13 +5,17 @@ import android.content.* import android.content.res.Configuration import android.net.Uri import android.widget.Toast +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.datetime.Instant +import org.yrovas.linklater.domain.APIError import org.yrovas.linklater.ui.activity.AppActivity import kotlin.math.abs fun checkURL(url: String) = url.contains(Regex("^https?://.+[.].+")) @@ -81,17 +85,18 @@ fun Context.getAppVersion(): String { @Preview(name = "Light Mode", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) annotation class ThemePreview -fun Context.launch(job: suspend () -> Unit) { - (this as AppActivity).launch { job() } -} - -fun String?.isNull(): Boolean { - return this == null -} - -fun String?.isNotNull(): Boolean { - return this != null +fun Context.launch(dispatcher: CoroutineDispatcher = Dispatchers.Main, job: suspend () -> Unit) { + (this as AppActivity).launch(dispatcher, job) } fun String.intoTags(): List = split(" ").filter { it.isNotBlank() }.distinct() + +suspend fun SnackbarHostState.show(error: APIError) { + when (error) { + APIError.NO_CONNECTION -> showSnackbar("Could not connect to LinkDing ") + APIError.INCORRECT_AUTH -> showSnackbar("Invalid Token") + APIError.INCORRECT_ENDPOINT -> showSnackbar("Invalid API Endpoint") + APIError.NO_AUTH_PROVIDED -> {} + } +} diff --git a/app/src/main/java/org/yrovas/linklater/ui/activity/AppActivity.kt b/app/src/main/java/org/yrovas/linklater/ui/activity/AppActivity.kt index 12f5901..d580f3f 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/activity/AppActivity.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/activity/AppActivity.kt @@ -10,6 +10,9 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.remember import androidx.lifecycle.lifecycleScope import com.ramcosta.composedestinations.spec.NavHostGraphSpec +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainCoroutineDispatcher import kotlinx.coroutines.launch import org.yrovas.linklater.AppComponent import org.yrovas.linklater.create @@ -22,8 +25,8 @@ abstract class AppActivity : ComponentActivity() { AppComponent::class.create(this) } - fun launch(job: suspend () -> Unit) { - lifecycleScope.launch { job() } + fun launch(dispatcher: CoroutineDispatcher = Dispatchers.Main, job: suspend () -> Unit) { + lifecycleScope.launch(dispatcher) { job() } } protected fun setContent(navGraph: NavHostGraphSpec) { diff --git a/app/src/main/java/org/yrovas/linklater/ui/screens/HomeScreen.kt b/app/src/main/java/org/yrovas/linklater/ui/screens/HomeScreen.kt index 500801f..d4bf0d5 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/screens/HomeScreen.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/screens/HomeScreen.kt @@ -27,6 +27,7 @@ import com.ramcosta.composedestinations.generated.destinations.SaveBookmarkScree import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.launch import org.yrovas.linklater.domain.APIError +import org.yrovas.linklater.show import org.yrovas.linklater.ui.common.AppBar import org.yrovas.linklater.ui.common.BookmarkRow import org.yrovas.linklater.ui.common.Frame @@ -106,15 +107,6 @@ fun HomeScreen( } } -private suspend fun SnackbarHostState.show(error: APIError) { - when (error) { - APIError.NO_CONNECTION -> showSnackbar("Could not connect to LinkDing ") - APIError.INCORRECT_AUTH -> showSnackbar("Invalid Token") - APIError.INCORRECT_ENDPOINT -> showSnackbar("Invalid API Endpoint") - APIError.NO_AUTH_PROVIDED -> {} - } -} - //@ThemePreview //@Composable //fun HomeScreenPreview() { diff --git a/app/src/main/java/org/yrovas/linklater/ui/screens/PreferencesScreen.kt b/app/src/main/java/org/yrovas/linklater/ui/screens/PreferencesScreen.kt index 5a7635e..2d52243 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/screens/PreferencesScreen.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/screens/PreferencesScreen.kt @@ -19,9 +19,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme.colorScheme @@ -29,8 +29,10 @@ import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,19 +45,18 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator -import org.yrovas.linklater.ThemePreview +import kotlinx.coroutines.launch import org.yrovas.linklater.checkBookmarkAPIToken import org.yrovas.linklater.checkURL -import org.yrovas.linklater.data.local.EmptyPrefStore -import org.yrovas.linklater.data.remote.EmptyBookmarkAPI import org.yrovas.linklater.getAppVersion import org.yrovas.linklater.openUri +import org.yrovas.linklater.show import org.yrovas.linklater.ui.common.Frame import org.yrovas.linklater.ui.common.Icon import org.yrovas.linklater.ui.common.TextPreference import org.yrovas.linklater.ui.state.PreferencesScreenState -import org.yrovas.linklater.ui.theme.AppTheme +import org.yrovas.linklater.ui.state.PreferencesScreenState.Effect +import org.yrovas.linklater.ui.state.PreferencesScreenState.Event import org.yrovas.linklater.ui.theme.padding @Destination @@ -69,6 +70,23 @@ fun PreferencesScreen( @Suppress("NAME_SHADOWING") val state = viewModel { state() } val defaultBookmark by state.defaultBookmark.collectAsState() + val scope = rememberCoroutineScope() + + LaunchedEffect(true) { + scope.launch { + state.effect.collect { effect -> + when (effect) { + is Effect.RefreshError -> { + snackState.show(effect.error) + } + + Effect.RefreshComplete -> { + snackState.showSnackbar("Refresh Completed") + } + } + } + } + } Frame( page = "Preferences", @@ -181,7 +199,13 @@ fun PreferencesScreen( onCheckedChange = { state.saveDefaultBookmark(shared = it) }) + Spacer(modifier = Modifier.weight(1F)) + Button(modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { state.sendEvent(Event.FetchAllBookmarks) }) { + Text(text = "Fetch All Bookmarks") + } + Spacer(modifier = Modifier.height(padding.double)) Text( modifier = Modifier .align(Alignment.CenterHorizontally) @@ -238,12 +262,14 @@ private fun StyledCheckPreference( } } -@ThemePreview -@Composable -fun PreferencesScreenPreview() { - AppTheme { - PreferencesScreen(EmptyDestinationsNavigator, - SnackbarHostState(), - { PreferencesScreenState(EmptyBookmarkAPI(), EmptyPrefStore()) }) - } -} +//@ThemePreview +//@Composable +//fun PreferencesScreenPreview() { +// AppTheme { +// PreferencesScreen(EmptyDestinationsNavigator, +// SnackbarHostState(), +// { PreferencesScreenState(EmptyBookmarkAPI(), +// EmptyPrefStore() +// ) }) +// } +//} diff --git a/app/src/main/java/org/yrovas/linklater/ui/state/HomeScreenState.kt b/app/src/main/java/org/yrovas/linklater/ui/state/HomeScreenState.kt index 57615a9..e9d5632 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/state/HomeScreenState.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/state/HomeScreenState.kt @@ -1,6 +1,5 @@ package org.yrovas.linklater.ui.state -import android.util.Log import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -43,40 +42,7 @@ class HomeScreenState( private val _bookmarkCount = MutableStateFlow(0) val bookmarkCount = _bookmarkCount.asStateFlow() - private fun refreshBookmarks() { - _isRefreshing.update { true } - viewModelScope.launch(Dispatchers.IO) { - val res = api.getBookmarks(page = 0) - sendEffect { - if (res.isOk) Effect.RefreshOk - else Effect.RefreshError(res.errorOrThrow()) - } - _isRefreshing.update { false } - res.ifOk { bookmarkSource.insertBookmarks(it) } - } - } - -// fun fullSync() { -// viewModelScope.launch(Dispatchers.IO) { -// var res = api.getBookmarks(0) -// var page = 0 -// while (true) when (res) { -// is Res.Err -> break -// is Res.Ok -> { -// if (res.data.isEmpty()) { -// break -// } -// Log.d( -// "DEBUG", "fullSync: FETCHED ${res.data} from page $page" -// ) -// bookmarkSource.insertBookmarks(res.data) -// res = api.getBookmarks(page++) -// } -// } -// } -// } - - init { + private fun fetchLocalBookmarks() = viewModelScope.launch(Dispatchers.IO) { bookmarkSource.getBookmarks().collect { bookmarks -> _displayedBookmarks.update { @@ -85,6 +51,8 @@ class HomeScreenState( _bookmarkCount.emit(bookmarkSource.getBookmarkCount()) } } + + private fun fetchRemoteBookmarks() = viewModelScope.launch(Dispatchers.IO) { // when authenticated refresh api.authProvided.transformWhile { emit(it); !it }.collect { @@ -93,6 +61,18 @@ class HomeScreenState( } } } + + private fun refreshBookmarks() { + _isRefreshing.update { true } + viewModelScope.launch(Dispatchers.IO) { + val res = api.getBookmarks(page = 0) + sendEffect( + if (res.isOk) Effect.RefreshOk + else Effect.RefreshError(res.errorOrThrow()) + ) + _isRefreshing.update { false } + res.ifOk { bookmarkSource.insertBookmarks(it) } + } } override fun handleEvent(event: Event) { @@ -100,4 +80,9 @@ class HomeScreenState( Event.RefreshBookmarks -> refreshBookmarks() } } + + init { + fetchLocalBookmarks() + fetchRemoteBookmarks() + } } diff --git a/app/src/main/java/org/yrovas/linklater/ui/state/PreferencesScreenState.kt b/app/src/main/java/org/yrovas/linklater/ui/state/PreferencesScreenState.kt index 9d5346e..196dcef 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/state/PreferencesScreenState.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/state/PreferencesScreenState.kt @@ -1,6 +1,6 @@ package org.yrovas.linklater.ui.state -import androidx.lifecycle.ViewModel +import android.util.Log import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -8,17 +8,36 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import me.tatarka.inject.annotations.Inject +import org.yrovas.linklater.ApplicationScope import org.yrovas.linklater.data.LocalBookmark import org.yrovas.linklater.data.local.PrefDataStore import org.yrovas.linklater.data.local.Prefs +import org.yrovas.linklater.domain.APIError import org.yrovas.linklater.domain.BookmarkAPI +import org.yrovas.linklater.domain.BookmarkDataSource +import org.yrovas.linklater.domain.Res import org.yrovas.linklater.intoTags +import org.yrovas.linklater.ui.state.PreferencesScreenState.Effect +import org.yrovas.linklater.ui.state.PreferencesScreenState.Event @Inject class PreferencesScreenState( private val bookmarkAPI: BookmarkAPI, private val prefStore: PrefDataStore, -) : ViewModel() { + private val appScope: ApplicationScope, + private val bookmarkSource: BookmarkDataSource, + private val api: BookmarkAPI, +) : ScreenState() { + + sealed interface Event : ScreenEvent { + data object FetchAllBookmarks : Event + } + + sealed interface Effect : ScreenEffect { + data class RefreshError(val error: APIError) : Effect + data object RefreshComplete : Effect + } + private val _bookmarkEndpoint = MutableStateFlow("") var bookmarkEndpoint = _bookmarkEndpoint.asStateFlow() @@ -99,6 +118,41 @@ class PreferencesScreenState( } } + private fun fetchAllRemoteBookmarks() { + // guard against going back to home by launching from application scope + appScope.launch(Dispatchers.IO) { + var res = api.getBookmarks(0) + var page = 0 + while (true) when (res) { + is Res.Err -> { + Log.d( + TAG, + "fetchAllRemoteBookmarks: stopping due to ${res.error}" + ) + break + } + + is Res.Ok -> { + if (res.data.isEmpty()) { + Log.d( + TAG, + "fetchAllRemoteBookmarks: stopping due to empty result set" + ) + break + } + Log.d( + TAG, + "fetchAllRemoteBookmarks: FETCHED ${res.data} from page $page" + ) + bookmarkSource.insertBookmarks(res.data) + res = api.getBookmarks(page++) + } + } + sendEffect(Effect.RefreshComplete) + } + } + + init { viewModelScope.launch { saveBookmarkAPIToken(prefStore.getPref(Prefs.LINKDING_TOKEN, "")) @@ -115,4 +169,10 @@ class PreferencesScreenState( ) } } + + override fun handleEvent(event: Event) { + when (event) { + Event.FetchAllBookmarks -> fetchAllRemoteBookmarks() + } + } } diff --git a/app/src/main/java/org/yrovas/linklater/ui/state/ScreenState.kt b/app/src/main/java/org/yrovas/linklater/ui/state/ScreenState.kt index 76964ee..4aba8e3 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/state/ScreenState.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/state/ScreenState.kt @@ -27,14 +27,14 @@ abstract class ScreenState : } } + fun sendEvent(builder: () -> Event) = sendEvent(builder()) fun sendEvent(event: Event) { - val newEvent = event - viewModelScope.launch { _event.emit(newEvent) } + viewModelScope.launch { _event.emit(event) } } - protected fun sendEffect(builder: () -> Effect) { - val effectValue = builder() - viewModelScope.launch { _effect.send(effectValue) } + protected fun sendEffect(builder: () -> Effect) = sendEffect(builder()) + protected fun sendEffect(effect: Effect) { + viewModelScope.launch { _effect.send(effect) } } abstract fun handleEvent(event: Event) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03b0942..fa37d5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] app-versionID = "org.yrovas.linklater" -app-versionCode = "7" -app-versionName = "0.1.6" +app-versionCode = "8" +app-versionName = "0.1.7" app-compileSDK = "34" app-targetSDK = "34" app-minimumSDK = "23"