diff --git a/.idea/misc.xml b/.idea/misc.xml index 35279bc..49ffb09 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -14,6 +14,13 @@ + + + + + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3b11f86..3a769d9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { implementation(libs.lifecycle) implementation(libs.hilt.android.core) kapt(libs.hilt.android.kapt) + implementation(libs.hilt.navigation.compose) implementation(libs.hilt.work.core) kapt(libs.hilt.work.kapt) implementation(libs.work.core) diff --git a/app/src/main/java/com/wbrawner/nanoflux/SyncWorker.kt b/app/src/main/java/com/wbrawner/nanoflux/SyncWorker.kt index b8bd576..7ccdb02 100644 --- a/app/src/main/java/com/wbrawner/nanoflux/SyncWorker.kt +++ b/app/src/main/java/com/wbrawner/nanoflux/SyncWorker.kt @@ -12,6 +12,7 @@ import com.wbrawner.nanoflux.network.repository.IconRepository import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.runBlocking +import timber.log.Timber import javax.inject.Inject @HiltWorker @@ -33,14 +34,9 @@ class SyncWorker @AssistedInject constructor( override fun doWork(): Result { return runBlocking { + Timber.v("Unread entryRepo: ${entryRepository}r") try { - categoryRepository.getAll(true) - feedRepository.getAll(true).forEach { - if (it.feed.iconId != null) { - iconRepository.getFeedIcon(it.feed.id) - } - } - entryRepository.getAll(true) + syncAll(categoryRepository, feedRepository, iconRepository, entryRepository) Result.success() } catch (e: Exception) { Result.failure(Data.Builder().putString("message", e.message?: "Unknown failure").build()) diff --git a/app/src/main/java/com/wbrawner/nanoflux/Utils.kt b/app/src/main/java/com/wbrawner/nanoflux/Utils.kt new file mode 100644 index 0000000..2d0f776 --- /dev/null +++ b/app/src/main/java/com/wbrawner/nanoflux/Utils.kt @@ -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()) +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/EntryViewModel.kt b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/EntryViewModel.kt new file mode 100644 index 0000000..c063044 --- /dev/null +++ b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/EntryViewModel.kt @@ -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(null) + val errorMessage = _errorMessage.asStateFlow() + private val _entry = MutableStateFlow(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}" +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/MainViewModel.kt b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/MainViewModel.kt index 08c054b..269cb73 100644 --- a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/MainViewModel.kt @@ -5,7 +5,10 @@ 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.network.repository.IconRepository +import com.wbrawner.nanoflux.storage.model.Entry import com.wbrawner.nanoflux.storage.model.EntryAndFeed +import com.wbrawner.nanoflux.syncAll import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -18,29 +21,16 @@ import javax.inject.Inject class MainViewModel @Inject constructor( private val feedRepository: FeedRepository, private val categoryRepository: CategoryRepository, + private val iconRepository: IconRepository, private val entryRepository: EntryRepository, private val logger: Timber.Tree ) : ViewModel() { - private val _state = MutableStateFlow(FeedState.Loading) - val state = _state.asStateFlow() - fun loadUnread() { - viewModelScope.launch(Dispatchers.IO) { - try { - var entries = entryRepository.getAllUnread() - 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)) + init { + viewModelScope.launch { + if (entryRepository.getCount() == 0L) { + syncAll(categoryRepository, feedRepository, iconRepository, entryRepository) } } } - - sealed class FeedState { - object Loading : FeedState() - class Failed(val errorMessage: String?): FeedState() - object Empty: FeedState() - class Success(val entries: List): FeedState() - } } \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/UnreadViewModel.kt b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/UnreadViewModel.kt new file mode 100644 index 0000000..d22402b --- /dev/null +++ b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/UnreadViewModel.kt @@ -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(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}" +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt index bfdf86f..33767db 100644 --- a/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt +++ b/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt @@ -34,6 +34,7 @@ fun EntryList( onStarClicked: (entry: Entry) -> Unit, onExternalLinkClicked: (entry: Entry) -> Unit ) { + // TODO: Add pull to refresh LazyColumn { items(entries) { entry -> 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_YEAR = 31_540_000_000.0 +val now = GregorianCalendar().timeInMillis fun Date.timeSince(): String { - val difference = System.currentTimeMillis() - time + val difference = now - time return when { difference >= MILLIS_IN_YEAR -> "${(difference / MILLIS_IN_YEAR).roundToInt()} years ago" difference >= MILLIS_IN_MONTH -> "${(difference / MILLIS_IN_MONTH).roundToInt()} months ago" diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt new file mode 100644 index 0000000..32d1141 --- /dev/null +++ b/app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt @@ -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) + } + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt index e8d7dea..9e17c0f 100644 --- a/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt +++ b/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt @@ -5,18 +5,20 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.viewmodel.compose.viewModel 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.work.OneTimeWorkRequestBuilder 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.MainViewModel import kotlinx.coroutines.launch +import java.util.* @Composable fun MainScreen( @@ -32,30 +35,24 @@ fun MainScreen( mainViewModel: MainViewModel = viewModel() ) { val context = LocalContext.current - LaunchedEffect(key1 = authViewModel.state) { - val workRequest = OneTimeWorkRequestBuilder().build() - WorkManager.getInstance(context).enqueue(workRequest) - mainViewModel.loadUnread() + LaunchedEffect(key1 = context) { + WorkManager.getInstance(context) + .enqueue(OneTimeWorkRequestBuilder().build()) } - val state = mainViewModel.state.collectAsState() val navController = rememberNavController() MainScaffold( - state.value, navController, - { - - }, - {}, - {}, - {}, - {}, - {} + { navController.navigate("unread") }, + { navController.navigate("starred") }, + { navController.navigate("history") }, + { navController.navigate("feeds") }, + { navController.navigate("categories") }, + { navController.navigate("settings") }, ) } @Composable fun MainScaffold( - state: MainViewModel.FeedState, navController: NavHostController, onUnreadClicked: () -> Unit, onStarredClicked: () -> Unit, @@ -64,40 +61,60 @@ fun MainScaffold( onCategoriesClicked: () -> Unit, onSettingsClicked: () -> Unit, ) { - val scaffoldState = rememberScaffoldState() + val snackbarHostState = remember { SnackbarHostState() } + val scaffoldState = rememberScaffoldState(snackbarHostState = snackbarHostState) val coroutineScope = rememberCoroutineScope() Scaffold( scaffoldState = scaffoldState, topBar = { - TopAppBar(navigationIcon = { - IconButton(onClick = { coroutineScope.launch { scaffoldState.drawerState.open() } }) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = "Menu" + TopAppBar( + navigationIcon = { + IconButton(onClick = { coroutineScope.launch { scaffoldState.drawerState.open() } }) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = "Menu" + ) + } + }, + title = { + Text( + text = navController.currentDestination?.displayName?.capitalize(Locale.ENGLISH) + ?: "Unread" ) - } - }, title = { Text(text = "Unread") }) + }) }, drawerContent = { DrawerButton(onClick = onUnreadClicked, icon = Icons.Default.Email, text = "Unread") 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 = onCategoriesClicked, icon = Icons.Default.Info, text = "Categories") - DrawerButton(onClick = onSettingsClicked, icon = Icons.Default.Settings, text = "Settings") + DrawerButton( + onClick = onCategoriesClicked, + icon = Icons.Default.Info, + text = "Categories" + ) + DrawerButton( + onClick = onSettingsClicked, + icon = Icons.Default.Settings, + text = "Settings" + ) } ) { - when (state) { - is MainViewModel.FeedState.Loading -> CircularProgressIndicator() - is MainViewModel.FeedState.Failed -> Text("TODO: Failed to load entries: ${state.errorMessage}") - is MainViewModel.FeedState.Success -> EntryList( - entries = state.entries, - onEntryItemClicked = { /*TODO*/ }, - onFeedClicked = { /*TODO*/ }, - onToggleReadClicked = { /*TODO*/ }, - onStarClicked = { /*TODO*/ }) { + // TODO: Extract routes to constants + NavHost(navController, startDestination = "unread") { + composable("unread") { + UnreadScreen(navController, snackbarHostState, hiltViewModel()) + } + composable( + "entries/{entryId}", + arguments = listOf(navArgument("entryId") { type = NavType.LongType }) + ) { + 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() { NanofluxApp { val navController = rememberNavController() - MainScaffold(MainViewModel.FeedState.Loading, navController, {}, {}, {}, {}, {}, {}) + MainScaffold(navController, {}, {}, {}, {}, {}, {}) } } \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt new file mode 100644 index 0000000..ac50c82 --- /dev/null +++ b/app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt @@ -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*/ }) { + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27807ab..e23cf9d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } 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-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-kapt = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-work" } junit = { module = "junit:junit", version = "4.12" } diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt b/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt index 8c26d5a..0270c2b 100644 --- a/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt +++ b/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt @@ -5,6 +5,7 @@ import com.wbrawner.nanoflux.network.repository.PREF_KEY_AUTH_TOKEN import com.wbrawner.nanoflux.storage.model.* import io.ktor.client.* import io.ktor.client.engine.cio.* +import io.ktor.client.features.* import io.ktor.client.features.json.* import io.ktor.client.features.json.serializer.* import io.ktor.client.features.logging.* @@ -166,6 +167,9 @@ data class CreateFeedResponse(@SerialName("feed_id") val feedId: Long) @Serializable data class EntryResponse(val total: Long, val entries: List) +@Serializable +data class UpdateEntryRequest(@SerialName("entry_ids") val entryIds: List, val status: Entry.Status) + @Suppress("unused") class KtorMinifluxApiService @Inject constructor( private val sharedPreferences: SharedPreferences, @@ -185,7 +189,7 @@ class KtorMinifluxApiService @Inject constructor( } } - level = LogLevel.ALL + level = LogLevel.HEADERS } } private val _baseUrl: AtomicReference = AtomicReference() @@ -276,6 +280,7 @@ class KtorMinifluxApiService @Inject constructor( } override suspend fun updateFeed(id: Long, feed: FeedJson): Feed = client.put(url("feeds/$id")) { + contentType(ContentType.Application.Json) body = feed } @@ -362,10 +367,8 @@ class KtorMinifluxApiService @Inject constructor( override suspend fun updateEntries(entryIds: List, status: Entry.Status): HttpResponse = client.put(url("entries")) { -// body = @Serializable object { -// val entry_ids = entryIds -// val status = status -// } + contentType(ContentType.Application.Json) + body = UpdateEntryRequest(entryIds, status) } override suspend fun toggleEntryBookmark(id: Long): HttpResponse = diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt index 6cbf35f..1f376e5 100644 --- a/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt +++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt @@ -1,9 +1,11 @@ package com.wbrawner.nanoflux.network.repository +import com.wbrawner.nanoflux.network.EntryResponse import com.wbrawner.nanoflux.network.MinifluxApiService import com.wbrawner.nanoflux.storage.dao.EntryDao import com.wbrawner.nanoflux.storage.model.Entry import com.wbrawner.nanoflux.storage.model.EntryAndFeed +import kotlinx.coroutines.flow.Flow import timber.log.Timber import javax.inject.Inject @@ -12,15 +14,18 @@ class EntryRepository @Inject constructor( private val entryDao: EntryDao, private val logger: Timber.Tree ) { - suspend fun getAll(fetch: Boolean = false): List { + fun observeUnread(): Flow> = entryDao.observeAll() + + fun getCount(): Long = entryDao.getCount() + + suspend fun getAll(fetch: Boolean = false, afterId: Long = 0): List { if (fetch) { - var page = 0 - while (true) { - try { - entryDao.insertAll(apiService.getEntries(offset = 1000L * page++, limit = 1000).entries) - } catch (e: Exception) { - break - } + getEntries { page -> + apiService.getEntries( + offset = 100L * page, + limit = 100, + afterEntryId = afterId + ) } } return entryDao.getAll() @@ -28,23 +33,47 @@ class EntryRepository @Inject constructor( suspend fun getAllUnread(fetch: Boolean = false): List { if (fetch) { - var page = 0 - while (true) { -// try { - val response = apiService.getEntries( - offset = 10000000000L,// * page++, - limit = 1000, - status = listOf(Entry.Status.UNREAD) - ) - entryDao.insertAll( - response.entries - ) -// } catch (e: Exception) { -// Log.e("EntryRepository", "Error", e) -// break -// } + getEntries { page -> + apiService.getEntries( + offset = 100L * page, + limit = 100, + status = listOf(Entry.Status.UNREAD) + ) } } 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 + } + } } \ No newline at end of file diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/UserRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/UserRepository.kt index fe1be70..1d6fb7a 100644 --- a/network/src/main/java/com/wbrawner/nanoflux/network/repository/UserRepository.kt +++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/UserRepository.kt @@ -38,7 +38,7 @@ class UserRepository @Inject constructor( var correctedServer = if (server.startsWith("http")) { server } else { - "http://$server" + "https://$server" } if (!correctedServer.endsWith("/v1")) { correctedServer = if (correctedServer.endsWith("/")) { diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt index d4cbc35..52ec07b 100644 --- a/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt +++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt @@ -3,32 +3,49 @@ package com.wbrawner.nanoflux.storage.dao import androidx.room.* import com.wbrawner.nanoflux.storage.model.Entry import com.wbrawner.nanoflux.storage.model.EntryAndFeed +import kotlinx.coroutines.flow.Flow @Dao interface EntryDao { + @Query("SELECT COUNT(*) FROM Entry") + fun getCount(): Long + @Transaction - @Query("SELECT * FROM Entry") + @Query("SELECT * FROM Entry ORDER BY createdAt DESC") + fun observeAll(): Flow> + + @Transaction + @Query("SELECT * FROM Entry ORDER BY createdAt DESC") fun getAll(): List @Transaction - @Query("SELECT * FROM Entry WHERE status = \"UNREAD\"") + @Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY createdAt DESC") fun getAllUnread(): List @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 @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 @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 + @Query("SELECT id FROM Entry ORDER BY id DESC LIMIT 1") + fun getLatestId(): Long + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertAll(entries: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(vararg entry: Entry) + + @Update + fun update(entry: Entry) + @Delete fun delete(entry: Entry) } \ No newline at end of file