mirror of https://github.com/readrops/Readrops.git
Merge branch 'develop' into pr/Alkarex/163
This commit is contained in:
commit
d5d8b16148
|
@ -15,17 +15,18 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: set up JDK 1.8
|
||||
uses: actions/setup-java@v1
|
||||
- name: set up JDK 1.17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 1.8
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- name: Android Emulator Runner
|
||||
uses: ReactiveCircus/android-emulator-runner@v2.20.0
|
||||
uses: ReactiveCircus/android-emulator-runner@v2.28.0
|
||||
with:
|
||||
api-level: 29
|
||||
script: ./gradlew clean build connectedCheck jacocoFullReport
|
||||
- uses: codecov/codecov-action@v2.1.0
|
||||
with:
|
||||
files: ./build/reports/jacoco/jacocoFullReport.xml
|
||||
fail_ci_if_error: true
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
|
@ -12,9 +12,6 @@ android {
|
|||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/androidTest/assets".toString())
|
||||
|
@ -33,12 +30,17 @@ android {
|
|||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
jvmTarget = '17'
|
||||
freeCompilerArgs = ["-Xstring-concat=inline"]
|
||||
}
|
||||
lint {
|
||||
abortOnError false
|
||||
}
|
||||
namespace 'com.readrops.api'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -58,7 +60,7 @@ dependencies {
|
|||
implementation 'com.gitlab.mvysny.konsume-xml:konsume-xml:1.0'
|
||||
implementation 'org.redundent:kotlin-xml-builder:1.7.3'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
|
||||
api 'com.squareup.okhttp3:okhttp:4.9.1'
|
||||
|
||||
implementation('com.squareup.retrofit2:retrofit:2.9.0') {
|
||||
exclude group: 'com.squareup.okhttp3', module: 'okhttp3'
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.readrops.api">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- for tests only -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.readrops.api">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package com.readrops.api
|
||||
|
||||
import com.chimerapps.niddler.interceptor.okhttp.NiddlerOkHttpInterceptor
|
||||
import com.readrops.api.localfeed.LocalRSSDataSource
|
||||
import com.readrops.api.services.Credentials
|
||||
import com.readrops.api.services.freshrss.FreshRSSDataSource
|
||||
|
@ -12,6 +11,7 @@ import com.readrops.api.services.nextcloudnews.adapters.NextNewsFeedsAdapter
|
|||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFoldersAdapter
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsItemsAdapter
|
||||
import com.readrops.api.utils.AuthInterceptor
|
||||
import com.readrops.api.utils.ErrorInterceptor
|
||||
import com.readrops.db.entities.Item
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
|
@ -30,12 +30,15 @@ val apiModule = module {
|
|||
.callTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.addInterceptor(get<AuthInterceptor>())
|
||||
.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
|
||||
.addInterceptor(get<ErrorInterceptor>())
|
||||
//.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
|
||||
.build()
|
||||
}
|
||||
|
||||
single { AuthInterceptor() }
|
||||
|
||||
single { ErrorInterceptor() }
|
||||
|
||||
single { LocalRSSDataSource(get()) }
|
||||
|
||||
//region freshrss
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
package com.readrops.api.localfeed
|
||||
|
||||
import android.accounts.NetworkErrorException
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.gitlab.mvysny.konsumexml.Konsumer
|
||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||
import com.readrops.api.localfeed.json.JSONFeedAdapter
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.api.utils.AuthInterceptor
|
||||
import com.readrops.api.utils.exceptions.HttpException
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.exceptions.UnknownFormatException
|
||||
import com.readrops.db.entities.Feed
|
||||
|
@ -21,7 +21,6 @@ import okio.Buffer
|
|||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import java.io.IOException
|
||||
import java.lang.Exception
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
||||
|
@ -32,7 +31,7 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
|||
* @param headers request headers
|
||||
* @return a Feed object with its items
|
||||
*/
|
||||
@Throws(ParseException::class, UnknownFormatException::class, NetworkErrorException::class, IOException::class)
|
||||
@Throws(ParseException::class, UnknownFormatException::class, HttpException::class, IOException::class)
|
||||
@WorkerThread
|
||||
fun queryRSSResource(url: String, headers: Headers?): Pair<Feed, List<Item>>? {
|
||||
get<AuthInterceptor>().credentials = null
|
||||
|
@ -46,7 +45,7 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
|||
pair
|
||||
}
|
||||
response.code == HttpURLConnection.HTTP_NOT_MODIFIED -> null
|
||||
else -> throw NetworkErrorException("$url returned ${response.code} code : ${response.message}")
|
||||
else -> throw HttpException(response)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -74,7 +73,8 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
|||
val rootKonsumer = nextElement(LocalRSSHelper.RSS_ROOT_NAMES)
|
||||
rootKonsumer?.let { type = LocalRSSHelper.guessRSSType(rootKonsumer) }
|
||||
} catch (e: Exception) {
|
||||
throw UnknownFormatException(e.message)
|
||||
close()
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
package com.readrops.api.services.freshrss
|
||||
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
|
||||
import com.readrops.db.entities.Item
|
||||
import okhttp3.MultipartBody
|
||||
import java.io.StringReader
|
||||
import java.util.Properties
|
||||
|
||||
class NewFreshRSSDataSource(private val service: NewFreshRSSService) {
|
||||
|
||||
suspend fun login(login: String, password: String): String {
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("Email", login)
|
||||
.addFormDataPart("Passwd", password)
|
||||
.build()
|
||||
|
||||
val response = service.login(requestBody)
|
||||
|
||||
val properties = Properties()
|
||||
properties.load(StringReader(response.string()))
|
||||
|
||||
response.close()
|
||||
return properties.getProperty("Auth")
|
||||
}
|
||||
|
||||
suspend fun getWriteToken(): String = service.getWriteToken().string()
|
||||
|
||||
suspend fun getUserInfo(): FreshRSSUserInfo = service.userInfo()
|
||||
|
||||
suspend fun sync() {
|
||||
|
||||
}
|
||||
|
||||
suspend fun getFolders() = service.getFolders()
|
||||
|
||||
suspend fun getFeeds() = service.getFeeds()
|
||||
|
||||
suspend fun getItems(excludeTargets: List<String>, max: Int, lastModified: Long): List<Item> {
|
||||
return service.getItems(excludeTargets, max, lastModified)
|
||||
}
|
||||
|
||||
suspend fun getStarredItems(max: Int) = service.getStarredItems(max)
|
||||
|
||||
suspend fun getItemsIds(excludeTarget: String, includeTarget: String, max: Int): List<String> {
|
||||
return service.getItemsIds(excludeTarget, includeTarget, max)
|
||||
}
|
||||
|
||||
private suspend fun setItemsReadState(read: Boolean, itemIds: List<String>, token: String) {
|
||||
return if (read) {
|
||||
service.setItemsState(token, GOOGLE_READ, null, itemIds)
|
||||
} else {
|
||||
service.setItemsState(token, null, GOOGLE_READ, itemIds)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setItemStarState(starred: Boolean, itemIds: List<String>, token: String) {
|
||||
return if (starred) {
|
||||
service.setItemsState(token, GOOGLE_STARRED, null, itemIds)
|
||||
} else {
|
||||
service.setItemsState(token, null, GOOGLE_STARRED, itemIds)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createFeed(token: String, feedUrl: String) {
|
||||
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe");
|
||||
}
|
||||
|
||||
suspend fun deleteFeed(token: String, feedUrl: String) {
|
||||
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe")
|
||||
}
|
||||
|
||||
suspend fun updateFeed(token: String, feedUrl: String, title: String, folderId: String) {
|
||||
service.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit")
|
||||
}
|
||||
|
||||
suspend fun createFolder(token: String, tagName: String) {
|
||||
service.createFolder(token, "$FOLDER_PREFIX$tagName")
|
||||
}
|
||||
|
||||
suspend fun updateFolder(token: String, folderId: String, name: String) {
|
||||
service.updateFolder(token, folderId, "$FOLDER_PREFIX$name")
|
||||
}
|
||||
|
||||
suspend fun deleteFolder(token: String, folderId: String) {
|
||||
service.deleteFolder(token, folderId)
|
||||
}
|
||||
|
||||
suspend fun setItemsReadState(syncData: FreshRSSSyncData, token: String) {
|
||||
if (syncData.readItemsIds.isNotEmpty()) {
|
||||
setItemsReadState(true, syncData.readItemsIds, token)
|
||||
}
|
||||
|
||||
if (syncData.unreadItemsIds.isNotEmpty()) {
|
||||
setItemsReadState(false, syncData.unreadItemsIds, token)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setItemsStarState(syncData: FreshRSSSyncData, token: String) {
|
||||
if (syncData.starredItemsIds.isNotEmpty()) {
|
||||
setItemStarState(true, syncData.starredItemsIds, token)
|
||||
}
|
||||
|
||||
if (syncData.unstarredItemsIds.isNotEmpty()) {
|
||||
setItemStarState(false, syncData.unstarredItemsIds, token)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_ITEMS = 2500
|
||||
private const val MAX_STARRED_ITEMS = 1000
|
||||
|
||||
const val GOOGLE_READ = "user/-/state/com.google/read"
|
||||
const val GOOGLE_UNREAD = "user/-/state/com.google/unread"
|
||||
const val GOOGLE_STARRED = "user/-/state/com.google/starred"
|
||||
const val GOOGLE_READING_LIST = "user/-/state/com.google/reading-list"
|
||||
|
||||
const val FEED_PREFIX = "feed/"
|
||||
const val FOLDER_PREFIX = "user/-/label/"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
package com.readrops.api.services.freshrss
|
||||
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.Item
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface NewFreshRSSService {
|
||||
|
||||
@POST("accounts/ClientLogin")
|
||||
suspend fun login(@Body body: RequestBody?): ResponseBody
|
||||
|
||||
@GET("reader/api/0/token")
|
||||
suspend fun getWriteToken(): ResponseBody
|
||||
|
||||
@GET("reader/api/0/user-info")
|
||||
suspend fun userInfo(): FreshRSSUserInfo
|
||||
|
||||
@GET("reader/api/0/subscription/list?output=json")
|
||||
suspend fun getFeeds(): List<Feed>
|
||||
|
||||
@GET("reader/api/0/tag/list?output=json")
|
||||
suspend fun getFolders(): List<Folder>
|
||||
|
||||
@GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list")
|
||||
suspend fun getItems(@Query("xt") excludeTarget: List<String>?, @Query("n") max: Int,
|
||||
@Query("ot") lastModified: Long?): List<Item>
|
||||
|
||||
@GET("reader/api/0/stream/contents/user/-/state/com.google/starred")
|
||||
suspend fun getStarredItems(@Query("n") max: Int): List<Item>
|
||||
|
||||
@GET("reader/api/0/stream/items/ids")
|
||||
suspend fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?,
|
||||
@Query("n") max: Int): List<String>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("reader/api/0/edit-tag")
|
||||
suspend fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?,
|
||||
@Field("r") removeAction: String?, @Field("i") itemIds: List<String>)
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("reader/api/0/subscription/edit")
|
||||
suspend fun createOrDeleteFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("ac") action: String)
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("reader/api/0/subscription/edit")
|
||||
suspend fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String,
|
||||
@Field("a") folderId: String, @Field("ac") action: String)
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("reader/api/0/edit-tag")
|
||||
suspend fun createFolder(@Field("T") token: String, @Field("a") tagName: String)
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("reader/api/0/rename-tag")
|
||||
suspend fun updateFolder(@Field("T") token: String, @Field("s") folderId: String, @Field("dest") newFolderId: String)
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("reader/api/0/disable-tag")
|
||||
suspend fun deleteFolder(@Field("T") token: String, @Field("s") folderId: String)
|
||||
|
||||
companion object {
|
||||
const val END_POINT = "/api/greader.php/"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package com.readrops.api.utils
|
||||
|
||||
import com.readrops.api.utils.exceptions.HttpException
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class ErrorInterceptor() : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw HttpException(response)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
package com.readrops.api.utils
|
||||
|
||||
import android.nfc.FormatException
|
||||
import com.readrops.api.localfeed.LocalRSSHelper
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
data class ParsingResult(
|
||||
val url: String,
|
||||
val label: String?,
|
||||
)
|
||||
|
||||
object HtmlParser {
|
||||
|
||||
suspend fun getFaviconLink(url: String, client: OkHttpClient): String? {
|
||||
val document = getHTMLHeadFromUrl(url, client)
|
||||
val elements = document.select("link")
|
||||
|
||||
for (element in elements) {
|
||||
if (element.attributes()["rel"].lowercase().contains("icon")) {
|
||||
return element.absUrl("href")
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun getFeedLink(url: String, client: OkHttpClient): List<ParsingResult> {
|
||||
val results = mutableListOf<ParsingResult>()
|
||||
|
||||
val document = getHTMLHeadFromUrl(url, client)
|
||||
val elements = document.select("link")
|
||||
|
||||
for (element in elements) {
|
||||
val type = element.attributes()["type"]
|
||||
|
||||
if (LocalRSSHelper.isRSSType(type)) {
|
||||
results += ParsingResult(
|
||||
url = element.absUrl("href"),
|
||||
label = element.attributes()["title"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private fun getHTMLHeadFromUrl(url: String, client: OkHttpClient): Document {
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
if (response.header(ApiUtils.CONTENT_TYPE_HEADER)!!.contains(ApiUtils.HTML_CONTENT_TYPE)
|
||||
) {
|
||||
val body = response.body!!.source()
|
||||
|
||||
val stringBuilder = StringBuilder()
|
||||
var collectionStarted = false
|
||||
|
||||
while (!body.exhausted()) {
|
||||
val currentLine = body.readUtf8LineStrict()
|
||||
|
||||
when {
|
||||
currentLine.contains("<head>") -> {
|
||||
stringBuilder.append(currentLine)
|
||||
collectionStarted = true
|
||||
}
|
||||
currentLine.contains("</head>") -> {
|
||||
stringBuilder.append(currentLine)
|
||||
break
|
||||
}
|
||||
collectionStarted -> {
|
||||
stringBuilder.append(currentLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!stringBuilder.contains("<head>") || !stringBuilder.contains("</head>"))
|
||||
throw FormatException("Failed to get HTML head")
|
||||
|
||||
body.close()
|
||||
return Jsoup.parse(stringBuilder.toString(), url)
|
||||
} else {
|
||||
throw FormatException("The response is not a html file")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package com.readrops.api.utils.exceptions
|
||||
|
||||
import okhttp3.Response
|
||||
|
||||
|
||||
class HttpException(val response: Response) : Exception() {
|
||||
|
||||
val code: Int
|
||||
get() = response.code
|
||||
|
||||
override val message: String
|
||||
get() = "HTTP " + response.code + " " + response.message
|
||||
}
|
|
@ -5,6 +5,7 @@ import com.readrops.api.TestUtils
|
|||
import com.readrops.api.apiModule
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.api.utils.AuthInterceptor
|
||||
import com.readrops.api.utils.exceptions.HttpException
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.exceptions.UnknownFormatException
|
||||
import junit.framework.TestCase.*
|
||||
|
@ -149,7 +150,7 @@ class LocalRSSDataSourceTest : KoinTest {
|
|||
assertNull(pair)
|
||||
}
|
||||
|
||||
@Test(expected = NetworkErrorException::class)
|
||||
@Test(expected = HttpException::class)
|
||||
fun response404Test() {
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND))
|
||||
|
||||
|
|
|
@ -0,0 +1,260 @@
|
|||
package com.readrops.api.services.freshrss
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.apiModule
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.koin.test.KoinTest
|
||||
import org.koin.test.KoinTestRule
|
||||
import org.koin.test.get
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URLEncoder
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FreshRSSDataSourceTest : KoinTest {
|
||||
|
||||
private lateinit var freshRSSDataSource: NewFreshRSSDataSource
|
||||
private val mockServer = MockWebServer()
|
||||
|
||||
@get:Rule
|
||||
val koinTestRule = KoinTestRule.create {
|
||||
modules(apiModule, module {
|
||||
single {
|
||||
Retrofit.Builder()
|
||||
.baseUrl("http://localhost:8080/")
|
||||
.client(get())
|
||||
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
|
||||
.build()
|
||||
.create(NewFreshRSSService::class.java)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
mockServer.start(8080)
|
||||
freshRSSDataSource = NewFreshRSSDataSource(get())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginTest() {
|
||||
runBlocking {
|
||||
val responseBody = TestUtils.loadResource("services/freshrss/login_response_body")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(responseBody)))
|
||||
|
||||
val authString = freshRSSDataSource.login("Login", "Password")
|
||||
assertEquals("login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a", authString)
|
||||
|
||||
val request = mockServer.takeRequest()
|
||||
val requestBody = request.body.readUtf8()
|
||||
|
||||
assertTrue {
|
||||
requestBody.contains("name=\"Email\"") && requestBody.contains("Login")
|
||||
}
|
||||
|
||||
assertTrue {
|
||||
requestBody.contains("name=\"Passwd\"") && requestBody.contains("Password")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writeTokenTest() = runBlocking {
|
||||
val responseBody = TestUtils.loadResource("services/freshrss/writetoken_response_body")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(responseBody)))
|
||||
|
||||
val writeToken = freshRSSDataSource.getWriteToken()
|
||||
|
||||
assertEquals("PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg", writeToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun userInfoTest() = runBlocking {
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foldersTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("services/freshrss/adapters/folders.json")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(stream)))
|
||||
|
||||
val folders = freshRSSDataSource.getFolders()
|
||||
assertTrue { folders.size == 1 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun feedsTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("services/freshrss/adapters/feeds.json")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(stream)))
|
||||
|
||||
val feeds = freshRSSDataSource.getFeeds()
|
||||
assertTrue { feeds.size == 1 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun itemsTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("services/freshrss/adapters/items.json")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(stream)))
|
||||
|
||||
val items = freshRSSDataSource.getItems(listOf(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_STARRED), 100, 21343321321321)
|
||||
assertTrue { items.size == 2 }
|
||||
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.requestUrl!!) {
|
||||
assertEquals(listOf(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_STARRED), queryParameterValues("xt"))
|
||||
assertEquals("100", queryParameter("n"))
|
||||
assertEquals("21343321321321", queryParameter("ot"))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun starredItemsTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("services/freshrss/adapters/items.json")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(stream)))
|
||||
|
||||
val items = freshRSSDataSource.getStarredItems(100)
|
||||
assertTrue { items.size == 2 }
|
||||
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
assertEquals("100", request.requestUrl!!.queryParameter("n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getItemsIdsTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("services/freshrss/adapters/items_starred_ids.json")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(stream)))
|
||||
|
||||
val ids = freshRSSDataSource.getItemsIds(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_READING_LIST, 100)
|
||||
assertTrue { ids.size == 5 }
|
||||
|
||||
val request = mockServer.takeRequest()
|
||||
with(request.requestUrl!!) {
|
||||
assertEquals(NewFreshRSSDataSource.GOOGLE_READ, queryParameter("xt"))
|
||||
assertEquals(NewFreshRSSDataSource.GOOGLE_READING_LIST, queryParameter("s"))
|
||||
assertEquals("100", queryParameter("n"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createFeedTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.createFeed("token", "https://feed.url")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
|
||||
assertTrue { contains("ac=subscribe") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteFeedTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.deleteFeed("token", "https://feed.url")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
|
||||
assertTrue { contains("ac=unsubscribe") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateFeedTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.updateFeed("token", "https://feed.url", "title", "folderId")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
|
||||
assertTrue { contains("t=title") }
|
||||
assertTrue { contains("a=folderId") }
|
||||
assertTrue { contains("ac=edit") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createFolderTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.createFolder("token", "folder")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("a=${URLEncoder.encode("${NewFreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateFolderTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.updateFolder("token", "folderId", "folder")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("s=folderId") }
|
||||
assertTrue { contains("dest=${URLEncoder.encode("${NewFreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteFolderTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.deleteFolder("token", "folderId")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("s=folderId") }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package com.readrops.api.utils
|
||||
|
||||
import com.readrops.api.utils.exceptions.HttpException
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class ErrorInterceptorTest {
|
||||
|
||||
private val interceptor = ErrorInterceptor()
|
||||
private val server = MockWebServer()
|
||||
private lateinit var client: OkHttpClient
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
client = OkHttpClient.Builder()
|
||||
.addInterceptor(interceptor)
|
||||
.build()
|
||||
server.start(8080)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.close()
|
||||
}
|
||||
|
||||
@Test(expected = HttpException::class)
|
||||
fun interceptorTest() {
|
||||
server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND))
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/url")).build()).execute()
|
||||
//val request = server.takeRequest()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
package com.readrops.api.utils
|
||||
|
||||
import android.nfc.FormatException
|
||||
import com.readrops.api.TestUtils
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.koin.dsl.module
|
||||
import org.koin.test.KoinTest
|
||||
import org.koin.test.KoinTestRule
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class HtmlParserTest : KoinTest {
|
||||
|
||||
private val mockServer = MockWebServer()
|
||||
|
||||
@get:Rule
|
||||
val koinTestRule = KoinTestRule.create {
|
||||
modules(module {
|
||||
single {
|
||||
OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.build()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun before() {
|
||||
mockServer.start()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun after() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getFeedLinkTest() {
|
||||
val stream = TestUtils.loadResource("utils/file.html")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
val result =
|
||||
HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
|
||||
|
||||
assertTrue { result.size == 1 }
|
||||
assertTrue { result.first().url.endsWith("/rss") }
|
||||
assertEquals("RSS", result.first().label)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = FormatException::class)
|
||||
fun getFeedLinkWithoutHeadTest() {
|
||||
val stream = TestUtils.loadResource("utils/file_without_head.html")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) }
|
||||
}
|
||||
|
||||
@Test(expected = FormatException::class)
|
||||
fun getFeedLinkNoHtmlFileTest() {
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/rss+xml"))
|
||||
|
||||
|
||||
runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getFaviconLinkTest() {
|
||||
val stream = TestUtils.loadResource("utils/file.html")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
|
||||
|
||||
assertTrue { result!!.contains("favicon.ico") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getFaviconLinkWithoutHeadTest() {
|
||||
val stream = TestUtils.loadResource("utils/file_without_icon.html")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
SID=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a
|
||||
LSID=null
|
||||
Auth=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a
|
|
@ -0,0 +1 @@
|
|||
PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg
|
|
@ -0,0 +1,601 @@
|
|||
<html lang="en" op="news">
|
||||
<head>
|
||||
<meta name="referrer" content="origin">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" type="text/css" href="news.css?t8fsBYOw2Gz0ODjGokUo">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="rss">
|
||||
<title>Hacker News</title>
|
||||
</head>
|
||||
<body>
|
||||
<center>
|
||||
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
|
||||
<tr>
|
||||
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
|
||||
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
|
||||
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
|
||||
</td>
|
||||
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
|
||||
<a href="login?goto=news">login</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="pagespace" title="" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr class='athing' id='36826210'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826210'href='vote?id=36826210&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&goto=news">hide</a> | <a href="item?id=36826210">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36813688'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36813688'href='vote?id=36813688&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&goto=news">hide</a> | <a href="item?id=36813688">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797650'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797650'href='vote?id=36797650&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&goto=news">hide</a> | <a href="item?id=36797650">26 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36827034'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36827034'href='vote?id=36827034&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&goto=news">hide</a> | <a href="item?id=36827034">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823565'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823565'href='vote?id=36823565&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple's interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&goto=news">hide</a> | <a href="item?id=36823565">20 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823605'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823605'href='vote?id=36823605&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&goto=news">hide</a> | <a href="item?id=36823605">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826177'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826177'href='vote?id=36826177&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&goto=news">hide</a> | <a href="item?id=36826177">10 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824595'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824595'href='vote?id=36824595&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos – HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&goto=news">hide</a> | <a href="item?id=36824595">23 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825992'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825992'href='vote?id=36825992&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you've ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&goto=news">hide</a> | <a href="item?id=36825992">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825345'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825345'href='vote?id=36825345&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&goto=news">hide</a> | <a href="item?id=36825345">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823516'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823516'href='vote?id=36823516&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&goto=news">hide</a> | <a href="item?id=36823516">66 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824450'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824450'href='vote?id=36824450&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane – Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&goto=news">hide</a> | <a href="item?id=36824450">93 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825481'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825481'href='vote?id=36825481&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&goto=news">hide</a> | <a href="item?id=36825481">15 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823375'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823375'href='vote?id=36823375&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&goto=news">hide</a> | <a href="item?id=36823375">22 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823524'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823524'href='vote?id=36823524&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe – Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&goto=news">hide</a> | <a href="item?id=36823524">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824607'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824607'href='vote?id=36824607&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&goto=news">hide</a> | <a href="item?id=36824607">43 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825913'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825913'href='vote?id=36825913&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine – open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&goto=news">hide</a> | <a href="item?id=36825913">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797471'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797471'href='vote?id=36797471&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&goto=news">hide</a> | <a href="item?id=36797471">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825204'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825204'href='vote?id=36825204&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&goto=news">hide</a> | <a href="item?id=36825204">72 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822880'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822880'href='vote?id=36822880&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&goto=news">hide</a> | <a href="item?id=36822880">58 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36803767'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36803767'href='vote?id=36803767&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&goto=news">hide</a> | <a href="item?id=36803767">18 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824330'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824330'href='vote?id=36824330&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&goto=news">hide</a> | <a href="item?id=36824330">11 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826111'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826111'href='vote?id=36826111&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&goto=news">hide</a> | <a href="item?id=36826111">121 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36784114'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36784114'href='vote?id=36784114&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&goto=news">hide</a> | <a href="item?id=36784114">75 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36808566'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36808566'href='vote?id=36808566&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823723'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823723'href='vote?id=36823723&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&goto=news">hide</a> | <a href="item?id=36823723">107 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824856'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824856'href='vote?id=36824856&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&goto=news">hide</a> | <a href="item?id=36824856">55 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822530'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822530'href='vote?id=36822530&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&goto=news">hide</a> | <a href="item?id=36822530">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36783937'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36783937'href='vote?id=36783937&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&goto=news">hide</a> | <a href="item?id=36783937">57 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826664'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826664'href='vote?id=36826664&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class="morespace" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="s.gif" height="10" width="0">
|
||||
<table width="100%" cellspacing="0" cellpadding="1">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<br>
|
||||
<center>
|
||||
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
|
||||
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</body>
|
||||
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,593 @@
|
|||
<html lang="en" op="news">
|
||||
<body>
|
||||
<center>
|
||||
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
|
||||
<tr>
|
||||
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
|
||||
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
|
||||
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
|
||||
</td>
|
||||
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
|
||||
<a href="login?goto=news">login</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="pagespace" title="" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr class='athing' id='36826210'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826210'href='vote?id=36826210&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&goto=news">hide</a> | <a href="item?id=36826210">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36813688'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36813688'href='vote?id=36813688&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&goto=news">hide</a> | <a href="item?id=36813688">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797650'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797650'href='vote?id=36797650&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&goto=news">hide</a> | <a href="item?id=36797650">26 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36827034'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36827034'href='vote?id=36827034&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&goto=news">hide</a> | <a href="item?id=36827034">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823565'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823565'href='vote?id=36823565&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple's interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&goto=news">hide</a> | <a href="item?id=36823565">20 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823605'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823605'href='vote?id=36823605&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&goto=news">hide</a> | <a href="item?id=36823605">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826177'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826177'href='vote?id=36826177&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&goto=news">hide</a> | <a href="item?id=36826177">10 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824595'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824595'href='vote?id=36824595&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos – HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&goto=news">hide</a> | <a href="item?id=36824595">23 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825992'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825992'href='vote?id=36825992&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you've ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&goto=news">hide</a> | <a href="item?id=36825992">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825345'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825345'href='vote?id=36825345&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&goto=news">hide</a> | <a href="item?id=36825345">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823516'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823516'href='vote?id=36823516&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&goto=news">hide</a> | <a href="item?id=36823516">66 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824450'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824450'href='vote?id=36824450&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane – Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&goto=news">hide</a> | <a href="item?id=36824450">93 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825481'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825481'href='vote?id=36825481&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&goto=news">hide</a> | <a href="item?id=36825481">15 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823375'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823375'href='vote?id=36823375&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&goto=news">hide</a> | <a href="item?id=36823375">22 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823524'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823524'href='vote?id=36823524&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe – Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&goto=news">hide</a> | <a href="item?id=36823524">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824607'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824607'href='vote?id=36824607&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&goto=news">hide</a> | <a href="item?id=36824607">43 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825913'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825913'href='vote?id=36825913&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine – open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&goto=news">hide</a> | <a href="item?id=36825913">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797471'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797471'href='vote?id=36797471&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&goto=news">hide</a> | <a href="item?id=36797471">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825204'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825204'href='vote?id=36825204&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&goto=news">hide</a> | <a href="item?id=36825204">72 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822880'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822880'href='vote?id=36822880&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&goto=news">hide</a> | <a href="item?id=36822880">58 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36803767'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36803767'href='vote?id=36803767&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&goto=news">hide</a> | <a href="item?id=36803767">18 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824330'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824330'href='vote?id=36824330&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&goto=news">hide</a> | <a href="item?id=36824330">11 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826111'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826111'href='vote?id=36826111&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&goto=news">hide</a> | <a href="item?id=36826111">121 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36784114'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36784114'href='vote?id=36784114&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&goto=news">hide</a> | <a href="item?id=36784114">75 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36808566'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36808566'href='vote?id=36808566&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823723'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823723'href='vote?id=36823723&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&goto=news">hide</a> | <a href="item?id=36823723">107 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824856'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824856'href='vote?id=36824856&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&goto=news">hide</a> | <a href="item?id=36824856">55 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822530'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822530'href='vote?id=36822530&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&goto=news">hide</a> | <a href="item?id=36822530">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36783937'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36783937'href='vote?id=36783937&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&goto=news">hide</a> | <a href="item?id=36783937">57 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826664'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826664'href='vote?id=36826664&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class="morespace" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="s.gif" height="10" width="0">
|
||||
<table width="100%" cellspacing="0" cellpadding="1">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<br>
|
||||
<center>
|
||||
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
|
||||
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</body>
|
||||
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,600 @@
|
|||
<html lang="en" op="news">
|
||||
<head>
|
||||
<meta name="referrer" content="origin">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" type="text/css" href="news.css?t8fsBYOw2Gz0ODjGokUo">
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="rss">
|
||||
<title>Hacker News</title>
|
||||
</head>
|
||||
<body>
|
||||
<center>
|
||||
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
|
||||
<tr>
|
||||
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
|
||||
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
|
||||
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
|
||||
</td>
|
||||
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
|
||||
<a href="login?goto=news">login</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="pagespace" title="" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr class='athing' id='36826210'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826210'href='vote?id=36826210&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&goto=news">hide</a> | <a href="item?id=36826210">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36813688'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36813688'href='vote?id=36813688&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&goto=news">hide</a> | <a href="item?id=36813688">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797650'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797650'href='vote?id=36797650&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&goto=news">hide</a> | <a href="item?id=36797650">26 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36827034'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36827034'href='vote?id=36827034&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&goto=news">hide</a> | <a href="item?id=36827034">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823565'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823565'href='vote?id=36823565&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple's interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&goto=news">hide</a> | <a href="item?id=36823565">20 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823605'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823605'href='vote?id=36823605&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&goto=news">hide</a> | <a href="item?id=36823605">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826177'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826177'href='vote?id=36826177&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&goto=news">hide</a> | <a href="item?id=36826177">10 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824595'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824595'href='vote?id=36824595&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos – HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&goto=news">hide</a> | <a href="item?id=36824595">23 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825992'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825992'href='vote?id=36825992&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you've ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&goto=news">hide</a> | <a href="item?id=36825992">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825345'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825345'href='vote?id=36825345&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&goto=news">hide</a> | <a href="item?id=36825345">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823516'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823516'href='vote?id=36823516&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&goto=news">hide</a> | <a href="item?id=36823516">66 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824450'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824450'href='vote?id=36824450&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane – Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&goto=news">hide</a> | <a href="item?id=36824450">93 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825481'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825481'href='vote?id=36825481&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&goto=news">hide</a> | <a href="item?id=36825481">15 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823375'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823375'href='vote?id=36823375&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&goto=news">hide</a> | <a href="item?id=36823375">22 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823524'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823524'href='vote?id=36823524&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe – Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&goto=news">hide</a> | <a href="item?id=36823524">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824607'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824607'href='vote?id=36824607&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&goto=news">hide</a> | <a href="item?id=36824607">43 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825913'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825913'href='vote?id=36825913&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine – open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&goto=news">hide</a> | <a href="item?id=36825913">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797471'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797471'href='vote?id=36797471&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&goto=news">hide</a> | <a href="item?id=36797471">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825204'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825204'href='vote?id=36825204&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&goto=news">hide</a> | <a href="item?id=36825204">72 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822880'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822880'href='vote?id=36822880&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&goto=news">hide</a> | <a href="item?id=36822880">58 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36803767'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36803767'href='vote?id=36803767&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&goto=news">hide</a> | <a href="item?id=36803767">18 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824330'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824330'href='vote?id=36824330&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&goto=news">hide</a> | <a href="item?id=36824330">11 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826111'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826111'href='vote?id=36826111&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&goto=news">hide</a> | <a href="item?id=36826111">121 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36784114'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36784114'href='vote?id=36784114&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&goto=news">hide</a> | <a href="item?id=36784114">75 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36808566'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36808566'href='vote?id=36808566&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823723'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823723'href='vote?id=36823723&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&goto=news">hide</a> | <a href="item?id=36823723">107 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824856'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824856'href='vote?id=36824856&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&goto=news">hide</a> | <a href="item?id=36824856">55 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822530'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822530'href='vote?id=36822530&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&goto=news">hide</a> | <a href="item?id=36822530">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36783937'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36783937'href='vote?id=36783937&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&goto=news">hide</a> | <a href="item?id=36783937">57 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826664'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826664'href='vote?id=36826664&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class="morespace" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="s.gif" height="10" width="0">
|
||||
<table width="100%" cellspacing="0" cellpadding="1">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<br>
|
||||
<center>
|
||||
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
|
||||
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</body>
|
||||
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
|
||||
</html>
|
||||
|
|
@ -18,9 +18,6 @@ android {
|
|||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
|
@ -41,17 +38,29 @@ android {
|
|||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.4.0"
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError false
|
||||
}
|
||||
|
||||
|
||||
namespace 'com.readrops.app'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -74,7 +83,7 @@ dependencies {
|
|||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
implementation 'androidx.preference:preference:1.1.1'
|
||||
implementation "androidx.work:work-runtime-ktx:2.5.0"
|
||||
implementation "androidx.work:work-runtime-ktx:2.8.0"
|
||||
implementation "androidx.fragment:fragment-ktx:1.3.5"
|
||||
implementation "androidx.browser:browser:1.3.0"
|
||||
|
||||
|
@ -103,4 +112,21 @@ dependencies {
|
|||
debugImplementation 'com.facebook.flipper:flipper:0.96.1'
|
||||
debugImplementation 'com.facebook.soloader:soloader:0.10.1'
|
||||
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.96.1'
|
||||
|
||||
def composeBom = platform('androidx.compose:compose-bom:2022.12.00')
|
||||
implementation composeBom
|
||||
androidTestImplementation composeBom
|
||||
|
||||
implementation 'androidx.activity:activity-compose:1.5.1'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
|
||||
def voyager = "1.0.0-rc03"
|
||||
implementation "cafe.adriel.voyager:voyager-navigator:$voyager"
|
||||
implementation "cafe.adriel.voyager:voyager-bottom-sheet-navigator:$voyager"
|
||||
implementation "cafe.adriel.voyager:voyager-tab-navigator:$voyager"
|
||||
implementation "cafe.adriel.voyager:voyager-androidx:$voyager"
|
||||
//implementation "cafe.adriel.voyager:voyager-koin:$voyager"
|
||||
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:1.3.3"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:1.3.3"
|
||||
}
|
||||
|
|
|
@ -31,4 +31,21 @@
|
|||
|
||||
-keep class com.readrops.api.localfeed.** { *; }
|
||||
|
||||
-keep class com.readrops.api.opml.model.** { *; }
|
||||
-keep class com.readrops.api.opml.model.** { *; }
|
||||
|
||||
# Please add these rules to your existing keep rules in order to suppress warnings.
|
||||
# This is generated automatically by the Android Gradle plugin.
|
||||
-dontwarn javax.xml.stream.Location
|
||||
-dontwarn javax.xml.stream.XMLInputFactory
|
||||
-dontwarn javax.xml.stream.XMLStreamReader
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLParameters
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLSocket
|
||||
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||
-dontwarn org.conscrypt.Conscrypt$Version
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
-dontwarn org.conscrypt.ConscryptHostnameVerifier
|
||||
-dontwarn org.joda.convert.FromString
|
||||
-dontwarn org.joda.convert.ToString
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
|
@ -1,6 +1,5 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.readrops.app">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:name=".ReadropsDebugApp"
|
||||
|
|
|
@ -24,7 +24,7 @@ public class ReadropsDebugApp extends ReadropsApp implements Configuration.Provi
|
|||
super.onCreate();
|
||||
SoLoader.init(this, false);
|
||||
|
||||
initFlipper();
|
||||
//initFlipper();
|
||||
}
|
||||
|
||||
private void initFlipper() {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.readrops.app">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
@ -61,7 +60,8 @@
|
|||
android:name=".itemslist.MainActivity"
|
||||
android:label="@string/articles"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/SplashTheme">
|
||||
android:theme="@style/SplashTheme"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
|
@ -75,15 +75,16 @@
|
|||
<activity
|
||||
android:name=".addfeed.AddFeedActivity"
|
||||
android:label="@string/add_feed_title"
|
||||
android:parentActivityName=".itemslist.MainActivity">
|
||||
android:parentActivityName=".itemslist.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter android:label="@string/new_feed">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -1,8 +1,6 @@
|
|||
package com.readrops.app
|
||||
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.chimerapps.niddler.core.AndroidNiddler
|
||||
import com.chimerapps.niddler.core.Niddler
|
||||
import com.readrops.api.services.Credentials
|
||||
import com.readrops.app.account.AccountViewModel
|
||||
import com.readrops.app.addfeed.AddFeedsViewModel
|
||||
|
@ -63,7 +61,7 @@ val appModule = module {
|
|||
|
||||
single { PreferenceManager.getDefaultSharedPreferences(androidContext()) }
|
||||
|
||||
single<Niddler> {
|
||||
/* single<Niddler> {
|
||||
val niddler = AndroidNiddler.Builder()
|
||||
.setNiddlerInformation(AndroidNiddler.fromApplication(get()))
|
||||
.setPort(0)
|
||||
|
@ -73,5 +71,5 @@ val appModule = module {
|
|||
niddler.attachToApplication(get())
|
||||
|
||||
niddler.apply { start() }
|
||||
}
|
||||
}*/
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
package com.readrops.app.feedsfolders.feeds;
|
||||
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.app.Dialog;
|
||||
import android.os.Bundle;
|
||||
|
@ -20,6 +22,8 @@ import com.readrops.db.entities.Folder;
|
|||
import com.readrops.db.entities.account.Account;
|
||||
import com.readrops.db.pojo.FeedWithFolder;
|
||||
|
||||
import org.koin.android.compat.SharedViewModelCompat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
@ -27,10 +31,6 @@ import java.util.TreeMap;
|
|||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
||||
|
||||
import org.koin.android.compat.SharedViewModelCompat;
|
||||
|
||||
public class EditFeedDialogFragment extends DialogFragment implements AdapterView.OnItemSelectedListener {
|
||||
|
||||
private TextInputEditText feedName;
|
||||
|
@ -60,7 +60,7 @@ public class EditFeedDialogFragment extends DialogFragment implements AdapterVie
|
|||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
|
||||
viewModel = SharedViewModelCompat.getSharedViewModel(this, ManageFeedsFoldersViewModel.class);
|
||||
viewModel = SharedViewModelCompat.sharedViewModel(this, ManageFeedsFoldersViewModel.class).getValue();
|
||||
|
||||
feedWithFolder = getArguments().getParcelable("feedWithFolder");
|
||||
account = getArguments().getParcelable(ACCOUNT);
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package com.readrops.app.feedsfolders.feeds;
|
||||
|
||||
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
||||
|
||||
import android.content.res.Resources;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
|
@ -15,21 +17,19 @@ import androidx.recyclerview.widget.LinearLayoutManager;
|
|||
import com.afollestad.materialdialogs.MaterialDialog;
|
||||
import com.readrops.app.R;
|
||||
import com.readrops.app.databinding.FragmentFeedsBinding;
|
||||
import com.readrops.app.feedsfolders.ManageFeedsFoldersViewModel;
|
||||
import com.readrops.app.utils.SharedPreferencesManager;
|
||||
import com.readrops.app.utils.Utils;
|
||||
import com.readrops.app.feedsfolders.ManageFeedsFoldersViewModel;
|
||||
import com.readrops.db.entities.Feed;
|
||||
import com.readrops.db.entities.account.Account;
|
||||
import com.readrops.db.pojo.FeedWithFolder;
|
||||
|
||||
import org.koin.android.compat.SharedViewModelCompat;
|
||||
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.observers.DisposableCompletableObserver;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
||||
|
||||
import org.koin.android.compat.SharedViewModelCompat;
|
||||
|
||||
|
||||
public class FeedsFragment extends Fragment {
|
||||
|
||||
|
@ -64,7 +64,7 @@ public class FeedsFragment extends Fragment {
|
|||
if (account.getPassword() == null)
|
||||
account.setPassword(SharedPreferencesManager.readString(account.getPasswordKey()));
|
||||
|
||||
viewModel = SharedViewModelCompat.getSharedViewModel(this, ManageFeedsFoldersViewModel.class);
|
||||
viewModel = SharedViewModelCompat.sharedViewModel(this, ManageFeedsFoldersViewModel.class).getValue();
|
||||
viewModel.setAccount(account);
|
||||
|
||||
viewModel.getFeedsWithFolder().observe(this, feedWithFolders -> {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package com.readrops.app.feedsfolders.folders;
|
||||
|
||||
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
||||
|
||||
import android.content.res.Resources;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
|
@ -23,14 +25,12 @@ import com.readrops.app.utils.Utils;
|
|||
import com.readrops.db.entities.Folder;
|
||||
import com.readrops.db.entities.account.Account;
|
||||
|
||||
import org.koin.android.compat.SharedViewModelCompat;
|
||||
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.observers.DisposableSingleObserver;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
||||
|
||||
import org.koin.android.compat.SharedViewModelCompat;
|
||||
|
||||
public class FoldersFragment extends Fragment {
|
||||
|
||||
private FoldersAdapter adapter;
|
||||
|
@ -65,7 +65,7 @@ public class FoldersFragment extends Fragment {
|
|||
account.setPassword(SharedPreferencesManager.readString(account.getPasswordKey()));
|
||||
|
||||
adapter = new FoldersAdapter(this::openFolderOptionsDialog);
|
||||
viewModel = SharedViewModelCompat.getSharedViewModel(this, ManageFeedsFoldersViewModel.class);
|
||||
viewModel = SharedViewModelCompat.sharedViewModel(this, ManageFeedsFoldersViewModel.class).getValue();
|
||||
|
||||
viewModel.setAccount(account);
|
||||
viewModel.getFeedCountByAccount()
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.app.PendingIntent
|
|||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
@ -95,13 +96,19 @@ class SyncWorker(context: Context, parameters: WorkerParameters) : Worker(contex
|
|||
}
|
||||
}
|
||||
|
||||
val intentFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
}
|
||||
|
||||
val notificationBuilder = NotificationCompat.Builder(applicationContext, ReadropsApp.SYNC_CHANNEL_ID)
|
||||
.setContentTitle(notifContent.title)
|
||||
.setContentText(notifContent.content)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(notifContent.content))
|
||||
.setSmallIcon(R.drawable.ic_notif)
|
||||
.setContentIntent(PendingIntent.getActivity(applicationContext, 0,
|
||||
intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
intent, intentFlag))
|
||||
.setAutoCancel(true)
|
||||
|
||||
notifContent.item?.let {
|
||||
|
@ -126,8 +133,14 @@ class SyncWorker(context: Context, parameters: WorkerParameters) : Worker(contex
|
|||
putExtra(ReadropsKeys.ITEM_ID, item.id)
|
||||
}
|
||||
|
||||
val intentFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
}
|
||||
|
||||
return NotificationCompat.Action.Builder(R.drawable.ic_read_later, applicationContext.getString(R.string.read_later),
|
||||
PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, intentFlag))
|
||||
.setAllowGeneratedReplies(false)
|
||||
.build()
|
||||
}
|
||||
|
@ -137,10 +150,19 @@ class SyncWorker(context: Context, parameters: WorkerParameters) : Worker(contex
|
|||
putExtra(ReadropsKeys.ITEM_ID, item.id)
|
||||
}
|
||||
|
||||
return NotificationCompat.Action.Builder(R.drawable.ic_read, applicationContext.getString(R.string.read),
|
||||
PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
NotificationCompat.Action.Builder(R.drawable.ic_read, applicationContext.getString(R.string.read),
|
||||
PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, PendingIntent.FLAG_IMMUTABLE))
|
||||
.setAllowGeneratedReplies(false)
|
||||
.build()
|
||||
} else {
|
||||
NotificationCompat.Action.Builder(R.drawable.ic_read, applicationContext.getString(R.string.read),
|
||||
PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, PendingIntent.FLAG_IMMUTABLE))
|
||||
.setAllowGeneratedReplies(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
class MarkReadReceiver : BroadcastReceiver(), KoinComponent {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package com.readrops.app.repositories;
|
||||
|
||||
import android.accounts.NetworkErrorException;
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
@ -114,7 +113,7 @@ public class LocalFeedRepository extends ARepository {
|
|||
} catch (UnknownFormatException e) {
|
||||
Log.d(TAG, "addFeeds: " + e.getMessage());
|
||||
insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.FORMAT_ERROR);
|
||||
} catch (NetworkErrorException | IOException e) {
|
||||
} catch (IOException e) {
|
||||
Log.d(TAG, "addFeeds: " + e.getMessage());
|
||||
insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.NETWORK_ERROR);
|
||||
} catch (Exception e) {
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
package com.readrops.app.settings;
|
||||
|
||||
|
||||
import static android.app.Activity.RESULT_OK;
|
||||
import static com.readrops.app.utils.OPMLHelper.OPEN_OPML_FILE_REQUEST;
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT_ID;
|
||||
import static com.readrops.app.utils.ReadropsKeys.EDIT_ACCOUNT;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Notification;
|
||||
import android.app.PendingIntent;
|
||||
|
@ -21,7 +27,6 @@ import androidx.preference.Preference;
|
|||
import androidx.preference.PreferenceFragmentCompat;
|
||||
|
||||
import com.afollestad.materialdialogs.MaterialDialog;
|
||||
import com.readrops.app.utils.OPMLHelper;
|
||||
import com.readrops.api.opml.OPMLParser;
|
||||
import com.readrops.app.R;
|
||||
import com.readrops.app.ReadropsApp;
|
||||
|
@ -30,6 +35,7 @@ import com.readrops.app.account.AddAccountActivity;
|
|||
import com.readrops.app.feedsfolders.ManageFeedsFoldersActivity;
|
||||
import com.readrops.app.notifications.NotificationPermissionActivity;
|
||||
import com.readrops.app.utils.FileUtils;
|
||||
import com.readrops.app.utils.OPMLHelper;
|
||||
import com.readrops.app.utils.PermissionManager;
|
||||
import com.readrops.app.utils.SharedPreferencesManager;
|
||||
import com.readrops.app.utils.Utils;
|
||||
|
@ -38,6 +44,8 @@ import com.readrops.db.entities.Folder;
|
|||
import com.readrops.db.entities.account.Account;
|
||||
import com.readrops.db.entities.account.AccountType;
|
||||
|
||||
import org.koin.android.compat.ViewModelCompat;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
@ -47,14 +55,6 @@ import io.reactivex.observers.DisposableCompletableObserver;
|
|||
import io.reactivex.schedulers.Schedulers;
|
||||
import kotlin.Unit;
|
||||
|
||||
import static android.app.Activity.RESULT_OK;
|
||||
import static com.readrops.app.utils.OPMLHelper.OPEN_OPML_FILE_REQUEST;
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT_ID;
|
||||
import static com.readrops.app.utils.ReadropsKeys.EDIT_ACCOUNT;
|
||||
|
||||
import org.koin.android.compat.ViewModelCompat;
|
||||
|
||||
/**
|
||||
* A simple {@link Fragment} subclass.
|
||||
*/
|
||||
|
@ -274,11 +274,18 @@ public class AccountSettingsFragment extends PreferenceFragmentCompat {
|
|||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setDataAndType(Uri.parse(absolutePath), "text/plain");
|
||||
|
||||
int intentFlag;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
intentFlag = PendingIntent.FLAG_IMMUTABLE;
|
||||
} else {
|
||||
intentFlag = PendingIntent.FLAG_IMMUTABLE;
|
||||
}
|
||||
|
||||
Notification notification = new NotificationCompat.Builder(getContext(), ReadropsApp.OPML_EXPORT_CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.opml_export))
|
||||
.setContentText(name)
|
||||
.setSmallIcon(R.drawable.ic_notif)
|
||||
.setContentIntent(PendingIntent.getActivity(getContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setContentIntent(PendingIntent.getActivity(getContext(), 0, intent, intentFlag))
|
||||
.setAutoCancel(true)
|
||||
.build();
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM12,17H6v-2h6V17zM15,13H9v-2h6V13zM18,9h-6V7h6V9z"/>
|
||||
</vector>
|
|
@ -0,0 +1 @@
|
|||
/build
|
|
@ -0,0 +1,117 @@
|
|||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.readrops.app.compose'
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.readrops.app.compose"
|
||||
minSdk rootProject.ext.minSdkVersion
|
||||
targetSdk rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
|
||||
debug {
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
|
||||
testCoverageEnabled true
|
||||
applicationIdSuffix ".debug"
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.4.0"
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':api')
|
||||
implementation project(':db')
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.8.0'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
|
||||
def composeBom = platform('androidx.compose:compose-bom:2023.10.01')
|
||||
implementation composeBom
|
||||
androidTestImplementation composeBom
|
||||
|
||||
implementation 'androidx.palette:palette-ktx:1.0.0'
|
||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
|
||||
implementation "com.google.accompanist:accompanist-swiperefresh:0.30.1"
|
||||
|
||||
def voyager = "1.0.0-rc03"
|
||||
implementation "cafe.adriel.voyager:voyager-navigator:$voyager"
|
||||
implementation "cafe.adriel.voyager:voyager-bottom-sheet-navigator:$voyager"
|
||||
implementation "cafe.adriel.voyager:voyager-tab-navigator:$voyager"
|
||||
implementation "cafe.adriel.voyager:voyager-androidx:$voyager"
|
||||
implementation "cafe.adriel.voyager:voyager-koin:$voyager"
|
||||
implementation "cafe.adriel.voyager:voyager-transitions:$voyager"
|
||||
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:1.4.3"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:1.4.3"
|
||||
|
||||
def lifecycle_version = "2.6.1"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"
|
||||
|
||||
/*def koin_version = "3.3.3"
|
||||
implementation "io.insert-koin:koin-core:$koin_version"
|
||||
implementation "io.insert-koin:koin-android:$koin_version"
|
||||
implementation "io.insert-koin:koin-androidx-compose:3.4.2"*/
|
||||
|
||||
androidTestImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version"
|
||||
androidTestImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
|
||||
|
||||
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
|
||||
|
||||
implementation "io.coil-kt:coil:2.4.0"
|
||||
implementation "io.coil-kt:coil-compose:2.4.0"
|
||||
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
||||
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
|
||||
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.1"
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
|
@ -0,0 +1,58 @@
|
|||
package com.readrops.app.compose
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.readrops.api.apiModule
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.app.compose.util.FeedColors
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.koin.dsl.module
|
||||
import org.koin.test.KoinTestRule
|
||||
import java.net.HttpURLConnection
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FeedColorsTest {
|
||||
|
||||
private val mockServer = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
|
||||
KoinTestRule.create {
|
||||
modules(apiModule, module {
|
||||
single { context }
|
||||
})
|
||||
}
|
||||
|
||||
mockServer.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun after() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getFeedColorTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("favicon.ico")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "image/jpeg")
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
val url = mockServer.url("/rss").toString()
|
||||
val color = FeedColors.getFeedColor(url)
|
||||
|
||||
assertTrue { color != 0 }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package com.readrops.app.compose
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
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.Item
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.joda.time.LocalDateTime
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GetFoldersWithFeedsTest {
|
||||
|
||||
private lateinit var database: Database
|
||||
private lateinit var getFoldersWithFeeds: GetFoldersWithFeeds
|
||||
private val account = Account(accountType = AccountType.LOCAL)
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
|
||||
|
||||
runTest {
|
||||
account.id = database.newAccountDao().insert(account).toInt()
|
||||
|
||||
// inserting 3 folders
|
||||
repeat(3) { time ->
|
||||
database.newFolderDao()
|
||||
.insert(Folder(name = "Folder $time", accountId = account.id))
|
||||
}
|
||||
|
||||
// inserting 2 feeds, not linked to any folder
|
||||
repeat(2) { time ->
|
||||
database.newFeedDao().insert(Feed(name = "Feed $time", accountId = account.id))
|
||||
}
|
||||
|
||||
// inserting 2 feeds linked to first folder (Folder 0)
|
||||
repeat(2) { time ->
|
||||
database.newFeedDao()
|
||||
.insert(Feed(name = "Feed ${time + 2}", folderId = 1, accountId = account.id))
|
||||
}
|
||||
|
||||
// inserting 3 items linked to first feed (Feed 0)
|
||||
repeat(3) { time ->
|
||||
database.newItemDao()
|
||||
.insert(Item(title = "Item $time", feedId = 1, pubDate = LocalDateTime.now()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getFoldersWithFeedsTest() = runTest {
|
||||
getFoldersWithFeeds = GetFoldersWithFeeds(database)
|
||||
val job = launch {
|
||||
getFoldersWithFeeds.get(account.id)
|
||||
.collect { foldersAndFeeds ->
|
||||
|
||||
|
||||
assertTrue { foldersAndFeeds.size == 4 }
|
||||
assertTrue { foldersAndFeeds.entries.first().value.size == 2 }
|
||||
assertTrue { foldersAndFeeds.entries.last().key == null }
|
||||
assertTrue { foldersAndFeeds[null]!!.size == 2 }
|
||||
assertTrue { foldersAndFeeds[null]!!.first().unreadCount == 3 }
|
||||
}
|
||||
}
|
||||
|
||||
// for an unknown reason, the coroutine must be canceled to stop the test, and I don't really know why
|
||||
job.cancel()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
package com.readrops.app.compose
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.readrops.api.apiModule
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.api.utils.AuthInterceptor
|
||||
import com.readrops.app.compose.repositories.LocalRSSRepository
|
||||
import com.readrops.db.Database
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.koin.dsl.module
|
||||
import org.koin.test.KoinTest
|
||||
import org.koin.test.KoinTestRule
|
||||
import org.koin.test.get
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
|
||||
class LocalRSSRepositoryTest : KoinTest {
|
||||
|
||||
private val mockServer: MockWebServer = MockWebServer()
|
||||
private val account = Account(accountType = AccountType.LOCAL)
|
||||
private lateinit var database: Database
|
||||
private lateinit var repository: LocalRSSRepository
|
||||
private lateinit var feeds: List<Feed>
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
|
||||
|
||||
KoinTestRule.create {
|
||||
modules(apiModule, module {
|
||||
single { database }
|
||||
single {
|
||||
OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.addInterceptor(get<AuthInterceptor>())
|
||||
.build()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mockServer.start()
|
||||
val url = mockServer.url("/rss")
|
||||
|
||||
account.id = database.accountDao().compatInsert(account).toInt()
|
||||
feeds = listOf(
|
||||
Feed(
|
||||
name = "feedTest",
|
||||
url = url.toString(),
|
||||
accountId = account.id,
|
||||
),
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
database.newFeedDao().insert(feeds).run {
|
||||
feeds.first().id = first().toInt()
|
||||
}
|
||||
}
|
||||
|
||||
repository = LocalRSSRepository(get(), database, account)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun synchronizeTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("rss_feed.xml")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/xml; charset=UTF-8")
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
val result = repository.synchronize(null) {
|
||||
assertEquals(it.name, feeds.first().name)
|
||||
}
|
||||
|
||||
assertTrue { result.first.items.isNotEmpty() }
|
||||
assertTrue { database.itemDao().itemExists(result.first.items.first().guid!!, account.id) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun synchronizeWithFeedsTest(): Unit = runBlocking {
|
||||
val stream = TestUtils.loadResource("rss_feed.xml")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/xml; charset=UTF-8")
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
val result = repository.synchronize(feeds) {
|
||||
assertEquals(it.name, feeds.first().name)
|
||||
}
|
||||
|
||||
assertTrue { result.first.items.isNotEmpty() }
|
||||
assertTrue { database.itemDao().itemExists(result.first.items.first().guid!!, account.id) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.readrops.app.compose
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
object TestUtils {
|
||||
|
||||
fun loadResource(path: String): InputStream =
|
||||
javaClass.classLoader?.getResourceAsStream(path)!!
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 7.4 KiB |
|
@ -0,0 +1,61 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"
|
||||
xmlns:atom="http://www.w3.org/1999/xhtml">
|
||||
<channel>
|
||||
<title>Hacker News</title>
|
||||
<atom:link href="https://news.ycombinator.com/feed/" rel="self" />
|
||||
<link>https://news.ycombinator.com/</link>
|
||||
<description>Links for the intellectually curious, ranked by readers.</description>
|
||||
<item>
|
||||
<title>Africa declared free of wild polio</title>
|
||||
<link>https://www.bbc.com/news/world-africa-53887947</link>
|
||||
<pubDate>Tue, 25 Aug 2020 17:15:49 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24273602</comments>
|
||||
<author>Author 1</author>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24273602">Comments</a>]]></description>
|
||||
<media:description>media description</media:description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Palantir S-1</title>
|
||||
<link>https://www.sec.gov/Archives/edgar/data/1321655/000119312520230013/d904406ds1.htm</link>
|
||||
<pubDate>Tue, 25 Aug 2020 21:03:42 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24276086</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24276086">Comments</a>]]></description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Openwifi: Linux mac80211 compatible full-stack 802.11/Wi-Fi design based on SDR</title>
|
||||
<link>https://github.com/open-sdr/openwifi</link>
|
||||
<pubDate>Tue, 25 Aug 2020 17:45:19 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24273919</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24273919">Comments</a>]]></description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Syllabus for Eric's PhD Students</title>
|
||||
<link>https://docs.google.com/document/d/11D3kHElzS2HQxTwPqcaTnU5HCJ8WGE5brTXI4KLf4dM/edit</link>
|
||||
<pubDate>Tue, 25 Aug 2020 18:55:12 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24274699</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24274699">Comments</a>]]></description>
|
||||
</item>
|
||||
<item>
|
||||
<title>WebBundles harmful to content blocking, security tools, and the open web</title>
|
||||
<link>https://brave.com/webbundles-harmful-to-content-blocking-security-tools-and-the-open-web/</link>
|
||||
<pubDate>Tue, 25 Aug 2020 19:18:50 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24274968</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24274968">Comments</a>]]></description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Zappos CEO Tony Hsieh is stepping down after 21 years</title>
|
||||
<link>https://footwearnews.com/2020/business/executive-moves/zappos-ceo-tony-hsieh-steps-down-1203045974/</link>
|
||||
<pubDate>Tue, 25 Aug 2020 06:11:42 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24268522</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24268522">Comments</a>]]></description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Evgeny Kuznetsov practices with Bauer stick that has hole in the blade</title>
|
||||
<link>https://russianmachineneverbreaks.com/2020/07/17/evgeny-kuznetsov-practices-with-bauer-stick-that-has-hole-in-the-blade/</link>
|
||||
<pubDate>Tue, 25 Aug 2020 19:38:09 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24275159</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24275159">Comments</a>]]></description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:name=".ReadropsApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Readrops">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="Articles">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,32 @@
|
|||
package com.readrops.app.compose
|
||||
|
||||
import com.readrops.app.compose.account.AccountViewModel
|
||||
import com.readrops.app.compose.account.selection.AccountSelectionViewModel
|
||||
import com.readrops.app.compose.feeds.FeedViewModel
|
||||
import com.readrops.app.compose.repositories.BaseRepository
|
||||
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
|
||||
import com.readrops.app.compose.repositories.LocalRSSRepository
|
||||
import com.readrops.app.compose.timelime.TimelineViewModel
|
||||
import com.readrops.db.entities.account.Account
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val composeAppModule = module {
|
||||
|
||||
viewModel { TimelineViewModel(get(), get()) }
|
||||
|
||||
viewModel { FeedViewModel(get(), get(), get()) }
|
||||
|
||||
viewModel { AccountSelectionViewModel(get()) }
|
||||
|
||||
viewModel { AccountViewModel(get()) }
|
||||
|
||||
single { GetFoldersWithFeeds(get()) }
|
||||
|
||||
// repositories
|
||||
|
||||
factory<BaseRepository> { (account: Account) ->
|
||||
LocalRSSRepository(get(), get(), account)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package com.readrops.app.compose
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.material3.*
|
||||
import cafe.adriel.voyager.navigator.CurrentScreen
|
||||
import cafe.adriel.voyager.navigator.Navigator
|
||||
import com.readrops.app.compose.account.selection.AccountSelectionScreen
|
||||
import com.readrops.app.compose.account.selection.AccountSelectionViewModel
|
||||
import com.readrops.app.compose.home.HomeScreen
|
||||
import com.readrops.app.compose.util.theme.ReadropsTheme
|
||||
import org.koin.androidx.viewmodel.ext.android.getViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val viewModel = getViewModel<AccountSelectionViewModel>()
|
||||
val accountExists = viewModel.accountExists()
|
||||
|
||||
setContent {
|
||||
ReadropsTheme {
|
||||
Navigator(
|
||||
screen = if (accountExists) HomeScreen() else AccountSelectionScreen()
|
||||
) {
|
||||
CurrentScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package com.readrops.app.compose
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import com.readrops.api.apiModule
|
||||
import com.readrops.db.dbModule
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.logger.Level
|
||||
|
||||
open class ReadropsApp : Application(), KoinComponent, ImageLoaderFactory {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
startKoin {
|
||||
androidLogger(Level.ERROR)
|
||||
androidContext(this@ReadropsApp)
|
||||
|
||||
modules(apiModule, dbModule, composeAppModule)
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.okHttpClient { get() }
|
||||
.crossfade(true)
|
||||
.build()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
package com.readrops.app.compose.account
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import cafe.adriel.voyager.navigator.tab.Tab
|
||||
import cafe.adriel.voyager.navigator.tab.TabOptions
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.account.credentials.AccountCredentialsScreen
|
||||
import com.readrops.app.compose.account.selection.AccountSelectionDialog
|
||||
import com.readrops.app.compose.account.selection.AccountSelectionScreen
|
||||
import com.readrops.app.compose.util.components.SelectableIconText
|
||||
import com.readrops.app.compose.util.components.TwoChoicesDialog
|
||||
import com.readrops.app.compose.util.theme.LargeSpacer
|
||||
import com.readrops.app.compose.util.theme.MediumSpacer
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
import org.koin.androidx.compose.getViewModel
|
||||
|
||||
object AccountTab : Tab {
|
||||
|
||||
override val options: TabOptions
|
||||
@Composable
|
||||
get() = TabOptions(
|
||||
index = 3u,
|
||||
title = stringResource(R.string.account)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val viewModel = getViewModel<AccountViewModel>()
|
||||
|
||||
val closeHome by viewModel.closeHome.collectAsStateWithLifecycle()
|
||||
val state by viewModel.accountState.collectAsStateWithLifecycle()
|
||||
|
||||
if (closeHome) {
|
||||
navigator.replaceAll(AccountSelectionScreen())
|
||||
}
|
||||
|
||||
when (state.dialog) {
|
||||
DialogState.DeleteAccount -> {
|
||||
TwoChoicesDialog(
|
||||
title = stringResource(R.string.delete_account),
|
||||
text = stringResource(R.string.delete_account_question),
|
||||
icon = rememberVectorPainter(image = Icons.Default.Delete),
|
||||
confirmText = stringResource(R.string.delete),
|
||||
dismissText = stringResource(R.string.cancel),
|
||||
onDismiss = { viewModel.closeDialog() },
|
||||
onConfirm = {
|
||||
viewModel.closeDialog()
|
||||
viewModel.deleteAccount()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DialogState.NewAccount -> {
|
||||
AccountSelectionDialog(
|
||||
onDismiss = { viewModel.closeDialog() },
|
||||
onValidate = { accountType ->
|
||||
viewModel.closeDialog()
|
||||
navigator.push(AccountCredentialsScreen(accountType, state.account))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(R.string.account)) },
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { viewModel.openDialog(DialogState.NewAccount) }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_add_account),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_freshrss),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
Text(
|
||||
text = state.account.accountName!!,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
|
||||
LargeSpacer()
|
||||
|
||||
SelectableIconText(
|
||||
icon = painterResource(id = R.drawable.ic_add_account),
|
||||
text = stringResource(R.string.credentials),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
onClick = { }
|
||||
)
|
||||
|
||||
SelectableIconText(
|
||||
icon = painterResource(id = R.drawable.ic_notifications),
|
||||
text = stringResource(R.string.notifications),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
onClick = { }
|
||||
)
|
||||
|
||||
SelectableIconText(
|
||||
icon = rememberVectorPainter(image = Icons.Default.AccountCircle),
|
||||
text = stringResource(R.string.delete_account),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = { viewModel.openDialog(DialogState.DeleteAccount) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
package com.readrops.app.compose.account
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.readrops.app.compose.base.TabViewModel
|
||||
import com.readrops.db.Database
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AccountViewModel(
|
||||
private val database: Database
|
||||
) : TabViewModel(database) {
|
||||
|
||||
private val _closeHome = MutableStateFlow(false)
|
||||
val closeHome = _closeHome.asStateFlow()
|
||||
|
||||
private val _accountState = MutableStateFlow(AccountState())
|
||||
val accountState = _accountState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountEvent.collect { account ->
|
||||
_accountState.update {
|
||||
it.copy(
|
||||
account = account
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openDialog(dialog: DialogState) = _accountState.update { it.copy(dialog = dialog) }
|
||||
|
||||
fun closeDialog() = _accountState.update { it.copy(dialog = null) }
|
||||
|
||||
fun deleteAccount() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
database.newAccountDao()
|
||||
.delete(currentAccount!!)
|
||||
|
||||
_closeHome.update { true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AccountState(
|
||||
val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL),
|
||||
val dialog: DialogState? = null,
|
||||
)
|
||||
|
||||
sealed interface DialogState {
|
||||
object DeleteAccount : DialogState
|
||||
object NewAccount : DialogState
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package com.readrops.app.compose.account.credentials
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.androidx.AndroidScreen
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.readrops.app.compose.home.HomeScreen
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
|
||||
class AccountCredentialsScreen(
|
||||
private val accountType: AccountType,
|
||||
private val account: Account? = null,
|
||||
) : AndroidScreen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = "AccountCredentialsScreen"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
|
||||
Button(onClick = { navigator.replaceAll(HomeScreen()) }) {
|
||||
Text(
|
||||
text = "skip"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package com.readrops.app.compose.account.selection
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
|
||||
@Composable
|
||||
fun AccountSelectionDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onValidate: (AccountType) -> Unit,
|
||||
) {
|
||||
BaseDialog(
|
||||
title = stringResource(R.string.new_account),
|
||||
icon = painterResource(id = R.drawable.ic_add_account),
|
||||
onDismiss = onDismiss
|
||||
) {
|
||||
AccountType.values().forEach { type ->
|
||||
SelectableImageText(
|
||||
image = painterResource(
|
||||
id = if (type != AccountType.LOCAL)
|
||||
type.iconRes
|
||||
else
|
||||
R.drawable.ic_rss_feed_grey
|
||||
),
|
||||
text = stringResource(id = type.typeName),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
padding = MaterialTheme.spacing.shortSpacing,
|
||||
imageSize = 36.dp,
|
||||
onClick = { onValidate(type) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package com.readrops.app.compose.account.selection
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.size
|
||||
import androidx.compose.material3.Icon
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cafe.adriel.voyager.androidx.AndroidScreen
|
||||
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.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")
|
||||
|
||||
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 -> {
|
||||
// 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
|
||||
|
||||
navigator.push(AccountCredentialsScreen(accountType))
|
||||
viewModel.resetNavState()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
package com.readrops.app.compose.account.selection
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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(
|
||||
private val database: Database,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : ViewModel(), KoinComponent {
|
||||
|
||||
private val _navState = MutableStateFlow<NavState>(NavState.Idle)
|
||||
val navState = _navState.asStateFlow()
|
||||
|
||||
fun accountExists(): Boolean {
|
||||
val accountCount = runBlocking {
|
||||
database.newAccountDao().selectAccountCount()
|
||||
}
|
||||
|
||||
return accountCount > 0
|
||||
}
|
||||
|
||||
fun createAccount(accountType: AccountType) {
|
||||
if (accountType == AccountType.LOCAL) {
|
||||
createLocalAccount()
|
||||
} else {
|
||||
_navState.update { NavState.GoToAccountCredentialsScreen(accountType) }
|
||||
}
|
||||
}
|
||||
|
||||
fun resetNavState() {
|
||||
_navState.update { NavState.Idle }
|
||||
}
|
||||
|
||||
private fun createLocalAccount() {
|
||||
val context = get<Context>()
|
||||
val account = Account(
|
||||
url = null,
|
||||
accountName = context.getString(AccountType.LOCAL.typeName),
|
||||
accountType = AccountType.LOCAL,
|
||||
isCurrentAccount = true
|
||||
)
|
||||
|
||||
viewModelScope.launch(dispatcher) {
|
||||
database.newAccountDao().insert(account)
|
||||
|
||||
_navState.update { NavState.GoToHomeScreen }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sealed class NavState {
|
||||
object Idle : NavState()
|
||||
object GoToHomeScreen : NavState()
|
||||
class GoToAccountCredentialsScreen(val accountType: AccountType) : NavState()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
package com.readrops.app.compose.base
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.readrops.app.compose.repositories.BaseRepository
|
||||
import com.readrops.db.Database
|
||||
import com.readrops.db.entities.account.Account
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
/**
|
||||
* Custom ViewModel for Tab screens handling account change
|
||||
*/
|
||||
abstract class TabViewModel(
|
||||
private val database: Database,
|
||||
) : ViewModel(), KoinComponent {
|
||||
|
||||
/**
|
||||
* Repository intended to be rebuilt when the current account changes
|
||||
*/
|
||||
protected var repository: BaseRepository? = null
|
||||
|
||||
protected var currentAccount: Account? = null
|
||||
|
||||
protected val accountEvent = MutableSharedFlow<Account>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
database.newAccountDao()
|
||||
.selectCurrentAccount()
|
||||
.distinctUntilChanged()
|
||||
.collect { account ->
|
||||
if (account != null) {
|
||||
currentAccount = account
|
||||
repository = get(parameters = { parametersOf(account) })
|
||||
|
||||
accountEvent.emit(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package com.readrops.app.compose.feeds
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil.compose.AsyncImage
|
||||
import com.readrops.app.compose.util.theme.ShortSpacer
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
import com.readrops.app.compose.util.toDp
|
||||
import com.readrops.db.entities.Feed
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FeedItem(
|
||||
feed: Feed,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = MaterialTheme.spacing.mediumSpacing,
|
||||
vertical = MaterialTheme.spacing.shortSpacing
|
||||
)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = feed.iconUrl,
|
||||
contentDescription = feed.name!!,
|
||||
modifier = Modifier.size(MaterialTheme.typography.bodyLarge.toDp())
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Text(
|
||||
text = feed.name!!,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package com.readrops.app.compose.feeds
|
||||
|
||||
import com.readrops.app.compose.util.components.TextFieldError
|
||||
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
|
||||
|
||||
data class FeedState(
|
||||
val foldersAndFeeds: FolderAndFeedsState = FolderAndFeedsState.InitialState,
|
||||
val dialog: DialogState? = null,
|
||||
val areFoldersExpanded: Boolean = false
|
||||
)
|
||||
|
||||
sealed interface DialogState {
|
||||
object AddFeed : DialogState
|
||||
object AddFolder : DialogState
|
||||
class DeleteFeed(val feed: Feed) : DialogState
|
||||
class DeleteFolder(val folder: Folder) : DialogState
|
||||
class UpdateFeed(val feed: Feed, val folder: Folder?) : DialogState
|
||||
class UpdateFolder(val folder: Folder) : DialogState
|
||||
class FeedSheet(val feed: Feed, val folder: Folder?) : DialogState
|
||||
}
|
||||
|
||||
sealed class FolderAndFeedsState {
|
||||
object InitialState : FolderAndFeedsState()
|
||||
data class ErrorState(val exception: Exception) : FolderAndFeedsState()
|
||||
data class LoadedState(val values: Map<Folder?, List<Feed>>) : FolderAndFeedsState()
|
||||
}
|
||||
|
||||
data class AddFeedDialogState(
|
||||
val url: String = "",
|
||||
val selectedAccount: Account = Account(accountName = ""),
|
||||
val accounts: List<Account> = listOf(),
|
||||
val error: TextFieldError? = null,
|
||||
) {
|
||||
val isError: Boolean get() = error != null
|
||||
}
|
||||
|
||||
data class UpdateFeedDialogState(
|
||||
val feedId: Int = 0,
|
||||
val feedName: String = "",
|
||||
val feedNameError: TextFieldError? = null,
|
||||
val feedUrl: String = "",
|
||||
val feedUrlError: TextFieldError? = null,
|
||||
val accountType: AccountType? = null,
|
||||
val selectedFolder: Folder? = null,
|
||||
val folders: List<Folder> = listOf(),
|
||||
val isAccountDropDownExpanded: Boolean = false,
|
||||
) {
|
||||
val isFeedNameError
|
||||
get() = feedNameError != null
|
||||
|
||||
val isFeedUrlError
|
||||
get() = feedUrlError != null
|
||||
|
||||
val isFeedUrlReadOnly: Boolean
|
||||
get() = accountType != null && !accountType.accountConfig!!.isFeedUrlEditable
|
||||
|
||||
val hasFolders = folders.isNotEmpty()
|
||||
}
|
||||
|
||||
data class FolderState(
|
||||
val folder: Folder = Folder(),
|
||||
val nameError: TextFieldError? = null,
|
||||
) {
|
||||
val name = folder.name
|
||||
|
||||
val isError = nameError != null
|
||||
}
|
|
@ -0,0 +1,294 @@
|
|||
package com.readrops.app.compose.feeds
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cafe.adriel.voyager.navigator.tab.Tab
|
||||
import cafe.adriel.voyager.navigator.tab.TabOptions
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.feeds.dialogs.AddFeedDialog
|
||||
import com.readrops.app.compose.feeds.dialogs.FeedModalBottomSheet
|
||||
import com.readrops.app.compose.feeds.dialogs.FolderDialog
|
||||
import com.readrops.app.compose.feeds.dialogs.UpdateFeedDialog
|
||||
import com.readrops.app.compose.util.components.CenteredProgressIndicator
|
||||
import com.readrops.app.compose.util.components.ErrorMessage
|
||||
import com.readrops.app.compose.util.components.Placeholder
|
||||
import com.readrops.app.compose.util.components.TwoChoicesDialog
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
import com.readrops.db.entities.Feed
|
||||
import org.koin.androidx.compose.getViewModel
|
||||
|
||||
object FeedTab : Tab {
|
||||
|
||||
override val options: TabOptions
|
||||
@Composable
|
||||
get() = TabOptions(
|
||||
index = 2u,
|
||||
title = stringResource(R.string.feeds)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val viewModel = getViewModel<FeedViewModel>()
|
||||
|
||||
val state by viewModel.feedsState.collectAsStateWithLifecycle()
|
||||
|
||||
when (val dialog = state.dialog) {
|
||||
is DialogState.AddFeed -> {
|
||||
AddFeedDialog(
|
||||
viewModel = viewModel,
|
||||
onDismiss = {
|
||||
viewModel.closeDialog()
|
||||
viewModel.resetAddFeedDialogState()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.DeleteFeed -> {
|
||||
TwoChoicesDialog(
|
||||
title = stringResource(R.string.delete_feed),
|
||||
text = "Do you want to delete feed ${dialog.feed.name}?",
|
||||
icon = rememberVectorPainter(image = Icons.Default.Delete),
|
||||
confirmText = stringResource(R.string.delete),
|
||||
dismissText = stringResource(R.string.cancel),
|
||||
onDismiss = { viewModel.closeDialog() },
|
||||
onConfirm = {
|
||||
viewModel.deleteFeed(dialog.feed)
|
||||
viewModel.closeDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
},
|
||||
onUpdateColor = {},
|
||||
onDelete = { viewModel.openDialog(DialogState.DeleteFeed(dialog.feed)) },
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.UpdateFeed -> {
|
||||
UpdateFeedDialog(
|
||||
viewModel = viewModel,
|
||||
onDismissRequest = { viewModel.closeDialog() }
|
||||
)
|
||||
}
|
||||
|
||||
DialogState.AddFolder -> {
|
||||
FolderDialog(
|
||||
viewModel = viewModel,
|
||||
onDismiss = {
|
||||
viewModel.closeDialog()
|
||||
viewModel.resetFolderState()
|
||||
},
|
||||
onValidate = {
|
||||
viewModel.folderValidate()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.DeleteFolder -> {
|
||||
TwoChoicesDialog(
|
||||
title = stringResource(R.string.delete_folder),
|
||||
text = "Do you want to delete folder ${dialog.folder.name}?",
|
||||
icon = rememberVectorPainter(image = Icons.Default.Delete),
|
||||
confirmText = stringResource(R.string.delete),
|
||||
dismissText = stringResource(R.string.cancel),
|
||||
onDismiss = { viewModel.closeDialog() },
|
||||
onConfirm = {
|
||||
viewModel.deleteFolder(dialog.folder)
|
||||
viewModel.closeDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.UpdateFolder -> {
|
||||
FolderDialog(
|
||||
updateFolder = true,
|
||||
viewModel = viewModel,
|
||||
onDismiss = {
|
||||
viewModel.closeDialog()
|
||||
viewModel.resetFolderState()
|
||||
},
|
||||
onValidate = {
|
||||
viewModel.folderValidate(updateFolder = true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
null -> {}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(R.string.feeds)) },
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { viewModel.setFolderExpandState(state.areFoldersExpanded.not()) }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = if (state.areFoldersExpanded)
|
||||
R.drawable.ic_unfold_less
|
||||
else
|
||||
R.drawable.ic_unfold_more),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
Column {
|
||||
SmallFloatingActionButton(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = MaterialTheme.spacing.veryShortSpacing,
|
||||
bottom = MaterialTheme.spacing.shortSpacing
|
||||
),
|
||||
onClick = { viewModel.openDialog(DialogState.AddFolder) }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_new_folder),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = { viewModel.openDialog(DialogState.AddFeed) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
when (state.foldersAndFeeds) {
|
||||
is FolderAndFeedsState.LoadedState -> {
|
||||
val foldersAndFeeds =
|
||||
(state.foldersAndFeeds as FolderAndFeedsState.LoadedState).values
|
||||
|
||||
if (foldersAndFeeds.isNotEmpty()) {
|
||||
LazyColumn {
|
||||
items(
|
||||
items = foldersAndFeeds.toList()
|
||||
) { folderWithFeeds ->
|
||||
fun onFeedLongClick(feed: Feed) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
uriHandler.openUri(feed.siteUrl!!)
|
||||
}
|
||||
|
||||
if (folderWithFeeds.first != null) {
|
||||
val folder = folderWithFeeds.first!!
|
||||
|
||||
FolderExpandableItem(
|
||||
folder = folder,
|
||||
feeds = folderWithFeeds.second,
|
||||
isExpanded = state.areFoldersExpanded,
|
||||
onFeedClick = { feed ->
|
||||
viewModel.openDialog(
|
||||
DialogState.FeedSheet(feed, folder)
|
||||
)
|
||||
},
|
||||
onFeedLongClick = { feed -> onFeedLongClick(feed) },
|
||||
onUpdateFolder = {
|
||||
viewModel.openDialog(
|
||||
DialogState.UpdateFolder(folder)
|
||||
)
|
||||
},
|
||||
onDeleteFolder = {
|
||||
viewModel.openDialog(
|
||||
DialogState.DeleteFolder(folder)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
val feeds = folderWithFeeds.second
|
||||
|
||||
for (feed in feeds) {
|
||||
FeedItem(
|
||||
feed = feed,
|
||||
onClick = {
|
||||
viewModel.openDialog(
|
||||
DialogState.FeedSheet(feed, null)
|
||||
)
|
||||
},
|
||||
onLongClick = { onFeedLongClick(feed) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Placeholder(
|
||||
text = stringResource(R.string.no_feed),
|
||||
painter = painterResource(R.drawable.ic_rss_feed_grey)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is FolderAndFeedsState.InitialState -> {
|
||||
CenteredProgressIndicator()
|
||||
}
|
||||
|
||||
is FolderAndFeedsState.ErrorState -> {
|
||||
val exception = (state.foldersAndFeeds as FolderAndFeedsState.ErrorState).exception
|
||||
ErrorMessage(exception = exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,329 @@
|
|||
package com.readrops.app.compose.feeds
|
||||
|
||||
import android.util.Patterns
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.readrops.api.localfeed.LocalRSSDataSource
|
||||
import com.readrops.api.utils.HtmlParser
|
||||
import com.readrops.app.compose.base.TabViewModel
|
||||
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
|
||||
import com.readrops.app.compose.util.components.TextFieldError
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flatMapConcat
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import java.net.UnknownHostException
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class FeedViewModel(
|
||||
database: Database,
|
||||
private val getFoldersWithFeeds: GetFoldersWithFeeds,
|
||||
private val localRSSDataSource: LocalRSSDataSource,
|
||||
) : TabViewModel(database), KoinComponent {
|
||||
|
||||
private val _feedState = MutableStateFlow(FeedState())
|
||||
val feedsState = _feedState.asStateFlow()
|
||||
|
||||
private val _addFeedDialogState = MutableStateFlow(AddFeedDialogState())
|
||||
val addFeedDialogState = _addFeedDialogState.asStateFlow()
|
||||
|
||||
private val _updateFeedDialogState = MutableStateFlow(UpdateFeedDialogState())
|
||||
val updateFeedDialogState = _updateFeedDialogState.asStateFlow()
|
||||
|
||||
private val _folderState = MutableStateFlow(FolderState())
|
||||
val folderState = _folderState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch(context = Dispatchers.IO) {
|
||||
accountEvent
|
||||
.flatMapConcat { account ->
|
||||
getFoldersWithFeeds.get(account.id)
|
||||
}
|
||||
.catch { throwable ->
|
||||
_feedState.update {
|
||||
it.copy(foldersAndFeeds = FolderAndFeedsState.ErrorState(Exception(throwable)))
|
||||
}
|
||||
}
|
||||
.collect { foldersAndFeeds ->
|
||||
_feedState.update {
|
||||
it.copy(foldersAndFeeds = FolderAndFeedsState.LoadedState(foldersAndFeeds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(context = Dispatchers.IO) {
|
||||
database.newAccountDao()
|
||||
.selectAllAccounts()
|
||||
.collect { accounts ->
|
||||
_addFeedDialogState.update { dialogState ->
|
||||
dialogState.copy(
|
||||
accounts = accounts,
|
||||
selectedAccount = accounts.find { it.isCurrentAccount }!!
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(context = Dispatchers.IO) {
|
||||
accountEvent
|
||||
.flatMapConcat { account ->
|
||||
database.newFolderDao()
|
||||
.selectFolders(account.id)
|
||||
}
|
||||
.collect { folders ->
|
||||
_updateFeedDialogState.update {
|
||||
it.copy(
|
||||
folders = folders,
|
||||
accountType = currentAccount!!.accountType
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setFolderExpandState(isExpanded: Boolean) =
|
||||
_feedState.update { it.copy(areFoldersExpanded = isExpanded) }
|
||||
|
||||
fun closeDialog() = _feedState.update { it.copy(dialog = null) }
|
||||
|
||||
fun openDialog(state: DialogState) {
|
||||
if (state is DialogState.UpdateFeed) {
|
||||
_updateFeedDialogState.update {
|
||||
it.copy(
|
||||
feedId = state.feed.id,
|
||||
feedName = state.feed.name!!,
|
||||
feedUrl = state.feed.url!!,
|
||||
selectedFolder = state.folder
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state is DialogState.UpdateFolder) {
|
||||
_folderState.update {
|
||||
it.copy(
|
||||
folder = state.folder
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_feedState.update { it.copy(dialog = state) }
|
||||
}
|
||||
|
||||
fun deleteFeed(feed: Feed) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
repository?.deleteFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFolder(folder: Folder) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
repository?.deleteFolder(folder)
|
||||
}
|
||||
}
|
||||
|
||||
// Add feed
|
||||
|
||||
fun setAddFeedDialogURL(url: String) {
|
||||
_addFeedDialogState.update {
|
||||
it.copy(
|
||||
url = url,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAddFeedDialogSelectedAccount(account: Account) {
|
||||
_addFeedDialogState.update { it.copy(selectedAccount = account) }
|
||||
}
|
||||
|
||||
fun addFeedDialogValidate() {
|
||||
val url = _addFeedDialogState.value.url
|
||||
|
||||
when {
|
||||
url.isEmpty() -> {
|
||||
_addFeedDialogState.update {
|
||||
it.copy(error = TextFieldError.EmptyField)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
!Patterns.WEB_URL.matcher(url).matches() -> {
|
||||
_addFeedDialogState.update {
|
||||
it.copy(error = TextFieldError.BadUrl)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
else -> viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (localRSSDataSource.isUrlRSSResource(url)) {
|
||||
// TODO add support for all account types
|
||||
repository?.insertNewFeeds(listOf(url))
|
||||
|
||||
closeDialog()
|
||||
} else {
|
||||
val rssUrls = HtmlParser.getFeedLink(url, get())
|
||||
|
||||
if (rssUrls.isEmpty()) {
|
||||
_addFeedDialogState.update {
|
||||
it.copy(error = TextFieldError.NoRSSFeed)
|
||||
}
|
||||
} else {
|
||||
// TODO add support for all account types
|
||||
repository?.insertNewFeeds(rssUrls.map { it.url })
|
||||
|
||||
closeDialog()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is UnknownHostException -> _addFeedDialogState.update { it.copy(error = TextFieldError.UnreachableUrl) }
|
||||
else -> _addFeedDialogState.update { it.copy(error = TextFieldError.NoRSSFeed) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAddFeedDialogState() {
|
||||
_addFeedDialogState.update {
|
||||
it.copy(
|
||||
url = "",
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// add feed
|
||||
|
||||
// update feed
|
||||
|
||||
fun setAccountDropDownState(isExpanded: Boolean) {
|
||||
_updateFeedDialogState.update {
|
||||
it.copy(isAccountDropDownExpanded = isExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedFolder(folder: Folder) {
|
||||
_updateFeedDialogState.update {
|
||||
it.copy(selectedFolder = folder)
|
||||
}
|
||||
}
|
||||
|
||||
fun setUpdateFeedDialogStateFeedName(feedName: String) {
|
||||
_updateFeedDialogState.update {
|
||||
it.copy(
|
||||
feedName = feedName,
|
||||
feedNameError = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setUpdateFeedDialogFeedUrl(feedUrl: String) {
|
||||
_updateFeedDialogState.update {
|
||||
it.copy(
|
||||
feedUrl = feedUrl,
|
||||
feedUrlError = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFeedDialogValidate() {
|
||||
val feedName = _updateFeedDialogState.value.feedName
|
||||
val feedUrl = _updateFeedDialogState.value.feedUrl
|
||||
|
||||
when {
|
||||
feedName.isEmpty() -> {
|
||||
_updateFeedDialogState.update {
|
||||
it.copy(feedNameError = TextFieldError.EmptyField)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
feedUrl.isEmpty() -> {
|
||||
_updateFeedDialogState.update {
|
||||
it.copy(feedUrlError = TextFieldError.EmptyField)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
!Patterns.WEB_URL.matcher(feedUrl).matches() -> {
|
||||
_updateFeedDialogState.update {
|
||||
it.copy(feedUrlError = TextFieldError.BadUrl)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
else -> {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
with(_updateFeedDialogState.value) {
|
||||
repository?.updateFeed(
|
||||
Feed(
|
||||
id = feedId,
|
||||
name = feedName,
|
||||
url = feedUrl,
|
||||
folderId = selectedFolder?.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
closeDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update feed
|
||||
|
||||
// add/update folder
|
||||
|
||||
fun setFolderName(name: String) = _folderState.update {
|
||||
it.copy(
|
||||
folder = it.folder.copy(name = name),
|
||||
nameError = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun resetFolderState() = _folderState.update {
|
||||
it.copy(
|
||||
folder = Folder(),
|
||||
nameError = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun folderValidate(updateFolder: Boolean = false) {
|
||||
val name = _folderState.value.name.orEmpty()
|
||||
|
||||
if (name.isEmpty()) {
|
||||
_folderState.update { it.copy(nameError = TextFieldError.EmptyField) }
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (updateFolder) {
|
||||
repository?.updateFolder(_folderState.value.folder)
|
||||
} else {
|
||||
repository?.addFolder(_folderState.value.folder.apply {
|
||||
accountId = currentAccount!!.id
|
||||
})
|
||||
}
|
||||
|
||||
closeDialog()
|
||||
resetFolderState()
|
||||
}
|
||||
}
|
||||
|
||||
// add/update folder
|
||||
}
|
|
@ -0,0 +1,143 @@
|
|||
package com.readrops.app.compose.feeds
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.util.theme.MediumSpacer
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
|
||||
@Composable
|
||||
fun FolderExpandableItem(
|
||||
folder: Folder,
|
||||
feeds: List<Feed>,
|
||||
isExpanded: Boolean = false,
|
||||
onFeedClick: (Feed) -> Unit,
|
||||
onFeedLongClick: (Feed) -> Unit,
|
||||
onUpdateFolder: () -> Unit,
|
||||
onDeleteFolder: () -> Unit
|
||||
) {
|
||||
var isFolderExpanded by remember { mutableStateOf(false) }
|
||||
var isDropDownMenuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(isExpanded) {
|
||||
isFolderExpanded = isExpanded
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.animateContentSize(
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = LinearOutSlowInEasing,
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clickable { isFolderExpanded = isFolderExpanded.not() }
|
||||
.padding(
|
||||
start = MaterialTheme.spacing.mediumSpacing,
|
||||
top = MaterialTheme.spacing.veryShortSpacing,
|
||||
bottom = MaterialTheme.spacing.veryShortSpacing
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_folder_grey),
|
||||
contentDescription = folder.name
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
Text(
|
||||
text = folder.name!!,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { isDropDownMenuExpanded = isDropDownMenuExpanded.not() }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = isDropDownMenuExpanded,
|
||||
onDismissRequest = { isDropDownMenuExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = "Update") },
|
||||
onClick = {
|
||||
isDropDownMenuExpanded = false
|
||||
onUpdateFolder()
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = stringResource(R.string.delete)) },
|
||||
onClick = {
|
||||
isDropDownMenuExpanded = false
|
||||
onDeleteFolder()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
if (isFolderExpanded) {
|
||||
for (feed in feeds) {
|
||||
FeedItem(
|
||||
feed = feed,
|
||||
onClick = { onFeedClick(feed) },
|
||||
onLongClick = { onFeedLongClick(feed) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
package com.readrops.app.compose.feeds.dialogs
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.feeds.FeedViewModel
|
||||
import com.readrops.app.compose.util.components.BaseDialog
|
||||
import com.readrops.app.compose.util.theme.LargeSpacer
|
||||
import com.readrops.app.compose.util.theme.ShortSpacer
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddFeedDialog(
|
||||
viewModel: FeedViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.addFeedDialogState.collectAsStateWithLifecycle()
|
||||
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
BaseDialog(
|
||||
title = stringResource(R.string.add_feed_item),
|
||||
icon = painterResource(id = R.drawable.ic_rss_feed_grey),
|
||||
onDismiss = onDismiss
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.url,
|
||||
label = {
|
||||
Text(text = "URL")
|
||||
},
|
||||
onValueChange = { viewModel.setAddFeedDialogURL(it) },
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (state.url.isNotEmpty()) {
|
||||
IconButton(
|
||||
onClick = { viewModel.setAddFeedDialogURL("") }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
isError = state.isError,
|
||||
supportingText = { Text(state.error?.errorText().orEmpty()) }
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = isExpanded,
|
||||
onExpandedChange = { isExpanded = isExpanded.not() }
|
||||
) {
|
||||
ExposedDropdownMenu(
|
||||
expanded = isExpanded,
|
||||
onDismissRequest = { isExpanded = false }
|
||||
) {
|
||||
for (account in state.accounts) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = account.accountName!!) },
|
||||
onClick = {
|
||||
isExpanded = false
|
||||
viewModel.setAddFeedDialogSelectedAccount(account)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = if (state.selectedAccount.isLocal) {
|
||||
R.drawable.ic_rss_feed_grey
|
||||
} else
|
||||
state.selectedAccount.accountType!!.iconRes
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.selectedAccount.accountName!!,
|
||||
readOnly = true,
|
||||
onValueChange = {},
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = if (state.selectedAccount.isLocal) {
|
||||
R.drawable.ic_rss_feed_grey
|
||||
} else
|
||||
state.selectedAccount.accountType!!.iconRes
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier.menuAnchor()
|
||||
)
|
||||
}
|
||||
|
||||
LargeSpacer()
|
||||
|
||||
TextButton(
|
||||
onClick = { viewModel.addFeedDialogValidate() },
|
||||
) {
|
||||
Text(text = stringResource(R.string.validate))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,156 @@
|
|||
package com.readrops.app.compose.feeds.dialogs
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
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.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil.compose.AsyncImage
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.util.theme.LargeSpacer
|
||||
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,
|
||||
onUpdateColor: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { onDismissRequest() }
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = MaterialTheme.spacing.largeSpacing
|
||||
)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = feed.iconUrl,
|
||||
contentDescription = feed.name!!,
|
||||
modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing)
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = feed.name!!,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (folder != null) {
|
||||
VeryShortSpacer()
|
||||
|
||||
Text(
|
||||
text = folder.name!!,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = MaterialTheme.spacing.mediumSpacing
|
||||
)
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
BottomSheetOption(
|
||||
text = stringResource(R.string.open),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_open_in_browser),
|
||||
onClick = onOpen
|
||||
)
|
||||
|
||||
BottomSheetOption(
|
||||
text = "Update",
|
||||
icon = Icons.Default.Create,
|
||||
onClick = onUpdate
|
||||
)
|
||||
|
||||
BottomSheetOption(
|
||||
text = "Update color",
|
||||
icon = ImageVector.vectorResource(R.drawable.ic_color),
|
||||
onClick = onUpdateColor
|
||||
)
|
||||
|
||||
BottomSheetOption(
|
||||
text = stringResource(R.string.delete),
|
||||
icon = Icons.Default.Delete,
|
||||
onClick = onDelete
|
||||
)
|
||||
}
|
||||
|
||||
LargeSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomSheetOption(
|
||||
text: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.clickable { onClick() }
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = MaterialTheme.spacing.mediumSpacing,
|
||||
vertical = MaterialTheme.spacing.shortSpacing
|
||||
)
|
||||
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = text
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
package com.readrops.app.compose.feeds.dialogs
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.feeds.FeedViewModel
|
||||
import com.readrops.app.compose.util.components.BaseDialog
|
||||
import com.readrops.app.compose.util.theme.LargeSpacer
|
||||
|
||||
@Composable
|
||||
fun FolderDialog(
|
||||
updateFolder: Boolean = false,
|
||||
viewModel: FeedViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onValidate: () -> Unit
|
||||
) {
|
||||
val state by viewModel.folderState.collectAsStateWithLifecycle()
|
||||
|
||||
BaseDialog(
|
||||
title = stringResource(id = if (updateFolder) R.string.edit_folder else R.string.add_folder),
|
||||
icon = painterResource(id = if (updateFolder) R.drawable.ic_folder_grey else R.drawable.ic_new_folder),
|
||||
onDismiss = onDismiss
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.name.orEmpty(),
|
||||
label = {
|
||||
Text(text = "URL")
|
||||
},
|
||||
onValueChange = { viewModel.setFolderName(it) },
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (!state.name.isNullOrEmpty()) {
|
||||
IconButton(
|
||||
onClick = { viewModel.setFolderName("") }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
isError = state.isError,
|
||||
supportingText = { Text(text = state.nameError?.errorText().orEmpty()) }
|
||||
)
|
||||
|
||||
LargeSpacer()
|
||||
|
||||
TextButton(
|
||||
onClick = { onValidate() },
|
||||
) {
|
||||
Text(text = stringResource(R.string.validate))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
package com.readrops.app.compose.feeds.dialogs
|
||||
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.feeds.FeedViewModel
|
||||
import com.readrops.app.compose.util.components.BaseDialog
|
||||
import com.readrops.app.compose.util.theme.LargeSpacer
|
||||
import com.readrops.app.compose.util.theme.MediumSpacer
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UpdateFeedDialog(
|
||||
viewModel: FeedViewModel,
|
||||
onDismissRequest: () -> Unit
|
||||
) {
|
||||
val state by viewModel.updateFeedDialogState.collectAsStateWithLifecycle()
|
||||
|
||||
BaseDialog(
|
||||
title = stringResource(R.string.edit_feed),
|
||||
icon = painterResource(id = R.drawable.ic_rss_feed_grey),
|
||||
onDismiss = onDismissRequest
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.feedName,
|
||||
onValueChange = { viewModel.setUpdateFeedDialogStateFeedName(it) },
|
||||
label = { Text(text = stringResource(R.string.feed_name)) },
|
||||
singleLine = true,
|
||||
isError = state.isFeedNameError,
|
||||
supportingText = {
|
||||
if (state.isFeedNameError) {
|
||||
Text(
|
||||
text = state.feedNameError?.errorText().orEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.feedUrl,
|
||||
onValueChange = { viewModel.setUpdateFeedDialogFeedUrl(it) },
|
||||
label = { Text(text = stringResource(R.string.feed_url)) },
|
||||
singleLine = true,
|
||||
readOnly = state.isFeedUrlReadOnly,
|
||||
isError = state.isFeedUrlError,
|
||||
supportingText = {
|
||||
if (state.isFeedUrlError) {
|
||||
Text(
|
||||
text = state.feedUrlError?.errorText().orEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = state.isAccountDropDownExpanded && state.hasFolders,
|
||||
onExpandedChange = { viewModel.setAccountDropDownState(state.isAccountDropDownExpanded.not()) }
|
||||
) {
|
||||
ExposedDropdownMenu(
|
||||
expanded = state.isAccountDropDownExpanded && state.hasFolders,
|
||||
onDismissRequest = { viewModel.setAccountDropDownState(false) }
|
||||
) {
|
||||
for (folder in state.folders) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = folder.name!!) },
|
||||
onClick = {
|
||||
viewModel.setSelectedFolder(folder)
|
||||
viewModel.setAccountDropDownState(false)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_folder_grey),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.selectedFolder?.name.orEmpty(),
|
||||
readOnly = true,
|
||||
enabled = state.hasFolders,
|
||||
onValueChange = {},
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = state.isAccountDropDownExpanded)
|
||||
},
|
||||
leadingIcon = {
|
||||
if (state.selectedFolder != null) {
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_folder_grey),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.menuAnchor()
|
||||
)
|
||||
}
|
||||
|
||||
LargeSpacer()
|
||||
|
||||
TextButton(
|
||||
onClick = { viewModel.updateFeedDialogValidate() },
|
||||
) {
|
||||
Text(text = stringResource(R.string.validate))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
package com.readrops.app.compose.home
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import cafe.adriel.voyager.androidx.AndroidScreen
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import cafe.adriel.voyager.navigator.tab.CurrentTab
|
||||
import cafe.adriel.voyager.navigator.tab.TabNavigator
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.account.AccountTab
|
||||
import com.readrops.app.compose.feeds.FeedTab
|
||||
import com.readrops.app.compose.more.MoreTab
|
||||
import com.readrops.app.compose.timelime.TimelineTab
|
||||
|
||||
class HomeScreen : AndroidScreen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
TabNavigator(
|
||||
tab = TimelineTab
|
||||
) { tabNavigator ->
|
||||
CompositionLocalProvider(LocalNavigator provides navigator) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
BottomAppBar {
|
||||
NavigationBarItem(
|
||||
selected = tabNavigator.current.key == TimelineTab.key,
|
||||
onClick = { tabNavigator.current = TimelineTab },
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_timeline),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
label = { Text("Timeline") }
|
||||
)
|
||||
|
||||
NavigationBarItem(
|
||||
selected = tabNavigator.current.key == FeedTab.key,
|
||||
onClick = { tabNavigator.current = FeedTab },
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_rss_feed_grey),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
label = { Text(text = stringResource(R.string.feeds)) }
|
||||
)
|
||||
|
||||
NavigationBarItem(
|
||||
selected = tabNavigator.current.key == AccountTab.key,
|
||||
onClick = { tabNavigator.current = AccountTab },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AccountCircle,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
label = { Text(text = stringResource(R.string.account)) }
|
||||
)
|
||||
|
||||
NavigationBarItem(
|
||||
selected = tabNavigator.current.key == MoreTab.key,
|
||||
onClick = { tabNavigator.current = MoreTab },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
label = { Text("More") }
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
CurrentTab()
|
||||
}
|
||||
|
||||
BackHandler(
|
||||
enabled = tabNavigator.current != TimelineTab,
|
||||
onBack = { tabNavigator.current = TimelineTab }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package com.readrops.app.compose.item
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import cafe.adriel.voyager.androidx.AndroidScreen
|
||||
|
||||
class ItemScreen : AndroidScreen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
Text(text ="item screen")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
package com.readrops.app.compose.more
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.unit.dp
|
||||
import cafe.adriel.voyager.navigator.tab.Tab
|
||||
import cafe.adriel.voyager.navigator.tab.TabOptions
|
||||
import com.readrops.app.compose.BuildConfig
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.util.components.SelectableIconText
|
||||
import com.readrops.app.compose.util.theme.LargeSpacer
|
||||
import com.readrops.app.compose.util.theme.MediumSpacer
|
||||
import com.readrops.app.compose.util.theme.ShortSpacer
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
|
||||
object MoreTab : Tab {
|
||||
|
||||
override val options: TabOptions
|
||||
@Composable
|
||||
get() = TabOptions(
|
||||
index = 4u,
|
||||
title = "More"
|
||||
)
|
||||
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LargeSpacer()
|
||||
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_freshrss),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Text(
|
||||
text = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
|
||||
LargeSpacer()
|
||||
|
||||
SelectableIconText(
|
||||
icon = painterResource(id = R.drawable.ic_settings),
|
||||
text = stringResource(R.string.settings),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
onClick = { }
|
||||
)
|
||||
|
||||
SelectableIconText(
|
||||
icon = painterResource(id = R.drawable.ic_settings),
|
||||
text = "Backup",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
onClick = { }
|
||||
)
|
||||
|
||||
SelectableIconText(
|
||||
icon = painterResource(id = R.drawable.ic_settings),
|
||||
text = "Open-source libraries",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
onClick = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
package com.readrops.app.compose.repositories
|
||||
|
||||
import com.readrops.api.services.SyncResult
|
||||
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
|
||||
|
||||
data class ErrorResult(
|
||||
val values: Map<Feed, Exception>
|
||||
)
|
||||
|
||||
abstract class ARepository(
|
||||
val database: Database,
|
||||
val account: Account
|
||||
) {
|
||||
|
||||
/**
|
||||
* This method is intended for remote accounts.
|
||||
*/
|
||||
abstract suspend fun login()
|
||||
|
||||
/**
|
||||
* Global synchronization for the local account.
|
||||
* @param selectedFeeds feeds to be updated
|
||||
* @param onUpdate get synchronization status
|
||||
* @return returns the result of the synchronization used by notifications
|
||||
* and errors per feed if occurred to be transmitted to the user
|
||||
*/
|
||||
abstract suspend fun synchronize(
|
||||
selectedFeeds: List<Feed>?,
|
||||
onUpdate: (Feed) -> Unit
|
||||
): Pair<SyncResult, ErrorResult>
|
||||
|
||||
/**
|
||||
* Global synchronization for remote accounts. Unlike the local account, remote accounts
|
||||
* won't benefit from synchronization status and granular synchronization
|
||||
*/
|
||||
abstract suspend fun synchronize(): SyncResult
|
||||
|
||||
abstract suspend fun insertNewFeeds(urls: List<String>)
|
||||
}
|
||||
|
||||
abstract class BaseRepository(
|
||||
database: Database,
|
||||
account: Account,
|
||||
) : ARepository(database, account) {
|
||||
|
||||
open suspend fun updateFeed(feed: Feed) = database.newFeedDao().updateFeedFields(feed.id, feed.name!!, feed.url!!, feed.folderId)
|
||||
|
||||
open suspend fun deleteFeed(feed: Feed) = database.newFeedDao().delete(feed)
|
||||
|
||||
open suspend fun addFolder(folder: Folder) = database.newFolderDao().insert(folder)
|
||||
|
||||
open suspend fun updateFolder(folder: Folder) = database.newFolderDao().update(folder)
|
||||
|
||||
open suspend fun deleteFolder(folder: Folder) = database.newFolderDao().delete(folder)
|
||||
|
||||
open suspend fun setItemReadState(item: Item) {
|
||||
database.newItemDao().updateReadState(item.id, item.isRead)
|
||||
}
|
||||
|
||||
open suspend fun setItemStarState(item: Item) {
|
||||
database.newItemDao().updateStarState(item.id, item.isStarred)
|
||||
}
|
||||
|
||||
open suspend fun setAllItemsRead(accountId: Int) {
|
||||
database.newItemDao().setAllItemsRead(accountId)
|
||||
}
|
||||
|
||||
open suspend fun setAllStarredItemsRead(accountId: Int) {
|
||||
database.newItemDao().setAllStarredItemsRead(accountId)
|
||||
}
|
||||
|
||||
open suspend fun setAllItemsReadByFeed(feedId: Int, accountId: Int) {
|
||||
database.newItemDao().setAllItemsReadByFeed(feedId, accountId)
|
||||
}
|
||||
|
||||
open suspend fun setAllItemsReadByFolder(folderId: Int, accountId: Int) {
|
||||
database.newItemDao().setAllItemsReadByFolder(folderId, accountId)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package com.readrops.app.compose.repositories
|
||||
|
||||
import com.readrops.db.Database
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
class GetFoldersWithFeeds(
|
||||
private val database: Database,
|
||||
) {
|
||||
|
||||
fun get(accountId: Int): Flow<Map<Folder?, List<Feed>>> {
|
||||
return combine(
|
||||
flow = database.newFolderDao()
|
||||
.selectFoldersAndFeeds(accountId),
|
||||
flow2 = database.newFeedDao()
|
||||
.selectFeedsWithoutFolder(accountId)
|
||||
) { folders, feedsWithoutFolder ->
|
||||
val foldersWithFeeds = folders.groupBy(
|
||||
keySelector = {
|
||||
Folder(
|
||||
id = it.folderId,
|
||||
name = it.folderName,
|
||||
accountId = it.accountId
|
||||
) as Folder?
|
||||
},
|
||||
valueTransform = {
|
||||
Feed(
|
||||
id = it.feedId,
|
||||
name = it.feedName,
|
||||
iconUrl = it.feedIcon,
|
||||
url = it.feedUrl,
|
||||
siteUrl = it.feedSiteUrl,
|
||||
unreadCount = it.unreadCount
|
||||
)
|
||||
}
|
||||
).mapValues { listEntry ->
|
||||
if (listEntry.value.any { it.id == 0 }) {
|
||||
listOf()
|
||||
} else {
|
||||
listEntry.value
|
||||
}
|
||||
}
|
||||
|
||||
if (feedsWithoutFolder.isNotEmpty()) {
|
||||
foldersWithFeeds + mapOf(
|
||||
Pair(
|
||||
null,
|
||||
feedsWithoutFolder.map { it.feed.apply { unreadCount = it.unreadCount } })
|
||||
)
|
||||
} else {
|
||||
foldersWithFeeds
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
package com.readrops.app.compose.repositories
|
||||
|
||||
import com.readrops.api.localfeed.LocalRSSDataSource
|
||||
import com.readrops.api.services.SyncResult
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.api.utils.HtmlParser
|
||||
import com.readrops.app.compose.util.FeedColors
|
||||
import com.readrops.app.compose.util.Utils
|
||||
import com.readrops.db.Database
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.db.entities.account.Account
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Headers
|
||||
import org.jsoup.Jsoup
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
class LocalRSSRepository(
|
||||
private val dataSource: LocalRSSDataSource,
|
||||
database: Database,
|
||||
account: Account
|
||||
) : BaseRepository(database, account), KoinComponent {
|
||||
|
||||
override suspend fun login() { /* useless here */
|
||||
}
|
||||
|
||||
override suspend fun synchronize(
|
||||
selectedFeeds: List<Feed>?,
|
||||
onUpdate: (Feed) -> Unit
|
||||
): Pair<SyncResult, ErrorResult> {
|
||||
val errors = mutableMapOf<Feed, Exception>()
|
||||
val syncResult = SyncResult()
|
||||
|
||||
val feeds = if (selectedFeeds.isNullOrEmpty()) {
|
||||
database.newFeedDao().selectFeeds(account.id)
|
||||
} else selectedFeeds
|
||||
|
||||
for (feed in feeds) {
|
||||
onUpdate(feed)
|
||||
|
||||
val headers = Headers.Builder()
|
||||
if (feed.etag != null) {
|
||||
headers[ApiUtils.IF_NONE_MATCH_HEADER] = feed.etag!!
|
||||
}
|
||||
if (feed.lastModified != null) {
|
||||
headers[ApiUtils.IF_MODIFIED_HEADER] = feed.lastModified!!
|
||||
}
|
||||
|
||||
try {
|
||||
val pair = dataSource.queryRSSResource(feed.url!!, headers.build())
|
||||
|
||||
pair?.let {
|
||||
insertNewItems(it.second, feed)
|
||||
syncResult.items = it.second
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
errors[feed] = e
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return Pair(syncResult, ErrorResult(errors))
|
||||
}
|
||||
|
||||
override suspend fun synchronize(): SyncResult =
|
||||
throw NotImplementedError("This method can't be called here")
|
||||
|
||||
|
||||
override suspend fun insertNewFeeds(urls: List<String>) = withContext(Dispatchers.IO) {
|
||||
for (url in urls) {
|
||||
try {
|
||||
val result = dataSource.queryRSSResource(url, null)!!
|
||||
insertFeed(result.first)
|
||||
} catch (e: Exception) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun insertNewItems(items: List<Item>, feed: Feed) {
|
||||
items.sortedWith(Item::compareTo) // TODO Check if ordering is useful in this situation
|
||||
val itemsToInsert = mutableListOf<Item>()
|
||||
|
||||
for (item in items) {
|
||||
if (!database.itemDao().itemExists(item.guid!!, feed.accountId)) {
|
||||
if (item.description != null) {
|
||||
item.cleanDescription = Jsoup.parse(item.description).text()
|
||||
}
|
||||
|
||||
if (item.content != null) {
|
||||
item.readTime = Utils.readTimeFromString(item.content!!)
|
||||
} else if (item.description != null) {
|
||||
item.readTime = Utils.readTimeFromString(item.cleanDescription!!)
|
||||
}
|
||||
|
||||
item.feedId = feed.id
|
||||
itemsToInsert += item
|
||||
}
|
||||
}
|
||||
|
||||
database.newItemDao().insert(itemsToInsert)
|
||||
}
|
||||
|
||||
private suspend fun insertFeed(feed: Feed): Feed {
|
||||
require(!database.newFeedDao().feedExists(feed.url!!, account.id)) {
|
||||
"Feed already exists for account ${account.accountName}"
|
||||
}
|
||||
|
||||
return feed.apply {
|
||||
accountId = account.id
|
||||
// we need empty headers to query the feed just after, without any 304 result
|
||||
etag = null
|
||||
lastModified = null
|
||||
|
||||
iconUrl = HtmlParser.getFaviconLink(siteUrl!!, get()).also { feedUrl ->
|
||||
feedUrl?.let { backgroundColor = FeedColors.getFeedColor(it) }
|
||||
}
|
||||
|
||||
id = database.newFeedDao().insert(this).toInt()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
package com.readrops.app.compose.timelime
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.readrops.app.compose.R
|
||||
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.filters.ListSortType
|
||||
import com.readrops.db.queries.QueryFilters
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FilterBottomSheet(
|
||||
viewModel: TimelineViewModel,
|
||||
filters: QueryFilters,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.filters)
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { viewModel.setShowReadItemsState(!filters.showReadItems) }
|
||||
) {
|
||||
Checkbox(
|
||||
checked = filters.showReadItems,
|
||||
onCheckedChange = { viewModel.setShowReadItemsState(!filters.showReadItems) }
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.show_read_articles)
|
||||
)
|
||||
}
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
fun setSortTypeState() {
|
||||
viewModel.setSortTypeState(
|
||||
if (filters.sortType == ListSortType.NEWEST_TO_OLDEST)
|
||||
ListSortType.OLDEST_TO_NEWEST
|
||||
else
|
||||
ListSortType.NEWEST_TO_OLDEST
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { setSortTypeState() }
|
||||
) {
|
||||
Checkbox(
|
||||
checked = filters.sortType == ListSortType.OLDEST_TO_NEWEST,
|
||||
onCheckedChange = { setSortTypeState() }
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Text(
|
||||
text = "Show oldest items first"
|
||||
)
|
||||
}
|
||||
|
||||
LargeSpacer()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
package com.readrops.app.compose.timelime
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.readrops.api.utils.DateUtils
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.util.components.IconText
|
||||
import com.readrops.app.compose.util.theme.ShortSpacer
|
||||
import com.readrops.app.compose.util.theme.VeryShortSpacer
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
import com.readrops.db.pojo.ItemWithFeed
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun TimelineItem(
|
||||
itemWithFeed: ItemWithFeed,
|
||||
onClick: () -> Unit,
|
||||
onFavorite: () -> Unit,
|
||||
onReadLater: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
compactLayout: Boolean = false,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.padding(horizontal = MaterialTheme.spacing.shortSpacing)
|
||||
.alpha(if (itemWithFeed.item.isRead) 0.6f else 1f)
|
||||
.clickable { onClick() }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = MaterialTheme.spacing.shortSpacing,
|
||||
end = MaterialTheme.spacing.shortSpacing,
|
||||
top = MaterialTheme.spacing.shortSpacing,
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = itemWithFeed.feedIconUrl,
|
||||
contentDescription = null,
|
||||
placeholder = painterResource(R.drawable.ic_rss_feed_grey),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
VeryShortSpacer()
|
||||
|
||||
Text(
|
||||
text = itemWithFeed.feedName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (itemWithFeed.bgColor != 0) Color(itemWithFeed.bgColor) else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
Surface(
|
||||
color = if (itemWithFeed.bgColor != 0) Color(itemWithFeed.bgColor) else MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(48.dp)
|
||||
) {
|
||||
Text(
|
||||
text = DateUtils.formattedDateByLocal(itemWithFeed.item.pubDate!!),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (itemWithFeed.bgColor != 0) Color.White else MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = MaterialTheme.spacing.shortSpacing,
|
||||
vertical = MaterialTheme.spacing.veryShortSpacing
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = MaterialTheme.spacing.shortSpacing)
|
||||
) {
|
||||
if (itemWithFeed.folder != null) {
|
||||
IconText(
|
||||
icon = painterResource(id = R.drawable.ic_folder_grey),
|
||||
text = itemWithFeed.folder!!.name!!,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "·",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.veryShortSpacing)
|
||||
)
|
||||
}
|
||||
|
||||
IconText(
|
||||
icon = painterResource(id = R.drawable.ic_hourglass_empty),
|
||||
text = if (itemWithFeed.item.readTime < 1) "> 1 min" else "${itemWithFeed.item.readTime.roundToInt()} mins",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Text(
|
||||
text = itemWithFeed.item.title!!,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.shortSpacing)
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
if (itemWithFeed.item.cleanDescription != null && !compactLayout) {
|
||||
Text(
|
||||
text = itemWithFeed.item.cleanDescription!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.shortSpacing)
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
}
|
||||
|
||||
if (itemWithFeed.item.hasImage && !compactLayout) {
|
||||
AsyncImage(
|
||||
model = itemWithFeed.item.imageLink,
|
||||
contentDescription = itemWithFeed.item.title!!,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.aspectRatio(16f / 9f)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(MaterialTheme.spacing.shortSpacing)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (itemWithFeed.item.isStarred) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable { onFavorite() }
|
||||
)
|
||||
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_read_later),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable { onReadLater() }
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Share,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable { onShare() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,266 @@
|
|||
package com.readrops.app.compose.timelime
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import cafe.adriel.voyager.navigator.tab.Tab
|
||||
import cafe.adriel.voyager.navigator.tab.TabOptions
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.item.ItemScreen
|
||||
import com.readrops.app.compose.timelime.drawer.TimelineDrawer
|
||||
import com.readrops.app.compose.util.components.CenteredColumn
|
||||
import com.readrops.app.compose.util.components.TwoChoicesDialog
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
import com.readrops.db.filters.FilterType
|
||||
import org.koin.androidx.compose.getViewModel
|
||||
|
||||
|
||||
object TimelineTab : Tab {
|
||||
|
||||
override val options: TabOptions
|
||||
@Composable
|
||||
get() = TabOptions(
|
||||
index = 1u,
|
||||
title = "Timeline",
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val viewModel = getViewModel<TimelineViewModel>()
|
||||
val state by viewModel.timelineState.collectAsStateWithLifecycle()
|
||||
val items = state.itemState.collectAsLazyPagingItems()
|
||||
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberLazyListState()
|
||||
|
||||
// Use the depreciated refresh swipe as the material 3 one isn't available yet
|
||||
val swipeState = rememberSwipeRefreshState(state.isRefreshing)
|
||||
val drawerState = rememberDrawerState(
|
||||
initialValue = DrawerValue.Closed,
|
||||
confirmStateChange = {
|
||||
if (it == DrawerValue.Closed) {
|
||||
viewModel.closeDrawer()
|
||||
} else {
|
||||
viewModel.openDrawer()
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
)
|
||||
|
||||
BackHandler(
|
||||
enabled = state.isDrawerOpen,
|
||||
onBack = { viewModel.closeDrawer() }
|
||||
)
|
||||
|
||||
LaunchedEffect(state.isDrawerOpen) {
|
||||
if (state.isDrawerOpen) {
|
||||
drawerState.open()
|
||||
} else {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
|
||||
when (state.dialog) {
|
||||
DialogState.ConfirmDialog -> {
|
||||
TwoChoicesDialog(
|
||||
title = "Mark all items as read",
|
||||
text = "Do you really want to mark all items as read?",
|
||||
icon = painterResource(id = R.drawable.ic_rss_feed_grey),
|
||||
confirmText = "Validate",
|
||||
dismissText = "Cancel",
|
||||
onDismiss = { viewModel.closeDialog() },
|
||||
onConfirm = {
|
||||
viewModel.closeDialog()
|
||||
viewModel.setAllItemsRead()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DialogState.FilterSheet -> {
|
||||
FilterBottomSheet(
|
||||
viewModel = viewModel,
|
||||
filters = state.filters,
|
||||
onDismiss = {
|
||||
viewModel.closeDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
null -> {}
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
TimelineDrawer(
|
||||
state = state,
|
||||
onClickDefaultItem = {
|
||||
viewModel.updateDrawerDefaultItem(it)
|
||||
},
|
||||
onFolderClick = {
|
||||
viewModel.updateDrawerFolderSelection(it)
|
||||
},
|
||||
onFeedClick = {
|
||||
viewModel.updateDrawerFeedSelection(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = when (state.filters.filterType) {
|
||||
FilterType.FEED_FILTER -> state.filterFeedName
|
||||
FilterType.FOLDER_FILER -> state.filterFolderName
|
||||
FilterType.READ_IT_LATER_FILTER -> stringResource(R.string.read_later)
|
||||
FilterType.STARS_FILTER -> stringResource(R.string.favorites)
|
||||
FilterType.NO_FILTER -> stringResource(R.string.articles)
|
||||
FilterType.NEW -> stringResource(R.string.new_articles)
|
||||
}
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = { viewModel.openDrawer() }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { viewModel.openDialog(DialogState.FilterSheet) }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_filter_list),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.refreshTimeline() }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_sync),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (state.filters.filterType == FilterType.NO_FILTER) {
|
||||
viewModel.openDialog(DialogState.ConfirmDialog)
|
||||
} else {
|
||||
viewModel.setAllItemsRead()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_done_all),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
SwipeRefresh(
|
||||
state = swipeState,
|
||||
onRefresh = { viewModel.refreshTimeline() },
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
when {
|
||||
items.isLoading() -> {
|
||||
Log.d("TAG", "loading")
|
||||
CenteredColumn {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
items.isError() -> Text(text = "error")
|
||||
else -> {
|
||||
LazyColumn(
|
||||
state = scrollState,
|
||||
contentPadding = PaddingValues(vertical = MaterialTheme.spacing.shortSpacing),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.shortSpacing)
|
||||
) {
|
||||
items(
|
||||
count = items.itemCount,
|
||||
//key = { items[it]!! },
|
||||
contentType = { "item_with_feed" }
|
||||
) { itemCount ->
|
||||
val itemWithFeed = items[itemCount]!!
|
||||
|
||||
TimelineItem(
|
||||
itemWithFeed = itemWithFeed,
|
||||
onClick = {
|
||||
viewModel.setItemRead(itemWithFeed.item)
|
||||
navigator.push(ItemScreen())
|
||||
},
|
||||
onFavorite = { viewModel.updateStarState(itemWithFeed.item) },
|
||||
onReadLater = {},
|
||||
onShare = {
|
||||
viewModel.shareItem(itemWithFeed.item, context)
|
||||
},
|
||||
compactLayout = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun <T : Any> LazyPagingItems<T>.isLoading(): Boolean {
|
||||
return loadState.append is LoadState.Loading //|| loadState.refresh is LoadState.Loading
|
||||
}
|
||||
|
||||
fun <T : Any> LazyPagingItems<T>.isError(): Boolean {
|
||||
return loadState.append is LoadState.Error //|| loadState.refresh is LoadState.Error
|
||||
}
|
|
@ -0,0 +1,255 @@
|
|||
package com.readrops.app.compose.timelime
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.readrops.app.compose.base.TabViewModel
|
||||
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.Item
|
||||
import com.readrops.db.filters.FilterType
|
||||
import com.readrops.db.filters.ListSortType
|
||||
import com.readrops.db.pojo.ItemWithFeed
|
||||
import com.readrops.db.queries.ItemsQueryBuilder
|
||||
import com.readrops.db.queries.QueryFilters
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TimelineViewModel(
|
||||
private val database: Database,
|
||||
private val getFoldersWithFeeds: GetFoldersWithFeeds,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : TabViewModel(database) {
|
||||
|
||||
private val _timelineState = MutableStateFlow(TimelineState())
|
||||
val timelineState = _timelineState.asStateFlow()
|
||||
|
||||
private val filters = MutableStateFlow(_timelineState.value.filters)
|
||||
|
||||
init {
|
||||
viewModelScope.launch(dispatcher) {
|
||||
combine(
|
||||
accountEvent,
|
||||
filters
|
||||
) { account, filters ->
|
||||
filters.accountId = account.id
|
||||
Pair(account, filters)
|
||||
}.collectLatest { (account, filters) ->
|
||||
val query = ItemsQueryBuilder.buildItemsQuery(filters)
|
||||
|
||||
_timelineState.update {
|
||||
it.copy(
|
||||
itemState = Pager(
|
||||
config = PagingConfig(
|
||||
pageSize = 10,
|
||||
prefetchDistance = 10
|
||||
),
|
||||
pagingSourceFactory = {
|
||||
database.newItemDao().selectAll(query)
|
||||
},
|
||||
).flow
|
||||
.cachedIn(viewModelScope)
|
||||
)
|
||||
}
|
||||
|
||||
getFoldersWithFeeds.get(account.id)
|
||||
.collect { foldersAndFeeds ->
|
||||
_timelineState.update {
|
||||
it.copy(
|
||||
foldersAndFeeds = foldersAndFeeds
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshTimeline() {
|
||||
_timelineState.update { it.copy(isRefreshing = true) }
|
||||
viewModelScope.launch(dispatcher) {
|
||||
repository?.synchronize(null) {
|
||||
|
||||
}
|
||||
|
||||
_timelineState.update {
|
||||
it.copy(
|
||||
isRefreshing = false,
|
||||
endSynchronizing = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openDrawer() {
|
||||
_timelineState.update { it.copy(isDrawerOpen = true) }
|
||||
}
|
||||
|
||||
fun closeDrawer() {
|
||||
_timelineState.update { it.copy(isDrawerOpen = false) }
|
||||
}
|
||||
|
||||
fun updateDrawerDefaultItem(selection: FilterType) {
|
||||
_timelineState.update {
|
||||
it.copy(
|
||||
filters = updateFilters {
|
||||
it.filters.copy(
|
||||
filterType = selection
|
||||
)
|
||||
},
|
||||
isDrawerOpen = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDrawerFolderSelection(folder: Folder) {
|
||||
_timelineState.update {
|
||||
it.copy(
|
||||
filters = updateFilters {
|
||||
it.filters.copy(
|
||||
filterType = FilterType.FOLDER_FILER,
|
||||
filterFolderId = folder.id,
|
||||
filterFeedId = 0
|
||||
)
|
||||
},
|
||||
filterFolderName = folder.name!!,
|
||||
isDrawerOpen = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDrawerFeedSelection(feed: Feed) {
|
||||
_timelineState.update {
|
||||
it.copy(
|
||||
filters = updateFilters {
|
||||
it.filters.copy(
|
||||
filterType = FilterType.FEED_FILTER,
|
||||
filterFeedId = feed.id,
|
||||
filterFolderId = 0
|
||||
)
|
||||
},
|
||||
filterFeedName = feed.name!!,
|
||||
isDrawerOpen = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFilters(block: () -> QueryFilters): QueryFilters {
|
||||
val filter = block()
|
||||
filters.update { filter }
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
fun setItemRead(item: Item) {
|
||||
item.isRead = true
|
||||
updateItemReadState(item)
|
||||
}
|
||||
|
||||
private fun updateItemReadState(item: Item) {
|
||||
viewModelScope.launch(dispatcher) {
|
||||
repository?.setItemReadState(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateStarState(item: Item) {
|
||||
viewModelScope.launch(dispatcher) {
|
||||
with(item) {
|
||||
isStarred = isStarred.not()
|
||||
repository?.setItemStarState(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 setAllItemsRead() {
|
||||
viewModelScope.launch(dispatcher) {
|
||||
when (_timelineState.value.filters.filterType) {
|
||||
FilterType.FEED_FILTER ->
|
||||
repository?.setAllItemsReadByFeed(
|
||||
_timelineState.value.filters.filterFeedId,
|
||||
currentAccount!!.id
|
||||
)
|
||||
|
||||
FilterType.FOLDER_FILER -> repository?.setAllItemsReadByFolder(
|
||||
_timelineState.value.filters.filterFolderId,
|
||||
currentAccount!!.id
|
||||
)
|
||||
|
||||
FilterType.READ_IT_LATER_FILTER -> TODO()
|
||||
FilterType.STARS_FILTER -> repository?.setAllStarredItemsRead(currentAccount!!.id)
|
||||
FilterType.NO_FILTER -> repository?.setAllItemsRead(currentAccount!!.id)
|
||||
FilterType.NEW -> TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openDialog(dialog: DialogState) = _timelineState.update { it.copy(dialog = dialog) }
|
||||
|
||||
fun closeDialog() = _timelineState.update { it.copy(dialog = null) }
|
||||
|
||||
fun setShowReadItemsState(showReadItems: Boolean) {
|
||||
_timelineState.update {
|
||||
it.copy(
|
||||
filters = updateFilters {
|
||||
it.filters.copy(
|
||||
showReadItems = showReadItems
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSortTypeState(sortType: ListSortType) {
|
||||
_timelineState.update {
|
||||
it.copy(
|
||||
filters = updateFilters {
|
||||
it.filters.copy(
|
||||
sortType = sortType
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class TimelineState(
|
||||
val isRefreshing: Boolean = false,
|
||||
val isDrawerOpen: Boolean = false,
|
||||
val endSynchronizing: Boolean = false,
|
||||
val filters: QueryFilters = QueryFilters(),
|
||||
val filterFeedName: String = "",
|
||||
val filterFolderName: String = "",
|
||||
val foldersAndFeeds: Map<Folder?, List<Feed>> = emptyMap(),
|
||||
val itemState: Flow<PagingData<ItemWithFeed>> = emptyFlow(),
|
||||
val dialog: DialogState? = null
|
||||
)
|
||||
|
||||
sealed interface DialogState {
|
||||
object ConfirmDialog : DialogState
|
||||
object FilterSheet : DialogState
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package com.readrops.app.compose.timelime.drawer
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.readrops.app.compose.util.theme.DrawerSpacing
|
||||
|
||||
@Composable
|
||||
fun DrawerFeedItem(
|
||||
label: @Composable () -> Unit,
|
||||
icon: @Composable () -> Unit,
|
||||
badge: @Composable () -> Unit,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = NavigationDrawerItemDefaults.colors()
|
||||
|
||||
Surface(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
color = colors.containerColor(selected = selected).value,
|
||||
shape = CircleShape,
|
||||
modifier = modifier
|
||||
.height(36.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 24.dp)
|
||||
) {
|
||||
val iconColor = colors.iconColor(selected).value
|
||||
CompositionLocalProvider(LocalContentColor provides iconColor, content = icon)
|
||||
|
||||
DrawerSpacing()
|
||||
|
||||
Box(Modifier.weight(1f)) {
|
||||
val labelColor = colors.textColor(selected).value
|
||||
CompositionLocalProvider(LocalContentColor provides labelColor, content = label)
|
||||
}
|
||||
|
||||
DrawerSpacing()
|
||||
|
||||
val badgeColor = colors.badgeColor(selected).value
|
||||
CompositionLocalProvider(LocalContentColor provides badgeColor, content = badge)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
package com.readrops.app.compose.timelime.drawer
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
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.rotate
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.util.theme.DrawerSpacing
|
||||
import com.readrops.db.entities.Feed
|
||||
|
||||
@Composable
|
||||
fun DrawerFolderItem(
|
||||
label: @Composable () -> Unit,
|
||||
icon: @Composable () -> Unit,
|
||||
badge: @Composable () -> Unit,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
feeds: List<Feed>,
|
||||
selectedFeed: Int,
|
||||
onFeedClick: (Feed) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = NavigationDrawerItemDefaults.colors()
|
||||
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
val rotationState by animateFloatAsState(
|
||||
targetValue = if (isExpanded) 180f else 0f,
|
||||
label = "drawer item arrow rotation"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.animateContentSize(
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = LinearOutSlowInEasing,
|
||||
)
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
color = colors.containerColor(selected = selected).value,
|
||||
shape = CircleShape,
|
||||
modifier = modifier
|
||||
.height(56.dp)
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = LinearOutSlowInEasing,
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 24.dp)
|
||||
) {
|
||||
val iconColor = colors.iconColor(selected).value
|
||||
CompositionLocalProvider(LocalContentColor provides iconColor, content = icon)
|
||||
|
||||
DrawerSpacing()
|
||||
|
||||
Box(Modifier.weight(1f)) {
|
||||
val labelColor = colors.textColor(selected).value
|
||||
CompositionLocalProvider(LocalContentColor provides labelColor, content = label)
|
||||
}
|
||||
|
||||
DrawerSpacing()
|
||||
|
||||
val badgeColor = colors.badgeColor(selected).value
|
||||
CompositionLocalProvider(LocalContentColor provides badgeColor, content = badge)
|
||||
|
||||
DrawerSpacing()
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clickable { isExpanded = isExpanded.not() }
|
||||
.rotate(rotationState),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isExpanded && feeds.isNotEmpty()) {
|
||||
for (feed in feeds) {
|
||||
DrawerFeedItem(
|
||||
label = {
|
||||
Text(
|
||||
text = feed.name!!,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
AsyncImage(
|
||||
model = feed.iconUrl,
|
||||
contentDescription = feed.name,
|
||||
placeholder = painterResource(id = R.drawable.ic_folder_grey),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
},
|
||||
badge = { Text(feed.unreadCount.toString()) },
|
||||
selected = feed.id == selectedFeed,
|
||||
onClick = { onFeedClick(feed) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,184 @@
|
|||
package com.readrops.app.compose.timelime.drawer
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
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 androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.timelime.TimelineState
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.filters.FilterType
|
||||
|
||||
@Composable
|
||||
fun TimelineDrawer(
|
||||
state: TimelineState,
|
||||
onClickDefaultItem: (FilterType) -> Unit,
|
||||
onFolderClick: (Folder) -> Unit,
|
||||
onFeedClick: (Feed) -> Unit,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Spacer(modifier = Modifier.size(MaterialTheme.spacing.drawerSpacing))
|
||||
|
||||
DrawerDefaultItems(
|
||||
selectedItem = state.filters.filterType,
|
||||
onClick = { onClickDefaultItem(it) }
|
||||
)
|
||||
|
||||
DrawerDivider()
|
||||
|
||||
Column {
|
||||
for (folderEntry in state.foldersAndFeeds) {
|
||||
val folder = folderEntry.key
|
||||
|
||||
if (folder != null) {
|
||||
DrawerFolderItem(
|
||||
label = {
|
||||
Text(
|
||||
text = folder.name!!,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_folder_grey),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
badge = {
|
||||
Text(folderEntry.value.sumOf { it.unreadCount }.toString())
|
||||
},
|
||||
selected = state.filters.filterFolderId == folder.id,
|
||||
onClick = { onFolderClick(folder) },
|
||||
feeds = folderEntry.value,
|
||||
selectedFeed = state.filters.filterFeedId,
|
||||
onFeedClick = { onFeedClick(it) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
} else {
|
||||
val feeds = folderEntry.value
|
||||
|
||||
for (feed in feeds) {
|
||||
DrawerFeedItem(
|
||||
label = {
|
||||
Text(
|
||||
text = feed.name!!,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
AsyncImage(
|
||||
model = feed.iconUrl,
|
||||
contentDescription = feed.name,
|
||||
placeholder = painterResource(id = R.drawable.ic_folder_grey),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
},
|
||||
badge = { Text(feed.unreadCount.toString()) },
|
||||
selected = feed.id == state.filters.filterFeedId,
|
||||
onClick = { onFeedClick(feed) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawerDefaultItems(
|
||||
selectedItem: FilterType,
|
||||
onClick: (FilterType) -> Unit,
|
||||
) {
|
||||
NavigationDrawerItem(
|
||||
label = { Text(text = stringResource(R.string.articles)) },
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_timeline),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
selected = selectedItem == FilterType.NO_FILTER,
|
||||
onClick = { onClick(FilterType.NO_FILTER) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
|
||||
NavigationDrawerItem(
|
||||
label = { Text("New articles") },
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_new),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
selected = selectedItem == FilterType.NEW,
|
||||
onClick = { onClick(FilterType.NEW) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
|
||||
NavigationDrawerItem(
|
||||
label = { Text(text = stringResource(R.string.favorites)) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Star,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
selected = selectedItem == FilterType.STARS_FILTER,
|
||||
onClick = { onClick(FilterType.STARS_FILTER) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
|
||||
NavigationDrawerItem(
|
||||
label = { Text(text = stringResource(R.string.read_later)) },
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_read_later),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
selected = selectedItem == FilterType.READ_IT_LATER_FILTER,
|
||||
onClick = { onClick(FilterType.READ_IT_LATER_FILTER) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawerDivider() {
|
||||
Divider(
|
||||
thickness = 2.dp,
|
||||
modifier = Modifier.padding(
|
||||
vertical = MaterialTheme.spacing.drawerSpacing,
|
||||
horizontal = 28.dp // M3 guidelines
|
||||
)
|
||||
)
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.readrops.app.compose.util
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
fun TextStyle.toDp(): Dp = fontSize.value.dp
|
|
@ -0,0 +1,48 @@
|
|||
package com.readrops.app.compose.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.palette.graphics.Palette
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
object FeedColors : KoinComponent {
|
||||
|
||||
suspend fun getFeedColor(feedUrl: String): Int {
|
||||
val context = get<Context>() // TODO maybe call imageLoader directly ? may require some DI changes
|
||||
|
||||
val result = context.imageLoader
|
||||
.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(feedUrl)
|
||||
.allowHardware(false)
|
||||
.build()
|
||||
).drawable as BitmapDrawable
|
||||
|
||||
val palette = Palette.from(result.bitmap).generate()
|
||||
|
||||
val dominantSwatch = palette.dominantSwatch
|
||||
return if (dominantSwatch != null && !isColorTooBright(dominantSwatch.rgb)
|
||||
&& !isColorTooDark(dominantSwatch.rgb)) {
|
||||
dominantSwatch.rgb
|
||||
} else 0
|
||||
}
|
||||
|
||||
private fun isColorTooBright(@ColorInt color: Int): Boolean {
|
||||
return getColorLuma(color) > 210
|
||||
}
|
||||
|
||||
private fun isColorTooDark(@ColorInt color: Int): Boolean {
|
||||
return getColorLuma(color) < 40
|
||||
}
|
||||
|
||||
private fun getColorLuma(@ColorInt color: Int): Double {
|
||||
val r = color shr 16 and 0xff
|
||||
val g = color shr 8 and 0xff
|
||||
val b = color shr 0 and 0xff
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package com.readrops.app.compose.util
|
||||
|
||||
object Utils {
|
||||
|
||||
private const val AVERAGE_WORDS_PER_MINUTE = 250
|
||||
|
||||
fun readTimeFromString(value: String): Double {
|
||||
val nbWords = value.split("\\s+").size
|
||||
return nbWords.toDouble() / AVERAGE_WORDS_PER_MINUTE
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
package com.readrops.app.compose.util.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.readrops.app.compose.util.theme.MediumSpacer
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
|
||||
@Composable
|
||||
fun BaseDialog(
|
||||
title: String,
|
||||
icon: Painter,
|
||||
onDismiss: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(MaterialTheme.spacing.largeSpacing)
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(MaterialTheme.spacing.largeSpacing)
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package com.readrops.app.compose.util.components
|
||||
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun CenteredProgressIndicator(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
CenteredColumn {
|
||||
CircularProgressIndicator(modifier)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package com.readrops.app.compose.util.components
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
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.text.style.TextAlign
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.util.theme.ShortSpacer
|
||||
import com.readrops.app.compose.util.theme.VeryShortSpacer
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
|
||||
@Composable
|
||||
fun ErrorMessage(
|
||||
exception: Exception?
|
||||
) {
|
||||
CenteredColumn {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_error),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing)
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Text(
|
||||
text = "An error occurred",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
||||
VeryShortSpacer()
|
||||
|
||||
if (exception != null) {
|
||||
val name = exception.javaClass.simpleName
|
||||
val message = exception.message
|
||||
|
||||
Text(
|
||||
text = "$name: $message",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,174 @@
|
|||
package com.readrops.app.compose.util.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
import com.readrops.app.compose.util.toDp
|
||||
|
||||
@Composable
|
||||
fun BaseText(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = LocalContentColor.current,
|
||||
spacing: Dp = MaterialTheme.spacing.veryShortSpacing,
|
||||
onClick: (() -> Unit)? = null,
|
||||
leftContent: @Composable () -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = if (onClick != null) modifier.clickable { onClick() } else modifier,
|
||||
) {
|
||||
leftContent()
|
||||
|
||||
Spacer(Modifier.width(spacing))
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = style,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconText(
|
||||
icon: Painter,
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = LocalContentColor.current,
|
||||
tint: Color = LocalContentColor.current,
|
||||
spacing: Dp = MaterialTheme.spacing.veryShortSpacing,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
BaseText(
|
||||
text = text,
|
||||
style = style,
|
||||
color = color,
|
||||
spacing = spacing,
|
||||
modifier = modifier,
|
||||
onClick = onClick
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
tint = tint,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(style.toDp()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageText(
|
||||
image: Painter,
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = LocalContentColor.current,
|
||||
spacing: Dp = MaterialTheme.spacing.veryShortSpacing,
|
||||
imageSize: Dp = style.toDp(),
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
BaseText(
|
||||
text = text,
|
||||
style = style,
|
||||
color = color,
|
||||
spacing = spacing,
|
||||
modifier = modifier,
|
||||
onClick = onClick
|
||||
) {
|
||||
Image(
|
||||
painter = image,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(imageSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun SelectableIconText(
|
||||
icon: Painter,
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = LocalContentColor.current,
|
||||
tint: Color = LocalContentColor.current,
|
||||
spacing: Dp = MaterialTheme.spacing.veryShortSpacing,
|
||||
padding: Dp = MaterialTheme.spacing.shortSpacing
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(padding)
|
||||
) {
|
||||
BaseText(
|
||||
text = text,
|
||||
style = style,
|
||||
color = color,
|
||||
spacing = spacing
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
tint = tint,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(style.toDp()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SelectableImageText(
|
||||
image: Painter,
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = LocalContentColor.current,
|
||||
spacing: Dp = MaterialTheme.spacing.veryShortSpacing,
|
||||
padding: Dp = MaterialTheme.spacing.shortSpacing,
|
||||
imageSize: Dp = style.toDp()
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(padding)
|
||||
) {
|
||||
BaseText(
|
||||
text = text,
|
||||
style = style,
|
||||
color = color,
|
||||
spacing = spacing
|
||||
) {
|
||||
Image(
|
||||
painter = image,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(imageSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package com.readrops.app.compose.util.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import com.readrops.app.compose.util.theme.ShortSpacer
|
||||
import com.readrops.app.compose.util.toDp
|
||||
|
||||
|
||||
@Composable
|
||||
fun CenteredColumn(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Placeholder(
|
||||
text: String,
|
||||
painter: Painter,
|
||||
) {
|
||||
CenteredColumn {
|
||||
Icon(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(MaterialTheme.typography.displayMedium.toDp() * 1.5f)
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.displaySmall
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package com.readrops.app.compose.util.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
|
||||
sealed class TextFieldError {
|
||||
object EmptyField : TextFieldError()
|
||||
object BadUrl : TextFieldError()
|
||||
object UnreachableUrl : TextFieldError()
|
||||
object NoRSSFeed : TextFieldError()
|
||||
object NoRSSUrl : TextFieldError()
|
||||
|
||||
@Composable
|
||||
fun errorText(): String =
|
||||
// TODO replace by string resources
|
||||
when (this) {
|
||||
BadUrl -> "Input is not a valid URL"
|
||||
EmptyField -> "Field can't be empty"
|
||||
NoRSSFeed -> "No RSS feed found"
|
||||
NoRSSUrl -> "The provided URL is not a valid RSS feed"
|
||||
UnreachableUrl -> "Unreachable URL"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.readrops.app.compose.util.components
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
|
||||
@Composable
|
||||
fun TwoChoicesDialog(
|
||||
title: String,
|
||||
text: String,
|
||||
icon: Painter,
|
||||
confirmText: String,
|
||||
dismissText: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = { Text(text = title) },
|
||||
text = { Text(text = text) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(text = confirmText)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss
|
||||
) {
|
||||
Text(text = dismissText)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package com.readrops.app.compose.util.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val md_theme_light_primary = Color(0xFF0062A2)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFFD1E4FF)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF001D35)
|
||||
val md_theme_light_secondary = Color(0xFFA43D00)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFFFDBCD)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF360F00)
|
||||
val md_theme_light_tertiary = Color(0xFF006D3D)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFF97F7B7)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF00210F)
|
||||
val md_theme_light_error = Color(0xFFBA1A1A)
|
||||
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||
val md_theme_light_background = Color(0xFFF8FDFF)
|
||||
val md_theme_light_onBackground = Color(0xFF001F25)
|
||||
val md_theme_light_surface = Color(0xFFF8FDFF)
|
||||
val md_theme_light_onSurface = Color(0xFF001F25)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFDFE2EB)
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF42474E)
|
||||
val md_theme_light_outline = Color(0xFF73777F)
|
||||
val md_theme_light_inverseOnSurface = Color(0xFFD6F6FF)
|
||||
val md_theme_light_inverseSurface = Color(0xFF00363F)
|
||||
val md_theme_light_inversePrimary = Color(0xFF9DCAFF)
|
||||
val md_theme_light_shadow = Color(0xFF000000)
|
||||
val md_theme_light_surfaceTint = Color(0xFF0062A2)
|
||||
val md_theme_light_outlineVariant = Color(0xFFC3C7CF)
|
||||
val md_theme_light_scrim = Color(0xFF000000)
|
||||
|
||||
val md_theme_dark_primary = Color(0xFF9DCAFF)
|
||||
val md_theme_dark_onPrimary = Color(0xFF003257)
|
||||
val md_theme_dark_primaryContainer = Color(0xFF00497C)
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFFD1E4FF)
|
||||
val md_theme_dark_secondary = Color(0xFFFFB597)
|
||||
val md_theme_dark_onSecondary = Color(0xFF581D00)
|
||||
val md_theme_dark_secondaryContainer = Color(0xFF7D2D00)
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFFFDBCD)
|
||||
val md_theme_dark_tertiary = Color(0xFF7BDA9C)
|
||||
val md_theme_dark_onTertiary = Color(0xFF00391D)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF00522C)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFF97F7B7)
|
||||
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||
val md_theme_dark_onError = Color(0xFF690005)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_background = Color(0xFF001F25)
|
||||
val md_theme_dark_onBackground = Color(0xFFA6EEFF)
|
||||
val md_theme_dark_surface = Color(0xFF001F25)
|
||||
val md_theme_dark_onSurface = Color(0xFFA6EEFF)
|
||||
val md_theme_dark_surfaceVariant = Color(0xFF42474E)
|
||||
val md_theme_dark_onSurfaceVariant = Color(0xFFC3C7CF)
|
||||
val md_theme_dark_outline = Color(0xFF8D9199)
|
||||
val md_theme_dark_inverseOnSurface = Color(0xFF001F25)
|
||||
val md_theme_dark_inverseSurface = Color(0xFFA6EEFF)
|
||||
val md_theme_dark_inversePrimary = Color(0xFF0062A2)
|
||||
val md_theme_dark_shadow = Color(0xFF000000)
|
||||
val md_theme_dark_surfaceTint = Color(0xFF9DCAFF)
|
||||
val md_theme_dark_outlineVariant = Color(0xFF42474E)
|
||||
val md_theme_dark_scrim = Color(0xFF000000)
|
||||
|
||||
|
||||
val seed = Color(0xFF0072BC)
|
|
@ -0,0 +1,45 @@
|
|||
package com.readrops.app.compose.util.theme
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class Spacing(
|
||||
val veryShortSpacing: Dp = 4.dp,
|
||||
val shortSpacing: Dp = 8.dp,
|
||||
val mediumSpacing: Dp = 16.dp,
|
||||
val largeSpacing: Dp = 24.dp,
|
||||
val veryLargeSpacing: Dp = 48.dp,
|
||||
val drawerSpacing: Dp = 12.dp
|
||||
)
|
||||
|
||||
val LocalSpacing = compositionLocalOf { Spacing() }
|
||||
|
||||
val MaterialTheme.spacing
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalSpacing.current
|
||||
|
||||
@Composable
|
||||
fun VeryShortSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.veryShortSpacing))
|
||||
|
||||
@Composable
|
||||
fun ShortSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.shortSpacing))
|
||||
|
||||
@Composable
|
||||
fun MediumSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.mediumSpacing))
|
||||
|
||||
@Composable
|
||||
fun LargeSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.largeSpacing))
|
||||
|
||||
@Composable
|
||||
fun VeryLargeSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.veryLargeSpacing))
|
||||
|
||||
@Composable
|
||||
fun DrawerSpacing() = Spacer(Modifier.size(MaterialTheme.spacing.drawerSpacing))
|
|
@ -0,0 +1,90 @@
|
|||
package com.readrops.app.compose.util.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onError = md_theme_light_onError,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
surfaceTint = md_theme_light_surfaceTint,
|
||||
outlineVariant = md_theme_light_outlineVariant,
|
||||
scrim = md_theme_light_scrim,
|
||||
)
|
||||
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onError = md_theme_dark_onError,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
surfaceTint = md_theme_dark_surfaceTint,
|
||||
outlineVariant = md_theme_dark_outlineVariant,
|
||||
scrim = md_theme_dark_scrim,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ReadropsTheme(
|
||||
useDarkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = if (!useDarkTheme) {
|
||||
LightColors
|
||||
} else {
|
||||
DarkColors
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
content = content
|
||||
)
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
<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 android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M13,8c0,-2.21 -1.79,-4 -4,-4S5,5.79 5,8s1.79,4 4,4S13,10.21 13,8zM15,10v2h3v3h2v-3h3v-2h-3V7h-2v3H15zM1,18v2h16v-2c0,-2.66 -5.33,-4 -8,-4S1,15.34 1,18z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM14,13v4h-4v-4H7l5,-5 5,5h-3z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,3c-4.97,0 -9,4.03 -9,9s4.03,9 9,9c0.83,0 1.5,-0.67 1.5,-1.5 0,-0.39 -0.15,-0.74 -0.39,-1.01 -0.23,-0.26 -0.38,-0.61 -0.38,-0.99 0,-0.83 0.67,-1.5 1.5,-1.5L16,16c2.76,0 5,-2.24 5,-5 0,-4.42 -4.03,-8 -9,-8zM6.5,12c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,9 6.5,9 8,9.67 8,10.5 7.33,12 6.5,12zM9.5,8C8.67,8 8,7.33 8,6.5S8.67,5 9.5,5s1.5,0.67 1.5,1.5S10.33,8 9.5,8zM14.5,8c-0.83,0 -1.5,-0.67 -1.5,-1.5S13.67,5 14.5,5s1.5,0.67 1.5,1.5S15.33,8 14.5,8zM17.5,12c-0.83,0 -1.5,-0.67 -1.5,-1.5S16.67,9 17.5,9s1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M18,7l-1.41,-1.41 -6.34,6.34 1.41,1.41L18,7zM22.24,5.59L11.66,16.17 7.48,12l-1.41,1.41L11.66,19l12,-12 -1.42,-1.41zM0.41,13.41L6,19l1.41,-1.41L1.83,12 0.41,13.41z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M10,18h4v-2h-4v2zM3,6v2h18L21,6L3,6zM6,13h12v-2L6,11v2z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="#727272"
|
||||
android:viewportHeight="24.0" android:viewportWidth="24.0"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FF000000" android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M6,2v6h0.01L6,8.01 10,12l-4,4 0.01,0.01L6,16.01L6,22h12v-5.99h-0.01L18,16l-4,-4 4,-3.99 -0.01,-0.01L18,8L18,2L6,2zM16,16.5L16,20L8,20v-3.5l4,-4 4,4zM12,11.5l-4,-4L8,4h8v3.5l-4,4z"/>
|
||||
</vector>
|
|
@ -0,0 +1,170 @@
|
|||
<?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 android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V6C22,4.89 21.11,4 20,4zM8.5,15H7.3l-2.55,-3.5V15H3.5V9h1.25l2.5,3.5V9H8.5V15zM13.5,10.26H11v1.12h2.5v1.26H11v1.11h2.5V15h-4V9h4V10.26zM20.5,14c0,0.55 -0.45,1 -1,1h-4c-0.55,0 -1,-0.45 -1,-1V9h1.25v4.51h1.13V9.99h1.25v3.51h1.12V9h1.25V14z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24.0" android:viewportWidth="24.0"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FF000000" android:pathData="M20,6h-8l-2,-2L4,4c-1.11,0 -1.99,0.89 -1.99,2L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2L22,8c0,-1.11 -0.89,-2 -2,-2zM19,14h-3v3h-2v-3h-3v-2h3L14,9h2v3h3v2z"/>
|
||||
</vector>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue