Compare commits

...

35 Commits

Author SHA1 Message Date
Alexandre Alapetite d6ede0d2ce
Merge 9c73738cab into 36cdf84b34 2024-05-04 23:11:41 +02:00
Shinokuni 36cdf84b34 Fetch FreshRSS folders and feeds 2024-05-04 21:44:05 +02:00
Shinokuni 0d69cfd66d Fix CI build 2024-05-04 19:41:42 +02:00
Shinokuni 8071f0b477 Add new FeedDao upsert method 2024-05-04 19:29:20 +02:00
Shinokuni 052c83cb35 Add new FolderDao upsert method 2024-05-04 18:13:19 +02:00
Shinokuni e9536e99ed Improve http errors wording 2024-04-30 23:44:13 +02:00
Shinokuni f14ed7f331 Add initial FreshRSS login with new kotlin repository 2024-04-30 22:40:38 +02:00
Shinokuni a7c0749641 Improve AccountSelectionScreen UI 2024-04-29 13:08:15 +02:00
Shinokuni cc7b874ef5 Add initial UI of AccountCredentialsScreen 2024-04-29 00:03:28 +02:00
Shinokuni 2c105f596a Fix GetFoldersWithFeeds tests... 2024-04-27 21:20:35 +02:00
Shinokuni 9f87077945 Switch CI to ubuntu runner 2024-04-26 18:25:29 +02:00
Shinokuni a3ffde0d73 Fix item read time calculation 2024-04-26 18:02:36 +02:00
Shinokuni 6893e9a199 Fix once and for all folders and feeds query with simple read state 2024-04-26 17:08:58 +02:00
Shinokuni c55a9dc5e4 Fix BottomBarState not being updated in ItemScreen 2024-04-26 15:55:09 +02:00
Shinokuni 45dc199ea2 Prevent AndroidView being composed more than once in ItemScreen 2024-04-21 22:24:44 +02:00
Shinokuni 841b56e7e5 Adjust ItemBottomBar icon tint color depending of background color in ItemScreen 2024-04-21 22:16:45 +02:00
Shinokuni 8566b55e3f Display unread new items count in TimelineDrawer 2024-04-17 15:10:08 +02:00
Shinokuni ea51df49bc Display feed description in FeedBottomSheet instead of folder name 2024-04-15 16:34:39 +02:00
Shinokuni 99ff159434 Fix some feed parsing failures 2024-04-15 14:21:29 +02:00
Shinokuni c115a1edcc Fix content display for some items in ItemScreen 2024-04-15 12:28:37 +02:00
Shinokuni afbf8129ca Add options to download/share image on long press in ItemScreen 2024-04-14 14:40:32 +02:00
Shinokuni eeb054f068 Add little template to html blockquote tag 2024-04-13 16:34:34 +02:00
Shinokuni d486bd92f9 Use Inter custom font in ItemWebView 2024-04-13 15:58:16 +02:00
Shinokuni 7b644cbc97 Open webView urls in external navigator 2024-04-13 14:24:06 +02:00
Shinokuni 02a3f82b72 Extract views from ItemScreen 2024-04-13 14:12:55 +02:00
Shinokuni 91378f0a54 Add collapsable bottom bar in ItemScreen
Gather all actions at the bottom of the screen:
* set read state
* set start state
* share url
* open url
2024-04-13 13:03:37 +02:00
Shinokuni bf7ac41d6e Inject Koin context in Compose hierarchy 2024-04-10 17:14:16 +02:00
Shinokuni c071426bbd Display item content in ItemScreen 2024-04-10 16:56:30 +02:00
Shinokuni da51f504e4 Fix crash for some feeds in TimelineTab 2024-04-07 17:34:56 +02:00
Shinokuni 16e70519e4 Add initial ItemScreen UI 2024-04-07 17:32:53 +02:00
Shinokuni 0ccb4aa9c8 Add OPML export in AccountTab 2024-04-06 22:51:56 +02:00
Shinokuni 8a5c22d144 Migrate more dependencies to version catalog 2024-04-05 22:41:23 +02:00
Alexandre Alapetite 9c73738cab
Merge branch 'develop' into fix-freshrss-get-link 2024-03-31 19:23:59 +02:00
Alexandre Alapetite d5d8b16148
Merge branch 'develop' into pr/Alkarex/163 2024-02-22 21:53:19 +01:00
Alexandre Alapetite 7e5c0268ea
Fix FreshRSS get link
#fix https://github.com/readrops/Readrops/issues/162
#fix https://github.com/FreshRSS/FreshRSS/issues/4567
2022-08-31 12:47:30 +02:00
75 changed files with 2336 additions and 587 deletions

View File

@ -11,7 +11,7 @@ on:
jobs:
build:
runs-on: macos-latest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@ -20,8 +20,13 @@ jobs:
with:
distribution: 'temurin'
java-version: '17'
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Android Emulator Runner
uses: ReactiveCircus/android-emulator-runner@v2.28.0
uses: ReactiveCircus/android-emulator-runner@v2.30.1
with:
api-level: 29
script: ./gradlew clean build connectedCheck jacocoFullReport

View File

@ -52,15 +52,15 @@ dependencies {
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
implementation(libs.bundles.koin)
testImplementation(libs.bundles.kointest)
implementation 'com.gitlab.mvysny.konsume-xml:konsume-xml:1.0'
implementation 'org.redundent:kotlin-xml-builder:1.7.3'
implementation(libs.konsumexml)
implementation(libs.kotlinxmlbuilder)
api 'com.squareup.okhttp3:okhttp:4.9.1'
implementation(libs.okhttp)
testImplementation(libs.okhttp.mockserver)
implementation('com.squareup.retrofit2:retrofit:2.9.0') {
exclude group: 'com.squareup.okhttp3', module: 'okhttp3'
@ -71,7 +71,7 @@ dependencies {
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0'
implementation 'com.squareup.moshi:moshi:1.12.0'
implementation 'com.squareup.moshi:moshi:1.15.1'
api 'io.reactivex.rxjava2:rxandroid:2.1.1'
api 'org.jsoup:jsoup:1.13.1'

View File

@ -3,8 +3,13 @@ package com.readrops.api
import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.services.Credentials
import com.readrops.api.services.freshrss.FreshRSSDataSource
import com.readrops.api.services.freshrss.FreshRSSService
import com.readrops.api.services.freshrss.adapters.*
import com.readrops.api.services.freshrss.NewFreshRSSDataSource
import com.readrops.api.services.freshrss.NewFreshRSSService
import com.readrops.api.services.freshrss.adapters.FreshRSSFeedsAdapter
import com.readrops.api.services.freshrss.adapters.FreshRSSFoldersAdapter
import com.readrops.api.services.freshrss.adapters.FreshRSSItemsAdapter
import com.readrops.api.services.freshrss.adapters.FreshRSSItemsIdsAdapter
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfoAdapter
import com.readrops.api.services.nextcloudnews.NextNewsDataSource
import com.readrops.api.services.nextcloudnews.NextNewsService
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFeedsAdapter
@ -27,12 +32,12 @@ val apiModule = module {
single {
OkHttpClient.Builder()
.callTimeout(1, TimeUnit.MINUTES)
.readTimeout(1, TimeUnit.HOURS)
.addInterceptor(get<AuthInterceptor>())
.addInterceptor(get<ErrorInterceptor>())
//.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
.build()
.callTimeout(1, TimeUnit.MINUTES)
.readTimeout(1, TimeUnit.HOURS)
.addInterceptor(get<AuthInterceptor>())
.addInterceptor(get<ErrorInterceptor>())
//.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
.build()
}
single { AuthInterceptor() }
@ -45,14 +50,15 @@ val apiModule = module {
factory { params -> FreshRSSDataSource(get(parameters = { params })) }
factory { params -> NewFreshRSSDataSource(get(parameters = { params })) }
factory { (credentials: Credentials) ->
Retrofit.Builder()
.baseUrl(credentials.url)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
.build()
.create(FreshRSSService::class.java)
.baseUrl(credentials.url)
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
.build()
.create(NewFreshRSSService::class.java)
}
single(named("freshrssMoshi")) {

View File

@ -29,6 +29,7 @@ class ATOMFeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
"link" -> parseLink(this@allChildrenAutoIgnore, feed)
"subtitle" -> description = nullableText()
"entry" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore)
else -> skipContents()
}
}
}

View File

@ -26,6 +26,7 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
when (tagName) {
"channel" -> parseChannel(this, feed)
"item" -> items += itemAdapter.fromXml(this)
else -> skipContents()
}
}
}

View File

@ -10,7 +10,6 @@ import java.io.OutputStream
object OPMLParser {
@JvmStatic
suspend fun read(stream: InputStream): Map<Folder?, List<Feed>> {
try {
val adapter = OPMLAdapter()
@ -23,7 +22,6 @@ object OPMLParser {
}
}
@JvmStatic
suspend fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream) {
val opml = xml("opml") {
attribute("version", "2.0")
@ -64,5 +62,6 @@ object OPMLParser {
outputStream.write(opml.toString().toByteArray())
outputStream.flush()
outputStream.close()
}
}

View File

@ -4,12 +4,13 @@ import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
class SyncResult(var items: List<Item> = mutableListOf(),
var starredItems: List<Item> = mutableListOf(),
var feeds: List<Feed> = listOf(),
var folders: List<Folder> = listOf(),
var unreadIds: List<String>? = null,
var readIds: List<String>? = null,
var starredIds: List<String>? = null,
var isError: Boolean = false
data class SyncResult(
var items: List<Item> = mutableListOf(),
var starredItems: List<Item> = mutableListOf(),
var feeds: List<Feed> = listOf(),
var folders: List<Folder> = listOf(),
var unreadIds: List<String>? = null,
var readIds: List<String>? = null,
var starredIds: List<String>? = null,
var isError: Boolean = false
)

View File

@ -1,7 +1,11 @@
package com.readrops.api.services.freshrss
import com.readrops.api.services.SyncResult
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
import com.readrops.db.entities.Item
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import okhttp3.MultipartBody
import java.io.StringReader
import java.util.Properties
@ -28,15 +32,19 @@ class NewFreshRSSDataSource(private val service: NewFreshRSSService) {
suspend fun getUserInfo(): FreshRSSUserInfo = service.userInfo()
suspend fun sync() {
suspend fun sync(): SyncResult = with(CoroutineScope(Dispatchers.IO)) {
return SyncResult().apply {
folders = async { getFolders() }.await()
feeds = async { getFeeds() }.await()
//items = async { getItems(listOf(GOOGLE_READ, GOOGLE_STARRED), MAX_ITEMS, null) }.await()
}
}
suspend fun getFolders() = service.getFolders()
suspend fun getFeeds() = service.getFeeds()
suspend fun getItems(excludeTargets: List<String>, max: Int, lastModified: Long): List<Item> {
suspend fun getItems(excludeTargets: List<String>, max: Int, lastModified: Long?): List<Item> {
return service.getItems(excludeTargets, max, lastModified)
}

View File

@ -89,9 +89,11 @@ class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
while (reader.hasNext()) {
reader.beginObject()
when (reader.nextName()) {
"href" -> href = reader.nextNullableString()
else -> reader.skipValue()
while (reader.hasNext()) {
when (reader.nextName()) {
"href" -> href = reader.nextString()
else -> reader.skipValue()
}
}
reader.endObject()

View File

@ -3,7 +3,7 @@ package com.readrops.api.utils
import org.joda.time.LocalDateTime
import org.joda.time.format.DateTimeFormat
import org.joda.time.format.DateTimeFormatterBuilder
import java.util.*
import java.util.Locale
object DateUtils {
@ -35,16 +35,16 @@ object DateUtils {
null
} else try {
val formatter = DateTimeFormatterBuilder()
.appendOptional(DateTimeFormat.forPattern("$RSS_2_BASE_PATTERN ").parser) // with timezone
.appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN).parser) // no timezone, important order here
.appendOptional(DateTimeFormat.forPattern(ATOM_JSON_DATE_FORMAT).parser)
.appendOptional(DateTimeFormat.forPattern(GMT_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(OFFSET_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(ISO_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(EDT_PATTERN).parser)
.toFormatter()
.withLocale(Locale.ENGLISH)
.withOffsetParsed()
.appendOptional(DateTimeFormat.forPattern("$RSS_2_BASE_PATTERN ").parser) // with timezone
.appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN).parser) // no timezone, important order here
.appendOptional(DateTimeFormat.forPattern(ATOM_JSON_DATE_FORMAT).parser)
.appendOptional(DateTimeFormat.forPattern(GMT_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(OFFSET_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(ISO_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(EDT_PATTERN).parser)
.toFormatter()
.withLocale(Locale.ENGLISH)
.withOffsetParsed()
formatter.parseLocalDateTime(value)
} catch (e: Exception) {
@ -54,14 +54,26 @@ object DateUtils {
@JvmStatic
fun formattedDateByLocal(dateTime: LocalDateTime): String {
return DateTimeFormat.mediumDate()
.withLocale(Locale.getDefault())
.print(dateTime)
.withLocale(Locale.getDefault())
.print(dateTime)
}
@JvmStatic
fun formattedDateTimeByLocal(dateTime: LocalDateTime): String {
return DateTimeFormat.forPattern("dd MMM yyyy · HH:mm")
.withLocale(Locale.getDefault())
.print(dateTime)
.withLocale(Locale.getDefault())
.print(dateTime)
}
fun formattedDate(dateTime: LocalDateTime): String {
val pattern = if (dateTime.year != LocalDateTime.now().year) {
"dd MMMM yyyy"
} else {
"dd MMMM"
}
return DateTimeFormat.forPattern(pattern)
.withLocale(Locale.getDefault())
.print(dateTime)
}
}

View File

@ -1,9 +1,10 @@
package com.readrops.api.utils.exceptions
import okhttp3.Response
import java.io.IOException
class HttpException(val response: Response) : Exception() {
class HttpException(val response: Response) : IOException() {
val code: Int
get() = response.code

View File

@ -89,10 +89,6 @@ dependencies {
implementation(libs.bundles.koin)
testImplementation(libs.bundles.kointest)
/*
testImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
testImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version"
*/
implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'com.github.bumptech.glide:compiler:4.12.0'
@ -118,4 +114,5 @@ dependencies {
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.96.1'
implementation(libs.bundles.room)
implementation(libs.bundles.paging)
}

View File

@ -34,6 +34,8 @@ android {
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
@ -81,9 +83,12 @@ dependencies {
androidTestImplementation(libs.coroutines.test)
implementation(libs.bundles.room)
implementation(libs.bundles.paging)
implementation(libs.bundles.koin)
androidTestImplementation(libs.bundles.kointest)
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
coreLibraryDesugaring(libs.jdk.desugar)
}

View File

@ -9,10 +9,19 @@
android:supportsRtl="true"
android:theme="@style/Theme.Readrops">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
android:label="Articles">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -23,5 +32,4 @@
</application>
</manifest>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,13 +1,19 @@
package com.readrops.app.compose
import com.readrops.api.services.Credentials
import com.readrops.app.compose.account.AccountScreenModel
import com.readrops.app.compose.account.selection.AccountSelectionViewModel
import com.readrops.app.compose.account.credentials.AccountCredentialsScreenModel
import com.readrops.app.compose.account.selection.AccountSelectionScreenModel
import com.readrops.app.compose.feeds.FeedScreenModel
import com.readrops.app.compose.item.ItemScreenModel
import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.app.compose.repositories.FreshRSSRepository
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.app.compose.repositories.LocalRSSRepository
import com.readrops.app.compose.timelime.TimelineScreenModel
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import org.koin.core.parameter.parametersOf
import org.koin.dsl.module
val composeAppModule = module {
@ -16,16 +22,26 @@ val composeAppModule = module {
factory { FeedScreenModel(get(), get(), get()) }
factory { AccountSelectionViewModel(get()) }
factory { AccountSelectionScreenModel(get()) }
factory { AccountScreenModel(get()) }
factory { (itemId: Int) -> ItemScreenModel(get(), itemId) }
factory { (accountType: AccountType) -> AccountCredentialsScreenModel(accountType, get()) }
single { GetFoldersWithFeeds(get()) }
// repositories
factory<BaseRepository> { (account: Account) ->
LocalRSSRepository(get(), get(), account)
when (account.accountType) {
AccountType.LOCAL -> LocalRSSRepository(get(), get(), account)
AccountType.FRESHRSS -> FreshRSSRepository(
get(), account,
get(parameters = { parametersOf(Credentials.toCredentials(account)) })
)
else -> throw IllegalArgumentException("Unknown account type")
}
}
}

View File

@ -7,29 +7,35 @@ import cafe.adriel.voyager.navigator.CurrentScreen
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior
import com.readrops.app.compose.account.selection.AccountSelectionScreen
import com.readrops.app.compose.account.selection.AccountSelectionViewModel
import com.readrops.app.compose.account.selection.AccountSelectionScreenModel
import com.readrops.app.compose.home.HomeScreen
import com.readrops.app.compose.util.theme.ReadropsTheme
import org.koin.androidx.viewmodel.ext.android.getViewModel
import org.koin.androidx.compose.KoinAndroidContext
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class MainActivity : ComponentActivity() {
class MainActivity : ComponentActivity(), KoinComponent {
@OptIn(KoinExperimentalAPI::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel = getViewModel<AccountSelectionViewModel>()
val accountExists = viewModel.accountExists()
val screenModel = get<AccountSelectionScreenModel>()
val accountExists = screenModel.accountExists()
setContent {
ReadropsTheme {
Navigator(
screen = if (accountExists) HomeScreen() else AccountSelectionScreen(),
disposeBehavior = NavigatorDisposeBehavior(
// prevent screenModels being recreated when opening a screen from a tab
disposeNestedNavigators = false
)
) {
CurrentScreen()
KoinAndroidContext {
ReadropsTheme {
Navigator(
screen = if (accountExists) HomeScreen() else AccountSelectionScreen(),
disposeBehavior = NavigatorDisposeBehavior(
// prevent screenModels being recreated when opening a screen from a tab
disposeNestedNavigators = false
)
) {
CurrentScreen()
}
}
}
}

View File

@ -2,19 +2,23 @@ package com.readrops.app.compose.account
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.net.toFile
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.api.opml.OPMLParser
import com.readrops.app.compose.base.TabScreenModel
import com.readrops.app.compose.repositories.ErrorResult
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import com.readrops.db.filters.MainFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -46,7 +50,7 @@ class AccountScreenModel(
if (dialog is DialogState.ErrorList) {
_accountState.update { it.copy(synchronizationErrors = null) }
} else if (dialog is DialogState.Error) {
_accountState.update { it.copy(opmlImportError = null) }
_accountState.update { it.copy(error = null) }
}
_accountState.update { it.copy(dialog = null) }
@ -61,6 +65,28 @@ class AccountScreenModel(
}
}
fun exportOPMLFile(uri: Uri, context: Context) {
screenModelScope.launch {
val stream = context.contentResolver.openOutputStream(uri)
if (stream == null) {
_accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) }
return@launch
}
val foldersAndFeeds =
GetFoldersWithFeeds(database).get(currentAccount!!.id, MainFilter.ALL).first()
OPMLParser.write(foldersAndFeeds, stream)
_accountState.update {
it.copy(
opmlExportSuccess = true,
opmlExportUri = uri
)
}
}
}
fun parseOPMLFile(uri: Uri, context: Context) {
screenModelScope.launch(Dispatchers.IO) {
val foldersAndFeeds: Map<Folder?, List<Feed>>
@ -68,13 +94,13 @@ class AccountScreenModel(
try {
val stream = context.contentResolver.openInputStream(uri)
if (stream == null) {
_accountState.update { it.copy(opmlImportError = NoSuchFileException(uri.toFile())) }
_accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) }
return@launch
}
foldersAndFeeds = OPMLParser.read(stream)
} catch (e: Exception) {
_accountState.update { it.copy(opmlImportError = e) }
_accountState.update { it.copy(error = e) }
return@launch
}
@ -109,13 +135,19 @@ class AccountScreenModel(
}
}
}
fun resetOPMLState() =
_accountState.update { it.copy(opmlExportUri = null, opmlExportSuccess = false) }
}
@Stable
data class AccountState(
val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL),
val dialog: DialogState? = null,
val synchronizationErrors: ErrorResult? = null,
val opmlImportError: Exception? = null
val error: Exception? = null,
val opmlExportSuccess: Boolean = false,
val opmlExportUri: Uri? = null,
)
sealed interface DialogState {
@ -126,4 +158,6 @@ sealed interface DialogState {
data class ErrorList(val errorResult: ErrorResult) : DialogState
data class Error(val exception: Exception) : DialogState
object OPMLChoice : DialogState
}

View File

@ -1,5 +1,6 @@
package com.readrops.app.compose.account
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
@ -69,10 +70,10 @@ object AccountTab : Tab {
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val viewModel = getScreenModel<AccountScreenModel>()
val screenModel = getScreenModel<AccountScreenModel>()
val closeHome by viewModel.closeHome.collectAsStateWithLifecycle()
val state by viewModel.accountState.collectAsStateWithLifecycle()
val closeHome by screenModel.closeHome.collectAsStateWithLifecycle()
val state by screenModel.accountState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
@ -80,13 +81,18 @@ object AccountTab : Tab {
navigator.replaceAll(AccountSelectionScreen())
}
val launcher =
val opmlImportLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let { viewModel.parseOPMLFile(uri, context) }
uri?.let { screenModel.parseOPMLFile(uri, context) }
}
LaunchedEffect(state.opmlImportError) {
if (state.opmlImportError != null) {
val opmlExportLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/xml")) { uri ->
uri?.let { screenModel.exportOPMLFile(uri, context) }
}
LaunchedEffect(state.error) {
if (state.error != null) {
val action = snackbarHostState.showSnackbar(
message = context.resources.getQuantityString(
R.plurals.error_occurred,
@ -97,9 +103,9 @@ object AccountTab : Tab {
)
if (action == SnackbarResult.ActionPerformed) {
viewModel.openDialog(DialogState.Error(state.opmlImportError!!))
screenModel.openDialog(DialogState.Error(state.error!!))
} else {
viewModel.closeDialog(DialogState.Error(state.opmlImportError!!))
screenModel.closeDialog(DialogState.Error(state.error!!))
}
}
}
@ -116,9 +122,31 @@ object AccountTab : Tab {
)
if (action == SnackbarResult.ActionPerformed) {
viewModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
screenModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
} else {
viewModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!))
screenModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!))
}
}
}
LaunchedEffect(state.opmlExportSuccess) {
if (state.opmlExportSuccess) {
val action = snackbarHostState.showSnackbar(
message = "OPML export success",
actionLabel = "Open file"
)
if (action == SnackbarResult.ActionPerformed) {
Intent().apply {
this.action = Intent.ACTION_VIEW
setDataAndType(state.opmlExportUri, "text/xml")
}.also {
context.startActivity(Intent.createChooser(it, null))
}
screenModel.resetOPMLState()
} else {
screenModel.resetOPMLState()
}
}
}
@ -131,20 +159,20 @@ object AccountTab : Tab {
icon = rememberVectorPainter(image = Icons.Default.Delete),
confirmText = stringResource(R.string.delete),
dismissText = stringResource(R.string.cancel),
onDismiss = { viewModel.closeDialog() },
onDismiss = { screenModel.closeDialog() },
onConfirm = {
viewModel.closeDialog()
viewModel.deleteAccount()
screenModel.closeDialog()
screenModel.deleteAccount()
}
)
}
is DialogState.NewAccount -> {
AccountSelectionDialog(
onDismiss = { viewModel.closeDialog() },
onDismiss = { screenModel.closeDialog() },
onValidate = { accountType ->
viewModel.closeDialog()
navigator.push(AccountCredentialsScreen(accountType, state.account))
screenModel.closeDialog()
navigator.push(AccountCredentialsScreen(accountType))
}
)
}
@ -160,17 +188,33 @@ object AccountTab : Tab {
is DialogState.ErrorList -> {
ErrorListDialog(
errorResult = dialog.errorResult,
onDismiss = { viewModel.closeDialog(dialog) }
onDismiss = { screenModel.closeDialog(dialog) }
)
}
is DialogState.Error -> {
ErrorDialog(
exception = dialog.exception,
onDismiss = { viewModel.closeDialog(dialog) }
onDismiss = { screenModel.closeDialog(dialog) }
)
}
is DialogState.OPMLChoice -> {
OPMLChoiceDialog(
onChoice = {
if (it == OPML.IMPORT) {
opmlImportLauncher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray())
} else {
opmlExportLauncher.launch("subscriptions.opml")
}
screenModel.closeDialog()
},
onDismiss = { screenModel.closeDialog() }
)
}
else -> {}
}
@ -192,7 +236,7 @@ object AccountTab : Tab {
},
floatingActionButton = {
FloatingActionButton(
onClick = { viewModel.openDialog(DialogState.NewAccount) }
onClick = { screenModel.openDialog(DialogState.NewAccount) }
) {
Icon(
painter = painterResource(id = R.drawable.ic_add_account),
@ -251,7 +295,7 @@ object AccountTab : Tab {
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
onClick = { launcher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray()) }
onClick = { screenModel.openDialog(DialogState.OPMLChoice) }
)
SelectableIconText(
@ -262,7 +306,7 @@ object AccountTab : Tab {
padding = MaterialTheme.spacing.mediumSpacing,
color = MaterialTheme.colorScheme.error,
tint = MaterialTheme.colorScheme.error,
onClick = { viewModel.openDialog(DialogState.DeleteAccount) }
onClick = { screenModel.openDialog(DialogState.DeleteAccount) }
)
}
}

View File

@ -0,0 +1,56 @@
package com.readrops.app.compose.account
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.readrops.app.compose.R
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.theme.spacing
enum class OPML {
IMPORT,
EXPORT
}
@Composable
fun OPMLChoiceDialog(
onChoice: (OPML) -> Unit,
onDismiss: () -> Unit
) {
BaseDialog(
title = stringResource(id = R.string.opml_import_export),
icon = painterResource(id = R.drawable.ic_import_export),
onDismiss = onDismiss
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clickable { onChoice(OPML.IMPORT) }
) {
Text(
text = stringResource(id = R.string.opml_import),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.clickable { onChoice(OPML.EXPORT) }
) {
Text(
text = stringResource(id = R.string.opml_export),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
)
}
}
}

View File

@ -1,41 +1,189 @@
package com.readrops.app.compose.account.credentials
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.readrops.app.compose.R
import com.readrops.app.compose.home.HomeScreen
import com.readrops.app.compose.util.components.AndroidScreen
import com.readrops.db.entities.account.Account
import com.readrops.app.compose.util.ErrorMessage
import com.readrops.app.compose.util.theme.ShortSpacer
import com.readrops.app.compose.util.theme.VeryLargeSpacer
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.entities.account.AccountType
import org.koin.core.parameter.parametersOf
class AccountCredentialsScreen(
private val accountType: AccountType,
private val account: Account? = null,
private val accountType: AccountType
) : AndroidScreen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel =
getScreenModel<AccountCredentialsScreenModel>(parameters = { parametersOf(accountType) })
Column {
Text(
text = "AccountCredentialsScreen"
)
val state by screenModel.state.collectAsStateWithLifecycle()
Spacer(modifier = Modifier.size(16.dp))
if (state.goToHomeScreen) {
navigator.replaceAll(HomeScreen())
}
Button(onClick = { navigator.replaceAll(HomeScreen()) }) {
Text(
text = "skip"
Box(
modifier = Modifier.imePadding()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
.padding(MaterialTheme.spacing.largeSpacing)
.verticalScroll(rememberScrollState())
) {
Image(
painter = painterResource(id = accountType.iconRes),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
ShortSpacer()
Text(
text = stringResource(id = accountType.typeName),
style = MaterialTheme.typography.headlineMedium
)
VeryLargeSpacer()
OutlinedTextField(
value = state.name,
onValueChange = { screenModel.onEvent(Event.NameEvent(it)) },
label = { Text(text = stringResource(id = R.string.account_name)) },
singleLine = true,
isError = state.isNameError,
supportingText = { Text(text = state.nameError?.errorText().orEmpty()) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth()
)
ShortSpacer()
OutlinedTextField(
value = state.url,
onValueChange = { screenModel.onEvent(Event.URLEvent(it)) },
label = { Text(text = stringResource(id = R.string.account_url)) },
singleLine = true,
isError = state.isUrlError,
supportingText = { Text(text = state.urlError?.errorText().orEmpty()) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth()
)
ShortSpacer()
OutlinedTextField(
value = state.login,
onValueChange = { screenModel.onEvent(Event.LoginEvent(it)) },
label = { Text(text = stringResource(id = R.string.login)) },
singleLine = true,
isError = state.isLoginError,
supportingText = { Text(text = state.passwordError?.errorText().orEmpty()) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth()
)
ShortSpacer()
OutlinedTextField(
value = state.password,
onValueChange = { screenModel.onEvent(Event.PasswordEvent(it)) },
label = { Text(text = stringResource(id = R.string.password)) },
trailingIcon = {
IconButton(
onClick = { screenModel.setPasswordVisibility(!state.isPasswordVisible) }
) {
Icon(
painter = painterResource(
id = if (state.isPasswordVisible) {
R.drawable.ic_visible_off
} else R.drawable.ic_visible
),
contentDescription = null
)
}
},
singleLine = true,
visualTransformation = if (state.isPasswordVisible)
VisualTransformation.None
else
PasswordVisualTransformation(),
isError = state.isPasswordError,
supportingText = { Text(text = state.passwordError?.errorText().orEmpty()) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
modifier = Modifier.fillMaxWidth()
)
ShortSpacer()
Button(
onClick = { screenModel.login() },
modifier = Modifier.fillMaxWidth()
) {
if (state.isLoginOnGoing) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp)
)
} else {
Text(text = stringResource(id = R.string.validate))
}
}
if (state.loginException != null) {
ShortSpacer()
Text(
text = ErrorMessage.get(exception = state.loginException!!),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}

View File

@ -0,0 +1,142 @@
package com.readrops.app.compose.account.credentials
import android.util.Patterns
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.app.compose.util.components.TextFieldError
import com.readrops.db.Database
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
class AccountCredentialsScreenModel(
private val accountType: AccountType,
private val database: Database,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : StateScreenModel<AccountCredentialsState>(AccountCredentialsState(name = accountType.name)),
KoinComponent {
fun onEvent(event: Event): Unit = with(mutableState) {
when (event) {
is Event.LoginEvent -> update { it.copy(login = event.value, loginError = null) }
is Event.NameEvent -> update { it.copy(name = event.value, nameError = null) }
is Event.PasswordEvent -> update {
it.copy(
password = event.value,
passwordError = null
)
}
is Event.URLEvent -> update { it.copy(url = event.value, urlError = null) }
}
}
fun setPasswordVisibility(isVisible: Boolean) {
mutableState.update { it.copy(isPasswordVisible = isVisible) }
}
fun login() {
if (validateFields()) {
mutableState.update { it.copy(isLoginOnGoing = true) }
with(state.value) {
val account = Account(
url = url,
accountName = name,
login = login,
password = password,
accountType = accountType,
isCurrentAccount = true
)
val repository = get<BaseRepository> { parametersOf(account) }
screenModelScope.launch(dispatcher) {
try {
repository.login(account)
} catch (e: Exception) {
mutableState.update {
it.copy(
loginException = e,
isLoginOnGoing = false
)
}
return@launch
}
database.newAccountDao().insert(account)
mutableState.update { it.copy(goToHomeScreen = true) }
}
}
}
}
private fun validateFields(): Boolean = with(mutableState.value) {
var validate = true
if (url.isEmpty()) {
mutableState.update { it.copy(urlError = TextFieldError.EmptyField) }
validate = false
}
if (name.isEmpty()) {
mutableState.update { it.copy(nameError = TextFieldError.EmptyField) }
validate = false
}
if (login.isEmpty()) {
mutableState.update { it.copy(loginError = TextFieldError.EmptyField) }
validate = false
}
if (password.isEmpty()) {
mutableState.update { it.copy(passwordError = TextFieldError.EmptyField) }
validate = false
}
if (url.isNotEmpty() && !Patterns.WEB_URL.matcher(url).matches()) {
mutableState.update { it.copy(urlError = TextFieldError.BadUrl) }
validate = false
}
return validate
}
}
data class AccountCredentialsState(
val url: String = "https://",
val urlError: TextFieldError? = null,
val name: String = "",
val nameError: TextFieldError? = null,
val login: String = "",
val loginError: TextFieldError? = null,
val password: String = "",
val passwordError: TextFieldError? = null,
val isPasswordVisible: Boolean = false,
val isLoginOnGoing: Boolean = false,
val goToHomeScreen: Boolean = false,
val loginException: Exception? = null
) {
val isUrlError = urlError != null
val isNameError = nameError != null
val isLoginError = loginError != null
val isPasswordError = passwordError != null
}
sealed class Event(val value: String) {
class URLEvent(value: String) : Event(value)
class NameEvent(value: String) : Event(value)
class LoginEvent(value: String) : Event(value)
class PasswordEvent(value: String) : Event(value)
}

View File

@ -1,80 +1,142 @@
package com.readrops.app.compose.account.selection
import androidx.compose.foundation.clickable
import android.graphics.drawable.AdaptiveIconDrawable
import android.os.Build
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.readrops.app.compose.R
import com.readrops.app.compose.account.credentials.AccountCredentialsScreen
import com.readrops.app.compose.home.HomeScreen
import com.readrops.app.compose.util.components.AndroidScreen
import com.readrops.app.compose.util.components.SelectableImageText
import com.readrops.app.compose.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.ShortSpacer
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.entities.account.AccountType
import org.koin.androidx.compose.getViewModel
class AccountSelectionScreen : AndroidScreen() {
@Composable
override fun Content() {
val viewModel = getViewModel<AccountSelectionViewModel>()
val navState by viewModel.navState.collectAsStateWithLifecycle()
val navigator = LocalNavigator.currentOrThrow
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize()
) {
Text(text = "Choose an account")
val screenModel = getScreenModel<AccountSelectionScreenModel>()
val state by screenModel.state.collectAsStateWithLifecycle()
Spacer(modifier = Modifier.size(8.dp))
AccountType.values().forEach { accountType ->
Row(
modifier = Modifier.clickable { viewModel.createAccount(accountType) }
) {
Icon(
painter = painterResource(id = R.drawable.ic_freshrss),
contentDescription = accountType.name,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.size(4.dp))
Text(text = accountType.name)
}
Spacer(modifier = Modifier.size(8.dp))
}
}
when (navState) {
is AccountSelectionViewModel.NavState.GoToHomeScreen -> {
when (state) {
is NavState.GoToHomeScreen -> {
// using replace makes the app crash due to a screen key conflict
navigator.replaceAll(HomeScreen())
}
is AccountSelectionViewModel.NavState.GoToAccountCredentialsScreen -> {
val accountType = (navState as AccountSelectionViewModel.NavState.GoToAccountCredentialsScreen).accountType
is NavState.GoToAccountCredentialsScreen -> {
val accountType = (state as NavState.GoToAccountCredentialsScreen).accountType
navigator.push(AccountCredentialsScreen(accountType))
viewModel.resetNavState()
screenModel.resetNavState()
}
else -> {}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(MaterialTheme.spacing.mediumSpacing)
) {
Image(
painter = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
contentDescription = null,
modifier = Modifier.size(64.dp)
)
ShortSpacer()
Text(
text = stringResource(id = R.string.app_name),
style = MaterialTheme.typography.headlineLarge
)
LargeSpacer()
Card {
Column(
modifier = Modifier.padding(MaterialTheme.spacing.largeSpacing)
) {
Text(
text = stringResource(id = R.string.choose_account),
style = MaterialTheme.typography.labelLarge
)
ShortSpacer()
AccountType.values().forEach { accountType ->
SelectableImageText(
image = adaptiveIconPainterResource(id = accountType.iconRes),
text = stringResource(id = accountType.typeName),
style = MaterialTheme.typography.titleLarge,
onClick = { screenModel.createAccount(accountType) },
spacing = MaterialTheme.spacing.shortSpacing,
)
}
SelectableImageText(
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
text = stringResource(id = R.string.opml_import),
style = MaterialTheme.typography.titleLarge,
onClick = { },
spacing = MaterialTheme.spacing.shortSpacing,
)
}
}
}
}
}
// from https://gist.github.com/tkuenneth/ddf598663f041dc79960cda503d14448
@Composable
fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter {
val res = LocalContext.current.resources
val theme = LocalContext.current.theme
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Android O supports adaptive icons, try loading this first (even though this is least likely to be the format).
val adaptiveIcon = ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable
if (adaptiveIcon != null) {
BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap())
} else {
// We couldn't load the drawable as an Adaptive Icon, just use painterResource
painterResource(id)
}
} else {
// We're not on Android O or later, just use painterResource
painterResource(id)
}
}

View File

@ -1,28 +1,23 @@
package com.readrops.app.compose.account.selection
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.db.Database
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class AccountSelectionViewModel(
class AccountSelectionScreenModel(
private val database: Database,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel(), KoinComponent {
private val _navState = MutableStateFlow<NavState>(NavState.Idle)
val navState = _navState.asStateFlow()
) : StateScreenModel<NavState>(NavState.Idle), KoinComponent {
fun accountExists(): Boolean {
val accountCount = runBlocking {
@ -36,12 +31,12 @@ class AccountSelectionViewModel(
if (accountType == AccountType.LOCAL) {
createLocalAccount()
} else {
_navState.update { NavState.GoToAccountCredentialsScreen(accountType) }
mutableState.update { NavState.GoToAccountCredentialsScreen(accountType) }
}
}
fun resetNavState() {
_navState.update { NavState.Idle }
mutableState.update { NavState.Idle }
}
private fun createLocalAccount() {
@ -53,17 +48,16 @@ class AccountSelectionViewModel(
isCurrentAccount = true
)
viewModelScope.launch(dispatcher) {
screenModelScope.launch(dispatcher) {
database.newAccountDao().insert(account)
_navState.update { NavState.GoToHomeScreen }
mutableState.update { NavState.GoToHomeScreen }
}
}
}
sealed class NavState {
object Idle : NavState()
object GoToHomeScreen : NavState()
class GoToAccountCredentialsScreen(val accountType: AccountType) : NavState()
}
sealed class NavState {
object Idle : NavState()
object GoToHomeScreen : NavState()
class GoToAccountCredentialsScreen(val accountType: AccountType) : NavState()
}

View File

@ -92,22 +92,16 @@ object FeedTab : Tab {
is DialogState.FeedSheet -> {
FeedModalBottomSheet(
feed = dialog.feed,
folder = dialog.folder,
onDismissRequest = { viewModel.closeDialog() },
onOpen = {
uriHandler.openUri(dialog.feed.siteUrl!!)
viewModel.closeDialog()
},
onUpdate = {
viewModel.openDialog(
DialogState.UpdateFeed(
dialog.feed,
dialog.folder
)
)
viewModel.openDialog(DialogState.UpdateFeed(dialog.feed, dialog.folder))
},
onUpdateColor = {},
onDelete = { viewModel.openDialog(DialogState.DeleteFeed(dialog.feed)) },
onDelete = { viewModel.openDialog(DialogState.DeleteFeed(dialog.feed)) }
)
}
@ -170,10 +164,12 @@ object FeedTab : Tab {
onClick = { viewModel.setFolderExpandState(state.areFoldersExpanded.not()) }
) {
Icon(
painter = painterResource(id = if (state.areFoldersExpanded)
R.drawable.ic_unfold_less
else
R.drawable.ic_unfold_more),
painter = painterResource(
id = if (state.areFoldersExpanded)
R.drawable.ic_unfold_less
else
R.drawable.ic_unfold_more
),
contentDescription = null
)
}
@ -282,7 +278,8 @@ object FeedTab : Tab {
}
is FolderAndFeedsState.ErrorState -> {
val exception = (state.foldersAndFeeds as FolderAndFeedsState.ErrorState).exception
val exception =
(state.foldersAndFeeds as FolderAndFeedsState.ErrorState).exception
ErrorMessage(exception = exception)
}
}

View File

@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
@ -31,13 +32,11 @@ import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.VeryShortSpacer
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedModalBottomSheet(
feed: Feed,
folder: Folder?,
onDismissRequest: () -> Unit,
onOpen: () -> Unit,
onUpdate: () -> Unit,
@ -58,6 +57,8 @@ fun FeedModalBottomSheet(
AsyncImage(
model = feed.iconUrl,
contentDescription = feed.name!!,
placeholder = painterResource(id = R.drawable.ic_rss_feed_grey),
error = painterResource(id = R.drawable.ic_rss_feed_grey),
modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing)
)
@ -71,12 +72,15 @@ fun FeedModalBottomSheet(
overflow = TextOverflow.Ellipsis
)
if (folder != null) {
if (feed.description != null) {
VeryShortSpacer()
Text(
text = folder.name!!,
style = MaterialTheme.typography.labelLarge
text = feed.description!!,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@ -0,0 +1,54 @@
package com.readrops.app.compose.item
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.readrops.app.compose.R
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.components.SelectableImageText
import com.readrops.app.compose.util.theme.spacing
enum class ItemImageChoice {
SHARE,
DOWNLOAD
}
@Composable
fun ItemImageDialog(
onChoice: (ItemImageChoice) -> Unit,
onDismiss: () -> Unit
) {
BaseDialog(
title = stringResource(id = R.string.image_options),
icon = painterResource(id = R.drawable.ic_image),
onDismiss = onDismiss
) {
Column {
SelectableImageText(
image = rememberVectorPainter(image = Icons.Default.Share),
text = stringResource(id = R.string.share_image),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.shortSpacing,
padding = MaterialTheme.spacing.shortSpacing,
imageSize = 16.dp,
onClick = { onChoice(ItemImageChoice.SHARE) }
)
SelectableImageText(
image = painterResource(id = R.drawable.ic_download),
text = stringResource(id = R.string.download_image),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.shortSpacing,
padding = MaterialTheme.spacing.shortSpacing,
imageSize = 16.dp,
onClick = { onChoice(ItemImageChoice.DOWNLOAD) }
)
}
}
}

View File

@ -1,13 +1,342 @@
package com.readrops.app.compose.item
import android.content.Intent
import android.net.Uri
import android.widget.RelativeLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.children
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.koin.getScreenModel
import coil.compose.AsyncImage
import com.readrops.api.utils.DateUtils
import com.readrops.app.compose.R
import com.readrops.app.compose.item.view.ItemNestedScrollView
import com.readrops.app.compose.item.view.ItemWebView
import com.readrops.app.compose.util.components.AndroidScreen
import com.readrops.app.compose.util.components.CenteredProgressIndicator
import com.readrops.app.compose.util.components.IconText
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.ShortSpacer
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.pojo.ItemWithFeed
import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt
class ItemScreen : AndroidScreen() {
class ItemScreen(
private val itemId: Int
) : AndroidScreen() {
@Composable
override fun Content() {
Text(text ="item screen")
val context = LocalContext.current
val density = LocalDensity.current
val screenModel =
getScreenModel<ItemScreenModel>(parameters = { parametersOf(itemId) })
val state by screenModel.state.collectAsStateWithLifecycle()
val primaryColor = MaterialTheme.colorScheme.primary
val backgroundColor = MaterialTheme.colorScheme.background
val onBackgroundColor = MaterialTheme.colorScheme.onBackground
val snackbarHostState = remember { SnackbarHostState() }
var isScrollable by remember { mutableStateOf(true) }
var refreshAndroidView by remember { mutableStateOf(true) }
// https://developer.android.com/develop/ui/compose/touch-input/pointer-input/scroll#parent-compose-child-view
val bottomBarHeight = 64.dp
val bottomBarHeightPx = with(density) { bottomBarHeight.roundToPx().toFloat() }
val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = bottomBarOffsetHeightPx.floatValue + delta
bottomBarOffsetHeightPx.floatValue = newOffset.coerceIn(-bottomBarHeightPx, 0f)
return Offset.Zero
}
}
}
if (state.imageDialogUrl != null) {
ItemImageDialog(
onChoice = {
if (it == ItemImageChoice.SHARE) {
screenModel.shareImage(state.imageDialogUrl!!, context)
} else {
screenModel.downloadImage(state.imageDialogUrl!!, context)
}
screenModel.closeImageDialog()
},
onDismiss = { screenModel.closeImageDialog() }
)
}
LaunchedEffect(state.fileDownloadedEvent) {
if (state.fileDownloadedEvent) {
snackbarHostState.showSnackbar("Downloaded file!")
}
}
if (state.itemWithFeed != null) {
val itemWithFeed = state.itemWithFeed!!
val item = itemWithFeed.item
val accentColor = if (itemWithFeed.bgColor != 0) {
Color(itemWithFeed.bgColor)
} else {
primaryColor
}
fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
}
Scaffold(
modifier = Modifier
.nestedScroll(nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
ItemScreenBottomBar(
state = state.bottomBarState,
accentColor = accentColor,
modifier = Modifier
.height(bottomBarHeight)
.offset {
if (isScrollable) {
IntOffset(
x = 0,
y = -bottomBarOffsetHeightPx.floatValue.roundToInt()
)
} else {
IntOffset(0, 0)
}
},
onShare = { screenModel.shareItem(item, context) },
onOpenUrl = { openUrl(item.link!!) },
onChangeReadState = {
screenModel.setItemReadState(item.apply { isRead = it })
},
onChangeStarState = {
screenModel.setItemStarState(item.apply { isStarred = it })
}
)
}
) { paddingValues ->
Box(
modifier = Modifier.padding(paddingValues)
) {
AndroidView(
factory = { context ->
ItemNestedScrollView(
context = context,
onGlobalLayoutListener = { viewHeight, contentHeight ->
isScrollable = viewHeight - contentHeight < 0
},
onUrlClick = { url -> openUrl(url) },
onImageLongPress = { url -> screenModel.openImageDialog(url) }
) {
if (item.imageLink != null) {
BackgroundTitle(itemWithFeed = itemWithFeed)
} else {
val tintColor = if (itemWithFeed.bgColor != 0) {
Color(itemWithFeed.bgColor)
} else {
MaterialTheme.colorScheme.onBackground
}
SimpleTitle(
itemWithFeed = itemWithFeed,
titleColor = tintColor,
accentColor = tintColor,
baseColor = MaterialTheme.colorScheme.onBackground,
bottomPadding = true
)
}
}
},
update = { nestedScrollView ->
if (refreshAndroidView) {
val relativeLayout =
(nestedScrollView.children.toList()[0] as RelativeLayout)
val webView = relativeLayout.children.toList()[1] as ItemWebView
webView.loadText(
itemWithFeed = itemWithFeed,
accentColor = accentColor,
backgroundColor = backgroundColor,
onBackgroundColor = onBackgroundColor
)
refreshAndroidView = false
}
}
)
}
}
} else {
CenteredProgressIndicator()
}
}
}
@Composable
fun BackgroundTitle(
itemWithFeed: ItemWithFeed,
) {
val onScrimColor = Color.White.copy(alpha = 0.85f)
val accentColor = if (itemWithFeed.bgColor != 0) {
Color(itemWithFeed.bgColor)
} else {
onScrimColor
}
Surface(
shape = RoundedCornerShape(
bottomStart = 24.dp,
bottomEnd = 24.dp
),
modifier = Modifier.height(IntrinsicSize.Max)
) {
AsyncImage(
model = itemWithFeed.item.imageLink,
contentDescription = null,
contentScale = ContentScale.Crop,
error = painterResource(id = R.drawable.ic_broken_image),
modifier = Modifier
.fillMaxSize()
)
Surface(
color = Color.Black.copy(alpha = 0.6f),
modifier = Modifier
.fillMaxSize()
) {
SimpleTitle(
itemWithFeed = itemWithFeed,
titleColor = onScrimColor,
accentColor = accentColor,
baseColor = onScrimColor,
bottomPadding = true
)
}
}
MediumSpacer()
}
@Composable
fun SimpleTitle(
itemWithFeed: ItemWithFeed,
titleColor: Color,
accentColor: Color,
baseColor: Color,
bottomPadding: Boolean,
) {
val item = itemWithFeed.item
val spacing = MaterialTheme.spacing.mediumSpacing
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(
start = spacing,
end = spacing,
top = spacing,
bottom = if (bottomPadding) spacing else 0.dp
)
) {
AsyncImage(
model = itemWithFeed.feedIconUrl,
contentDescription = itemWithFeed.feedName,
placeholder = painterResource(id = R.drawable.ic_rss_feed_grey),
error = painterResource(id = R.drawable.ic_rss_feed_grey),
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
)
ShortSpacer()
Text(
text = itemWithFeed.feedName,
style = MaterialTheme.typography.labelLarge,
color = baseColor,
textAlign = TextAlign.Center
)
ShortSpacer()
Text(
text = item.title!!,
style = MaterialTheme.typography.headlineMedium,
color = titleColor,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
if (item.author != null) {
ShortSpacer()
IconText(
icon = painterResource(id = R.drawable.ic_person),
text = itemWithFeed.item.author!!,
style = MaterialTheme.typography.labelMedium,
color = baseColor,
tint = accentColor
)
}
ShortSpacer()
val readTime =
if (item.readTime < 1) "< 1 min" else "${item.readTime.roundToInt()} mins"
Text(
text = "${DateUtils.formattedDate(item.pubDate!!)} · $readTime",
style = MaterialTheme.typography.labelMedium,
color = baseColor
)
}
}

View File

@ -0,0 +1,99 @@
package com.readrops.app.compose.item
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource
import com.readrops.app.compose.R
import com.readrops.app.compose.util.FeedColors
import com.readrops.app.compose.util.theme.spacing
data class BottomBarState(
val isRead: Boolean = false,
val isStarred: Boolean = false
)
@Composable
fun ItemScreenBottomBar(
state: BottomBarState,
accentColor: Color,
onShare: () -> Unit,
onOpenUrl: () -> Unit,
onChangeReadState: (Boolean) -> Unit,
onChangeStarState: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val tint = if (FeedColors.isColorDark(accentColor.toArgb()))
Color.White
else
Color.Black
Surface(
color = accentColor,
modifier = modifier.fillMaxWidth()
) {
Row(
horizontalArrangement = Arrangement.SpaceAround,
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
) {
IconButton(
onClick = { onChangeReadState(!state.isRead) }
) {
Icon(
painter = painterResource(
id = if (state.isRead)
R.drawable.ic_remove_done
else R.drawable.ic_done_all
),
tint = tint,
contentDescription = null
)
}
IconButton(
onClick = { onChangeStarState(!state.isStarred) }
) {
Icon(
painter = painterResource(
id = if (state.isStarred)
R.drawable.ic_star
else R.drawable.ic_star_outline
),
tint = tint,
contentDescription = null
)
}
IconButton(
onClick = onShare
) {
Icon(
imageVector = Icons.Default.Share,
tint = tint,
contentDescription = null
)
}
IconButton(
onClick = onOpenUrl
) {
Icon(
painter = painterResource(id = R.drawable.ic_open_in_browser),
tint = tint,
contentDescription = null
)
}
}
}
}

View File

@ -0,0 +1,162 @@
package com.readrops.app.compose.item
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Environment
import androidx.compose.runtime.Stable
import androidx.core.content.FileProvider
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import coil.imageLoader
import coil.request.ImageRequest
import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.db.Database
import com.readrops.db.entities.Item
import com.readrops.db.entities.account.Account
import com.readrops.db.pojo.ItemWithFeed
import com.readrops.db.queries.ItemSelectionQueryBuilder
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
import java.io.File
import java.io.FileOutputStream
class ItemScreenModel(
private val database: Database,
private val itemId: Int,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : StateScreenModel<ItemState>(ItemState()), KoinComponent {
//TODO Is this really the best solution?
lateinit var account: Account
lateinit var repository: BaseRepository
init {
screenModelScope.launch(dispatcher) {
database.newAccountDao().selectCurrentAccount()
.collect { account ->
this@ItemScreenModel.account = account!!
repository = get { parametersOf(account) }
val query = ItemSelectionQueryBuilder.buildQuery(
itemId = itemId,
separateState = account.config.useSeparateState
)
database.newItemDao().selectItemById(query)
.collect { itemWithFeed ->
mutableState.update {
it.copy(
itemWithFeed = itemWithFeed,
bottomBarState = BottomBarState(
isRead = itemWithFeed.item.isRead,
isStarred = itemWithFeed.item.isStarred
)
)
}
}
}
}
}
fun shareItem(item: Item, context: Context) {
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, item.link)
}.also {
context.startActivity(Intent.createChooser(it, null))
}
}
fun setItemReadState(item: Item) {
//TODO support separateState
screenModelScope.launch(dispatcher) {
repository.setItemReadState(item)
}
}
fun setItemStarState(item: Item) {
//TODO support separateState
screenModelScope.launch(dispatcher) {
repository.setItemStarState(item)
}
}
fun openImageDialog(url: String) = mutableState.update { it.copy(imageDialogUrl = url) }
fun closeImageDialog() = mutableState.update { it.copy(imageDialogUrl = null) }
fun downloadImage(url: String, context: Context) {
screenModelScope.launch(dispatcher) {
val bitmap = getImage(url, context)
val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
url.substringAfterLast('/')
)
FileOutputStream(target).apply {
bitmap.compress(Bitmap.CompressFormat.PNG, 90, this)
flush()
close()
}
mutableState.update { it.copy(fileDownloadedEvent = true) }
}
}
fun shareImage(url: String, context: Context) {
screenModelScope.launch(dispatcher) {
val bitmap = getImage(url, context)
val uri = saveImageInCache(bitmap, url, context)
Intent().apply {
action = Intent.ACTION_SEND
type = "image/*"
putExtra(Intent.EXTRA_STREAM, uri)
}.also {
context.startActivity(Intent.createChooser(it, null))
}
}
}
private suspend fun getImage(url: String, context: Context): Bitmap {
val downloader = context.imageLoader
return (downloader.execute(
ImageRequest.Builder(context)
.data(url)
.allowHardware(false)
.build()
).drawable as BitmapDrawable).bitmap
}
private fun saveImageInCache(bitmap: Bitmap, url: String, context: Context): Uri {
val imagesFolder = File(context.cacheDir.absolutePath, "images")
if (!imagesFolder.exists()) imagesFolder.mkdirs()
val image = File(imagesFolder, url.substringAfterLast('/'))
FileOutputStream(image).apply {
bitmap.compress(Bitmap.CompressFormat.PNG, 90, this)
flush()
close()
}
return FileProvider.getUriForFile(context, context.packageName, image)
}
}
@Stable
data class ItemState(
val itemWithFeed: ItemWithFeed? = null,
val bottomBarState: BottomBarState = BottomBarState(),
val imageDialogUrl: String? = null,
val fileDownloadedEvent: Boolean = false
)

View File

@ -0,0 +1,69 @@
package com.readrops.app.compose.item.view
import android.annotation.SuppressLint
import android.content.Context
import android.widget.RelativeLayout
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.ViewCompat
import androidx.core.widget.NestedScrollView
@SuppressLint("ResourceType", "ViewConstructor")
class ItemNestedScrollView(
context: Context,
onGlobalLayoutListener: (viewHeight: Int, contentHeight: Int) -> Unit,
onUrlClick: (String) -> Unit,
onImageLongPress: (String) -> Unit,
composeViewContent: @Composable () -> Unit
) : NestedScrollView(context) {
init {
addView(
RelativeLayout(context).apply {
ViewCompat.setNestedScrollingEnabled(this, true)
val composeView = ComposeView(context).apply {
id = 1
setContent {
composeViewContent()
}
}
val composeViewParams = RelativeLayout.LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.WRAP_CONTENT
)
composeViewParams.addRule(RelativeLayout.CENTER_HORIZONTAL)
composeView.layoutParams = composeViewParams
val webView = ItemWebView(
context = context,
onUrlClick = onUrlClick,
onImageLongPress = onImageLongPress
).apply {
id = 2
ViewCompat.setNestedScrollingEnabled(this, true)
}
val webViewParams = RelativeLayout.LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.WRAP_CONTENT
)
webViewParams.addRule(RelativeLayout.BELOW, composeView.id)
webView.layoutParams = webViewParams
addView(composeView)
addView(webView)
}
)
viewTreeObserver.addOnGlobalLayoutListener {
val viewHeight = this.measuredHeight
val contentHeight = getChildAt(0).height
onGlobalLayoutListener(viewHeight, contentHeight)
}
}
}

View File

@ -0,0 +1,85 @@
package com.readrops.app.compose.item.view
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.readrops.app.compose.R
import com.readrops.app.compose.util.Utils
import com.readrops.db.pojo.ItemWithFeed
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
@SuppressLint("SetJavaScriptEnabled", "ViewConstructor")
class ItemWebView(
context: Context,
onUrlClick: (String) -> Unit,
onImageLongPress: (String) -> Unit,
attrs: AttributeSet? = null,
) : WebView(context, attrs) {
init {
settings.javaScriptEnabled = true
settings.builtInZoomControls = true
settings.displayZoomControls = false
settings.setSupportZoom(false)
isVerticalScrollBarEnabled = false
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
url?.let { onUrlClick(it) }
return true
}
}
setOnLongClickListener {
val type = hitTestResult.type
if (type == HitTestResult.IMAGE_TYPE || type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
hitTestResult.extra?.let { onImageLongPress(it) }
}
false
}
}
fun loadText(
itemWithFeed: ItemWithFeed,
accentColor: Color,
backgroundColor: Color,
onBackgroundColor: Color
) {
val string = context.getString(
R.string.webview_html_template,
Utils.getCssColor(accentColor.toArgb()),
Utils.getCssColor(onBackgroundColor.toArgb()),
Utils.getCssColor(backgroundColor.toArgb()),
formatText(itemWithFeed)
)
loadDataWithBaseURL(
"file:///android_asset/",
string,
"text/html; charset=utf-8",
"UTF-8",
null
)
}
private fun formatText(itemWithFeed: ItemWithFeed): String {
return if (itemWithFeed.item.text != null) {
val document = if (itemWithFeed.websiteUrl != null) Jsoup.parse(
Parser.unescapeEntities(itemWithFeed.item.text, false), itemWithFeed.websiteUrl
) else Jsoup.parse(
Parser.unescapeEntities(itemWithFeed.item.text, false)
)
document.select("div,span").forEach { it.clearAttributes() }
return document.body().html()
} else {
""
}
}
}

View File

@ -17,7 +17,7 @@ abstract class ARepository(
/**
* This method is intended for remote accounts.
*/
abstract suspend fun login()
abstract suspend fun login(account: Account)
/**
* Global synchronization for the local account.

View File

@ -0,0 +1,71 @@
package com.readrops.app.compose.repositories
import com.readrops.api.services.Credentials
import com.readrops.api.services.SyncResult
import com.readrops.api.services.freshrss.NewFreshRSSDataSource
import com.readrops.api.utils.AuthInterceptor
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import com.readrops.db.entities.account.Account
import org.koin.core.component.KoinComponent
class FreshRSSRepository(
database: Database,
account: Account,
private val dataSource: NewFreshRSSDataSource,
) : BaseRepository(database, account), KoinComponent {
override suspend fun login(account: Account) {
val authInterceptor = getKoin().get<AuthInterceptor>()
authInterceptor.credentials = Credentials.toCredentials(account)
val authToken = dataSource.login(account.login!!, account.password!!)
account.token = authToken
// we got the authToken, time to provide it to make real calls
authInterceptor.credentials = Credentials.toCredentials(account)
val userInfo = dataSource.getUserInfo()
account.displayedName = userInfo.userName
}
override suspend fun synchronize(
selectedFeeds: List<Feed>,
onUpdate: (Feed) -> Unit
): Pair<SyncResult, ErrorResult> {
TODO("Not yet implemented")
}
override suspend fun synchronize(): SyncResult {
val syncResult = dataSource.sync().apply {
insertFolders(folders)
insertFeeds(feeds)
//insertItems(items)
}
return syncResult
}
override suspend fun insertNewFeeds(
newFeeds: List<Feed>,
onUpdate: (Feed) -> Unit
): ErrorResult {
TODO("Not yet implemented")
}
private suspend fun insertFeeds(feeds: List<Feed>) {
feeds.forEach { it.accountId = account.id }
database.newFeedDao().upsertFeeds(feeds, account)
}
private suspend fun insertFolders(folders: List<Folder>) {
folders.forEach { it.accountId = account.id }
database.newFolderDao().upsertFolders(folders, account)
}
private suspend fun insertItems(items: List<Item>) {
}
}

View File

@ -4,7 +4,7 @@ import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.filters.MainFilter
import com.readrops.db.queries.FoldersAndFeedsQueriesBuilder
import com.readrops.db.queries.FeedUnreadItemsCountQueryBuilder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@ -13,24 +13,23 @@ class GetFoldersWithFeeds(
) {
fun get(accountId: Int, mainFilter: MainFilter): Flow<Map<Folder?, List<Feed>>> {
val foldersAndFeedsQuery =
FoldersAndFeedsQueriesBuilder.buildFoldersAndFeedsQuery(accountId, mainFilter)
val feedsWithoutFolderQuery =
FoldersAndFeedsQueriesBuilder.buildFeedsWithoutFolderQuery(accountId, mainFilter)
val query = FeedUnreadItemsCountQueryBuilder.build(accountId, mainFilter)
return combine(
flow = database.newFolderDao()
.selectFoldersAndFeeds(foldersAndFeedsQuery),
flow2 = database.newFeedDao()
.selectFeedsWithoutFolder(feedsWithoutFolderQuery)
) { folders, feedsWithoutFolder ->
flow = database.newFolderDao().selectFoldersAndFeeds(accountId),
flow2 = database.newItemDao().selectFeedUnreadItemsCount(query)
) { folders, itemCounts ->
val foldersWithFeeds = folders.groupBy(
keySelector = {
Folder(
id = it.folderId,
name = it.folderName,
accountId = it.accountId
) as Folder?
if (it.folderId != null) {
Folder(
id = it.folderId!!,
name = it.folderName,
accountId = it.accountId
)
} else {
null
}
},
valueTransform = {
Feed(
@ -39,10 +38,12 @@ class GetFoldersWithFeeds(
iconUrl = it.feedIcon,
url = it.feedUrl,
siteUrl = it.feedSiteUrl,
unreadCount = it.unreadCount
description = it.feedDescription,
unreadCount = itemCounts[it.feedId] ?: 0
)
}
).mapValues { listEntry ->
// Empty folder case
if (listEntry.value.any { it.id == 0 }) {
listOf()
} else {
@ -50,24 +51,7 @@ class GetFoldersWithFeeds(
}
}
if (feedsWithoutFolder.isNotEmpty()) {
foldersWithFeeds + mapOf(
Pair(
null,
feedsWithoutFolder.map { feedWithoutFolder ->
Feed(
id = feedWithoutFolder.feedId,
name = feedWithoutFolder.feedName,
iconUrl = feedWithoutFolder.feedIcon,
url = feedWithoutFolder.feedUrl,
siteUrl = feedWithoutFolder.feedSiteUrl,
unreadCount = feedWithoutFolder.unreadCount
)
})
)
} else {
foldersWithFeeds
}
foldersWithFeeds.toSortedMap(nullsLast(Folder::compareTo))
}
}
}

View File

@ -1,5 +1,6 @@
package com.readrops.app.compose.repositories
import android.util.Log
import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.services.SyncResult
import com.readrops.api.utils.ApiUtils
@ -23,7 +24,7 @@ class LocalRSSRepository(
account: Account
) : BaseRepository(database, account), KoinComponent {
override suspend fun login() { /* useless here */
override suspend fun login(account: Account) { /* useless here */
}
override suspend fun synchronize(
@ -123,8 +124,12 @@ class LocalRSSRepository(
etag = null
lastModified = null
iconUrl = HtmlParser.getFaviconLink(siteUrl!!, get()).also { feedUrl ->
feedUrl?.let { backgroundColor = FeedColors.getFeedColor(it) }
try {
iconUrl = HtmlParser.getFaviconLink(siteUrl!!, get()).also { feedUrl ->
feedUrl?.let { backgroundColor = FeedColors.getFeedColor(it) }
}
} catch (e: Exception) {
Log.d("LocalRSSRepository", "insertFeed: ${e.message}")
}
id = database.newFeedDao().insert(this).toInt()

View File

@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.readrops.app.compose.R
import com.readrops.app.compose.repositories.ErrorResult
import com.readrops.app.compose.util.ErrorMessage
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.components.errorText
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.ShortSpacer
@ -44,7 +44,7 @@ fun ErrorListDialog(
modifier = Modifier.verticalScroll(scrollableState)
) {
for (error in errorResult.entries) {
Text(text = "${error.key.name}: ${errorText(error.value)}")
Text(text = "${error.key.name}: ${ErrorMessage.get(error.value)}")
ShortSpacer()
}

View File

@ -135,7 +135,7 @@ fun TimelineItem(
IconText(
icon = painterResource(id = R.drawable.ic_hourglass_empty),
text = if (itemWithFeed.item.readTime < 1) "> 1 min" else "${itemWithFeed.item.readTime.roundToInt()} mins",
text = if (itemWithFeed.item.readTime < 1) "< 1 min" else "${itemWithFeed.item.readTime.roundToInt()} mins",
style = MaterialTheme.typography.labelMedium
)
}

View File

@ -23,15 +23,18 @@ import com.readrops.db.queries.ItemsQueryBuilder
import com.readrops.db.queries.QueryFilters
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class TimelineScreenModel(
private val database: Database,
private val getFoldersWithFeeds: GetFoldersWithFeeds,
@ -76,56 +79,86 @@ class TimelineScreenModel(
)
}
}
}
}
screenModelScope.launch(dispatcher) {
accountEvent.flatMapConcat { database.newItemDao().selectUnreadNewItemsCount(it.id) }
.collectLatest { count ->
_timelineState.update {
it.copy(unreadNewItemsCount = count)
}
}
}
}
fun refreshTimeline() {
screenModelScope.launch(dispatcher) {
val selectedFeeds = if (currentAccount!!.isLocal) {
when (filters.value.subFilter) {
SubFilter.FEED -> listOf(
database.newFeedDao().selectFeed(filters.value.filterFeedId)
if (currentAccount!!.isLocal) {
refreshLocalAccount()
} else {
_timelineState.update { it.copy(isRefreshing = true) }
try {
repository?.synchronize()
} catch (e: Exception) {
// handle sync exceptions
}
_timelineState.update {
it.copy(
isRefreshing = false,
endSynchronizing = true
)
SubFilter.FOLDER -> database.newFeedDao()
.selectFeedsByFolder(filters.value.filterFolderId)
else -> listOf()
}
} else listOf()
_timelineState.update {
it.copy(
feedCount = 0,
feedMax = if (selectedFeeds.isNotEmpty())
selectedFeeds.size
else
database.newFeedDao().selectFeedCount(currentAccount!!.id)
)
}
}
}
_timelineState.update { it.copy(isRefreshing = true) }
val results = repository?.synchronize(
selectedFeeds = selectedFeeds,
onUpdate = { feed ->
_timelineState.update {
it.copy(
currentFeed = feed.name!!,
feedCount = it.feedCount + 1
)
}
}
private suspend fun refreshLocalAccount() {
val selectedFeeds = when (filters.value.subFilter) {
SubFilter.FEED -> listOf(
database.newFeedDao().selectFeed(filters.value.filterFeedId)
)
_timelineState.update {
it.copy(
isRefreshing = false,
endSynchronizing = true,
synchronizationErrors = if (results!!.second.isNotEmpty()) results.second else null
)
SubFilter.FOLDER -> database.newFeedDao()
.selectFeedsByFolder(filters.value.filterFolderId)
else -> listOf()
}
_timelineState.update {
it.copy(
feedCount = 0,
feedMax = if (selectedFeeds.isNotEmpty())
selectedFeeds.size
else
database.newFeedDao().selectFeedCount(currentAccount!!.id)
)
}
_timelineState.update { it.copy(isRefreshing = true) }
val results = repository?.synchronize(
selectedFeeds = selectedFeeds,
onUpdate = { feed ->
_timelineState.update {
it.copy(
currentFeed = feed.name!!,
feedCount = it.feedCount + 1
)
}
}
)
_timelineState.update {
it.copy(
isRefreshing = false,
endSynchronizing = true,
synchronizationErrors = if (results!!.second.isNotEmpty()) results.second else null
)
}
}
@ -289,6 +322,7 @@ data class TimelineState(
val isRefreshing: Boolean = false,
val isDrawerOpen: Boolean = false,
val currentFeed: String = "",
val unreadNewItemsCount: Int = 0,
val feedCount: Int = 0,
val feedMax: Int = 0,
val endSynchronizing: Boolean = false,

View File

@ -321,20 +321,24 @@ object TimelineTab : Tab {
count = items.itemCount,
key = items.itemKey { it.item.id },
) { itemCount ->
val itemWithFeed = items[itemCount]!!
val itemWithFeed = items[itemCount]
TimelineItem(
itemWithFeed = itemWithFeed,
onClick = {
viewModel.setItemRead(itemWithFeed.item)
navigator.push(ItemScreen())
},
onFavorite = { viewModel.updateStarState(itemWithFeed.item) },
onShare = {
viewModel.shareItem(itemWithFeed.item, context)
},
compactLayout = true
)
if (itemWithFeed != null) {
TimelineItem(
itemWithFeed = itemWithFeed,
onClick = {
viewModel.setItemRead(itemWithFeed.item)
navigator.push(ItemScreen(itemWithFeed.item.id))
},
onFavorite = {
viewModel.updateStarState(itemWithFeed.item)
},
onShare = {
viewModel.shareItem(itemWithFeed.item, context)
},
compactLayout = true
)
}
}
}

View File

@ -48,6 +48,7 @@ fun TimelineDrawer(
DrawerDefaultItems(
selectedItem = state.filters.mainFilter,
unreadNewItemsCount = state.unreadNewItemsCount,
onClick = { onClickDefaultItem(it) }
)
@ -62,7 +63,7 @@ fun TimelineDrawer(
label = {
Text(
text = folder.name!!,
maxLines = 1,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
@ -118,6 +119,7 @@ fun TimelineDrawer(
@Composable
fun DrawerDefaultItems(
selectedItem: MainFilter,
unreadNewItemsCount: Int,
onClick: (MainFilter) -> Unit,
) {
NavigationDrawerItem(
@ -134,7 +136,7 @@ fun DrawerDefaultItems(
)
NavigationDrawerItem(
label = { Text("New articles") },
label = { Text("New articles ($unreadNewItemsCount)") },
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_new),

View File

@ -0,0 +1,43 @@
package com.readrops.app.compose.util
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.readrops.api.utils.exceptions.HttpException
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.exceptions.UnknownFormatException
import com.readrops.app.compose.R
import java.io.IOException
import java.net.UnknownHostException
object ErrorMessage {
@Composable
fun get(exception: Exception) = when (exception) {
is HttpException -> getHttpMessage(exception)
is UnknownHostException -> stringResource(R.string.unreachable_url)
is NoSuchFileException -> stringResource(R.string.unable_open_file)
is IOException -> stringResource(R.string.network_failure, exception.message.orEmpty())
is ParseException, is UnknownFormatException -> stringResource(R.string.processing_feed_error)
else -> "${exception.javaClass.simpleName}: ${exception.message}"
}
@Composable
private fun getHttpMessage(exception: HttpException): String {
return when (exception.code) {
in 400..499 -> {
when (exception.code) {
400 -> stringResource(id = R.string.http_error_400)
401 -> stringResource(id = R.string.http_error_401)
403 -> stringResource(id = R.string.http_error_403)
404 -> stringResource(id = R.string.http_error_404)
else -> stringResource(id = R.string.http_error_4XX, exception.code)
}
}
in 500..599 -> {
stringResource(id = R.string.http_error_5XX, exception.code)
}
else -> stringResource(id = R.string.http_error, exception.code)
}
}
}

View File

@ -45,4 +45,7 @@ object FeedColors : KoinComponent {
val b = color shr 0 and 0xff
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
fun isColorDark(color: Int) = getColorLuma(color) < 130
}

View File

@ -1,11 +1,25 @@
package com.readrops.app.compose.util
import android.graphics.Color
import androidx.annotation.ColorInt
import java.util.Locale
object Utils {
private const val AVERAGE_WORDS_PER_MINUTE = 250
fun readTimeFromString(value: String): Double {
val nbWords = value.split("\\s+").size
val nbWords = value.split(Regex("\\s+")).size
return nbWords.toDouble() / AVERAGE_WORDS_PER_MINUTE
}
fun getCssColor(@ColorInt color: Int): String {
return String.format(
Locale.US, "rgba(%d,%d,%d,%.2f)",
Color.red(color),
Color.green(color),
Color.blue(color),
Color.alpha(color) / 255.0
)
}
}

View File

@ -4,12 +4,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.readrops.api.utils.exceptions.HttpException
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.exceptions.UnknownFormatException
import com.readrops.app.compose.R
import java.io.IOException
import java.net.UnknownHostException
import com.readrops.app.compose.util.ErrorMessage
@Composable
fun ErrorDialog(
@ -21,17 +17,7 @@ fun ErrorDialog(
icon = painterResource(id = R.drawable.ic_error),
onDismiss = onDismiss
) {
Text(text = errorText(exception = exception))
Text(text = ErrorMessage.get(exception = exception))
}
}
// TODO check compatibility with other accounts errors
@Composable
fun errorText(exception: Exception) = when (exception) {
is HttpException -> stringResource(id = R.string.unreachable_feed_http_error, exception.code.toString())
is UnknownHostException -> stringResource(R.string.unreachable_feed)
is NoSuchFileException -> stringResource(R.string.unable_open_file)
is IOException -> stringResource(R.string.network_failure, exception.message.orEmpty())
is ParseException, is UnknownFormatException -> stringResource(R.string.processing_feed_error)
else -> "${exception.javaClass.simpleName}: ${exception.message}"
}

View File

@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M21,5v6.59l-3,-3.01 -4,4.01 -4,-4 -4,4 -3,-3.01L3,5c0,-1.1 0.9,-2 2,-2h14c1.1,0 2,0.9 2,2zM18,11.42l3,3.01L21,19c0,1.1 -0.9,2 -2,2L5,21c-1.1,0 -2,-0.9 -2,-2v-6.58l3,2.99 4,-4 4,4 4,-3.99z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M5,20h14v-2H5V20zM19,9h-4V3H9v6H5l7,7L19,9z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
</vector>

View File

@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M1.79,12l5.58,5.59L5.96,19 0.37,13.41 1.79,12zM2.24,4.22L12.9,14.89l-1.28,1.28L7.44,12l-1.41,1.41L11.62,19l2.69,-2.69 4.89,4.89 1.41,-1.41L3.65,2.81 2.24,4.22zM17.14,13.49L23.62,7 22.2,5.59l-6.48,6.48 1.42,1.42zM17.96,7l-1.41,-1.41 -3.65,3.66 1.41,1.41L17.96,7z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
</vector>

View File

@ -155,6 +155,13 @@
<string name="unreachable_feed_http_error">Flux non attaignable, erreur HTTP %1$s</string>
<string name="network_failure">Erreur réseau: %1$s</string>
<string name="processing_feed_error">Erreur de traitement du flux</string>
<string name="unreachable_feed">Flux non attaignable</string>
<string name="unreachable_url">URL non attaignable</string>
<string name="unable_open_file">Impossible d\'ouvrir le fichier</string>
<string name="http_error_400">Erreur HTTP 400, veuillez vérifier l\'URL du serveur</string>
<string name="http_error_401">Erreur HTTP 401, veuillez vérifier vos identifiants</string>
<string name="http_error_403">Erreur HTTP 403, accès interdit</string>
<string name="http_error_404">Erreur HTTP 404, l\'URL n\'existe pas</string>
<string name="http_error_4XX">Erreur HTTP %1$s, veuillez vérifier vos champs</string>
<string name="http_error_5XX">Erreur HTTP %1$s, erreur serveur</string>
<string name="http_error">Erreur HTTP %1$s</string>
</resources>

View File

@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="webview_html_template" translatable="false"><![CDATA[
<!DOCTYPE html>
<html>
<head>
<meta charset=\"UTF-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
<style type=\"text/css\">
/*
%1$s: accent color
%2$s: text color
%3$s: background color
*/
@font-face {
font-family: Inter;
font-style: normal;
font-weight: normal;
font-display: swap;
src: url("fonts/Inter-Regular.woff2") format("woff2");
}
a:link, a:active, a:hover { color: %1$s }
* {
word-wrap: break-word !important;
line-height: 1.4;
}
body, blockquote, img, iframe, video, div, table, tbody, tr, td, blockquote, p, em, b, span {
max-width: 100%% !important;
}
body {
margin-left: 3%%;
margin-right: 3%%;
color: %2$s;
background-color: %3$s;
font-family: Inter !important;
}
figure, img, iframe, video {
margin: 0px;
display: inline;
height: auto;
max-width: 100%%;
}
iframe {
margin-top: 10px;
margin-bottom: 10px;
}
h1, p, div {
margin-top: 0px;
}
pre, code {
color: #FFFFFF;
background-color: #757575;
}
pre {
padding: 6px;
overflow: auto;
border-radius: 4px;
}
code {
padding: 2px;
border-radius: 2px;
}
pre > code {
padding: 0px;
border-radius: 0px;
}
figcaption {
font-style: italic;
font-size: small;
overflow: auto;
text-align: center;
padding-left: 3px;
padding-right: 3px;
padding-bottom: 10px;
}
blockquote {
width:60%%;
margin:30px auto;
padding:0px 3em;
border-left: 5px solid %1$s;
line-height: 1.6;
position: relative;
}
blockquote::before {
content: \"\\201C\";
color: %1$s;
font-size: 4em;
position: absolute;
left: 10px;
top: -10px;
}
blockquote::after{
content: \'\';
}
</style>
</head>
<body>
%4$s
</body>
</html>]]></string>
</resources>

View File

@ -161,6 +161,13 @@
<string name="unreachable_feed_http_error">Unreachable feed, HTTP error %1$s</string>
<string name="network_failure">Network failure: %1$s</string>
<string name="processing_feed_error">Processing feed error</string>
<string name="unreachable_feed">Unreachable feed</string>
<string name="unreachable_url">Unreachable URL</string>
<string name="unable_open_file">Unable to open file</string>
<string name="http_error_400">HTTP error 400, please check your server URL</string>
<string name="http_error_401">HTTP error 401, please check your credentials</string>
<string name="http_error_403">HTTP error 403, access forbidden</string>
<string name="http_error_404">HTTP error 404, URL not found</string>
<string name="http_error_4XX">HTTP error %1$s, please check your fields</string>
<string name="http_error_5XX">HTTP error %1$s, server error</string>
<string name="http_error">HTTP error %1$s</string>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<cache-path name="images" path="images/" />
</paths>
</resources>

View File

@ -34,8 +34,6 @@ ext {
minSdkVersion = 21
targetSdkVersion = 34
buildToolsVersion = "34.0.0"
koin_version = "3.3.3"
}
tasks.register('clean', Delete) {

View File

@ -80,20 +80,16 @@ dependencies {
kapt(libs.room.compiler)
androidTestImplementation(libs.room.testing)
implementation 'com.github.MatrixDev.Roomigrant:RoomigrantLib:0.3.4'
implementation 'com.github.MatrixDev.Roomigrant:RoomigrantLib:0.3.4' //TODO delete
kapt 'com.github.MatrixDev.Roomigrant:RoomigrantCompiler:0.3.4'
api 'androidx.paging:paging-runtime:2.1.2'
api 'androidx.paging:paging-common:2.1.2'
implementation(libs.bundles.paging)
api 'joda-time:joda-time:2.10.10'
api 'joda-time:joda-time:2.10.10' //TODO replace with java.time?
implementation(libs.bundles.koin)
testImplementation(libs.bundles.kointest)
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
api "androidx.paging:paging-runtime-ktx:3.2.1"
api "androidx.paging:paging-compose:3.2.1"
implementation(libs.bundles.coroutines)
androidTestImplementation(libs.coroutines.test)
}

View File

@ -0,0 +1,107 @@
package com.readrops.db.dao
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class NewFeedDaoTest {
private lateinit var database: Database
private lateinit var account: Account
@Before
fun before() = runTest {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
account = Account(accountType = AccountType.LOCAL).apply {
id = database.newAccountDao().insert(this).toInt()
}
repeat(2) { time ->
database.newFolderDao().insert(
Folder(
name = "Folder $time",
remoteId = "folder_$time",
accountId = account.id
)
)
}
repeat(3) { time ->
database.newFeedDao().insert(
Feed(
name = "Feed $time",
remoteId = "feed_$time",
remoteFolderId = "folder_${if (time % 2 == 0) 0 else 1}",
accountId = account.id
)
)
}
}
@After
fun after() {
database.close()
}
@Test
fun upsertFeedsTest() = runTest {
val newFeeds = listOf(
// updated feed (name + folder to null)
Feed(
name = "New Feed 0",
remoteId = "feed_0",
remoteFolderId = null,
accountId = account.id
),
// deleted feed
/*Feed(
name = "Feed 1",
remoteId = "feed_1",
remoteFolderId = "folder_1",
accountId = account.id
),*/
// updated feed (folder change)
Feed(
name = "Feed 2",
remoteId = "feed_2",
remoteFolderId = "folder_1",
accountId = account.id
),
// inserted feed
Feed(
name = "Feed 3",
remoteId = "feed_3",
remoteFolderId = "folder_0",
accountId = account.id
),
)
database.newFeedDao().upsertFeeds(newFeeds, account)
val allFeeds = database.newFeedDao().selectFeeds(account.id)
assertTrue(allFeeds.any { it.name == "New Feed 0" && it.folderId == null })
assertTrue(allFeeds.any { it.remoteId == "feed_2" && it.folderId == 2 })
assertFalse(allFeeds.any { it.remoteId == "feed_1" })
assertTrue(allFeeds.any { it.remoteId == "feed_3" && it.folderId == 1 })
}
}

View File

@ -0,0 +1,72 @@
package com.readrops.db.dao
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.readrops.db.Database
import com.readrops.db.entities.Folder
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class NewFolderDaoTest {
private lateinit var database: Database
private lateinit var account: Account
@Before
fun before() = runTest {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
account = Account(accountType = AccountType.LOCAL).apply {
id = database.newAccountDao().insert(this).toInt()
}
repeat(2) { time ->
database.newFolderDao().insert(
Folder(
name = "Folder $time",
remoteId = "folder_$time",
accountId = account.id
)
)
}
}
@After
fun after() {
database.close()
}
@Test
fun upsertFoldersTest() = runTest {
val remoteFolders = listOf(
// updated folder
Folder(name = "New Folder 0", remoteId = "folder_0", accountId = account.id),
// removed folder
//Folder(name = "Folder 1", remoteId = "folder_1"),
// new inserted Folder
Folder(name = "Folder 2", remoteId = "folder_2", accountId = account.id)
)
database.newFolderDao().upsertFolders(remoteFolders, account)
val allFolders = database.newFolderDao().selectFolders(account.id).first()
assertTrue(allFolders.any { it.name == "New Folder 0" })
assertFalse(allFolders.any { it.remoteId == "folder_1" })
assertTrue(allFolders.any { it.remoteId == "folder_2" })
}
}

View File

@ -3,9 +3,11 @@ package com.readrops.db.dao.newdao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.sqlite.db.SupportSQLiteQuery
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Item
import com.readrops.db.entities.account.Account
import com.readrops.db.pojo.FeedWithCount
import kotlinx.coroutines.flow.Flow
@ -35,4 +37,48 @@ abstract class NewFeedDao : NewBaseDao<Feed> {
@Query("Select count(*) from Feed Where account_id = :accountId")
abstract suspend fun selectFeedCount(accountId: Int): Int
@Query("Select remoteId From Feed Where account_id = :accountId")
abstract suspend fun selectFeedRemoteIds(accountId: Int): MutableList<String>
@Query("Select id From Folder Where remoteId = :remoteId And account_id = :accountId")
abstract suspend fun selectRemoteFolderLocalId(remoteId: String, accountId: Int): Int
@Query("Update Feed set name = :name, folder_id = :folderId Where remoteId = :remoteFeedId And account_id = :accountId")
abstract fun updateFeedNameAndFolder(remoteFeedId: String, accountId: Int, name: String, folderId: Int?)
@Query("Delete from Feed Where remoteId in (:ids) And account_id = :accountId")
abstract fun deleteByIds(ids: List<String>, accountId: Int)
/**
* Insert, update and delete feeds by account
*
* @param feeds feeds to insert or update
* @param account owner of the feeds
* @return the list of the inserted feeds ids
*/
@Transaction
open suspend fun upsertFeeds(feeds: List<Feed>, account: Account): List<Long> {
val localFeedIds = selectFeedRemoteIds(account.id)
val feedsToInsert = feeds.filter { feed -> localFeedIds.none { localFeedId -> feed.remoteId == localFeedId } }
val feedsToDelete = localFeedIds.filter { localFeedId -> feeds.none { feed -> localFeedId == feed.remoteId } }
feeds.forEach { feed ->
feed.folderId = if (feed.remoteFolderId == null) {
null
} else {
selectRemoteFolderLocalId(feed.remoteFolderId!!, account.id)
}
// works only for already existing feeds
updateFeedNameAndFolder(feed.remoteId!!, account.id, feed.name!!, feed.folderId)
}
if (feedsToDelete.isNotEmpty()) {
deleteByIds(feedsToDelete, account.id)
}
return insert(feedsToInsert)
}
}

View File

@ -2,23 +2,67 @@ package com.readrops.db.dao.newdao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RawQuery
import androidx.sqlite.db.SupportSQLiteQuery
import com.readrops.db.entities.Feed
import androidx.room.Transaction
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import com.readrops.db.entities.account.Account
import com.readrops.db.pojo.FolderWithFeed
import kotlinx.coroutines.flow.Flow
@Dao
interface NewFolderDao : NewBaseDao<Folder> {
@RawQuery(observedEntities = [Folder::class, Feed::class, Item::class])
fun selectFoldersAndFeeds(query: SupportSQLiteQuery): Flow<List<FolderWithFeed>>
@Query("""
Select Feed.id As feedId, Feed.name As feedName, Feed.icon_url As feedIcon, Feed.url As feedUrl,
Feed.siteUrl As feedSiteUrl, Feed.description as feedDescription,
Folder.id As folderId, Folder.name As folderName, Feed.account_id as accountId
From Feed Left Join Folder On Folder.id = Feed.folder_id
Where Feed.folder_id is NULL OR Feed.folder_id is NOT NULL And Feed.id is NULL Or Feed.id is NOT NULL And Feed.account_id = :accountId Group By Feed.id
UNION ALL
Select Feed.id As feedId, Feed.name As feedName, Feed.icon_url As feedIcon, Feed.url As feedUrl,
Feed.siteUrl As feedSiteUrl, Feed.description as feedDescription,
Folder.id As folderId, Folder.name As folderName, Folder.account_id as accountId
From Folder Left Join Feed On Folder.id = Feed.folder_id
Where Feed.id is NULL And Folder.account_id = :accountId
""")
fun selectFoldersAndFeeds(accountId: Int): Flow<List<FolderWithFeed>>
@Query("Select * From Folder Where account_id = :accountId")
fun selectFolders(accountId: Int): Flow<List<Folder>>
@Query("Select * From Folder Where name = :name And account_id = :accountId")
suspend fun selectFolderByName(name: String, accountId: Int): Folder?
@Query("Select remoteId From Folder Where account_id = :accountId")
suspend fun selectFolderRemoteIds(accountId: Int): List<String>
@Query("Update Folder set name = :name Where remoteId = :remoteId And account_id = :accountId")
suspend fun updateFolderName(name: String, remoteId: String, accountId: Int)
@Query("Delete From Folder Where remoteId in (:ids) And account_id = :accountId")
suspend fun deleteByIds(ids: List<String>, accountId: Int)
/**
* Insert, update and delete folders
*
* @param folders folders to insert or update
* @param account owner of the feeds
* @return the list of the inserted folders ids
*/
@Transaction
suspend fun upsertFolders(folders: List<Folder>, account: Account): List<Long> {
val localFolderIds = selectFolderRemoteIds(account.id)
val foldersToInsert = folders.filter { folder -> localFolderIds.none { localFolderId -> folder.remoteId == localFolderId } }
val foldersToDelete = localFolderIds.filter { localFolderId -> folders.none { folder -> localFolderId == folder.remoteId } }
// folders to update
folders.filter { folder -> localFolderIds.any { localFolderId -> folder.remoteId == localFolderId} }
.forEach { updateFolderName(it.name!!, it.remoteId!!, account.id) }
if (foldersToDelete.isNotEmpty()) {
deleteByIds(foldersToDelete, account.id)
}
return insert(foldersToInsert)
}
}

View File

@ -2,6 +2,7 @@ package com.readrops.db.dao.newdao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.MapColumn
import androidx.room.Query
import androidx.room.RawQuery
import androidx.sqlite.db.SupportSQLiteQuery
@ -10,6 +11,7 @@ import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import com.readrops.db.entities.ItemState
import com.readrops.db.pojo.ItemWithFeed
import kotlinx.coroutines.flow.Flow
@Dao
abstract class NewItemDao : NewBaseDao<Item> {
@ -17,6 +19,9 @@ abstract class NewItemDao : NewBaseDao<Item> {
@RawQuery(observedEntities = [Item::class, Feed::class, Folder::class, ItemState::class])
abstract fun selectAll(query: SupportSQLiteQuery): PagingSource<Int, ItemWithFeed>
@RawQuery(observedEntities = [Item::class, ItemState::class])
abstract fun selectItemById(query: SupportSQLiteQuery): Flow<ItemWithFeed>
@Query("Update Item Set read = :read Where id = :itemId")
abstract suspend fun updateReadState(itemId: Int, read: Boolean)
@ -41,4 +46,12 @@ abstract class NewItemDao : NewBaseDao<Item> {
@Query("Update Item set read = 1 Where feed_id IN (Select Feed.id From Feed Inner Join Folder " +
"On Feed.folder_id = Folder.id Where Folder.id = :folderId And Folder.account_id = :accountId)")
abstract suspend fun setAllItemsReadByFolder(folderId: Int, accountId: Int)
@Query("Select count(*) From Item Inner Join Feed On Item.feed_id = Feed.id Where read = 0 and account_id = :accountId " +
"And DateTime(Round(Item.pub_date / 1000), 'unixepoch') Between DateTime(DateTime(\"now\"), \"-24 hour\") And DateTime(\"now\")")
abstract fun selectUnreadNewItemsCount(accountId: Int): Flow<Int>
@RawQuery(observedEntities = [Item::class])
abstract fun selectFeedUnreadItemsCount(query: SupportSQLiteQuery):
Flow<Map<@MapColumn(columnName = "feed_id") Int, @MapColumn(columnName = "item_count") Int>>
}

View File

@ -13,14 +13,14 @@ data class FeedWithFolder(
) : Parcelable
data class FolderWithFeed(
val folderId: Int,
val folderName: String,
val folderId: Int?,
val folderName: String?,
val feedId: Int = 0,
val feedName: String? = null,
val feedIcon: String? = null,
val feedUrl: String? = null,
val feedDescription: String? = null,
val feedSiteUrl: String? = null,
val unreadCount: Int = 0,
val accountId: Int = 0
)
@ -30,6 +30,7 @@ data class FeedWithCount(
val feedIcon: String? = null,
val feedUrl: String? = null,
val feedSiteUrl: String? = null,
val feedDescription: String? = null,
val unreadCount: Int = 0,
val accountId: Int = 0
)

View File

@ -0,0 +1,25 @@
package com.readrops.db.queries
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import com.readrops.db.filters.MainFilter
import org.intellij.lang.annotations.Language
object FeedUnreadItemsCountQueryBuilder {
fun build(accountId: Int, mainFilter: MainFilter): SupportSQLiteQuery {
val filter = when (mainFilter) {
MainFilter.STARS -> "And Item.starred = 1"
MainFilter.NEW -> "And DateTime(Round(Item.pub_date / 1000), 'unixepoch') Between DateTime(DateTime(\"now\"), \"-24 hour\") And DateTime(\"now\") "
else -> ""
}
@Language("SQL")
val query = SimpleSQLiteQuery("""
Select feed_id, count(*) AS item_count From Item Inner Join Feed On Feed.id = Item.feed_id
Where read = 0 And account_id = $accountId $filter Group By feed_id
""".trimIndent())
return query
}
}

View File

@ -1,51 +0,0 @@
package com.readrops.db.queries
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import com.readrops.db.filters.MainFilter
import org.intellij.lang.annotations.Language
object FoldersAndFeedsQueriesBuilder {
fun buildFoldersAndFeedsQuery(accountId: Int, mainFilter: MainFilter): SupportSQLiteQuery {
val filter = when (mainFilter) {
MainFilter.STARS -> "And Item.starred = 1"
MainFilter.NEW -> "And DateTime(Round(Item.pub_date / 1000), 'unixepoch') Between DateTime(DateTime(\"now\"), \"-24 hour\") And DateTime(\"now\") "
else -> ""
}
@Language("SQL")
val query = SimpleSQLiteQuery("""
With main As (Select Folder.id As folderId, Folder.name As folderName, Feed.id As feedId,
Feed.name As feedName, Feed.icon_url As feedIcon, Feed.url As feedUrl, Feed.siteUrl As feedSiteUrl,
Folder.account_id As accountId, Item.read as itemRead
From Folder Left Join Feed On Folder.id = Feed.folder_id Left Join Item On Item.feed_id = Feed.id
Where Feed.folder_id is NULL OR Feed.folder_id is NOT NULL And Feed.account_id = $accountId $filter)
Select folderId, folderName, feedId, feedName, feedIcon, feedUrl, feedSiteUrl, accountId,
(Select count(*) From main Where (itemRead = 0)) as unreadCount
From main Group by feedId, folderId Order By folderName, feedName
""".trimIndent())
return query
}
fun buildFeedsWithoutFolderQuery(accountId: Int, mainFilter: MainFilter): SupportSQLiteQuery {
val filter = when (mainFilter) {
MainFilter.STARS -> "And Item.starred = 1 "
MainFilter.NEW -> "And DateTime(Round(Item.pub_date / 1000), 'unixepoch') Between DateTime(DateTime(\"now\"), \"-24 hour\") And DateTime(\"now\") "
else -> ""
}
@Language("SQL")
val query = SimpleSQLiteQuery("""
With main As (Select Feed.id As feedId, Feed.name As feedName, Feed.icon_url As feedIcon,
Feed.url As feedUrl, Feed.siteUrl As feedSiteUrl, Feed.account_id As accountId, Item.read As itemRead
From Feed Left Join Item On Feed.id = Item.feed_id Where Feed.folder_id is Null And Feed.account_id = $accountId $filter)
Select feedId, feedName, feedIcon, feedUrl, feedSiteUrl, accountId,
(Select count(*) From main Where (itemRead = 0)) as unreadCount From main Group by feedId Order By feedName
""".trimIndent())
return query
}
}

View File

@ -5,16 +5,21 @@ import androidx.sqlite.db.SupportSQLiteQueryBuilder
object ItemSelectionQueryBuilder {
private val COLUMNS = arrayOf("Item.id", "Item.remoteId", "title", "Item.description", "content",
"link", "pub_date", "image_link", "author", "Item.read", "text_color",
"background_color", "read_time", "Feed.name", "Feed.id as feedId", "siteUrl",
"Folder.id as folder_id", "Folder.name as folder_name")
private val COLUMNS = arrayOf(
"Item.id", "Item.remoteId", "title", "Item.description", "content",
"link", "pub_date", "image_link", "author", "Item.read", "text_color", "icon_url",
"background_color", "read_time", "Feed.name", "Feed.id as feedId", "siteUrl",
"Folder.id as folder_id", "Folder.name as folder_name"
)
private val SEPARATE_STATE_COLUMNS = arrayOf("case When ItemState.starred = 1 Then 1 else 0 End starred")
private val SEPARATE_STATE_COLUMNS =
arrayOf("case When ItemState.starred = 1 Then 1 else 0 End starred")
private const val JOIN = "Item Inner Join Feed On Item.feed_id = Feed.id Left Join Folder on Folder.id = Feed.folder_id"
private const val JOIN =
"Item Inner Join Feed On Item.feed_id = Feed.id Left Join Folder on Folder.id = Feed.folder_id"
private const val SEPARATE_STATE_JOIN = " Left Join ItemState On ItemState.remote_id = Item.remoteId"
private const val SEPARATE_STATE_JOIN =
" Left Join ItemState On ItemState.remote_id = Item.remoteId"
/**
* @param separateState Indicates if item state must be retrieved from ItemState table
@ -22,7 +27,8 @@ object ItemSelectionQueryBuilder {
@JvmStatic
fun buildQuery(itemId: Int, separateState: Boolean): SupportSQLiteQuery {
val tables = if (separateState) JOIN + SEPARATE_STATE_JOIN else JOIN
val columns = if (separateState) COLUMNS.plus(SEPARATE_STATE_COLUMNS) else COLUMNS.plus("starred")
val columns =
if (separateState) COLUMNS.plus(SEPARATE_STATE_COLUMNS) else COLUMNS.plus("starred")
return SupportSQLiteQueryBuilder.builder(tables).run {
columns(columns)

View File

@ -6,6 +6,8 @@ coil = "2.4.0"
coroutines = "1.8.0"
room = "2.6.1"
koin-bom = "3.5.0"
paging = "3.2.1"
okhttp = "4.11.0"
[libraries]
bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
@ -52,6 +54,17 @@ koin-android-compat = { module = "io.insert-koin:koin-android-compat" } #TODO re
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin-bom" }
koin-test-junit4 = { module = "io.insert-koin:koin-test-junit4", version.ref = "koin-bom" }
paging-runtime = { module = "androidx.paging:paging-runtime-ktx", version.ref = "paging" }
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-mockserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
konsumexml = "com.gitlab.mvysny.konsume-xml:konsume-xml:1.1"
kotlinxmlbuilder = "org.redundent:kotlin-xml-builder:1.7.3" #TODO update this
jdk-desugar = "com.android.tools:desugar_jdk_libs:2.0.4"
[bundles]
compose = ["bom", "compose-foundation", "compose-runtime", "compose-animation",
"compose-ui", "compose-ui-tooling", "compose-ui-tooling-preview", "compose-material3"]
@ -62,4 +75,5 @@ coil = ["coil-core", "coil-compose"]
coroutines = ["coroutines-core", "coroutines-android"]
room = ["room-runtime", "room-ktx", "room-rxjava2", "room-paging"]
koin = ["koin-bom", "koin-core", "koin-android", "koin-androidx-compose", "koin-android-compat"]
kointest = ["koin-test", "koin-test-junit4"]
kointest = ["koin-test", "koin-test-junit4"]
paging = ["paging-runtime", "paging-compose"]