mirror of https://github.com/readrops/Readrops.git
Compare commits
35 Commits
9c34d3f126
...
d6ede0d2ce
Author | SHA1 | Date |
---|---|---|
Alexandre Alapetite | d6ede0d2ce | |
Shinokuni | 36cdf84b34 | |
Shinokuni | 0d69cfd66d | |
Shinokuni | 8071f0b477 | |
Shinokuni | 052c83cb35 | |
Shinokuni | e9536e99ed | |
Shinokuni | f14ed7f331 | |
Shinokuni | a7c0749641 | |
Shinokuni | cc7b874ef5 | |
Shinokuni | 2c105f596a | |
Shinokuni | 9f87077945 | |
Shinokuni | a3ffde0d73 | |
Shinokuni | 6893e9a199 | |
Shinokuni | c55a9dc5e4 | |
Shinokuni | 45dc199ea2 | |
Shinokuni | 841b56e7e5 | |
Shinokuni | 8566b55e3f | |
Shinokuni | ea51df49bc | |
Shinokuni | 99ff159434 | |
Shinokuni | c115a1edcc | |
Shinokuni | afbf8129ca | |
Shinokuni | eeb054f068 | |
Shinokuni | d486bd92f9 | |
Shinokuni | 7b644cbc97 | |
Shinokuni | 02a3f82b72 | |
Shinokuni | 91378f0a54 | |
Shinokuni | bf7ac41d6e | |
Shinokuni | c071426bbd | |
Shinokuni | da51f504e4 | |
Shinokuni | 16e70519e4 | |
Shinokuni | 0ccb4aa9c8 | |
Shinokuni | 8a5c22d144 | |
Alexandre Alapetite | 9c73738cab | |
Alexandre Alapetite | d5d8b16148 | |
Alexandre Alapetite | 7e5c0268ea |
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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")) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
|
|||
when (tagName) {
|
||||
"channel" -> parseChannel(this, feed)
|
||||
"item" -> items += itemAdapter.fromXml(this)
|
||||
else -> skipContents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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.
Binary file not shown.
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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>) {
|
||||
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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}"
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<paths>
|
||||
<cache-path name="images" path="images/" />
|
||||
</paths>
|
||||
</resources>
|
|
@ -34,8 +34,6 @@ ext {
|
|||
minSdkVersion = 21
|
||||
targetSdkVersion = 34
|
||||
buildToolsVersion = "34.0.0"
|
||||
|
||||
koin_version = "3.3.3"
|
||||
}
|
||||
|
||||
tasks.register('clean', Delete) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
|
@ -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" })
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>>
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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"]
|
Loading…
Reference in New Issue