diff --git a/android/Omnivore/app/src/main/AndroidManifest.xml b/android/Omnivore/app/src/main/AndroidManifest.xml index a78d2d2f0..3983989ed 100644 --- a/android/Omnivore/app/src/main/AndroidManifest.xml +++ b/android/Omnivore/app/src/main/AndroidManifest.xml @@ -51,5 +51,11 @@ android:name=".ui.reader.PDFReaderActivity" android:theme="@style/Theme.AppCompat.NoActionBar" android:windowSoftInputMode="adjustNothing" /> + + + diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SavedItemQuery.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SavedItemQuery.kt index 9cc667461..bc5ec8840 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SavedItemQuery.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SavedItemQuery.kt @@ -8,11 +8,12 @@ import app.omnivore.omnivore.persistence.entities.Highlight data class SavedItemQueryResponse( val item: SavedItem?, val highlights: List, - val labels: List + val labels: List, + val state: String ) { companion object { fun emptyResponse(): SavedItemQueryResponse { - return SavedItemQueryResponse(null, listOf(), listOf()) + return SavedItemQueryResponse(null, listOf(), listOf(), state = "") } } } @@ -79,8 +80,8 @@ suspend fun Networker.savedItem(slug: String): SavedItemQueryResponse { content = article.articleFields.content ) - return SavedItemQueryResponse(item = savedItem, highlights, labels = savedItemLabels) + return SavedItemQueryResponse(item = savedItem, highlights, labels = savedItemLabels, state = article.articleFields.state?.rawValue ?: "") } catch (e: java.lang.Exception) { - return SavedItemQueryResponse(item = null, listOf(), labels = listOf()) + return SavedItemQueryResponse(item = null, listOf(), labels = listOf(), state = "") } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt index ededf42ad..2d38bcef9 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt @@ -41,123 +41,6 @@ import kotlinx.coroutines.launch import java.util.* import kotlin.math.roundToInt - -@Composable -fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewModel) { - val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher - - var isMenuExpanded by remember { mutableStateOf(false) } - var showWebPreferencesDialog by remember { mutableStateOf(false ) } - - val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null) - val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null) - val shouldPopView: Boolean by webReaderViewModel.shouldPopViewLiveData.observeAsState(false) - - val maxToolbarHeight = 48.dp - val maxToolbarHeightPx = with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() } - val toolbarHeightPx = remember { mutableStateOf(maxToolbarHeightPx) } - - // Create a connection to the nested scroll system and listen to the scroll happening inside child Column - val nestedScrollConnection = remember { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val delta = available.y - val newHeight = toolbarHeightPx.value + delta - toolbarHeightPx.value = newHeight.coerceIn(0f, maxToolbarHeightPx) - return Offset.Zero - } - } - } - - if (webReaderParams == null) { - webReaderViewModel.loadItem(slug = slug) - } - - if (webReaderParams != null) { - Box( - modifier = Modifier - .fillMaxSize() - .nestedScroll(nestedScrollConnection) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(webReaderViewModel.scrollState) - - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .requiredHeight(height = maxToolbarHeight) - ) { - } - WebReader(webReaderParams!!, webReaderViewModel.storedWebPreferences(isSystemInDarkTheme()), webReaderViewModel) - } - - TopAppBar( - modifier = Modifier - .height(height = with(LocalDensity.current) { - webReaderViewModel.currentToolbarHeight = toolbarHeightPx.value.toInt() - toolbarHeightPx.value.roundToInt().toDp() - } ), - backgroundColor = MaterialTheme.colorScheme.surfaceVariant, - title = {}, - actions = { - // Disabling menu until we implement local persistence - IconButton(onClick = { isMenuExpanded = true }) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = null - ) - } - IconButton(onClick = { showWebPreferencesDialog = true }) { - Icon( - imageVector = Icons.Filled.Settings, // TODO: set a better icon - contentDescription = null - ) - } - SavedItemContextMenu( - isExpanded = isMenuExpanded, - isArchived = webReaderParams!!.item.isArchived, - onDismiss = { isMenuExpanded = false }, - actionHandler = { webReaderViewModel.handleSavedItemAction(webReaderParams!!.item.savedItemId, it) } - ) - } - ) - - if (showWebPreferencesDialog) { - WebPreferencesDialog( - onDismiss = { - showWebPreferencesDialog = false - }, - webReaderViewModel = webReaderViewModel - ) - } - - if (annotation != null) { - AnnotationEditView( - initialAnnotation = annotation!!, - onSave = { - webReaderViewModel.saveAnnotation(it) - }, - onCancel = { - webReaderViewModel.cancelAnnotationEdit() - } - ) - } - } - - LaunchedEffect(shouldPopView) { - if (shouldPopView) { - onBackPressedDispatcher?.onBackPressed() - } - } - } else { - // TODO: add a proper loading view - Text("Loading...") - } -} - @SuppressLint("SetJavaScriptEnabled") @Composable fun WebReader( diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderLoadingContainer.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderLoadingContainer.kt new file mode 100644 index 000000000..0bee40bc6 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderLoadingContainer.kt @@ -0,0 +1,232 @@ +package app.omnivore.omnivore.ui.reader + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import app.omnivore.omnivore.MainActivity +import app.omnivore.omnivore.ui.savedItemViews.SavedItemContextMenu +import app.omnivore.omnivore.ui.theme.OmnivoreTheme +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import dagger.hilt.android.AndroidEntryPoint +import kotlin.math.roundToInt + + +@AndroidEntryPoint +class WebReaderLoadingContainerActivity: ComponentActivity() { + val viewModel: WebReaderViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val requestID = intent.getStringExtra("SAVED_ITEM_REQUEST_ID") ?: "" + + setContent { + val systemUiController = rememberSystemUiController() + val useDarkIcons = !isSystemInDarkTheme() + + OmnivoreTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = Color.Black) + .systemBarsPadding() + ) { + if (viewModel.hasFetchError.value == true) { + Text("We were unable to fetch your content.") + } else { + WebReaderLoadingContainer( + requestID = requestID, + onLibraryIconTap = { startMainActivity() }, + webReaderViewModel = viewModel + ) + } + } + } + + DisposableEffect(systemUiController, useDarkIcons) { + systemUiController.setSystemBarsColor( + color = Color.Black, + darkIcons = false + ) + + onDispose {} + } + } + + // animate the view up when keyboard appears + WindowCompat.setDecorFitsSystemWindows(window, false) + val rootView = findViewById(android.R.id.content).rootView + ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets -> + val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + rootView.setPadding(0, 0, 0, imeHeight) + insets + } + } + + private fun startMainActivity() { + val intent = Intent(this, MainActivity::class.java) + this.startActivity(intent) + } +} + +@Composable +fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, onLibraryIconTap: (() -> Unit)? = null, webReaderViewModel: WebReaderViewModel) { + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + + var isMenuExpanded by remember { mutableStateOf(false) } + var showWebPreferencesDialog by remember { mutableStateOf(false ) } + + val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null) + val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null) + val shouldPopView: Boolean by webReaderViewModel.shouldPopViewLiveData.observeAsState(false) + + val maxToolbarHeight = 48.dp + val maxToolbarHeightPx = with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() } + val toolbarHeightPx = remember { mutableStateOf(maxToolbarHeightPx) } + + // Create a connection to the nested scroll system and listen to the scroll happening inside child Column + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.y + val newHeight = toolbarHeightPx.value + delta + toolbarHeightPx.value = newHeight.coerceIn(0f, maxToolbarHeightPx) + return Offset.Zero + } + } + } + + if (webReaderParams == null) { + webReaderViewModel.loadItem(slug = slug, requestID = requestID) + } + + if (webReaderParams != null) { + Box( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(webReaderViewModel.scrollState) + + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .requiredHeight(height = maxToolbarHeight) + ) { + } + WebReader(webReaderParams!!, webReaderViewModel.storedWebPreferences(isSystemInDarkTheme()), webReaderViewModel) + } + + TopAppBar( + modifier = Modifier + .height(height = with(LocalDensity.current) { + webReaderViewModel.currentToolbarHeight = toolbarHeightPx.value.toInt() + toolbarHeightPx.value.roundToInt().toDp() + } ), + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + title = {}, + actions = { + if (onLibraryIconTap != null) { + IconButton(onClick = { onLibraryIconTap() }) { + Icon( + imageVector = Icons.Default.Home, + contentDescription = null + ) + } + } + IconButton(onClick = { isMenuExpanded = true }) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = null + ) + } + IconButton(onClick = { showWebPreferencesDialog = true }) { + Icon( + imageVector = Icons.Filled.Settings, // TODO: set a better icon + contentDescription = null + ) + } + SavedItemContextMenu( + isExpanded = isMenuExpanded, + isArchived = webReaderParams!!.item.isArchived, + onDismiss = { isMenuExpanded = false }, + actionHandler = { webReaderViewModel.handleSavedItemAction(webReaderParams!!.item.savedItemId, it) } + ) + } + ) + + if (showWebPreferencesDialog) { + WebPreferencesDialog( + onDismiss = { + showWebPreferencesDialog = false + }, + webReaderViewModel = webReaderViewModel + ) + } + + if (annotation != null) { + AnnotationEditView( + initialAnnotation = annotation!!, + onSave = { + webReaderViewModel.saveAnnotation(it) + }, + onCancel = { + webReaderViewModel.cancelAnnotationEdit() + } + ) + } + } + + LaunchedEffect(shouldPopView) { + if (shouldPopView) { + onBackPressedDispatcher?.onBackPressed() + } + } + } else { + Column( + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Text("Loading...", color = Color.White) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt index affa6166d..4bb3b4fa3 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt @@ -41,20 +41,41 @@ class WebReaderViewModel @Inject constructor( val annotationLiveData = MutableLiveData(null) val javascriptActionLoopUUIDLiveData = MutableLiveData(lastJavascriptActionLoopUUID) val shouldPopViewLiveData = MutableLiveData(false) + val hasFetchError = MutableLiveData(false) var hasTappedExistingHighlight = false var lastTapCoordinates: TapCoordinates? = null - fun loadItem(slug: String) { + fun loadItem(slug: String?, requestID: String?) { viewModelScope.launch { - val webReaderParams = loadItemFromServer(slug) + slug?.let { loadItemUsingSlug(it) } + requestID?.let { loadItemUsingRequestID(it) } + } + } - if (webReaderParams != null) { - Log.d("sync", "data loaded from server") - webReaderParamsLiveData.postValue(webReaderParams) - } else { - loadItemFromDB(slug) - } + private suspend fun loadItemUsingSlug(slug: String) { + val webReaderParams = loadItemFromServer(slug) + + if (webReaderParams != null) { + Log.d("sync", "data loaded from server") + webReaderParamsLiveData.postValue(webReaderParams) + } else { + loadItemFromDB(slug) + } + } + + private suspend fun loadItemUsingRequestID(requestID: String, requestCount: Int = 0) { + val webReaderParams = loadItemFromServer(requestID) + val isSuccessful = webReaderParams?.articleContent?.contentStatus == "SUCCEEDED" + + if (webReaderParams != null && isSuccessful) { + webReaderParamsLiveData.postValue(webReaderParams) + } else if (requestCount < 7) { + // delay then try again + delay(2000L) + loadItemUsingRequestID(requestID = requestID, requestCount = requestCount + 1) + } else { + hasFetchError.postValue(true) } } @@ -87,7 +108,7 @@ class WebReaderViewModel @Inject constructor( title = article.title, htmlContent = article.content ?: "", highlights = articleQueryResult.highlights, - contentStatus = "SUCCEEDED", + contentStatus = articleQueryResult.state, objectID = "", labelsJSONString = Gson().toJson(articleQueryResult.labels) ) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/save/SaveContent.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/save/SaveContent.kt index ff2146338..e0818befe 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/save/SaveContent.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/save/SaveContent.kt @@ -1,5 +1,7 @@ package app.omnivore.omnivore.ui.save +import android.content.Intent +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme @@ -11,13 +13,18 @@ import androidx.compose.material.* import androidx.compose.material.ButtonDefaults import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import app.omnivore.omnivore.MainActivity +import app.omnivore.omnivore.ui.reader.PDFReaderActivity +import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity import kotlinx.coroutines.launch @Composable @OptIn(ExperimentalMaterialApi::class) fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomSheetState, modifier: Modifier) { val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { Column( @@ -29,17 +36,41 @@ fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomShee .padding(top = 48.dp, bottom = 32.dp) ) { Text(text = viewModel.message ?: "Saving") - Button(onClick = { - coroutineScope.launch { - modalBottomSheetState.hide() + Row { + Button( + onClick = { + coroutineScope.launch { + modalBottomSheetState.hide() + viewModel.clientRequestID?.let { + val intent = Intent(context, WebReaderLoadingContainerActivity::class.java) + intent.putExtra("SAVED_ITEM_REQUEST_ID", it) + context.startActivity(intent) + } + } + }, + colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), + backgroundColor = Color.White + ) + ) { + Text(text = "Read Now") } - }, - colors = ButtonDefaults.buttonColors( + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + coroutineScope.launch { + modalBottomSheetState.hide() + } + }, + colors = ButtonDefaults.buttonColors( contentColor = Color(0xFF3D3D3D), backgroundColor = Color(0xffffd234) - ) - ) { - Text(text = "Dismiss") + ) + ) { + Text(text = "Read Later") + } } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/save/SaveViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/save/SaveViewModel.kt index 0dbdc7222..b02e92f59 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/save/SaveViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/save/SaveViewModel.kt @@ -29,6 +29,9 @@ class SaveViewModel @Inject constructor( var message by mutableStateOf(null) private set + var clientRequestID by mutableStateOf(null) + private set + private fun getAuthToken(): String? = runBlocking { datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken) } @@ -52,10 +55,12 @@ class SaveViewModel @Inject constructor( .build() try { + clientRequestID = UUID.randomUUID().toString() + val response = apolloClient.mutation( SaveUrlMutation( SaveUrlInput( - clientRequestId = UUID.randomUUID().toString(), + clientRequestId = clientRequestID!!, source = "android", url = url )