More progress on entries
This commit is contained in:
parent
7e243f1853
commit
2eba6c7c75
16 changed files with 398 additions and 101 deletions
|
@ -14,6 +14,13 @@
|
||||||
<entry key="../../../../layout/compose-model-1622575673269.xml" value="0.2170608108108108" />
|
<entry key="../../../../layout/compose-model-1622575673269.xml" value="0.2170608108108108" />
|
||||||
<entry key="../../../../layout/compose-model-1622578435598.xml" value="0.2170608108108108" />
|
<entry key="../../../../layout/compose-model-1622578435598.xml" value="0.2170608108108108" />
|
||||||
<entry key="../../../../layout/compose-model-1622578490958.xml" value="0.2361111111111111" />
|
<entry key="../../../../layout/compose-model-1622578490958.xml" value="0.2361111111111111" />
|
||||||
|
<entry key="../../../../layout/compose-model-1622633411023.xml" value="0.3091216216216216" />
|
||||||
|
<entry key="../../../../layout/compose-model-1622633755661.xml" value="0.4212962962962963" />
|
||||||
|
<entry key="../../../../layout/compose-model-1622646013817.xml" value="0.1638888888888889" />
|
||||||
|
<entry key="../../../../layout/compose-model-1622658640119.xml" value="0.2170608108108108" />
|
||||||
|
<entry key="../../../../layout/compose-model-1622768616366.xml" value="0.2179054054054054" />
|
||||||
|
<entry key="../../../../layout/compose-model-1622823049252.xml" value="2.0" />
|
||||||
|
<entry key="../../../../layout/compose-model-1623272040152.xml" value="0.6351851851851852" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|
|
@ -61,6 +61,7 @@ dependencies {
|
||||||
implementation(libs.lifecycle)
|
implementation(libs.lifecycle)
|
||||||
implementation(libs.hilt.android.core)
|
implementation(libs.hilt.android.core)
|
||||||
kapt(libs.hilt.android.kapt)
|
kapt(libs.hilt.android.kapt)
|
||||||
|
implementation(libs.hilt.navigation.compose)
|
||||||
implementation(libs.hilt.work.core)
|
implementation(libs.hilt.work.core)
|
||||||
kapt(libs.hilt.work.kapt)
|
kapt(libs.hilt.work.kapt)
|
||||||
implementation(libs.work.core)
|
implementation(libs.work.core)
|
||||||
|
|
|
@ -12,6 +12,7 @@ import com.wbrawner.nanoflux.network.repository.IconRepository
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltWorker
|
@HiltWorker
|
||||||
|
@ -33,14 +34,9 @@ class SyncWorker @AssistedInject constructor(
|
||||||
|
|
||||||
override fun doWork(): Result {
|
override fun doWork(): Result {
|
||||||
return runBlocking {
|
return runBlocking {
|
||||||
|
Timber.v("Unread entryRepo: ${entryRepository}r")
|
||||||
try {
|
try {
|
||||||
categoryRepository.getAll(true)
|
syncAll(categoryRepository, feedRepository, iconRepository, entryRepository)
|
||||||
feedRepository.getAll(true).forEach {
|
|
||||||
if (it.feed.iconId != null) {
|
|
||||||
iconRepository.getFeedIcon(it.feed.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entryRepository.getAll(true)
|
|
||||||
Result.success()
|
Result.success()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(Data.Builder().putString("message", e.message?: "Unknown failure").build())
|
Result.failure(Data.Builder().putString("message", e.message?: "Unknown failure").build())
|
||||||
|
|
23
app/src/main/java/com/wbrawner/nanoflux/Utils.kt
Normal file
23
app/src/main/java/com/wbrawner/nanoflux/Utils.kt
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
package com.wbrawner.nanoflux
|
||||||
|
|
||||||
|
import com.wbrawner.nanoflux.network.repository.CategoryRepository
|
||||||
|
import com.wbrawner.nanoflux.network.repository.EntryRepository
|
||||||
|
import com.wbrawner.nanoflux.network.repository.FeedRepository
|
||||||
|
import com.wbrawner.nanoflux.network.repository.IconRepository
|
||||||
|
|
||||||
|
suspend fun syncAll(
|
||||||
|
categoryRepository: CategoryRepository,
|
||||||
|
feedRepository: FeedRepository,
|
||||||
|
iconRepository: IconRepository,
|
||||||
|
entryRepository: EntryRepository
|
||||||
|
) {
|
||||||
|
// The order of operations is important here to prevent the DAOs from returning incomplete
|
||||||
|
// objects. E.g. The EntryDao returns an EntryAndFeed, which in turn contains a FeedCategoryIcon
|
||||||
|
categoryRepository.getAll(true)
|
||||||
|
feedRepository.getAll(true).forEach {
|
||||||
|
if (it.feed.iconId != null) {
|
||||||
|
iconRepository.getFeedIcon(it.feed.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entryRepository.getAll(true, entryRepository.getLatestId())
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
package com.wbrawner.nanoflux.data.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.wbrawner.nanoflux.network.repository.CategoryRepository
|
||||||
|
import com.wbrawner.nanoflux.network.repository.EntryRepository
|
||||||
|
import com.wbrawner.nanoflux.network.repository.FeedRepository
|
||||||
|
import com.wbrawner.nanoflux.storage.model.Entry
|
||||||
|
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class EntryViewModel @Inject constructor(
|
||||||
|
private val feedRepository: FeedRepository,
|
||||||
|
private val categoryRepository: CategoryRepository,
|
||||||
|
private val entryRepository: EntryRepository,
|
||||||
|
private val logger: Timber.Tree
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _loading = MutableStateFlow(true)
|
||||||
|
val loading = _loading.asStateFlow()
|
||||||
|
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||||
|
val errorMessage = _errorMessage.asStateFlow()
|
||||||
|
private val _entry = MutableStateFlow<EntryAndFeed?>(null)
|
||||||
|
val entry = _entry.asStateFlow()
|
||||||
|
|
||||||
|
suspend fun loadEntry(id: Long) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
_loading.value = true
|
||||||
|
_errorMessage.value = null
|
||||||
|
try {
|
||||||
|
val entry = entryRepository.getEntry(id)
|
||||||
|
_entry.value = entry
|
||||||
|
_loading.value = false
|
||||||
|
entryRepository.markEntryRead(entry.entry)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_errorMessage.value = e.message?: "Error fetching entry"
|
||||||
|
_loading.value = false
|
||||||
|
logger.e(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
_errorMessage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Get Base URL
|
||||||
|
fun getShareUrl(entry: Entry) = "baseUrl/${entry.shareCode}"
|
||||||
|
}
|
|
@ -5,7 +5,10 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.wbrawner.nanoflux.network.repository.CategoryRepository
|
import com.wbrawner.nanoflux.network.repository.CategoryRepository
|
||||||
import com.wbrawner.nanoflux.network.repository.EntryRepository
|
import com.wbrawner.nanoflux.network.repository.EntryRepository
|
||||||
import com.wbrawner.nanoflux.network.repository.FeedRepository
|
import com.wbrawner.nanoflux.network.repository.FeedRepository
|
||||||
|
import com.wbrawner.nanoflux.network.repository.IconRepository
|
||||||
|
import com.wbrawner.nanoflux.storage.model.Entry
|
||||||
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||||
|
import com.wbrawner.nanoflux.syncAll
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
@ -18,29 +21,16 @@ import javax.inject.Inject
|
||||||
class MainViewModel @Inject constructor(
|
class MainViewModel @Inject constructor(
|
||||||
private val feedRepository: FeedRepository,
|
private val feedRepository: FeedRepository,
|
||||||
private val categoryRepository: CategoryRepository,
|
private val categoryRepository: CategoryRepository,
|
||||||
|
private val iconRepository: IconRepository,
|
||||||
private val entryRepository: EntryRepository,
|
private val entryRepository: EntryRepository,
|
||||||
private val logger: Timber.Tree
|
private val logger: Timber.Tree
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow<FeedState>(FeedState.Loading)
|
|
||||||
val state = _state.asStateFlow()
|
|
||||||
|
|
||||||
fun loadUnread() {
|
init {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch {
|
||||||
try {
|
if (entryRepository.getCount() == 0L) {
|
||||||
var entries = entryRepository.getAllUnread()
|
syncAll(categoryRepository, feedRepository, iconRepository, entryRepository)
|
||||||
if (entries.isEmpty()) entries = entryRepository.getAllUnread(true)
|
|
||||||
val state = if (entries.isEmpty()) FeedState.Empty else FeedState.Success(entries)
|
|
||||||
_state.emit(state)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_state.emit(FeedState.Failed(e.localizedMessage))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class FeedState {
|
|
||||||
object Loading : FeedState()
|
|
||||||
class Failed(val errorMessage: String?): FeedState()
|
|
||||||
object Empty: FeedState()
|
|
||||||
class Success(val entries: List<EntryAndFeed>): FeedState()
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
package com.wbrawner.nanoflux.data.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.wbrawner.nanoflux.network.repository.CategoryRepository
|
||||||
|
import com.wbrawner.nanoflux.network.repository.EntryRepository
|
||||||
|
import com.wbrawner.nanoflux.network.repository.FeedRepository
|
||||||
|
import com.wbrawner.nanoflux.storage.model.Entry
|
||||||
|
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class UnreadViewModel @Inject constructor(
|
||||||
|
private val feedRepository: FeedRepository,
|
||||||
|
private val categoryRepository: CategoryRepository,
|
||||||
|
private val entryRepository: EntryRepository,
|
||||||
|
private val logger: Timber.Tree
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _loading = MutableStateFlow(false)
|
||||||
|
val loading = _loading.asStateFlow()
|
||||||
|
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||||
|
val errorMessage = _errorMessage.asStateFlow()
|
||||||
|
val entries = entryRepository.observeUnread()
|
||||||
|
|
||||||
|
init {
|
||||||
|
logger.v("Unread entryRepo: ${entryRepository}r")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
_errorMessage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Get Base URL
|
||||||
|
fun getShareUrl(entry: Entry) = "baseUrl/${entry.shareCode}"
|
||||||
|
}
|
|
@ -34,6 +34,7 @@ fun EntryList(
|
||||||
onStarClicked: (entry: Entry) -> Unit,
|
onStarClicked: (entry: Entry) -> Unit,
|
||||||
onExternalLinkClicked: (entry: Entry) -> Unit
|
onExternalLinkClicked: (entry: Entry) -> Unit
|
||||||
) {
|
) {
|
||||||
|
// TODO: Add pull to refresh
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(entries) { entry ->
|
items(entries) { entry ->
|
||||||
EntryListItem(
|
EntryListItem(
|
||||||
|
@ -209,8 +210,9 @@ private const val MILLIS_IN_WEEK = 604_800_000.0
|
||||||
private const val MILLIS_IN_MONTH = 2_628_000_000.0
|
private const val MILLIS_IN_MONTH = 2_628_000_000.0
|
||||||
private const val MILLIS_IN_YEAR = 31_540_000_000.0
|
private const val MILLIS_IN_YEAR = 31_540_000_000.0
|
||||||
|
|
||||||
|
val now = GregorianCalendar().timeInMillis
|
||||||
fun Date.timeSince(): String {
|
fun Date.timeSince(): String {
|
||||||
val difference = System.currentTimeMillis() - time
|
val difference = now - time
|
||||||
return when {
|
return when {
|
||||||
difference >= MILLIS_IN_YEAR -> "${(difference / MILLIS_IN_YEAR).roundToInt()} years ago"
|
difference >= MILLIS_IN_YEAR -> "${(difference / MILLIS_IN_YEAR).roundToInt()} years ago"
|
||||||
difference >= MILLIS_IN_MONTH -> "${(difference / MILLIS_IN_MONTH).roundToInt()} months ago"
|
difference >= MILLIS_IN_MONTH -> "${(difference / MILLIS_IN_MONTH).roundToInt()} months ago"
|
||||||
|
|
69
app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt
Normal file
69
app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
package com.wbrawner.nanoflux.ui
|
||||||
|
|
||||||
|
import android.text.Html
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.verticalScroll
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import com.wbrawner.nanoflux.data.viewmodel.EntryViewModel
|
||||||
|
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EntryScreen(entryId: Long, entryViewModel: EntryViewModel) {
|
||||||
|
val loading by entryViewModel.loading.collectAsState()
|
||||||
|
val errorMessage by entryViewModel.errorMessage.collectAsState()
|
||||||
|
val entry by entryViewModel.entry.collectAsState()
|
||||||
|
LaunchedEffect(key1 = entryId) {
|
||||||
|
entryViewModel.loadEntry(entryId)
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
errorMessage?.let {
|
||||||
|
Text("Unable to load entry: $it")
|
||||||
|
}
|
||||||
|
entry?.let {
|
||||||
|
Entry(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Entry(entry: EntryAndFeed) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
EntryListItem(
|
||||||
|
entry = entry.entry,
|
||||||
|
feed = entry.feed,
|
||||||
|
onEntryItemClicked = { /*TODO*/ },
|
||||||
|
onFeedClicked = { /*TODO*/ },
|
||||||
|
onToggleReadClicked = { /*TODO*/ },
|
||||||
|
onStarClicked = { /*TODO*/ },
|
||||||
|
onExternalLinkClicked = { /* TODO */ }
|
||||||
|
)
|
||||||
|
// Adds view to Compose
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
factory = { context ->
|
||||||
|
TextView(context).apply {
|
||||||
|
text = Html.fromHtml(entry.entry.content)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,18 +5,20 @@ import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.navArgument
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
@ -25,6 +27,7 @@ import com.wbrawner.nanoflux.SyncWorker
|
||||||
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
|
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
|
||||||
import com.wbrawner.nanoflux.data.viewmodel.MainViewModel
|
import com.wbrawner.nanoflux.data.viewmodel.MainViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
|
@ -32,30 +35,24 @@ fun MainScreen(
|
||||||
mainViewModel: MainViewModel = viewModel()
|
mainViewModel: MainViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(key1 = authViewModel.state) {
|
LaunchedEffect(key1 = context) {
|
||||||
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>().build()
|
WorkManager.getInstance(context)
|
||||||
WorkManager.getInstance(context).enqueue(workRequest)
|
.enqueue(OneTimeWorkRequestBuilder<SyncWorker>().build())
|
||||||
mainViewModel.loadUnread()
|
|
||||||
}
|
}
|
||||||
val state = mainViewModel.state.collectAsState()
|
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
MainScaffold(
|
MainScaffold(
|
||||||
state.value,
|
|
||||||
navController,
|
navController,
|
||||||
{
|
{ navController.navigate("unread") },
|
||||||
|
{ navController.navigate("starred") },
|
||||||
},
|
{ navController.navigate("history") },
|
||||||
{},
|
{ navController.navigate("feeds") },
|
||||||
{},
|
{ navController.navigate("categories") },
|
||||||
{},
|
{ navController.navigate("settings") },
|
||||||
{},
|
|
||||||
{}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScaffold(
|
fun MainScaffold(
|
||||||
state: MainViewModel.FeedState,
|
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
onUnreadClicked: () -> Unit,
|
onUnreadClicked: () -> Unit,
|
||||||
onStarredClicked: () -> Unit,
|
onStarredClicked: () -> Unit,
|
||||||
|
@ -64,40 +61,60 @@ fun MainScaffold(
|
||||||
onCategoriesClicked: () -> Unit,
|
onCategoriesClicked: () -> Unit,
|
||||||
onSettingsClicked: () -> Unit,
|
onSettingsClicked: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scaffoldState = rememberScaffoldState()
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scaffoldState = rememberScaffoldState(snackbarHostState = snackbarHostState)
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
scaffoldState = scaffoldState,
|
scaffoldState = scaffoldState,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(navigationIcon = {
|
TopAppBar(
|
||||||
IconButton(onClick = { coroutineScope.launch { scaffoldState.drawerState.open() } }) {
|
navigationIcon = {
|
||||||
Icon(
|
IconButton(onClick = { coroutineScope.launch { scaffoldState.drawerState.open() } }) {
|
||||||
imageVector = Icons.Default.Menu,
|
Icon(
|
||||||
contentDescription = "Menu"
|
imageVector = Icons.Default.Menu,
|
||||||
|
contentDescription = "Menu"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = navController.currentDestination?.displayName?.capitalize(Locale.ENGLISH)
|
||||||
|
?: "Unread"
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
}, title = { Text(text = "Unread") })
|
|
||||||
},
|
},
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
DrawerButton(onClick = onUnreadClicked, icon = Icons.Default.Email, text = "Unread")
|
DrawerButton(onClick = onUnreadClicked, icon = Icons.Default.Email, text = "Unread")
|
||||||
DrawerButton(onClick = onStarredClicked, icon = Icons.Default.Star, text = "Starred")
|
DrawerButton(onClick = onStarredClicked, icon = Icons.Default.Star, text = "Starred")
|
||||||
DrawerButton(onClick = onHistoryClicked, icon = Icons.Default.DateRange, text = "History")
|
DrawerButton(
|
||||||
|
onClick = onHistoryClicked,
|
||||||
|
icon = Icons.Default.DateRange,
|
||||||
|
text = "History"
|
||||||
|
)
|
||||||
DrawerButton(onClick = onFeedsClicked, icon = Icons.Default.List, text = "Feeds")
|
DrawerButton(onClick = onFeedsClicked, icon = Icons.Default.List, text = "Feeds")
|
||||||
DrawerButton(onClick = onCategoriesClicked, icon = Icons.Default.Info, text = "Categories")
|
DrawerButton(
|
||||||
DrawerButton(onClick = onSettingsClicked, icon = Icons.Default.Settings, text = "Settings")
|
onClick = onCategoriesClicked,
|
||||||
|
icon = Icons.Default.Info,
|
||||||
|
text = "Categories"
|
||||||
|
)
|
||||||
|
DrawerButton(
|
||||||
|
onClick = onSettingsClicked,
|
||||||
|
icon = Icons.Default.Settings,
|
||||||
|
text = "Settings"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
when (state) {
|
// TODO: Extract routes to constants
|
||||||
is MainViewModel.FeedState.Loading -> CircularProgressIndicator()
|
NavHost(navController, startDestination = "unread") {
|
||||||
is MainViewModel.FeedState.Failed -> Text("TODO: Failed to load entries: ${state.errorMessage}")
|
composable("unread") {
|
||||||
is MainViewModel.FeedState.Success -> EntryList(
|
UnreadScreen(navController, snackbarHostState, hiltViewModel())
|
||||||
entries = state.entries,
|
}
|
||||||
onEntryItemClicked = { /*TODO*/ },
|
composable(
|
||||||
onFeedClicked = { /*TODO*/ },
|
"entries/{entryId}",
|
||||||
onToggleReadClicked = { /*TODO*/ },
|
arguments = listOf(navArgument("entryId") { type = NavType.LongType })
|
||||||
onStarClicked = { /*TODO*/ }) {
|
) {
|
||||||
|
EntryScreen(it.arguments!!.getLong("entryId"), hiltViewModel())
|
||||||
}
|
}
|
||||||
is MainViewModel.FeedState.Empty -> Text("TODO: No entries")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -132,6 +149,6 @@ fun DrawerButton(onClick: () -> Unit, icon: ImageVector, text: String) {
|
||||||
fun MainScaffold_Preview() {
|
fun MainScaffold_Preview() {
|
||||||
NanofluxApp {
|
NanofluxApp {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
MainScaffold(MainViewModel.FeedState.Loading, navController, {}, {}, {}, {}, {}, {})
|
MainScaffold(navController, {}, {}, {}, {}, {}, {})
|
||||||
}
|
}
|
||||||
}
|
}
|
46
app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt
Normal file
46
app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
package com.wbrawner.nanoflux.ui
|
||||||
|
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.wbrawner.nanoflux.data.viewmodel.UnreadViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UnreadScreen(
|
||||||
|
navController: NavController,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
|
unreadViewModel: UnreadViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val loading by unreadViewModel.loading.collectAsState()
|
||||||
|
val errorMessage by unreadViewModel.errorMessage.collectAsState()
|
||||||
|
val entries by unreadViewModel.entries.collectAsState(emptyList())
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
if (loading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
errorMessage?.let {
|
||||||
|
coroutineScope.launch {
|
||||||
|
when (snackbarHostState.showSnackbar(it, "Retry")) {
|
||||||
|
// SnackbarResult.ActionPerformed -> unreadViewModel.loadUnread()
|
||||||
|
else -> unreadViewModel.dismissError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
Text("TODO: No entries")
|
||||||
|
} else {
|
||||||
|
EntryList(
|
||||||
|
entries = entries,
|
||||||
|
onEntryItemClicked = {
|
||||||
|
navController.navigate("entries/${it.id}")
|
||||||
|
},
|
||||||
|
onFeedClicked = {
|
||||||
|
navController.navigate("feeds/${it.id}")
|
||||||
|
},
|
||||||
|
onToggleReadClicked = { /*TODO*/ },
|
||||||
|
onStarClicked = { /*TODO*/ }) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -32,6 +32,7 @@ preference = { module = "androidx.preference:preference-ktx", version = "1.1.1"
|
||||||
lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version = "2.3.1" }
|
lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version = "2.3.1" }
|
||||||
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
|
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
|
||||||
hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" }
|
hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" }
|
||||||
|
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0-alpha02" }
|
||||||
hilt-work-core = { module = "androidx.hilt:hilt-work", version.ref = "hilt-work" }
|
hilt-work-core = { module = "androidx.hilt:hilt-work", version.ref = "hilt-work" }
|
||||||
hilt-work-kapt = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-work" }
|
hilt-work-kapt = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-work" }
|
||||||
junit = { module = "junit:junit", version = "4.12" }
|
junit = { module = "junit:junit", version = "4.12" }
|
||||||
|
|
|
@ -5,6 +5,7 @@ import com.wbrawner.nanoflux.network.repository.PREF_KEY_AUTH_TOKEN
|
||||||
import com.wbrawner.nanoflux.storage.model.*
|
import com.wbrawner.nanoflux.storage.model.*
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.engine.cio.*
|
import io.ktor.client.engine.cio.*
|
||||||
|
import io.ktor.client.features.*
|
||||||
import io.ktor.client.features.json.*
|
import io.ktor.client.features.json.*
|
||||||
import io.ktor.client.features.json.serializer.*
|
import io.ktor.client.features.json.serializer.*
|
||||||
import io.ktor.client.features.logging.*
|
import io.ktor.client.features.logging.*
|
||||||
|
@ -166,6 +167,9 @@ data class CreateFeedResponse(@SerialName("feed_id") val feedId: Long)
|
||||||
@Serializable
|
@Serializable
|
||||||
data class EntryResponse(val total: Long, val entries: List<Entry>)
|
data class EntryResponse(val total: Long, val entries: List<Entry>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UpdateEntryRequest(@SerialName("entry_ids") val entryIds: List<Long>, val status: Entry.Status)
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
class KtorMinifluxApiService @Inject constructor(
|
class KtorMinifluxApiService @Inject constructor(
|
||||||
private val sharedPreferences: SharedPreferences,
|
private val sharedPreferences: SharedPreferences,
|
||||||
|
@ -185,7 +189,7 @@ class KtorMinifluxApiService @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
level = LogLevel.ALL
|
level = LogLevel.HEADERS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val _baseUrl: AtomicReference<String?> = AtomicReference()
|
private val _baseUrl: AtomicReference<String?> = AtomicReference()
|
||||||
|
@ -276,6 +280,7 @@ class KtorMinifluxApiService @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateFeed(id: Long, feed: FeedJson): Feed = client.put(url("feeds/$id")) {
|
override suspend fun updateFeed(id: Long, feed: FeedJson): Feed = client.put(url("feeds/$id")) {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
body = feed
|
body = feed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -362,10 +367,8 @@ class KtorMinifluxApiService @Inject constructor(
|
||||||
|
|
||||||
override suspend fun updateEntries(entryIds: List<Long>, status: Entry.Status): HttpResponse =
|
override suspend fun updateEntries(entryIds: List<Long>, status: Entry.Status): HttpResponse =
|
||||||
client.put(url("entries")) {
|
client.put(url("entries")) {
|
||||||
// body = @Serializable object {
|
contentType(ContentType.Application.Json)
|
||||||
// val entry_ids = entryIds
|
body = UpdateEntryRequest(entryIds, status)
|
||||||
// val status = status
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun toggleEntryBookmark(id: Long): HttpResponse =
|
override suspend fun toggleEntryBookmark(id: Long): HttpResponse =
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
package com.wbrawner.nanoflux.network.repository
|
package com.wbrawner.nanoflux.network.repository
|
||||||
|
|
||||||
|
import com.wbrawner.nanoflux.network.EntryResponse
|
||||||
import com.wbrawner.nanoflux.network.MinifluxApiService
|
import com.wbrawner.nanoflux.network.MinifluxApiService
|
||||||
import com.wbrawner.nanoflux.storage.dao.EntryDao
|
import com.wbrawner.nanoflux.storage.dao.EntryDao
|
||||||
import com.wbrawner.nanoflux.storage.model.Entry
|
import com.wbrawner.nanoflux.storage.model.Entry
|
||||||
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ -12,15 +14,18 @@ class EntryRepository @Inject constructor(
|
||||||
private val entryDao: EntryDao,
|
private val entryDao: EntryDao,
|
||||||
private val logger: Timber.Tree
|
private val logger: Timber.Tree
|
||||||
) {
|
) {
|
||||||
suspend fun getAll(fetch: Boolean = false): List<EntryAndFeed> {
|
fun observeUnread(): Flow<List<EntryAndFeed>> = entryDao.observeAll()
|
||||||
|
|
||||||
|
fun getCount(): Long = entryDao.getCount()
|
||||||
|
|
||||||
|
suspend fun getAll(fetch: Boolean = false, afterId: Long = 0): List<EntryAndFeed> {
|
||||||
if (fetch) {
|
if (fetch) {
|
||||||
var page = 0
|
getEntries { page ->
|
||||||
while (true) {
|
apiService.getEntries(
|
||||||
try {
|
offset = 100L * page,
|
||||||
entryDao.insertAll(apiService.getEntries(offset = 1000L * page++, limit = 1000).entries)
|
limit = 100,
|
||||||
} catch (e: Exception) {
|
afterEntryId = afterId
|
||||||
break
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return entryDao.getAll()
|
return entryDao.getAll()
|
||||||
|
@ -28,23 +33,47 @@ class EntryRepository @Inject constructor(
|
||||||
|
|
||||||
suspend fun getAllUnread(fetch: Boolean = false): List<EntryAndFeed> {
|
suspend fun getAllUnread(fetch: Boolean = false): List<EntryAndFeed> {
|
||||||
if (fetch) {
|
if (fetch) {
|
||||||
var page = 0
|
getEntries { page ->
|
||||||
while (true) {
|
apiService.getEntries(
|
||||||
// try {
|
offset = 100L * page,
|
||||||
val response = apiService.getEntries(
|
limit = 100,
|
||||||
offset = 10000000000L,// * page++,
|
status = listOf(Entry.Status.UNREAD)
|
||||||
limit = 1000,
|
)
|
||||||
status = listOf(Entry.Status.UNREAD)
|
|
||||||
)
|
|
||||||
entryDao.insertAll(
|
|
||||||
response.entries
|
|
||||||
)
|
|
||||||
// } catch (e: Exception) {
|
|
||||||
// Log.e("EntryRepository", "Error", e)
|
|
||||||
// break
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return entryDao.getAllUnread()
|
return entryDao.getAllUnread()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getEntry(id: Long): EntryAndFeed {
|
||||||
|
entryDao.getAllByIds(id).firstOrNull()?.let {
|
||||||
|
return@getEntry it
|
||||||
|
}
|
||||||
|
|
||||||
|
entryDao.insertAll(apiService.getEntry(id))
|
||||||
|
return entryDao.getAllByIds(id).first()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markEntryRead(entry: Entry) {
|
||||||
|
apiService.updateEntries(listOf(entry.id), Entry.Status.READ)
|
||||||
|
entryDao.update(entry.copy(status = Entry.Status.READ))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markEntryUnread(entry: Entry) {
|
||||||
|
apiService.updateEntries(listOf(entry.id), Entry.Status.UNREAD)
|
||||||
|
entryDao.update(entry.copy(status = Entry.Status.UNREAD))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLatestId(): Long = entryDao.getLatestId()
|
||||||
|
|
||||||
|
private suspend fun getEntries(request: suspend (page: Int) -> EntryResponse) {
|
||||||
|
var page = 0
|
||||||
|
var totalPages = 1L
|
||||||
|
while (page++ < totalPages) {
|
||||||
|
val response = request(page)
|
||||||
|
entryDao.insertAll(
|
||||||
|
response.entries
|
||||||
|
)
|
||||||
|
totalPages = response.total / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -38,7 +38,7 @@ class UserRepository @Inject constructor(
|
||||||
var correctedServer = if (server.startsWith("http")) {
|
var correctedServer = if (server.startsWith("http")) {
|
||||||
server
|
server
|
||||||
} else {
|
} else {
|
||||||
"http://$server"
|
"https://$server"
|
||||||
}
|
}
|
||||||
if (!correctedServer.endsWith("/v1")) {
|
if (!correctedServer.endsWith("/v1")) {
|
||||||
correctedServer = if (correctedServer.endsWith("/")) {
|
correctedServer = if (correctedServer.endsWith("/")) {
|
||||||
|
|
|
@ -3,32 +3,49 @@ package com.wbrawner.nanoflux.storage.dao
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import com.wbrawner.nanoflux.storage.model.Entry
|
import com.wbrawner.nanoflux.storage.model.Entry
|
||||||
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface EntryDao {
|
interface EntryDao {
|
||||||
|
@Query("SELECT COUNT(*) FROM Entry")
|
||||||
|
fun getCount(): Long
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM Entry")
|
@Query("SELECT * FROM Entry ORDER BY createdAt DESC")
|
||||||
|
fun observeAll(): Flow<List<EntryAndFeed>>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM Entry ORDER BY createdAt DESC")
|
||||||
fun getAll(): List<EntryAndFeed>
|
fun getAll(): List<EntryAndFeed>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\"")
|
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY createdAt DESC")
|
||||||
fun getAllUnread(): List<EntryAndFeed>
|
fun getAllUnread(): List<EntryAndFeed>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM Entry WHERE id in (:ids)")
|
@Query("SELECT * FROM Entry WHERE id in (:ids) ORDER BY createdAt DESC")
|
||||||
fun getAllByIds(vararg ids: Long): List<EntryAndFeed>
|
fun getAllByIds(vararg ids: Long): List<EntryAndFeed>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM Entry WHERE feedId in (:ids)")
|
@Query("SELECT * FROM Entry WHERE feedId in (:ids) ORDER BY createdAt DESC")
|
||||||
fun getAllByFeedIds(vararg ids: Long): List<EntryAndFeed>
|
fun getAllByFeedIds(vararg ids: Long): List<EntryAndFeed>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" AND feedId in (:ids)")
|
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" AND feedId in (:ids) ORDER BY createdAt DESC")
|
||||||
fun getAllUnreadByFeedIds(vararg ids: Long): List<EntryAndFeed>
|
fun getAllUnreadByFeedIds(vararg ids: Long): List<EntryAndFeed>
|
||||||
|
|
||||||
|
@Query("SELECT id FROM Entry ORDER BY id DESC LIMIT 1")
|
||||||
|
fun getLatestId(): Long
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun insertAll(entries: List<Entry>)
|
fun insertAll(entries: List<Entry>)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
fun insertAll(vararg entry: Entry)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
fun update(entry: Entry)
|
||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
fun delete(entry: Entry)
|
fun delete(entry: Entry)
|
||||||
}
|
}
|
Loading…
Reference in a new issue