Merge pull request #2163 from omnivore-app/feat/android-notebooks

First pass at Android Notebooks, most parts in place, lots of styling needed
This commit is contained in:
Jackson Harper
2023-05-05 13:04:58 +08:00
committed by GitHub
12 changed files with 495 additions and 36 deletions

View File

@ -17,8 +17,8 @@ android {
applicationId "app.omnivore.omnivore"
minSdk 26
targetSdk 33
versionCode 60
versionName "0.0.60"
versionCode 61
versionName "0.0.61"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -148,6 +148,8 @@ dependencies {
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation 'com.github.jeziellago:compose-markdown:0.3.3'
}
apollo {

View File

@ -57,5 +57,10 @@
android:exported="true"
android:theme="@style/Theme.Omnivore"/>
<activity
android:name=".ui.notebook.NotebookActivity"
android:exported="true"
android:theme="@style/Theme.Omnivore"/>
</application>
</manifest>

View File

@ -7,4 +7,5 @@ sealed class Routes(val route: String) {
object Documentation: Routes("Documentation")
object PrivacyPolicy: Routes("PrivacyPolicy")
object TermsAndConditions: Routes("TermsAndConditions")
object Notebook: Routes("Notebook")
}

View File

@ -162,6 +162,24 @@ interface SavedItemDao {
)
fun getLibraryLiveDataSortedByRecentlyPublished(archiveFilter: Int): LiveData<List<SavedItemCardDataWithLabels>>
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.libraryColumns} " +
"FROM SavedItem " +
"LEFT OUTER JOIN SavedItemAndSavedItemLabelCrossRef on SavedItem.savedItemId = SavedItemAndSavedItemLabelCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemAndHighlightCrossRef on SavedItem.savedItemId = SavedItemAndHighlightCrossRef.savedItemId " +
"LEFT OUTER JOIN SavedItemLabel on SavedItemLabel.savedItemLabelId = SavedItemAndSavedItemLabelCrossRef.savedItemLabelId " +
"LEFT OUTER JOIN Highlight on highlight.highlightId = SavedItemAndHighlightCrossRef.highlightId " +
"WHERE SavedItem.savedItemId = :savedItemId " +
"AND SavedItem.serverSyncStatus != 2 " +
"AND Highlight.serverSyncStatus != 2 " +
"GROUP BY SavedItem.savedItemId "
)
fun getLibraryItemById(savedItemId: String): LiveData<SavedItemWithLabelsAndHighlights>
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.libraryColumns} " +
@ -173,7 +191,7 @@ interface SavedItemDao {
"LEFT OUTER JOIN Highlight on highlight.highlightId = SavedItemAndHighlightCrossRef.highlightId " +
"WHERE SavedItem.serverSyncStatus != 2 " +
"AND SavedItem.isArchived != :archiveFilter " +
"AND SavedItem.isArchived IN (:allowedArchiveStates) " +
"AND SavedItem.contentReader IN (:allowedContentReaders) " +
"AND CASE WHEN :hasRequiredLabels THEN SavedItemLabel.name in (:requiredLabels) ELSE 1 END " +
"AND CASE WHEN :hasExcludedLabels THEN SavedItemLabel.name is NULL OR SavedItemLabel.name not in (:excludedLabels) ELSE 1 END " +
@ -187,11 +205,11 @@ interface SavedItemDao {
"CASE WHEN :sortKey = 'recentlyRead' THEN SavedItem.readAt END DESC,\n" +
"CASE WHEN :sortKey = 'recentlyPublished' THEN SavedItem.publishDate END DESC"
)
fun _filteredLibraryData(archiveFilter: Int, sortKey: String, hasRequiredLabels: Int, hasExcludedLabels: Int, requiredLabels: List<String>, excludedLabels: List<String>, allowedContentReaders: List<String>): LiveData<List<SavedItemWithLabelsAndHighlights>>
fun _filteredLibraryData(allowedArchiveStates: List<Int>, sortKey: String, hasRequiredLabels: Int, hasExcludedLabels: Int, requiredLabels: List<String>, excludedLabels: List<String>, allowedContentReaders: List<String>): LiveData<List<SavedItemWithLabelsAndHighlights>>
fun filteredLibraryData(archiveFilter: Int, sortKey: String, requiredLabels: List<String>, excludedLabels: List<String>, allowedContentReaders: List<String>): LiveData<List<SavedItemWithLabelsAndHighlights>> {
fun filteredLibraryData(allowedArchiveStates: List<Int>, sortKey: String, requiredLabels: List<String>, excludedLabels: List<String>, allowedContentReaders: List<String>): LiveData<List<SavedItemWithLabelsAndHighlights>> {
return _filteredLibraryData(
archiveFilter = archiveFilter,
allowedArchiveStates = allowedArchiveStates,
sortKey = sortKey,
hasRequiredLabels = requiredLabels.size,
hasExcludedLabels = excludedLabels.size,

View File

@ -36,7 +36,7 @@ class LibraryViewModel @Inject constructor(
// Live Data
private var itemsLiveDataInternal = dataService.db.savedItemDao().filteredLibraryData(
archiveFilter = 1,
allowedArchiveStates = listOf(0),
sortKey = "newest",
requiredLabels = listOf(),
excludedLabels = listOf(),
@ -65,28 +65,6 @@ class LibraryViewModel @Inject constructor(
}
}
// runBlocking {
// datastoreRepo.getString(DatastoreKeys.lastUsedSavedItemFilter)?.let { str ->
// try {
// val filter = SavedItemFilter.values().first { it.rawValue == str }
// appliedFilterLiveData.postValue(filter)
// } catch (e: Exception) {
// Log.d("error", "invalid filter value stored in datastore repo: $e")
// }
//
// datastoreRepo.getString(DatastoreKeys.lastUsedSavedItemSortFilter)?.let { str ->
// try {
// val filter = SavedItemSortFilter.values().first { it.rawValue == str }
// appliedSortFilterLiveData.postValue(filter)
// } catch (e: Exception) {
// Log.d("error", "invalid sort filter value stored in datastore repo: $e")
// }
//
// handleFilterChanges()
// }
// }
// }
viewModelScope.launch {
handleFilterChanges()
for (slug in contentRequestChannel) {
@ -196,9 +174,10 @@ class LibraryViewModel @Inject constructor(
else -> "newest"
}
val archiveFilter = when (appliedFilterLiveData.value) {
SavedItemFilter.ARCHIVED -> 0
else -> 1
val allowedArchiveStates = when (appliedFilterLiveData.value) {
SavedItemFilter.ALL -> listOf(0, 1)
SavedItemFilter.ARCHIVED -> listOf(1)
else -> listOf(0)
}
val allowedContentReaders = when(appliedFilterLiveData.value) {
@ -221,7 +200,7 @@ class LibraryViewModel @Inject constructor(
}
val newData = dataService.db.savedItemDao().filteredLibraryData(
archiveFilter = archiveFilter,
allowedArchiveStates = allowedArchiveStates,
sortKey = sortKey,
requiredLabels = requiredLabels,
excludedLabels = excludeLabels,

View File

@ -0,0 +1,403 @@
package app.omnivore.omnivore.ui.notebook
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
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.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.omnivore.omnivore.MainActivity
import app.omnivore.omnivore.R
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.ui.library.*
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import dagger.hilt.android.AndroidEntryPoint
import dev.jeziellago.compose.markdowntext.MarkdownText
import kotlinx.coroutines.launch
@AndroidEntryPoint
class NotebookActivity: ComponentActivity() {
val viewModel: NotebookViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val savedItemId = intent.getStringExtra("SAVED_ITEM_ID")
setContent {
val systemUiController = rememberSystemUiController()
val useDarkIcons = !isSystemInDarkTheme()
DisposableEffect(systemUiController, useDarkIcons) {
systemUiController.setSystemBarsColor(
color = Color.Black,
darkIcons = false
)
onDispose {}
}
OmnivoreTheme {
Box(
modifier = Modifier
.fillMaxSize()
// .background(color = Color.Black)
) {
savedItemId?.let {
NotebookView(
savedItemId = savedItemId,
viewModel = viewModel
)
}
}
}
}
}
// // animate the view up when keyboard appears
// WindowCompat.setDecorFitsSystemWindows(window, false)
// val rootView = findViewById<View>(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)
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun NotebookView(savedItemId: String, viewModel: NotebookViewModel) {
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val savedItem = viewModel.getLibraryItemById(savedItemId).observeAsState()
val scrollState = rememberScrollState()
val modalBottomSheetState = rememberModalBottomSheetState(
ModalBottomSheetValue.Hidden,
)
val notes = savedItem.value?.highlights?.filter { it.type == "NOTE" } ?: listOf()
val highlights = savedItem.value?.highlights?.filter { it.type == "HIGHLIGHT" } ?: listOf()
ModalBottomSheetLayout(
modifier = Modifier.statusBarsPadding(),
sheetBackgroundColor = Color.Transparent,
sheetState = modalBottomSheetState,
sheetContent = {
EditNoteModal()
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Notebook") },
modifier = Modifier.statusBarsPadding(),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
navigationIcon = {
IconButton(onClick = {
onBackPressedDispatcher?.onBackPressed()
}) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.ArrowBack,
modifier = Modifier,
contentDescription = "Back"
)
}
},
// actions = {
// IconButton(onClick = {
//
// }) {
// Icon(
// imageVector = Icons.Default.MoreVert,
// contentDescription = null
// )
// }
// }
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(scrollState)
.fillMaxSize()
) {
savedItem.value?.let {
if (notes.isNotEmpty()) {
ArticleNotes(it)
}
HighlightsList(it)
}
Spacer(Modifier.weight(100f))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun EditNoteModal() {
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val annotation = remember { mutableStateOf("") }
BottomSheetUI() {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Note") },
modifier = Modifier.statusBarsPadding(),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
navigationIcon = {
IconButton(onClick = {
onBackPressedDispatcher?.onBackPressed()
}) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.ArrowBack,
modifier = Modifier,
contentDescription = "Back"
)
}
}
)
}
) { paddingValues ->
TextField(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
value = annotation.value, onValueChange = { annotation.value = it }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun ArticleNotes(item: SavedItemWithLabelsAndHighlights) {
val notes = item.highlights?.filter { it.type == "NOTE" } ?: listOf()
val showDialog = remember { mutableStateOf(false) }
val modalBottomSheetState = rememberModalBottomSheetState(
ModalBottomSheetValue.Expanded,
)
val annotation = remember { mutableStateOf("") }
Column(modifier = Modifier
.fillMaxWidth()
.padding(start = 15.dp)
) {
Text("Article Notes")
Divider(modifier = Modifier.padding(bottom= 15.dp))
notes.forEach { note ->
MarkdownText(
markdown = note.annotation ?: "",
fontSize = 14.sp,
style = TextStyle(lineHeight = 18.sp),
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
if (notes.isEmpty()) {
Button(
onClick = {
// viewModelScope.launch {
// datastoreRepo.clearValue(DatastoreKeys.omnivorePendingUserToken)
// }
},
modifier = Modifier
.padding(0.dp, end = 15.dp)
.fillMaxWidth(),
shape = androidx.compose.material.MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
// Text(
// text = "Add Notes...",
// style = androidx.compose.material.MaterialTheme.typography.subtitle2,
// modifier = Modifier
// .padding(vertical = 2.dp, horizontal = 0.dp),
// )
Spacer(Modifier.weight(1f))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HighlightsList(item: SavedItemWithLabelsAndHighlights) {
val highlights = item.highlights?.filter { it.type == "HIGHLIGHT" } ?: listOf()
val yellowColor = colorResource(R.color.cta_yellow)
val coroutineScope = rememberCoroutineScope()
val snackBarHostState = remember { SnackbarHostState() }
val clipboard: ClipboardManager? =
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
Column(modifier = Modifier
.fillMaxWidth()
.padding(start = 15.dp)
.padding(top = 40.dp, bottom = 100.dp)
) {
Text("Highlights")
Divider(modifier = Modifier.padding(bottom= 10.dp))
highlights.forEach { highlight ->
var isMenuOpen by remember { mutableStateOf(false) }
Row(modifier = Modifier
.fillMaxWidth()
.align(Alignment.End)
.padding(0.dp)
) {
Spacer(Modifier.weight(1f))
Box {
IconButton(onClick = { isMenuOpen = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
}
if (isMenuOpen) {
DropdownMenu(
expanded = isMenuOpen,
onDismissRequest = { isMenuOpen = false }
) {
DropdownMenuItem(
text = { Text("Copy") },
onClick = {
val clip = ClipData.newPlainText("highlight", highlight.quote)
clipboard?.let {
it
clipboard?.setPrimaryClip(clip)
} ?: run {
coroutineScope.launch {
snackBarHostState
.showSnackbar("Highlight copied")
}
}
isMenuOpen = false
}
)
}
}
}
}
highlight.quote?.let {
Row(modifier = Modifier
.padding(start = 2.dp, end = 15.dp)
.fillMaxWidth()
.drawWithCache {
onDrawWithContent {
// draw behind the content the vertical line on the left
drawLine(
color = yellowColor,
start = Offset.Zero,
end = Offset(0f, this.size.height),
strokeWidth = 10f
)
// draw the content
drawContent()
}
}) {
MarkdownText(
modifier = Modifier
.padding(start = 15.dp, end = 15.dp),
markdown = it,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
highlight.annotation?.let {
MarkdownText(
// modifier = Modifier.padding(paddingValues),
markdown = it,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
} ?: run {
// Surface(
// modifier = Modifier
// .padding(0.dp, end = 15.dp, top = 15.dp, bottom = 30.dp)
// .fillMaxWidth(),
// shape = androidx.compose.material.MaterialTheme.shapes.medium,
// color = MaterialTheme.colorScheme.surfaceVariant
// ) {
// Row {
// Text(
// text = "Add Notes...",
// style = androidx.compose.material.MaterialTheme.typography.subtitle2,
// modifier = Modifier.padding(vertical = 10.dp, horizontal = 10.dp)
// )
// }
// }
}
}
if (highlights.isEmpty()) {
Text(
text = "You have not added any highlights to this page.",
style = androidx.compose.material.MaterialTheme.typography.subtitle2,
modifier = Modifier.padding(vertical = 10.dp, horizontal = 10.dp)
)
}
}
}
@Composable
private fun BottomSheetUI(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth()
.clip(RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp))
.background(Color.White)
.statusBarsPadding()
.padding(top = 20.dp)
) {
content()
}
}

View File

@ -0,0 +1,23 @@
package app.omnivore.omnivore.ui.notebook
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.ui.library.SavedItemViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class NotebookViewModel @Inject constructor(
private val networker: Networker,
private val dataService: DataService,
private val datastoreRepo: DatastoreRepository
): ViewModel() {
fun getLibraryItemById(savedItemId: String): LiveData<SavedItemWithLabelsAndHighlights> {
return dataService.db.savedItemDao().getLibraryItemById(savedItemId)
}
}

View File

@ -26,6 +26,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
@ -33,8 +34,6 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import app.omnivore.omnivore.MainActivity
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.components.WebReaderLabelsSelectionSheet
@ -44,6 +43,8 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.roundToInt
import androidx.navigation.compose.rememberNavController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.ui.notebook.NotebookActivity
@AndroidEntryPoint
@ -55,7 +56,6 @@ class WebReaderLoadingContainerActivity: ComponentActivity() {
val requestID = intent.getStringExtra("SAVED_ITEM_REQUEST_ID")
val slug = intent.getStringExtra("SAVED_ITEM_SLUG")
setContent {
val systemUiController = rememberSystemUiController()
val useDarkIcons = !isSystemInDarkTheme()
@ -122,6 +122,8 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, o
webReaderViewModel.maxToolbarHeightPx = with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() }
webReaderViewModel.loadItem(slug = slug, requestID = requestID)
val context = LocalContext.current
val styledContent = webReaderParams?.let {
val webReaderContent = WebReaderContent(
preferences = webReaderViewModel.storedWebPreferences(isSystemInDarkTheme()),
@ -171,6 +173,18 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, o
)
}
}
webReaderParams?.let {
IconButton(onClick = {
val intent = Intent(context, NotebookActivity::class.java)
intent.putExtra("SAVED_ITEM_ID", it.item.savedItemId)
context.startActivity(intent)
}) {
Icon(
painter = painterResource(id = R.drawable.notebook),
contentDescription = null
)
}
}
IconButton(onClick = { showWebPreferencesDialog = true }) {
Icon(
painter = painterResource(id = R.drawable.format_letter_case),

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M184,112a8,8 0,0 1,-8 8L112,120a8,8 0,0 1,0 -16h64A8,8 0,0 1,184 112ZM176,136L112,136a8,8 0,0 0,0 16h64a8,8 0,0 0,0 -16ZM224,48L224,208a16,16 0,0 1,-16 16L48,224a16,16 0,0 1,-16 -16L32,48A16,16 0,0 1,48 32L208,32A16,16 0,0 1,224 48ZM48,208L72,208L72,48L48,48ZM208,208L208,48L88,48L88,208L208,208Z"
android:fillColor="#000000"/>
</vector>

View File

@ -10,4 +10,8 @@
<color name="gray_EBEBEB">#EBEBEB</color>
<color name="yellow_E6E4BF">#E6E4BF</color>
<color name="cta_yellow">#F8D457</color>
<!-- <color name="F8F8F8">#F8F8F8</color>-->
<!-- <color name="2E2E2B">#2E2E2B</color>-->
</resources>

View File

@ -13,6 +13,7 @@ dependencyResolutionManagement {
maven {
url = uri("https://customers.pspdfkit.com/maven")
}
maven { url 'https://jitpack.io' }
}
}
rootProject.name = "Omnivore"