Merge pull request #1159 from omnivore-app/feature/android-home-feed

Home Feed and WebView (Android)
This commit is contained in:
Satindar Dhillon 2022-09-01 17:00:54 -07:00 committed by GitHub
commit 7b4063822a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 471 additions and 54 deletions

View file

@ -139,12 +139,6 @@ dependencies {
implementation "androidx.security:security-crypto:1.0.0"
implementation "androidx.datastore:datastore-preferences:1.0.0"
// Activity KTX for viewModels()
// TODO: remove this since compose-activity imports ktx?
// implementation "androidx.activity:activity-ktx:1.3.1"
//Dagger - Hilt
implementation 'com.google.dagger:hilt-android:2.42'
kapt 'com.google.dagger:hilt-compiler:2.42'
@ -156,6 +150,8 @@ dependencies {
implementation 'com.google.android.gms:play-services-auth:20.2.0'
implementation "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
implementation("io.coil-kt:coil-compose:2.2.0")
}
apollo {

View file

@ -1,5 +1,5 @@
mutation SaveUrl ($input: SaveUrlInput!) {
saveUrl(input:$input){
mutation SaveUrl($input: SaveUrlInput!) {
saveUrl(input: $input) {
... on SaveSuccess {
url
}
@ -7,4 +7,4 @@ mutation SaveUrl ($input: SaveUrlInput!) {
errorCodes
}
}
}
}

View file

@ -0,0 +1,51 @@
query Search($after: String, $first: Int, $query: String) {
search(first: $first, after: $after, query: $query) {
... on SearchSuccess {
edges {
cursor
node {
id
title
slug
url
pageType
contentReader
createdAt
isArchived
readingProgressPercent
readingProgressAnchorIndex
author
image
description
publishedAt
ownedByViewer
originalArticleUrl
uploadFileId
labels {
id
name
color
}
pageId
shortId
quote
annotation
state
siteName
subscription
readAt
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on SearchError {
errorCodes
}
}
}

View file

@ -10,6 +10,7 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import app.omnivore.omnivore.ui.auth.LoginViewModel
import app.omnivore.omnivore.ui.home.HomeViewModel
import app.omnivore.omnivore.ui.root.RootView
import dagger.hilt.android.AndroidEntryPoint
@ -18,11 +19,12 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel: LoginViewModel by viewModels()
val loginViewModel: LoginViewModel by viewModels()
val homeViewModel: HomeViewModel by viewModels()
setContent {
OmnivoreTheme {
RootView(viewModel = viewModel)
RootView(loginViewModel, homeViewModel)
}
}

View file

@ -1,7 +1,7 @@
package app.omnivore.omnivore
sealed class Routes(val route: String) {
object Root : Routes("Root")
object Home : Routes("Home")
object Welcome : Routes("Welcome")
object WebReader : Routes("WebReader")
object Settings: Routes("Settings")
}

View file

@ -25,7 +25,7 @@ import java.util.*
@Composable
fun AppleAuthButton(viewModel: LoginViewModel) {
val showDialog = remember { mutableStateOf(false) }
val showDialog = remember { mutableStateOf(false) }
LoadingButtonWithIcon(
text = "Continue with Apple",

View file

@ -13,6 +13,7 @@ import com.google.android.gms.tasks.Task
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@ -36,6 +37,10 @@ class LoginViewModel @Inject constructor(
.distinctUntilChanged()
.asLiveData()
fun getAuthCookieString(): String? = runBlocking {
datastoreRepo.getString(DatastoreKeys.omnivoreAuthCookieString)
}
fun login(email: String, password: String) {
val emailLogin = RetrofitHelper.getInstance().create(EmailLoginSubmit::class.java)

View file

@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
@ -19,7 +18,6 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp

View file

@ -1,48 +1,114 @@
package app.omnivore.omnivore.ui.home
import android.annotation.SuppressLint
import androidx.compose.foundation.background
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.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.ui.auth.LoginViewModel
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import app.omnivore.omnivore.Routes
import kotlinx.coroutines.flow.distinctUntilChanged
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeView(
homeViewModel: HomeViewModel,
navController: NavHostController
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Home") },
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
actions = {
IconButton(onClick = { navController.navigate(Routes.Settings.route) }) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = null
)
}
}
)
}
) { paddingValues ->
HomeViewContent(
homeViewModel,
navController,
modifier = Modifier
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
)
)
}
}
@Composable
fun HomeView(viewModel: LoginViewModel) {
val context = LocalContext.current
fun HomeViewContent(
homeViewModel: HomeViewModel,
navController: NavHostController,
modifier: Modifier
) {
val listState = rememberLazyListState()
val linkedItems: List<LinkedItem> by homeViewModel.itemsLiveData.observeAsState(listOf())
Column(
LazyColumn(
state = listState,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
.padding(horizontal = 6.dp)
) {
Text("You have a valid auth token. Nice. Go save something in Chrome!")
Button(onClick = {
// Sign out google users
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.build()
val googleSignIn = GoogleSignIn.getClient(context, signInOptions)
googleSignIn.signOut()
viewModel.logout()
}) {
Text(text = "Logout")
items(linkedItems) { item ->
LinkedItemCard(
item = item,
onClickHandler = {
navController.navigate("WebReader/${item.slug}")
}
)
}
}
InfiniteListHandler(listState = listState) {
homeViewModel.load()
}
}
@Composable
fun InfiniteListHandler(
listState: LazyListState,
buffer: Int = 2,
onLoadMore: () -> Unit
) {
val loadMore = remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
lastVisibleItemIndex > (totalItemsNumber - buffer)
}
}
LaunchedEffect(loadMore) {
snapshotFlow { loadMore.value }
.distinctUntilChanged()
.collect {
onLoadMore()
}
}
}

View file

@ -0,0 +1,101 @@
package app.omnivore.omnivore.ui.home
import android.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.*
import app.omnivore.omnivore.AppleConstants
import app.omnivore.omnivore.Constants
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.ui.auth.AppleAuthDialog
import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.api.Optional
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository
): ViewModel() {
var cursor: String? = null
val itemsLiveData = MutableLiveData<List<LinkedItem>>(listOf())
private fun getAuthToken(): String? = runBlocking {
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
}
fun load() {
viewModelScope.launch {
val authToken = getAuthToken()
val apolloClient = ApolloClient.Builder()
.serverUrl("${Constants.apiURL}/api/graphql")
.addHttpHeader("Authorization", value = authToken ?: "")
.build()
val response = apolloClient.query(
SearchQuery(
after = Optional.presentIfNotNull(cursor),
first = Optional.presentIfNotNull(15),
)
).execute()
cursor = response.data?.search?.onSearchSuccess?.pageInfo?.endCursor
val itemList = response.data?.search?.onSearchSuccess?.edges ?: listOf()
val newItems = itemList.map {
LinkedItem(
id = it.node.id,
title = it.node.title,
createdAt = it.node.createdAt,
readAt = it.node.readAt,
readingProgress = it.node.readingProgressPercent,
readingProgressAnchor = it.node.readingProgressAnchorIndex,
imageURLString = it.node.image,
descriptionText = it.node.description,
slug = it.node.slug
)
}
itemsLiveData.value = (itemsLiveData.value ?: listOf()).plus(newItems)
}
}
}
public data class LinkedItem(
public val id: String,
public val title: String,
public val createdAt: Any,
// public val savedAt: Any,
public val readAt: Any?,
// public val updatedAt: Any,
public val readingProgress: Double,
public val readingProgressAnchor: Int,
public val imageURLString: String?,
// public val onDeviceImageURLString: String?,
// public val documentDirectoryPath: String?,
// public val pageURLString: String,
public val descriptionText: String?,
// public val publisherURLString: String?,
// public val siteName: String?,
// public val author: String?,
// public val publishDate: Any?,
public val slug: String,
// public val isArchived: Boolean,
// public val contentReader: String?,
// public val originalHtml: String?,
)

View file

@ -0,0 +1,53 @@
package app.omnivore.omnivore.ui.home
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.Image
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LinkedItemCard(
item: LinkedItem,
onClickHandler: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
onClick = { onClickHandler() },
shape = MaterialTheme.shapes.medium,
modifier = modifier
.fillMaxWidth()
.padding(15.dp)
) {
Row {
Image(
painter = rememberAsyncImagePainter(item.imageURLString),
contentDescription = "Image associated with linked item",
modifier = Modifier.size(100.dp)
)
Column(modifier = Modifier.padding(8.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = item.descriptionText ?: "",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}

View file

@ -0,0 +1,42 @@
package app.omnivore.omnivore.ui.reader
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.ui.viewinterop.AndroidView
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun ArticleWebView(slug: String, authCookieString: String) {
WebView.setWebContentsDebuggingEnabled(true)
val url = "https://demo.omnivore.app/app/me/$slug"
AndroidView(factory = {
WebView(it).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
settings.javaScriptEnabled = true
settings.allowContentAccess = true
settings.allowFileAccess = true
settings.domStorageEnabled = true
webViewClient = object : WebViewClient() {
}
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setCookie("https://api-demo.omnivore.app", authCookieString) {
loadUrl(url)
}
}
}, update = {
it.loadUrl(url)
})
}

View file

@ -1,19 +1,51 @@
package app.omnivore.omnivore.ui.root
import SettingsView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.ui.auth.LoginViewModel
import app.omnivore.omnivore.ui.auth.WelcomeScreen
import app.omnivore.omnivore.ui.home.HomeView
import app.omnivore.omnivore.ui.home.HomeViewModel
import app.omnivore.omnivore.ui.reader.ArticleWebView
@Composable
fun RootView(viewModel: LoginViewModel) {
val hasAuthToken: Boolean by viewModel.hasAuthTokenLiveData.observeAsState(false)
fun RootView(loginViewModel: LoginViewModel, homeViewModel: HomeViewModel) {
val hasAuthToken: Boolean by loginViewModel.hasAuthTokenLiveData.observeAsState(false)
if (hasAuthToken) {
HomeView(viewModel = viewModel)
} else {
WelcomeScreen(viewModel = viewModel)
}
if (hasAuthToken) {
PrimaryNavigator(loginViewModel = loginViewModel, homeViewModel = homeViewModel)
} else {
WelcomeScreen(viewModel = loginViewModel)
}
}
@Composable
fun PrimaryNavigator(loginViewModel: LoginViewModel, homeViewModel: HomeViewModel){
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Routes.Home.route) {
composable(Routes.Home.route) {
HomeView(
homeViewModel = homeViewModel,
navController = navController
)
}
composable("WebReader/{slug}") {
ArticleWebView(
it.arguments?.getString("slug") ?: "",
authCookieString = loginViewModel.getAuthCookieString() ?: ""
)
}
composable(Routes.Settings.route) {
SettingsView(loginViewModel = loginViewModel, navController = navController)
}
}
}

View file

@ -40,8 +40,6 @@ class SaveViewModel @Inject constructor(
val authToken = getAuthToken()
Log.d(ContentValues.TAG, "AuthToken: $authToken")
if (authToken == null) {
message = "You are not logged in. Please login before saving."
isLoading = false

View file

@ -0,0 +1,73 @@
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.ui.auth.LoginViewModel
import app.omnivore.omnivore.ui.home.HomeViewContent
import app.omnivore.omnivore.ui.home.HomeViewModel
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun SettingsView(
loginViewModel: LoginViewModel,
navController: NavHostController
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
actions = {
IconButton(onClick = { navController.navigate(Routes.Home.route) }) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null
)
}
}
)
}
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
.padding(horizontal = 16.dp)
) {
LogoutButton { loginViewModel.logout() }
}
}
}
@Composable
fun LogoutButton(actionHandler: () -> Unit) {
val context = LocalContext.current
Button(onClick = {
// Sign out google users
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.build()
val googleSignIn = GoogleSignIn.getClient(context, signInOptions)
googleSignIn.signOut()
actionHandler()
}) {
Text(text = "Logout")
}
}