diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9ec9268..ff63ab7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,7 @@ android { dependencies { implementation(libs.core.ktx) implementation(libs.lifecycle.runtime.ktx) + implementation(libs.lifecycle.viewmodel.ktx) implementation(libs.activity.compose) implementation(platform(libs.compose.bom)) implementation(libs.ui) @@ -90,4 +91,5 @@ dependencies { androidTestImplementation(libs.ui.test.junit4) debugImplementation(libs.ui.tooling) debugImplementation(libs.ui.test.manifest) + implementation(kotlin("script-runtime")) } diff --git a/app/src/main/java/org/yrovas/linklater/AppComponent.kt b/app/src/main/java/org/yrovas/linklater/AppComponent.kt new file mode 100644 index 0000000..90fcc6e --- /dev/null +++ b/app/src/main/java/org/yrovas/linklater/AppComponent.kt @@ -0,0 +1,73 @@ +package org.yrovas.linklater + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import io.ktor.client.HttpClient +import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +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.serialization.json.Json +import me.tatarka.inject.annotations.Component +import me.tatarka.inject.annotations.Provides +import me.tatarka.inject.annotations.Scope +import org.yrovas.linklater.data.local.PrefDataStore +import org.yrovas.linklater.data.local.PrefStore +import org.yrovas.linklater.data.remote.BookmarkAPI +import org.yrovas.linklater.data.remote.LinkDingAPI +import org.yrovas.linklater.ui.activity.DestinationHost + +val Context.dataStore: DataStore by preferencesDataStore(name = "preferences") + +@Scope +annotation class AppScope + +@Component +@AppScope +abstract class AppComponent( + @get:Provides val context: Context, +) { + abstract val destinationHost: DestinationHost + abstract val prefStore: PrefDataStore + + private val store: DataStore + get() = context.dataStore + + @Provides + fun providePrefStore(): PrefDataStore = PrefStore(store) + + @Provides + fun provideHttpClient(): HttpClient = HttpClient(Android) { + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + Log.i("Ktor =>", message) + } + } + level = LogLevel.ALL + } + install(ContentNegotiation) { + json(Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + }) + } + install(DefaultRequest) { + header(HttpHeaders.ContentType, ContentType.Application.Json) + } + } + + abstract val linkDingAPI: LinkDingAPI + val bookmarkAPI: BookmarkAPI + @Provides get() = linkDingAPI +} diff --git a/app/src/main/java/org/yrovas/linklater/data/local/PrefDataStore.kt b/app/src/main/java/org/yrovas/linklater/data/local/PrefDataStore.kt index 6b24537..329ba3b 100644 --- a/app/src/main/java/org/yrovas/linklater/data/local/PrefDataStore.kt +++ b/app/src/main/java/org/yrovas/linklater/data/local/PrefDataStore.kt @@ -1,10 +1,8 @@ -package org.yrovas.linklater.data +package org.yrovas.linklater.data.local import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.flow.Flow -import org.yrovas.linklater.AppScope -@AppScope interface PrefDataStore { suspend fun getPrefs(key: Preferences.Key, default: T): Flow suspend fun getPref(key: Preferences.Key, default: T): T diff --git a/app/src/main/java/org/yrovas/linklater/data/local/PrefStore.kt b/app/src/main/java/org/yrovas/linklater/data/local/PrefStore.kt index 9dbd4d8..3f56574 100644 --- a/app/src/main/java/org/yrovas/linklater/data/local/PrefStore.kt +++ b/app/src/main/java/org/yrovas/linklater/data/local/PrefStore.kt @@ -1,4 +1,4 @@ -package org.yrovas.linklater.data +package org.yrovas.linklater.data.local import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences @@ -21,7 +21,7 @@ object Prefs { @AppScope @Inject -class PreferenceStore(private val store: DataStore) : PrefDataStore { +class PrefStore(private val store: DataStore) : PrefDataStore { override suspend fun getPrefs( key: Preferences.Key, default: T, diff --git a/app/src/main/java/org/yrovas/linklater/data/remote/BookmarkAPI.kt b/app/src/main/java/org/yrovas/linklater/data/remote/BookmarkAPI.kt index 8828ba5..bff99ce 100644 --- a/app/src/main/java/org/yrovas/linklater/data/remote/BookmarkAPI.kt +++ b/app/src/main/java/org/yrovas/linklater/data/remote/BookmarkAPI.kt @@ -1,8 +1,10 @@ -package org.yrovas.linklater.domain +package org.yrovas.linklater.data.remote import android.content.Context -import org.yrovas.linklater.data.* -import org.yrovas.linklater.domain.* +import org.yrovas.linklater.data.Bookmark +import org.yrovas.linklater.data.LocalBookmark +import org.yrovas.linklater.domain.APIError +import org.yrovas.linklater.domain.Res interface BookmarkAPI { suspend fun authenticate(endpoint: String? = null, token: String? = null, validate: Boolean = false): Res diff --git a/app/src/main/java/org/yrovas/linklater/data/remote/EmptyBookmarkAPI.kt b/app/src/main/java/org/yrovas/linklater/data/remote/EmptyBookmarkAPI.kt index 0e05fbf..23d4d52 100644 --- a/app/src/main/java/org/yrovas/linklater/data/remote/EmptyBookmarkAPI.kt +++ b/app/src/main/java/org/yrovas/linklater/data/remote/EmptyBookmarkAPI.kt @@ -1,9 +1,12 @@ -package org.yrovas.linklater.domain +package org.yrovas.linklater.data.remote import android.content.Context -import kotlinx.coroutines.delay import org.yrovas.linklater.data.Bookmark import org.yrovas.linklater.data.LocalBookmark +import org.yrovas.linklater.domain.APIError +import org.yrovas.linklater.domain.Err +import org.yrovas.linklater.domain.Ok +import org.yrovas.linklater.domain.Res class EmptyBookmarkAPI : BookmarkAPI { override suspend fun authenticate( diff --git a/app/src/main/java/org/yrovas/linklater/data/remote/LinkDingAPI.kt b/app/src/main/java/org/yrovas/linklater/data/remote/LinkDingAPI.kt index 6f8e5ed..0cf7076 100644 --- a/app/src/main/java/org/yrovas/linklater/data/remote/LinkDingAPI.kt +++ b/app/src/main/java/org/yrovas/linklater/data/remote/LinkDingAPI.kt @@ -1,10 +1,13 @@ -package org.yrovas.linklater.domain +package org.yrovas.linklater.data.remote import android.content.Context import android.util.Log import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.request.* +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToJsonElement @@ -14,6 +17,11 @@ import org.yrovas.linklater.checkBookmarkAPIToken import org.yrovas.linklater.checkURL import org.yrovas.linklater.data.Bookmark import org.yrovas.linklater.data.LocalBookmark +import org.yrovas.linklater.domain.APIError +import org.yrovas.linklater.domain.Err +import org.yrovas.linklater.domain.Ok +import org.yrovas.linklater.domain.Res +import org.yrovas.linklater.domain.toRes import java.io.File const val BOOKMARKS_CACHE_PATH = "bookmark_page_cache.json" @@ -27,14 +35,19 @@ class LinkDingAPI( private var token: String? = null, private val pageSize: Int = 20, ) : BookmarkAPI { + init { + Log.d("DEBUG", "CREATING BOOKMARK API: ") + } - private val authProvided = !endpoint.isNullOrBlank() && !token.isNullOrBlank() + private val authProvided + get() = !endpoint.isNullOrBlank() && !token.isNullOrBlank() override suspend fun authenticate( endpoint: String?, token: String?, validate: Boolean, ): Res { + Log.d("DEBUG", "authenticate: with endpoint: $endpoint") if (!endpoint.isNullOrBlank()) { if (!checkURL(endpoint)) return Err(APIError.AUTH) this.endpoint = endpoint diff --git a/app/src/main/java/org/yrovas/linklater/domain/Res.kt b/app/src/main/java/org/yrovas/linklater/domain/Res.kt index 7849186..16b8a68 100644 --- a/app/src/main/java/org/yrovas/linklater/domain/Res.kt +++ b/app/src/main/java/org/yrovas/linklater/domain/Res.kt @@ -41,6 +41,34 @@ fun Res.then(ok: (data: D) -> T, err: (error: E) - } } +fun Res?.then( + ok: (data: D) -> T, + err: (error: E) -> T, + nil: () -> T, +): T { + return this?.then(ok, err) ?: nil() +} + +@Composable +fun Res.apply( + ok: @Composable (data: D) -> T, + err: @Composable (error: E) -> T, +): T { + return when (this) { + is Res.Err -> err(error) + is Res.Ok -> ok(data) + } +} + +@Composable +fun Res?.apply( + ok: @Composable (data: D) -> T, + err: @Composable (error: E) -> T, + nil: @Composable () -> T, +): T { + return this?.apply(ok, err) ?: nil() +} + fun Res.getOrNull(): D? { return when (this) { is Res.Err -> null 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 9a7115b..029991e 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 @@ -1,128 +1,100 @@ package org.yrovas.linklater.ui.activity -import android.app.Application -import android.content.Context +import android.annotation.SuppressLint import android.content.Intent -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.viewModels import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewmodel.viewModelFactory import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.generated.destinations.HomeScreenDestination +import com.ramcosta.composedestinations.generated.destinations.PreferencesScreenDestination +import com.ramcosta.composedestinations.generated.destinations.SaveBookmarkScreenDestination +import com.ramcosta.composedestinations.navigation.DependenciesContainerBuilder import com.ramcosta.composedestinations.navigation.dependency +import com.ramcosta.composedestinations.navigation.destination +import com.ramcosta.composedestinations.spec.DestinationSpec import com.ramcosta.composedestinations.spec.NavHostGraphSpec -import io.ktor.client.HttpClient -import io.ktor.client.engine.android.Android -import io.ktor.client.plugins.DefaultRequest -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.logging.* -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.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import me.tatarka.inject.annotations.* -import org.yrovas.linklater.AppViewModel -import org.yrovas.linklater.AppViewModelImpl -import org.yrovas.linklater.domain.BookmarkAPI -import org.yrovas.linklater.domain.LinkDingAPI +import me.tatarka.inject.annotations.Inject +import org.yrovas.linklater.AppComponent +import org.yrovas.linklater.create +import org.yrovas.linklater.data.local.Prefs +import org.yrovas.linklater.ui.state.HomeScreenState +import org.yrovas.linklater.ui.state.PreferencesScreenState +import org.yrovas.linklater.ui.state.SaveBookmarkScreenState import org.yrovas.linklater.ui.theme.AppTheme -@Scope -annotation class AppScope - -@Component -abstract class AppComponent( - @get:Provides val app: Application, -) { - companion object { - private var instance: AppComponent? = null - fun getInstance(context: Context) = - instance ?: AppComponent::class.create( - context.applicationContext as Application, - ).also { instance = it } - } -} - -@AppScope -@Provides -fun provideHttpClient(): HttpClient = HttpClient(Android) { - install(Logging) { - logger = object : Logger { - override fun log(message: String) { - Log.i("Ktor =>", message) - } - } - level = LogLevel.ALL - } - install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - }) - } - install(DefaultRequest) { - header(HttpHeaders.ContentType, ContentType.Application.Json) - } -} - -@AppScope -@Provides -fun provideBookmarkAPI(client: HttpClient): BookmarkAPI { - return LinkDingAPI(client) -} - -val Context.dataStore: DataStore by preferencesDataStore(name = "preferences") - -@Provides -fun provideDataStore(context: Context): DataStore = context.dataStore - -inline fun AppActivity.viewModel(crossinline factory: () -> VM): Lazy = - viewModels { - viewModelFactory { addInitializer(VM::class) { factory() } } - } - -@Inject abstract class AppActivity : ComponentActivity() { - private val appViewModel: AppViewModel by viewModel { AppViewModelImpl() } + val component by lazy(LazyThreadSafetyMode.NONE) { + AppComponent::class.create(this) + } + private val _setup_complete: MutableStateFlow = + MutableStateFlow(false) + private var setup_complete = _setup_complete.asStateFlow() fun launch(job: suspend () -> Unit) { lifecycleScope.launch { job() } } - fun setContent(navGraph: NavHostGraphSpec) { - val appComponent = AppComponent::class.create(applicationContext as Application) + protected fun setContent(navGraph: NavHostGraphSpec) { intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); -// appViewModel.loadPrefs() - - + val destinationHost = component.destinationHost + launch { + // setup code that runs on first boot + component.bookmarkAPI.authenticate( + endpoint = component.prefStore.getPref(Prefs.LINKDING_URL, ""), + token = component.prefStore.getPref(Prefs.LINKDING_TOKEN, ""), + ) + delay(3000) + _setup_complete.update { true } + } setContent { val snackState = remember { SnackbarHostState() } -// val homeScreenState = viewModel { homeScreenState() } - AppTheme { - DestinationsNavHost( - navGraph = navGraph, - dependenciesContainerBuilder = { -// navGraph(navGraph) { - dependency(snackState) - dependency(appViewModel) -// dependency(viewModel()) -// dependency(homeScreenState) -// dependency(viewModel()) -// } - }) + destinationHost(navGraph, snackState, setup_complete) } } } } + +typealias DestinationHost = @Composable (NavHostGraphSpec, SnackbarHostState, StateFlow) -> Unit + +@Inject +@Composable +fun DestinationHost( + homeScreenState: () -> HomeScreenState, + preferencesScreenState: () -> PreferencesScreenState, + saveBookmarkScreenState: () -> SaveBookmarkScreenState, + navGraph: NavHostGraphSpec, + snackbarHostState: SnackbarHostState, + setup_complete: StateFlow, +) { + DestinationsNavHost(navGraph = navGraph, dependenciesContainerBuilder = { + dependency(snackbarHostState) + destination(HomeScreenDestination) { + dependency(setup_complete) + } + provideState(HomeScreenDestination, homeScreenState) + provideState(SaveBookmarkScreenDestination, saveBookmarkScreenState) + provideState(PreferencesScreenDestination, preferencesScreenState) + }) +} + +@SuppressLint("ComposableNaming") +@Composable +private fun DependenciesContainerBuilder.provideState( + destination: DestinationSpec, + state: () -> ViewModel, +) { + destination(destination) { dependency(state) } +} diff --git a/app/src/main/java/org/yrovas/linklater/ui/activity/MainActivity.kt b/app/src/main/java/org/yrovas/linklater/ui/activity/MainActivity.kt index ab98b42..3e194b9 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/activity/MainActivity.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/activity/MainActivity.kt @@ -3,7 +3,6 @@ package org.yrovas.linklater.ui.activity import android.os.Bundle import com.ramcosta.composedestinations.generated.navgraphs.RootNavGraph import me.tatarka.inject.annotations.Inject -import org.yrovas.linklater.AppViewModelImpl @Inject class MainActivity : AppActivity() { diff --git a/app/src/main/java/org/yrovas/linklater/ui/activity/SaveBookmarkActivity.kt b/app/src/main/java/org/yrovas/linklater/ui/activity/SaveBookmarkActivity.kt index 82ddbd0..b21cdc1 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/activity/SaveBookmarkActivity.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/activity/SaveBookmarkActivity.kt @@ -5,8 +5,6 @@ import android.os.Bundle import android.util.Log import com.ramcosta.composedestinations.annotation.NavHostGraph import com.ramcosta.composedestinations.generated.navgraphs.SaveBookmarkActivityNavGraph -import me.tatarka.inject.annotations.Inject -import org.yrovas.linklater.AppViewModelImpl @NavHostGraph annotation class SaveBookmarkActivityGraph diff --git a/app/src/main/java/org/yrovas/linklater/ui/common/Components.kt b/app/src/main/java/org/yrovas/linklater/ui/common/Components.kt index 2b64a97..5ccc696 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/common/Components.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/common/Components.kt @@ -2,10 +2,24 @@ package org.yrovas.linklater.ui.common import android.content.Context import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -19,6 +33,7 @@ import org.yrovas.linklater.openUri import org.yrovas.linklater.timeAgo import org.yrovas.linklater.ui.theme.padding import java.net.URI + @Composable fun Frame( appBar: @Composable () -> Unit, @@ -28,8 +43,8 @@ fun Frame( ) { Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground + color = colorScheme.background, + contentColor = colorScheme.onBackground ) { Box(modifier = Modifier.fillMaxSize()) { Column { @@ -66,15 +81,13 @@ fun BookmarkRow( Text( text = URI(bookmark.url).host ?: bookmark.url, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.outline, + style = typography.labelLarge, + color = colorScheme.outline, ) Text( text = if (bookmark.date_modified.isNullOrBlank()) "" else timeAgo( Instant.parse(bookmark.date_modified), Clock.System.now() - ), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.outline + ), style = typography.labelLarge, color = colorScheme.outline ) } Spacer(modifier = androidx.compose.ui.Modifier.height(padding.tiny)) @@ -88,14 +101,14 @@ fun BookmarkRow( }, overflow = TextOverflow.Ellipsis, maxLines = 2, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary, + style = typography.titleLarge, + color = colorScheme.primary, modifier = Modifier.clickable { context.openUri(bookmark.url.toUri()) }) } if (!bookmark.description.isNullOrBlank() || !bookmark.website_description.isNullOrBlank()) { - Spacer(modifier = androidx.compose.ui.Modifier.height(padding.half)) + Spacer(modifier = Modifier.height(padding.half)) Row { Text( text = if (!bookmark.description.isNullOrBlank()) { @@ -107,19 +120,19 @@ fun BookmarkRow( }, overflow = TextOverflow.Ellipsis, maxLines = 2, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline + style = typography.bodyMedium, + color = colorScheme.outline ) } } if (bookmark.tags.isNotEmpty()) { - Spacer(modifier = androidx.compose.ui.Modifier.height(padding.half)) + Spacer(modifier = Modifier.height(padding.half)) LazyRow { items(bookmark.tags, key = { it }) { Text( - text = "#$it", color = MaterialTheme.colorScheme.tertiary + text = "#$it", color = colorScheme.tertiary ) - Spacer(modifier = androidx.compose.ui.Modifier.width(padding.standard)) + Spacer(modifier = Modifier.width(padding.standard)) } } } 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 3a0b09c..a127a56 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 @@ -1,12 +1,28 @@ package org.yrovas.linklater.ui.screens +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* +import androidx.compose.material.icons.filled.AddLink +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.runtime.* +import androidx.compose.material3.SnackbarHostState +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.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination @@ -16,25 +32,33 @@ import com.ramcosta.composedestinations.generated.destinations.SaveBookmarkScree import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import me.tatarka.inject.annotations.Inject -import org.yrovas.linklater.* +import org.yrovas.linklater.ThemePreview import org.yrovas.linklater.data.Bookmark -import org.yrovas.linklater.domain.* -import org.yrovas.linklater.ui.common.* +import org.yrovas.linklater.data.remote.EmptyBookmarkAPI +import org.yrovas.linklater.domain.APIError +import org.yrovas.linklater.domain.Err +import org.yrovas.linklater.domain.Ok +import org.yrovas.linklater.ui.common.AppBar +import org.yrovas.linklater.ui.common.BookmarkRow +import org.yrovas.linklater.ui.common.Frame +import org.yrovas.linklater.ui.common.Icon import org.yrovas.linklater.ui.state.HomeScreenState import org.yrovas.linklater.ui.theme.AppTheme import org.yrovas.linklater.ui.theme.padding @Destination(start = true) -@Inject @Composable fun HomeScreen( nav: DestinationsNavigator, snackState: SnackbarHostState, - appViewModel: AppViewModel, - state: HomeScreenState = viewModel { HomeScreenState(appViewModel.bookmarkAPI) }, + setup_complete: StateFlow, + state: () -> HomeScreenState, ) { + val state = viewModel { state() } + val setup_complete by setup_complete.collectAsState() val scope = rememberCoroutineScope() val bookmarks by state.displayedBookmarks.collectAsState() val listState = rememberLazyListState() @@ -50,6 +74,13 @@ fun HomeScreen( } } } + + LaunchedEffect(setup_complete) { + if (state.displayedBookmarks.value.isEmpty()) { + Log.d("DEBUG", "HomeScreen: REFRESHING") + refresh() + } + } Frame(appBar = { AppBar(page = "Bookmarks", back = null) { IconButton(onClick = { refresh() }) { @@ -64,17 +95,28 @@ fun HomeScreen( tint = colorScheme.primary ) } + } }, fab = { FloatingActionButton(modifier = Modifier.padding(padding.standard), onClick = { nav.navigate(SaveBookmarkScreenDestination) }, content = { Icon(imageVector = Icons.Default.AddLink) }) }, snackState = snackState) { - LazyColumn( - state = listState, - modifier = Modifier.padding(horizontal = padding.standard) - ) { - items(bookmarks, key = { it.id }) { BookmarkRow(it) } + if (!setup_complete) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.padding(horizontal = padding.standard) + ) { + items(bookmarks, key = { it.id }) { BookmarkRow(it) } + } } } } @@ -129,7 +171,10 @@ fun HomeScreenPreview() { ) AppTheme { HomeScreen( - EmptyDestinationsNavigator, SnackbarHostState(), PreviewAppViewModel(), state + EmptyDestinationsNavigator, + SnackbarHostState(), + MutableStateFlow(true), + { state } ) } } 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 8d04d02..d7e33fd 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 @@ -1,9 +1,16 @@ package org.yrovas.linklater.ui.screens import android.content.Context -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Create @@ -19,11 +26,20 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp +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 org.yrovas.linklater.* -import org.yrovas.linklater.ui.common.* +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import org.yrovas.linklater.ThemePreview +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.ui.common.AppBar +import org.yrovas.linklater.ui.common.Frame +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.theme.padding @@ -33,10 +49,10 @@ import org.yrovas.linklater.ui.theme.padding fun PreferencesScreen( nav: DestinationsNavigator, snackState: SnackbarHostState, - appViewModel: AppViewModel, - state: PreferencesScreenState = PreferencesScreenState(appViewModel), + state: () -> PreferencesScreenState, context: Context = LocalContext.current, ) { + val state = viewModel { state() } Frame(appBar = { AppBar(page = "Preferences", back = { nav.popBackStack() }) }, snackState = snackState) { @@ -53,7 +69,7 @@ fun PreferencesScreen( infoPreview = "include /api", infoTitle = "Enter the LinkDing API URL", info = { - Column() { + Column { Text( "Include the protocol (https://).", color = colorScheme.onSecondaryContainer @@ -99,7 +115,7 @@ fun PreferencesScreen( } } }, - state = state.bookmarkURL.collectAsState(), + state = state.bookmarkEndpoint.collectAsState(), onSave = { state.saveBookmarkURL(it) }, onCheck = { checkURL(it) }, ) @@ -140,8 +156,8 @@ fun PreferencesScreen( @Composable fun PreferencesScreenPreview() { AppTheme { -// PreferencesScreen( -// EmptyDestinationsNavigator, SnackbarHostState(), PreviewAppViewModel() -// ) + PreferencesScreen(EmptyDestinationsNavigator, + SnackbarHostState(), + { PreferencesScreenState(EmptyBookmarkAPI(), EmptyPrefStore()) }) } } diff --git a/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkActivityScreen.kt b/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkActivityScreen.kt index 12146ce..e5e24a5 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkActivityScreen.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkActivityScreen.kt @@ -6,32 +6,33 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.delay -import org.yrovas.linklater.AppViewModel import org.yrovas.linklater.onBackPressed -import org.yrovas.linklater.ui.activity.* +import org.yrovas.linklater.ui.activity.AppActivity +import org.yrovas.linklater.ui.activity.SaveBookmarkActivity +import org.yrovas.linklater.ui.activity.SaveBookmarkActivityGraph import org.yrovas.linklater.ui.state.SaveBookmarkScreenState @Destination(start = true) @Composable fun SaveBookmarkActivityScreen( nav: DestinationsNavigator, - appViewModel: AppViewModel, - state: SaveBookmarkScreenState, + saveBookmarkScreenState: () -> SaveBookmarkScreenState, snackState: SnackbarHostState, context: Context = LocalContext.current, ) { + val state = viewModel { saveBookmarkScreenState() } LaunchedEffect(true) { val url = (context as SaveBookmarkActivity).extractURL() Log.d("DEBUG/nav", "Extracting Intent URL: $url") state.updateBookmark(url = url) } SaveBookmarkScreen(nav = nav, - appViewModel = appViewModel, snackState = snackState, - state = state, + state = saveBookmarkScreenState, back = { context.onBackPressed() }, onSubmitSuccess = suspend { delay(1000) diff --git a/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkScreen.kt b/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkScreen.kt index 8e5b33d..525899a 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkScreen.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkScreen.kt @@ -65,16 +65,17 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +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 org.yrovas.linklater.AppViewModel +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import org.yrovas.linklater.ThemePreview +import org.yrovas.linklater.data.remote.EmptyBookmarkAPI import org.yrovas.linklater.domain.APIError -import org.yrovas.linklater.domain.EmptyBookmarkAPI import org.yrovas.linklater.domain.Err -import org.yrovas.linklater.domain.Error import org.yrovas.linklater.domain.Res +import org.yrovas.linklater.domain.apply import org.yrovas.linklater.domain.isNotNull import org.yrovas.linklater.domain.isOk import org.yrovas.linklater.launch @@ -93,10 +94,9 @@ import kotlin.math.max @Composable fun SaveBookmarkScreen( nav: DestinationsNavigator, - appViewModel: AppViewModel, snackState: SnackbarHostState, context: Context = LocalContext.current, - state: SaveBookmarkScreenState = SaveBookmarkScreenState(appViewModel.bookmarkAPI), + state: () -> SaveBookmarkScreenState, back: () -> Unit = { nav.popBackStack() }, onSubmitSuccess: suspend () -> Unit = { context.launch { @@ -105,6 +105,7 @@ fun SaveBookmarkScreen( back() }, ) { + val state = viewModel { state() } LaunchedEffect(true) { state.setup(context) } var isSubmitting by remember { mutableStateOf(false) } val submit = { @@ -126,36 +127,19 @@ fun SaveBookmarkScreen( }, snackState = snackState) { if (isSubmitting) SaveBookmarkResult( state = state, - snackState = snackState, onSubmitSuccess = onSubmitSuccess ) else SaveBookmarkFields(state = state, submit = submit) } } -@Composable -fun Res?.then( - ok: @Composable (data: D) -> T, - err: @Composable (error: E) -> T, - nil: @Composable () -> T, -): T { - return when (this) { - is Res.Err -> err(error) - is Res.Ok -> ok(data) - null -> nil() - } -} - @Composable fun SaveBookmarkResult( state: SaveBookmarkScreenState, - snackState: SnackbarHostState, onSubmitSuccess: suspend () -> Unit, context: Context = LocalContext.current, ) { val submitResult by state.submitResult.collectAsState() -// var networking by remember { mutableStateOf(false) } - LaunchedEffect(submitResult.isNotNull()) { if (submitResult.isNotNull() && submitResult!!.isOk) { onSubmitSuccess() @@ -167,7 +151,7 @@ fun SaveBookmarkResult( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize() ) { - submitResult.then(ok = { + submitResult.apply(ok = { CheckIcon {} Spacer(Modifier.height(padding.double)) Text("Saved Bookmark") @@ -183,9 +167,7 @@ fun SaveBookmarkResult( }, nil = { CircularProgressIndicator() - } - - ) + }) } } @@ -506,11 +488,10 @@ fun SaveBookmarkScreenPreview() { state.toggleSelectTag("cool") state.toggleSelectTag("selfhost") state.setSubmitResult(Err(APIError.AUTH)) -// SaveBookmarkScreen( -// nav = EmptyDestinationsNavigator, -// appViewModel = PreviewAppViewModel(), -// snackState = SnackbarHostState(), -// state = state -// ) + SaveBookmarkScreen( + nav = EmptyDestinationsNavigator, + snackState = SnackbarHostState(), + state = { state } + ) } } 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 04523c9..6a3c157 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 @@ -3,13 +3,16 @@ package org.yrovas.linklater.ui.state import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import me.tatarka.inject.annotations.Inject -import org.yrovas.linklater.AppViewModel -import org.yrovas.linklater.domain.BookmarkAPI import org.yrovas.linklater.data.Bookmark -import org.yrovas.linklater.domain.* +import org.yrovas.linklater.data.remote.BookmarkAPI +import org.yrovas.linklater.domain.APIError +import org.yrovas.linklater.domain.Res +import org.yrovas.linklater.domain.ok @Inject class HomeScreenState(private val api: BookmarkAPI) : ViewModel() { 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 03fba93..5442161 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,18 +1,58 @@ package org.yrovas.linklater.ui.state import androidx.lifecycle.ViewModel -import org.yrovas.linklater.AppViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import me.tatarka.inject.annotations.Inject +import org.yrovas.linklater.data.local.PrefDataStore +import org.yrovas.linklater.data.local.Prefs +import org.yrovas.linklater.data.remote.BookmarkAPI +//@Provides +//fun providePreferencesScreenState(appViewModel: () -> AppViewModelImpl): PreferencesScreenState = +// PreferencesScreenState(appViewModel) + +@Inject class PreferencesScreenState( - private val appViewModel: AppViewModel, + private val bookmarkAPI: BookmarkAPI, + private val prefStore: PrefDataStore, ) : ViewModel() { - val bookmarkURL = appViewModel.bookmarkEndpoint + init { + viewModelScope.launch(Dispatchers.IO) { + saveBookmarkAPIToken(prefStore.getPref(Prefs.LINKDING_TOKEN, "")) + saveBookmarkURL(prefStore.getPref(Prefs.LINKDING_URL, "")) + } + } + private val _bookmarkEndpoint: MutableStateFlow = MutableStateFlow("") + var bookmarkEndpoint = _bookmarkEndpoint.asStateFlow() + private val _bookmarkAPIToken: MutableStateFlow = MutableStateFlow("") + var bookmarkAPIToken = _bookmarkAPIToken.asStateFlow() + + private fun saveBookmarkConf( + url: String? = null, + token: String? = null, + ) { + if (url != null) _bookmarkEndpoint.update { url } + if (token != null) _bookmarkAPIToken.update { token } + + viewModelScope.launch(Dispatchers.IO) { + bookmarkAPI.authenticate( + bookmarkEndpoint.value, bookmarkAPIToken.value + ) + prefStore.setPref(Prefs.LINKDING_URL, bookmarkEndpoint.value) + prefStore.setPref(Prefs.LINKDING_TOKEN, bookmarkAPIToken.value) + } + } + fun saveBookmarkURL(url: String) { - appViewModel.saveBookmarkConf(url = url) + saveBookmarkConf(url = url) } - val bookmarkAPIToken = appViewModel.bookmarkAPIToken fun saveBookmarkAPIToken(token: String) { - appViewModel.saveBookmarkConf(token = token) + saveBookmarkConf(token = token) } } diff --git a/app/src/main/java/org/yrovas/linklater/ui/state/SaveBookmarkScreenState.kt b/app/src/main/java/org/yrovas/linklater/ui/state/SaveBookmarkScreenState.kt index 9897416..a7f4b83 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/state/SaveBookmarkScreenState.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/state/SaveBookmarkScreenState.kt @@ -4,14 +4,22 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.yrovas.linklater.AppViewModel +import me.tatarka.inject.annotations.Inject import org.yrovas.linklater.checkURL import org.yrovas.linklater.data.LocalBookmark -import org.yrovas.linklater.domain.* +import org.yrovas.linklater.data.remote.BookmarkAPI +import org.yrovas.linklater.domain.APIError +import org.yrovas.linklater.domain.Err +import org.yrovas.linklater.domain.Ok +import org.yrovas.linklater.domain.Res -class SaveBookmarkScreenState(private val api: BookmarkAPI) : ViewModel() { +@Inject +class SaveBookmarkScreenState(private val bookmarkAPI: BookmarkAPI) : + ViewModel() { private val _bookmarkToSave: MutableStateFlow = MutableStateFlow(LocalBookmark("")) var bookmarkToSave = _bookmarkToSave.asStateFlow() @@ -78,10 +86,9 @@ class SaveBookmarkScreenState(private val api: BookmarkAPI) : ViewModel() { ).filter { it.isNotBlank() }).distinct() val bookmark = bookmarkToSave.value.withUpdates(tags = tags.ifEmpty { null }) -// Log.d("DEBUG/save", "submitBookmark: $bookmark") viewModelScope.launch(Dispatchers.IO) { setSubmitResult( - when (val res = api.saveBookmark(bookmark)) { + when (val res = bookmarkAPI.saveBookmark(bookmark)) { is Res.Err -> Err(res.error) is Res.Ok -> Ok("Saved Bookmark to LinkDing") } @@ -93,7 +100,7 @@ class SaveBookmarkScreenState(private val api: BookmarkAPI) : ViewModel() { fun setup(context: Context) { viewModelScope.launch(Dispatchers.IO) { - setTags(api.getCachedTags(context)) + setTags(bookmarkAPI.getCachedTags(context)) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9cb0e2f..0d9e980 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } +lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle-runtime-ktx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } @@ -55,13 +56,9 @@ ui = { group = "androidx.compose.ui", name = "ui" } ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -acompanist = { group = "androidx.compose.material3", name = "material3", version.ref = "material" } destinations = { group = "io.github.raamcosta.compose-destinations", name = "core", version.ref = "destinations" } destinations-ksp = { group = "io.github.raamcosta.compose-destinations", name = "ksp", version.ref = "destinations" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } -# androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } -# androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } -# androidx-room-paging = { group = "androidx.room", name = "room-paging", version.ref = "room" } [plugins] android = { id = "com.android.application", version.ref = "agp" }