First Commit

This commit is contained in:
2021-03-10 18:17:24 +05:30
commit 207ce82ff2
360 changed files with 12105 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
PSMForums

138
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,138 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="java.util" alias="false" withSubpackages="false" />
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
</option>
<option name="PACKAGES_IMPORT_LAYOUT">
<value>
<package name="" alias="false" withSubpackages="true" />
<package name="java" alias="false" withSubpackages="true" />
<package name="javax" alias="false" withSubpackages="true" />
<package name="kotlin" alias="false" withSubpackages="true" />
<package name="" alias="true" withSubpackages="true" />
</value>
</option>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.8" />
</component>
</project>

6
.idea/copyright/Android.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Copyright (c) &amp;#36;today.year PSMForums. All rights reserved.&#10;Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);&#10;you may not use this file except in compliance with the License.&#10;You may obtain a copy of the License at&#10;http://www.apache.org/licenses/LICENSE-2.0&#10;Unless required by applicable law or agreed to in writing, software&#10;distributed under the License is distributed on an &quot;AS IS&quot; BASIS,&#10;WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#10;See the License for the specific language governing permissions and&#10;limitations under the License." />
<option name="myName" value="Android" />
</copyright>
</component>

7
.idea/copyright/profiles_settings.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<component name="CopyrightManager">
<settings default="Android">
<module2copyright>
<element module="All" copyright="Android" />
</module2copyright>
</settings>
</component>

17
.idea/dictionaries/Joshua.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<component name="ProjectDictionaryState">
<dictionary name="Joshua">
<words>
<w>feedless</w>
<w>feedly</w>
<w>joshuacerdenia</w>
<w>nicefeed</w>
<w>opml</w>
<w>pathified</w>
<w>rssifyer</w>
<w>sifyer</w>
<w>snackbar</w>
<w>supertitle</w>
<w>unstarred</w>
</words>
</dictionary>
</component>

22
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="PLATFORM" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="1.8" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
<option name="useQualifiedModuleNames" value="true" />
</GradleProjectSettings>
</option>
</component>
</project>

30
.idea/jarRepositories.xml generated Normal file
View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="BintrayJCenter" />
<option name="name" value="BintrayJCenter" />
<option name="url" value="https://jcenter.bintray.com/" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
</component>
</project>

9
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

6
.idea/render.experimental.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RenderSettings">
<option name="showDecorations" value="true" />
</component>
</project>

12
.idea/runConfigurations.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

10
LICENSE Normal file
View File

@@ -0,0 +1,10 @@
Copyright (c) 2021 PSMForums. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

34
README.md Normal file
View File

@@ -0,0 +1,34 @@
# **PSMForums**
_`Last Updated: March 10' 2021`_
PSMForums is an RSS Reader for Android. This started out as a personal project while getting the
hang of Kotlin. RSS is an old technology and there are already many readers out there, but I find
many of them hard to navigate and jam-packed with features which are way overkill or simply not needed.
The aim is an attractive and intuitive app, lightweight but fully functional with not too many frills.
### Early Access:
PSMForums is now in Beta. Please note that until a stable release, some functionality may still be
limited. I'm constantly updating this app and trying to make it better. I would truly appreciate
any and all feedback, especially if you find any issues or bugs, or have ideas for new features
(please open an issue). I also welcome contributions.<br>
## Screenshots:<br><br>
<img width="250" src="Screenshot(1)"> <img width="250" src="Screenshot(2).png"> <img width="250" src="Screenshot(3).png">
<img width="250" src="Screenshot(4).png"><img width="250" src="Screenshot(5).png"><img width="250" src="Screenshot(6).png">
## Feature List v1.0.0-Beta:
<ul>
<li>Build-In Dark Mode with system level integration to switch with the OS</li>
<li>Choose your RSS Feed from the pre-build or add a custom Feed</li>
<li>Background Sync so you don't miss out on any notifications</li>
<li>RSS parsing provided by <a href="https://github.com/prof18/RSS-Parser">RSS Parser</a></li>
<li>Search engine powered by <a href="https://developer.feedly.com/v3/search/">Feedly Search API</a></li>
<li>OPML support (importing and exporting) provided by <a href="https://github.com/rometools/rome">Rome Tools</a>
<li>Ability to organize feeds by category</li>
<li>Star/unstar and mark entries as read/unread</li>
</ul>

BIN
Screenshot (1).png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
Screenshot (2).png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

BIN
Screenshot (3).png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

BIN
Screenshot (4).png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

BIN
Screenshot (5).png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
Screenshot (6).png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 KiB

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

100
app/build.gradle Normal file
View File

@@ -0,0 +1,100 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
android {
compileSdkVersion 29
buildToolsVersion "29.0.3"
defaultConfig {
applicationId 'com.psmforums.rssfeed'
minSdkVersion 21
targetSdkVersion 29
versionCode 15
versionName '1.0-Beta'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
def room_version = "2.2.6"
def lifecycle_version = "2.3.0"
def hilt_version = '2.33-beta'
def androidx_hilt_version = "1.0.0-alpha03"
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
// UI
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'com.google.android.material:material:1.3.0'
implementation "com.leinardi.android:speed-dial:3.1.1"
// Lifecycle
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"
// Networking
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.google.code.gson:gson:2.8.6'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// RSS
implementation 'com.prof.rssparser:rssparser:3.1.3'
implementation 'com.rometools:rome-opml:1.15.0'
// Image Loading
implementation 'com.squareup.picasso:picasso:2.71828'
// Database
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
// debugImplementation 'com.amitshekhar.android:debug-db:1.0.4'
// WorkManager
implementation "androidx.work:work-runtime-ktx:2.5.0"
// Dependency Injection (Not Used Yet)
implementation "com.google.dagger:hilt-android:$hilt_version"
implementation "androidx.hilt:hilt-lifecycle-viewmodel:$androidx_hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
kapt "androidx.hilt:hilt-compiler:$androidx_hilt_version"
// Testing
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -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

BIN
app/release/app-release.apk Normal file

Binary file not shown.

View File

@@ -0,0 +1,20 @@
{
"version": 1,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.psmforums.rssfeed",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"properties": [],
"versionCode": 15,
"versionName": "1.0-Beta",
"enabled": true,
"outputFile": "app-release.apk"
}
]
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.joshuacerdenia.android.nicefeed", appContext.packageName)
}
}

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2021 PSMForums. All rights reserved.
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~ http://www.apache.org/licenses/LICENSE-2.0
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.joshuacerdenia.android.nicefeed">
<permission
android:name="com.joshuacerdenia.android.nicefeed.PRIVATE"
android:protectionLevel="signature"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.joshuacerdenia.android.nicefeed.PRIVATE"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE"
tools:node="remove" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:node="remove" />
<application
android:name=".NiceFeedApplication"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".ui.activity.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ui.activity.ManagingActivity" />
<receiver
android:name=".util.NotificationReceiver"
android:permission="com.joshuacerdenia.android.nicefeed.PRIVATE"
android:exported="false">
<intent-filter android:priority="-999">
<action android:name="com.joshuacerdenia.android.nicefeed.utils.work.SHOW_NOTIFICATION"/>
</intent-filter>
</receiver>
<meta-data
android:name="preloaded_fonts"
android:resource="@array/preloaded_fonts" />
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -0,0 +1,70 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import com.joshuacerdenia.android.nicefeed.data.NiceFeedRepository
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
import com.joshuacerdenia.android.nicefeed.data.local.database.NiceFeedDatabase
import com.joshuacerdenia.android.nicefeed.util.NetworkMonitor
import com.joshuacerdenia.android.nicefeed.util.Utils
import com.joshuacerdenia.android.nicefeed.util.work.BackgroundSyncWorker
import com.joshuacerdenia.android.nicefeed.util.work.NewEntriesWorker
import com.joshuacerdenia.android.nicefeed.util.work.SweeperWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class NiceFeedApplication : Application() {
private val applicationScope = CoroutineScope(Dispatchers.Default)
override fun onCreate() {
super.onCreate()
Utils.setTheme(NiceFeedPreferences.getTheme(this))
val database = NiceFeedDatabase.build(this)
val connectionMonitor = NetworkMonitor(this)
NiceFeedRepository.initialize(database, connectionMonitor)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(NotificationManager::class.java)
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).let { notificationManager?.createNotificationChannel(it) }
}
delayedInit()
}
private fun delayedInit() {
val isPolling = NiceFeedPreferences.getPollingSetting(this)
val isSyncing = NiceFeedPreferences.syncInBackground(this)
applicationScope.launch {
if (isPolling) NewEntriesWorker.start(applicationContext)
if (isSyncing) BackgroundSyncWorker.start(applicationContext)
SweeperWorker.start(applicationContext)
}
}
companion object {
const val NOTIFICATION_CHANNEL_ID = "nicefeed_new_entries"
}
}

View File

@@ -0,0 +1,145 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data
import androidx.lifecycle.LiveData
import com.joshuacerdenia.android.nicefeed.data.local.database.NiceFeedDatabase
import com.joshuacerdenia.android.nicefeed.data.model.cross.FeedTitleWithEntriesToggleable
import com.joshuacerdenia.android.nicefeed.data.model.cross.FeedWithEntries
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
import com.joshuacerdenia.android.nicefeed.data.model.entry.EntryToggleable
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedIdWithCategory
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedLight
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedManageable
import com.joshuacerdenia.android.nicefeed.util.NetworkMonitor
import java.util.concurrent.Executors
class NiceFeedRepository private constructor(
database: NiceFeedDatabase,
val networkMonitor: NetworkMonitor
) {
private val dao = database.combinedDao()
private val executor = Executors.newSingleThreadExecutor()
fun getFeed(feedId: String): LiveData<Feed?> = dao.getFeed(feedId)
fun getFeedsLight(): LiveData<List<FeedLight>> = dao.getFeedsLight()
fun getFeedIds(): LiveData<List<String>> = dao.getFeedIds()
fun getFeedIdsWithCategories(): LiveData<List<FeedIdWithCategory>> = dao.getFeedIdsWithCategories()
fun getFeedUrlsSynchronously(): List<String> = dao.getFeedUrlsSynchronously()
fun getFeedTitleWithEntriesToggleableSynchronously(feedId: String): FeedTitleWithEntriesToggleable {
return dao.getFeedTitleAndEntriesToggleableSynchronously(feedId)
}
fun getFeedsManageable(): LiveData<List<FeedManageable>> = dao.getFeedsManageable()
fun getEntry(entryId: String): LiveData<Entry?> = dao.getEntry(entryId)
fun getEntriesByFeed(feedId: String): LiveData<List<Entry>> = dao.getEntriesByFeed(feedId)
fun getNewEntries(max: Int): LiveData<List<Entry>> = dao.getNewEntries(max)
fun getStarredEntries(): LiveData<List<Entry>> = dao.getStarredEntries()
fun getEntriesToggleableByFeedSynchronously(feedId: String): List<EntryToggleable> {
return dao.getEntriesToggleableByFeedSynchronously(feedId)
}
fun addFeeds(vararg feed: Feed) {
executor.execute { dao.addFeeds(*feed) }
}
fun addFeedWithEntries(feedWithEntries: FeedWithEntries) {
executor.execute {
dao.addFeedAndEntries(feedWithEntries.feed, feedWithEntries.entries)
}
}
fun updateFeed(feed: Feed) {
executor.execute { dao.updateFeed(feed) }
}
fun updateFeedTitleAndCategory(feedId: String, title: String, category: String) {
executor.execute { dao.updateFeedTitleAndCategory(feedId, title, category) }
}
fun updateFeedCategory(vararg feedId: String, category: String) {
executor.execute { dao.updateFeedCategory(*feedId, category = category) }
}
fun updateFeedUnreadCount(feedId: String, count: Int) {
executor.execute { dao.updateFeedUnreadCount(feedId, count) }
}
fun updateEntryAndFeedUnreadCount(entryId: String, isRead: Boolean, isStarred: Boolean) {
executor.execute { dao.updateEntryAndFeedUnreadCount(entryId, isRead, isStarred) }
}
fun updateEntryIsStarred(vararg entryId: String, isStarred: Boolean) {
executor.execute { dao.updateEntryIsStarred(*entryId, isStarred = isStarred) }
}
fun updateEntryIsRead(vararg entryId: String, isRead: Boolean) {
executor.execute { dao.updateEntryIsReadAndFeedUnreadCount(*entryId, isRead = isRead) }
}
fun handleEntryUpdates(
feedId: String,
entriesToAdd: List<Entry>,
entriesToUpdate: List<Entry>,
entriesToDelete: List<Entry>,
) {
executor.execute {
dao.handleEntryUpdates(feedId, entriesToAdd, entriesToUpdate, entriesToDelete)
}
}
fun handleBackgroundUpdate(
feedId: String,
newEntries: List<Entry>,
oldEntries: List<EntryToggleable>,
feedImage: String?,
) {
executor.execute {
dao.handleBackgroundUpdate(feedId, newEntries, oldEntries, feedImage)
}
}
fun deleteFeedAndEntriesById(vararg feedId: String) {
executor.execute { dao.deleteFeedAndEntriesById(*feedId) }
}
fun deleteLeftoverItems() {
executor.execute { dao.deleteLeftoverItems() }
}
companion object {
private var INSTANCE: NiceFeedRepository? = null
fun initialize(database: NiceFeedDatabase, networkMonitor: NetworkMonitor) {
if (INSTANCE == null) INSTANCE = NiceFeedRepository(database, networkMonitor)
}
fun get(): NiceFeedRepository {
return INSTANCE ?: throw IllegalStateException("Repository must be initialized!")
}
}
}

View File

@@ -0,0 +1,175 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.local
import android.content.Context
import android.content.SharedPreferences
import com.joshuacerdenia.android.nicefeed.ui.dialog.SortFeedManagerFragment.Companion.SORT_BY_ADDED
object NiceFeedPreferences {
private const val NICE_FEED_PREFS = "NICE_FEED_PREFS"
private const val KEY_FEED_ID = "KEY_FEED_ID"
private const val KEY_FEED_MANAGER_ORDER = "KEY_FEED_MANAGER_ORDER"
private const val KEY_SORT_FEEDS = "KEY_SORT_FEEDS"
private const val KEY_SORT_ENTRIES = "KEY_SORT_ENTRIES"
private const val KEY_AUTO_UPDATE = "KEY_AUTO_UPDATE"
private const val KEY_LAST_POLLED_INDEX = "KEY_LAST_POLLED_INDEX"
private const val KEY_POLLING = "KEY_POLLING"
private const val KEY_TEXT_SIZE = "KEY_TEXT_SIZE"
private const val KEY_FONT = "KEY_FONT"
private const val KEY_MIN_CATEGORIES = "KEY_MIN_CATEGORIES"
private const val KEY_THEME = "KEY_THEME"
private const val KEY_VIEW_IN_BROWSER = "KEY_VIEW_IN_BROWSER"
private const val KEY_BANNER = "KEY_BANNER"
private const val KEY_SYNC_IN_BG = "KEY_SYNC_IN_BG"
private const val KEY_KEEP_ENTRIES = "KEY_KEEP_ENTRIES"
const val TEXT_SIZE_NORMAL = 0
const val TEXT_SIZE_LARGE = 1
const val TEXT_SIZE_LARGER = 2
private const val FONT_SANS = 0
const val FONT_SERIF = 1
private const val THEME_DEFAULT = 0
const val THEME_LIGHT = 1
const val THEME_DARK = 2
private const val FEED_ORDER_TITLE = 0
const val FEED_ORDER_UNREAD = 1
private const val ENTRY_ORDER_TITLE = 0
const val ENTRY_ORDER_UNREAD = 1
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(NICE_FEED_PREFS, Context.MODE_PRIVATE)
}
fun getLastViewedFeedId(context: Context): String? {
return getPrefs(context).getString(KEY_FEED_ID, null)
}
fun saveLastViewedFeedId(context: Context, feedId: String?) {
getPrefs(context).edit().putString(KEY_FEED_ID, feedId).apply()
}
fun getFeedManagerOrder(context: Context): Int {
return getPrefs(context).getInt(KEY_FEED_MANAGER_ORDER, SORT_BY_ADDED)
}
fun saveFeedManagerOrder(context: Context, sorter: Int) {
getPrefs(context).edit().putInt(KEY_FEED_MANAGER_ORDER, sorter).apply()
}
fun getFeedsOrder(context: Context): Int {
return getPrefs(context).getInt(KEY_SORT_FEEDS, FEED_ORDER_TITLE)
}
fun saveFeedsOrder(context: Context, order: Int) {
getPrefs(context).edit().putInt(KEY_SORT_FEEDS, order).apply()
}
fun getEntriesOrder(context: Context): Int {
return getPrefs(context).getInt(KEY_SORT_ENTRIES, ENTRY_ORDER_TITLE)
}
fun saveEntriesOrder(context: Context, order: Int) {
getPrefs(context).edit().putInt(KEY_SORT_ENTRIES, order).apply()
}
fun getAutoUpdateSetting(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_AUTO_UPDATE, true)
}
fun saveAutoUpdateSetting(context: Context, isOn: Boolean) {
getPrefs(context).edit().putBoolean(KEY_AUTO_UPDATE, isOn).apply()
}
fun getLastPolledIndex(context: Context): Int {
return getPrefs(context).getInt(KEY_LAST_POLLED_INDEX, 0)
}
fun saveLastPolledIndex(context: Context, index: Int) {
getPrefs(context).edit().putInt(KEY_LAST_POLLED_INDEX, index).apply()
}
fun getPollingSetting(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_POLLING, true)
}
fun savePollingSetting(context: Context, isPolling: Boolean) {
getPrefs(context).edit().putBoolean(KEY_POLLING, isPolling).apply()
}
fun getTextSize(context: Context): Int {
return getPrefs(context).getInt(KEY_TEXT_SIZE, TEXT_SIZE_NORMAL)
}
fun saveTextSize(context: Context, textSize: Int) {
getPrefs(context).edit().putInt(KEY_TEXT_SIZE, textSize).apply()
}
fun getFont(context: Context): Int {
return getPrefs(context).getInt(KEY_FONT, FONT_SANS)
}
fun saveFont(context: Context, font: Int) {
getPrefs(context).edit().putInt(KEY_FONT, font).apply()
}
fun getMinimizedCategories(context: Context): Set<String>? {
return getPrefs(context).getStringSet(KEY_MIN_CATEGORIES, emptySet())
}
fun saveMinimizedCategories(context: Context, categories: Set<String>) {
getPrefs(context).edit().putStringSet(KEY_MIN_CATEGORIES, categories).apply()
}
fun getTheme(context: Context): Int {
return getPrefs(context).getInt(KEY_THEME, THEME_DEFAULT)
}
fun saveTheme(context: Context, theme: Int) {
getPrefs(context).edit().putInt(KEY_THEME, theme).apply()
}
fun getBrowserSetting(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_VIEW_IN_BROWSER, false)
}
fun setBrowserSetting(context: Context, shouldViewInBrowser: Boolean) {
getPrefs(context).edit().putBoolean(KEY_VIEW_IN_BROWSER, shouldViewInBrowser).apply()
}
fun bannerIsEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_BANNER, true)
}
fun setBannerIsEnabled(context: Context, isEnabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_BANNER, isEnabled).apply()
}
fun syncInBackground(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_SYNC_IN_BG, false)
}
fun setSyncInBackground(context: Context, isOn: Boolean) {
getPrefs(context).edit().putBoolean(KEY_SYNC_IN_BG, isOn).apply()
}
fun keepOldUnreadEntries(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_KEEP_ENTRIES, true)
}
fun setKeepOldUnreadEntries(context: Context, isOn: Boolean) {
getPrefs(context).edit().putBoolean(KEY_KEEP_ENTRIES, isOn).apply()
}
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.local.database
import androidx.room.Dao
import androidx.room.Transaction
import com.joshuacerdenia.android.nicefeed.data.model.cross.FeedTitleWithEntriesToggleable
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
import com.joshuacerdenia.android.nicefeed.data.model.entry.EntryToggleable
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
@Dao
interface CombinedDao: FeedsDao, EntriesDao, FeedEntryCrossRefsDao {
@Transaction
fun addFeedAndEntries(feed: Feed, entries: List<Entry>) {
addFeeds(feed)
addEntries(entries)
addFeedEntryCrossRefs(feed.url, entries)
}
@Transaction
fun getFeedTitleAndEntriesToggleableSynchronously(
feedId: String
): FeedTitleWithEntriesToggleable {
return FeedTitleWithEntriesToggleable(
getFeedTitleSynchronously(feedId),
getEntriesToggleableByFeedSynchronously(feedId)
)
}
@Transaction
fun handleEntryUpdates(
feedId: String,
entriesToAdd: List<Entry>,
entriesToUpdate: List<Entry>,
entriesToDelete: List<Entry>,
) {
addEntries(entriesToAdd)
addFeedEntryCrossRefs(feedId, entriesToAdd)
updateEntries(entriesToUpdate)
deleteFeedEntryCrossRefs(feedId, entriesToDelete.map { it.url })
deleteEntries(entriesToDelete)
}
@Transaction
fun handleBackgroundUpdate(
feedId: String,
newEntries: List<Entry>,
oldEntries: List<EntryToggleable>,
feedImage: String?
) {
addEntries(newEntries)
addFeedEntryCrossRefs(feedId, newEntries)
oldEntries.map { it.url }.let { entryIds ->
deleteEntriesById(entryIds)
deleteFeedEntryCrossRefs(feedId, entryIds)
}
addToFeedUnreadCount(feedId, (newEntries.size - oldEntries.filter { !it.isRead }.size))
feedImage?.let { updateFeedImage(feedId, it) }
}
@Transaction
fun updateEntryAndFeedUnreadCount(
entryId: String,
isRead: Boolean,
isStarred: Boolean
) {
updateEntryIsStarred(entryId, isStarred = isStarred)
updateEntryIsReadAndFeedUnreadCount(entryId, isRead = isRead)
}
@Transaction
fun updateEntryIsReadAndFeedUnreadCount(vararg entryId: String, isRead: Boolean) {
updateEntryIsRead(*entryId, isRead = isRead)
(if (isRead) -1 else 1).let { addend ->
entryId.forEach { addToFeedUnreadCountByEntry(it, addend) }
}
}
@Transaction
fun deleteFeedAndEntriesById(vararg feedId: String) {
deleteEntriesByFeed(*feedId)
deleteCrossRefsByFeed(*feedId)
deleteFeeds(*feedId)
}
@Transaction
fun deleteLeftoverItems() {
deleteLeftoverCrossRefs()
deleteLeftoverEntries()
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.local.database
import androidx.lifecycle.LiveData
import androidx.room.*
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
import com.joshuacerdenia.android.nicefeed.data.model.entry.EntryToggleable
interface EntriesDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun addEntries(entries: List<Entry>)
@Query("SELECT * FROM Entry WHERE url = :entryId")
fun getEntry(entryId: String): LiveData<Entry?>
@Query(
"SELECT url, title, website, date, image, isStarred, isRead " +
"FROM Entry WHERE isRead = 0 ORDER BY date DESC LIMIT :max"
)
// Warning is for unspecified fields, which we want null
@SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
fun getNewEntries(max: Int): LiveData<List<Entry>>
@Query(
"SELECT url, title, website, date, image, isStarred, isRead " +
"FROM Entry WHERE isStarred = 1"
)
@SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
fun getStarredEntries(): LiveData<List<Entry>>
@Query(
"SELECT Entry.url, title, website, author, date, content, image, isStarred, isRead " +
"FROM FeedEntryCrossRef AS _junction " +
"INNER JOIN Entry ON (_junction.entryUrl = Entry.url) " +
"WHERE _junction.feedUrl = :feedId"
)
fun getEntriesByFeed(feedId: String): LiveData<List<Entry>>
@Query(
"SELECT Entry.url, isStarred, isRead " +
"FROM FeedEntryCrossRef AS _junction " +
"INNER JOIN Entry ON (_junction.entryUrl = Entry.url) " +
"WHERE _junction.feedUrl = :feedId"
)
fun getEntriesToggleableByFeedSynchronously(feedId: String): List<EntryToggleable>
@Update
fun updateEntries(entries: List<Entry>)
@Query("UPDATE Entry SET isStarred = :isStarred WHERE url IN (:entryId)")
fun updateEntryIsStarred(vararg entryId: String, isStarred: Boolean)
@Query("UPDATE Entry SET isRead = :isRead WHERE url IN (:entryId)")
fun updateEntryIsRead(vararg entryId: String, isRead: Boolean)
@Delete
fun deleteEntries(entries: List<Entry>)
@Query(
"DELETE FROM Entry WHERE url IN " +
"(SELECT url FROM FeedEntryCrossRef AS _junction " +
"INNER JOIN Entry ON (_junction.entryUrl = Entry.url) " +
"WHERE _junction.feedUrl IN (:feedId))"
)
fun deleteEntriesByFeed(vararg feedId: String)
@Query("DELETE FROM Entry WHERE url IN (:entryIds)")
fun deleteEntriesById(entryIds: List<String>)
@Query("DELETE FROM Entry WHERE url NOT IN (SELECT entryUrl FROM FeedEntryCrossRef)")
fun deleteLeftoverEntries()
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.local.database
import androidx.room.*
import com.joshuacerdenia.android.nicefeed.data.model.cross.FeedEntryCrossRef
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
interface FeedEntryCrossRefsDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun addFeedEntryCrossRefs(crossRefs: List<FeedEntryCrossRef>)
@Transaction
fun addFeedEntryCrossRefs(feedId: String, entries: List<Entry>) {
addFeedEntryCrossRefs(entries.map { FeedEntryCrossRef(feedId, it.url) })
}
@Query("DELETE FROM FeedEntryCrossRef WHERE feedUrl = :feedId AND entryUrl IN (:entryIds)")
fun deleteFeedEntryCrossRefs(feedId: String, entryIds: List<String>)
@Query("DELETE FROM FeedEntryCrossRef WHERE feedUrl IN (:feedId)")
fun deleteCrossRefsByFeed(vararg feedId: String)
@Query("DELETE FROM FeedEntryCrossRef WHERE feedUrl NOT IN (SELECT url FROM Feed)")
fun deleteLeftoverCrossRefs()
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.local.database
import androidx.lifecycle.LiveData
import androidx.room.*
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedIdWithCategory
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedLight
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedManageable
interface FeedsDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun addFeeds(vararg feed: Feed)
@Query("SELECT * FROM Feed WHERE url = :feedId")
fun getFeed(feedId: String): LiveData<Feed?>
@Query("SELECT url, title, imageUrl, category, unreadCount FROM Feed")
fun getFeedsLight(): LiveData<List<FeedLight>>
@Query("SELECT url, title, website, imageUrl, description, category FROM Feed")
fun getFeedsManageable(): LiveData<List<FeedManageable>>
@Query("SELECT url FROM Feed")
fun getFeedIds(): LiveData<List<String>>
@Query("SELECT url, category FROM Feed")
fun getFeedIdsWithCategories(): LiveData<List<FeedIdWithCategory>>
@Query("SELECT url FROM Feed")
fun getFeedUrlsSynchronously(): List<String>
@Query("SELECT title FROM Feed WHERE url = :feedId")
fun getFeedTitleSynchronously(feedId: String): String
@Update
fun updateFeed(feed: Feed)
@Transaction
fun updateFeedTitleAndCategory(feedId: String, title: String, category: String) {
updateFeedTitle(feedId, title)
updateFeedCategory(feedId, category = category)
}
@Query("UPDATE Feed SET title = :title WHERE url = :feedId")
fun updateFeedTitle(feedId: String, title: String)
@Query("UPDATE Feed SET category = :category WHERE url IN (:feedId)")
fun updateFeedCategory(vararg feedId: String, category: String)
@Query("UPDATE Feed SET imageUrl = :feedImage WHERE url = :feedId")
fun updateFeedImage(feedId: String, feedImage: String)
@Query("UPDATE Feed SET unreadCount = :count WHERE url = :feedId")
fun updateFeedUnreadCount(feedId: String, count: Int)
@Query("UPDATE Feed SET unreadCount = (unreadCount + :addend) WHERE url = :feedId")
fun addToFeedUnreadCount(feedId: String, addend: Int)
@Query(
"UPDATE Feed SET unreadCount = (unreadCount + :addend) WHERE url IN " +
"(SELECT url FROM FeedEntryCrossRef AS _junction " +
"INNER JOIN Feed ON (_junction.feedUrl = Feed.url) " +
"WHERE _junction.entryUrl = (:entryId))"
)
fun addToFeedUnreadCountByEntry(entryId: String, addend: Int)
@Query("DELETE FROM Feed WHERE url IN (:feedId)")
fun deleteFeeds(vararg feedId: String)
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.local.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
import com.joshuacerdenia.android.nicefeed.data.model.cross.FeedEntryCrossRef
@Database(
entities = [
Feed::class,
Entry::class,
FeedEntryCrossRef::class
],
version = 1
)
@TypeConverters(com.joshuacerdenia.android.nicefeed.data.local.database.TypeConverters::class)
abstract class NiceFeedDatabase : RoomDatabase() {
abstract fun combinedDao(): CombinedDao
companion object {
private const val DATABASE_NAME = "database"
fun build(context: Context): NiceFeedDatabase {
return Room.databaseBuilder(
context.applicationContext,
NiceFeedDatabase::class.java,
DATABASE_NAME
).build()
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.local.database
import androidx.room.TypeConverter
import java.util.*
class TypeConverters {
@TypeConverter
fun fromDate(date: Date?): Long? {
return date?.time
}
@TypeConverter
fun toDate(millisSinceEpoch: Long?): Date? {
return millisSinceEpoch?.let {
Date(it)
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model
data class CategoryHeader(
val category: String,
val isMinimized: Boolean,
var unreadCount: Int = 0
)

View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model
/* Holder for data that goes into the Feed RecyclerView.
Content can be an actual Feed item or category header. */
data class FeedMenuItem(val content: Any)

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model
import com.google.gson.annotations.SerializedName
import java.io.Serializable
data class SearchResultItem(
val title: String?,
@SerializedName("feedId") val id: String?,
val website: String?,
val description: String?,
val updated: String?,
@SerializedName("visualUrl") val imageUrl: String?
): Serializable

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model
import androidx.annotation.ColorRes
data class TopicBlock(
val topic: String,
@ColorRes val color: Int
)

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model
data class UpdateValues(
var added: Int = 0,
var updated: Int = 0
) {
fun isEmpty(): Boolean {
return added + updated == 0
}
fun clear() {
added = 0
updated = 0
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.cross
import androidx.room.Entity
import androidx.room.Index
@Entity(
primaryKeys = ["feedUrl", "entryUrl"],
indices = [(Index(value = ["entryUrl"]))]
)
data class FeedEntryCrossRef(
val feedUrl: String,
val entryUrl: String
)

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.cross
import com.joshuacerdenia.android.nicefeed.data.model.entry.EntryToggleable
data class FeedTitleWithEntriesToggleable(
val feedTitle: String,
val entriesToggleable: List<EntryToggleable>
)

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.cross
data class FeedTitleWithEntryIds(
val feedTitle: String,
val entryIds: List<String>
)

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.cross
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
data class FeedWithEntries(
@Embedded val feed: Feed,
@Relation(
parentColumn = "url",
entityColumn = "url",
associateBy = Junction(
value = FeedEntryCrossRef::class,
parentColumn = "feedUrl",
entityColumn = "entryUrl"
)
)
val entries: List<Entry>
)

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.entry
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable
import java.util.*
@Entity
data class Entry(
@PrimaryKey val url: String, // Doubles as URL
val title: String,
val website: String,
val author: String?,
val date: Date?,
val content: String?,
val image: String?,
var isStarred: Boolean = false,
var isRead: Boolean = false
) : Serializable {
// Compare new and existing versions of the same entry, ignoring certain properties
fun isSameAs(entry: Entry): Boolean {
val checklist = listOf(
entry.title == title,
entry.author == author,
entry.date == date,
entry.content == content,
entry.image == image
)
var count = 0
for (itemChecked in checklist) {
if (itemChecked) count += 1 else break
}
// ALL items must match to return true
return count == checklist.size
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.entry
import java.util.*
// Light version of Entry no content and author
data class EntryLight(
val url: String,
val title: String,
val website: String,
val date: Date?,
val image: String?,
var isStarred: Boolean = false,
var isRead: Boolean = false
)

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.entry
import java.util.*
// Minimal version of Entry no url, website, image, isStarred, isRead
data class EntryMinimal (
val title: String,
val date: Date?,
val author: String?,
val content: String
)

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.entry
// Entry data with only fields toggleable by user
data class EntryToggleable(
val url: String,
val isStarred: Boolean,
val isRead: Boolean,
)

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.feed
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable
@Entity
data class Feed(
@PrimaryKey val url: String, // Feed ID
var title: String,
val website: String,
val description: String? = null,
val imageUrl: String? = null,
var category: String = "Uncategorized",
var unreadCount: Int
): Serializable

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.feed
data class FeedIdWithCategory(
val url: String,
val category: String
)

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.feed
// Light version of Feed no website and description
data class FeedLight(
val url: String,
var title: String,
val imageUrl: String?,
var category: String,
var unreadCount: Int
)

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.model.feed
import java.io.Serializable
// Feed without unreadCount
data class FeedManageable(
val url: String,
var title: String,
val website: String,
val imageUrl: String?,
val description: String?,
var category: String
): Serializable

View File

@@ -0,0 +1,138 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.remote
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
import com.joshuacerdenia.android.nicefeed.data.model.cross.FeedWithEntries
import com.joshuacerdenia.android.nicefeed.util.BackupUrlManager
import com.joshuacerdenia.android.nicefeed.util.NetworkMonitor
import com.joshuacerdenia.android.nicefeed.util.extensions.shortened
import com.prof.rssparser.Channel
import com.prof.rssparser.Parser
import java.text.SimpleDateFormat
import java.util.*
/* Responsible for retrieving and parsing RSS feeds */
class FeedParser (private val networkMonitor: NetworkMonitor) {
private lateinit var rssParser: Parser
private val _feedRequestLiveData = MutableLiveData<FeedWithEntries?>()
val feedRequestLiveData: LiveData<FeedWithEntries?>
get() = _feedRequestLiveData
suspend fun getFeedSynchronously(url: String): FeedWithEntries? {
rssParser = Parser.Builder().build()
return if (networkMonitor.isOnline) {
try {
val channel = rssParser.getChannel(url)
ChannelMapper.makeFeedWithEntries(url, channel)
} catch(e: Exception) {
null
}
} else null
}
suspend fun requestFeed(url: String, backup: String? = null) {
rssParser = Parser.Builder().build()
if (networkMonitor.isOnline) {
BackupUrlManager.setBase(backup)
executeRequest(url)
} else {
_feedRequestLiveData.postValue(null)
}
}
fun cancelRequest() {
rssParser.cancel()
BackupUrlManager.reset()
}
private suspend fun executeRequest(url: String) {
// Automatically makes several requests with different possible URLs
Log.d(TAG, "Requesting $url")
try {
val channel = rssParser.getChannel(url)
val feedWithEntries = ChannelMapper.makeFeedWithEntries(url, channel)
_feedRequestLiveData.postValue(feedWithEntries)
} catch (e: Exception) {
// If the initial request fails, try backup URL in different variations
BackupUrlManager.getNextUrl()?.let { executeRequest(it) }
?: let {
_feedRequestLiveData.postValue(null)
Log.d(TAG, "Request failed")
}
}
}
/* Maps 'Channel' data into 'Feed' and 'Entry' objects */
private object ChannelMapper {
private const val MAX_ENTRIES = 300 // Arbitrary
private const val DATE_PATTERN = "EEE, d MMM yyyy HH:mm:ss Z"
fun makeFeedWithEntries(url: String, channel: Channel): FeedWithEntries {
val entries = mapEntries(channel, url)
val feed = Feed(
url = url, // The url that successfully completes the request is applied
website = channel.link ?: url,
title = channel.title ?: channel.link?.shortened() ?: url.shortened(),
description = channel.description,
imageUrl = channel.image?.url ?: channel.image?.link,
unreadCount = entries.size
)
Log.d(TAG, "Retrieved ${entries.size} entries from $url")
return FeedWithEntries(feed, entries)
}
private fun mapEntries(channel: Channel, url: String): List<Entry> {
val entries = mutableListOf<Entry>()
for (article in channel.articles) {
if (entries.size < MAX_ENTRIES) {
val entry = Entry(
url = article.link ?: article.guid ?: "",
website = channel.link ?: url,
title = article.title ?: UNTITLED,
author = article.author,
content = article.content ?: article.description.flagAsExcerpt(),
date = parseDate(article.pubDate),
image = article.image
)
entries.add(entry)
} else break
}
return entries
}
private fun parseDate(stringDate: String?): Date? {
return if (stringDate != null) {
SimpleDateFormat(DATE_PATTERN, Locale.ENGLISH).parse(stringDate)
} else null
}
private fun String?.flagAsExcerpt() = FLAG_EXCERPT + this
}
companion object {
private const val TAG = "FeedParser"
private const val UNTITLED = "Untitled"
const val FLAG_EXCERPT = "com.joshuacerdenia.android.nicefeed.excerpt "
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.remote
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.joshuacerdenia.android.nicefeed.data.model.SearchResultItem
import com.joshuacerdenia.android.nicefeed.data.remote.api.FeedlyApi
import com.joshuacerdenia.android.nicefeed.data.remote.api.SearchResult
import com.joshuacerdenia.android.nicefeed.util.NetworkMonitor
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.net.URLEncoder
/* Generates a search query and returns a list of results from Feedly */
class FeedSearcher(private val networkMonitor: NetworkMonitor) {
private val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
private val feedlyApi = retrofit.create(FeedlyApi::class.java)
fun getFeedList(query: String): LiveData<List<SearchResultItem>> {
return if (networkMonitor.isOnline) {
val queryString = createQueryString(query)
val request: Call<SearchResult> = feedlyApi.fetchSearchResult(queryString)
fetchSearchResult(request)
} else {
MutableLiveData(emptyList())
}
}
private fun createQueryString(query: String): String {
return Uri.Builder()
.path("v3/search/feeds")
.appendQueryParameter("count", RESULTS_COUNT.toString())
.appendQueryParameter("query", URLEncoder.encode(query, "UTF-8"))
.build()
.toString()
}
private fun fetchSearchResult(
request: Call<SearchResult>
): MutableLiveData<List<SearchResultItem>> {
val searchResultLiveData = MutableLiveData<List<SearchResultItem>>()
val callback = object : Callback<SearchResult> {
override fun onFailure(call: Call<SearchResult>, t: Throwable) {} // Do nothing
override fun onResponse(
call: Call<SearchResult>,
response: Response<SearchResult>
) {
val feedSearchResult = response.body()
searchResultLiveData.value = feedSearchResult?.items ?: emptyList()
}
}
request.enqueue(callback)
return searchResultLiveData
}
companion object {
private const val RESULTS_COUNT = 100
private const val BASE_URL = "https://cloud.feedly.com/"
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.remote.api
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Url
interface FeedlyApi {
@GET
fun fetchSearchResult(@Url url: String): Call<SearchResult>
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.data.remote.api
import com.google.gson.annotations.SerializedName
import com.joshuacerdenia.android.nicefeed.data.model.SearchResultItem
class SearchResult {
@SerializedName("results")
lateinit var items: List<SearchResultItem>
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui
interface FeedRequestCallbacks {
fun onRequestSubmitted(url: String, backup: String? = null)
fun onRequestDismissed()
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui
interface OnFinished {
fun onFinished()
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui
interface OnHomePressed {
fun onHomePressed()
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui
import androidx.appcompat.widget.Toolbar
interface OnToolbarInflated {
fun onToolbarInflated(toolbar: Toolbar, isNavigableUp: Boolean = true)
}

View File

@@ -0,0 +1,178 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.activity
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
import com.joshuacerdenia.android.nicefeed.ui.OnHomePressed
import com.joshuacerdenia.android.nicefeed.ui.fragment.EntryFragment
import com.joshuacerdenia.android.nicefeed.ui.fragment.EntryListFragment
import com.joshuacerdenia.android.nicefeed.ui.fragment.FeedListFragment
import com.joshuacerdenia.android.nicefeed.util.Utils
class MainActivity : AppCompatActivity(),
FeedListFragment.Callbacks,
EntryListFragment.Callbacks,
EntryFragment.Callbacks,
OnHomePressed
{
private lateinit var drawerLayout: DrawerLayout
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
drawerLayout = findViewById(R.id.drawerLayout)
Utils.setStatusBarMode(this)
if (getFragment(FRAGMENT_MAIN) == null) {
val feedId = intent?.getStringExtra(EXTRA_FEED_ID)
?: NiceFeedPreferences.getLastViewedFeedId(this)
val entryId = intent?.getStringExtra(EXTRA_ENTRY_ID)
val mainFragment = EntryListFragment.newInstance(feedId, entryId, entryId != null)
loadFragments(mainFragment, FeedListFragment.newInstance())
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
val feedId = intent?.getStringExtra(EXTRA_FEED_ID)
val entryId = intent?.getStringExtra(EXTRA_ENTRY_ID)
supportFragmentManager.popBackStack()
replaceMainFragment(EntryListFragment.newInstance(feedId, entryId, true), false)
drawerLayout.closeDrawers()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != Activity.RESULT_OK) {
return
} else if (requestCode == REQUEST_CODE_ADD_FEED) {
data?.getStringExtra(EXTRA_FEED_ID)?.let { feedId ->
loadFeed(feedId, true)
}
}
}
private fun loadFragments(main: Fragment, navigation: Fragment) {
supportFragmentManager.beginTransaction()
.add(R.id.main_fragment_container, main)
.add(R.id.drawer_fragment_container, navigation)
.commit()
}
private fun replaceMainFragment(newFragment: Fragment, addToBackStack: Boolean) {
if (addToBackStack) {
supportFragmentManager.beginTransaction()
.replace(R.id.main_fragment_container, newFragment)
.addToBackStack(null).commit()
} else {
supportFragmentManager.beginTransaction()
.replace(R.id.main_fragment_container, newFragment).commit()
}
}
private fun getFragment(code: Int): Fragment? {
val fragmentId = when (code) {
FRAGMENT_MAIN -> R.id.main_fragment_container
FRAGMENT_NAVIGATION -> R.id.drawer_fragment_container
else -> null
}
return if (fragmentId != null) {
supportFragmentManager.findFragmentById(fragmentId)
} else null
}
override fun onToolbarInflated(toolbar: Toolbar, isNavigableUp: Boolean) {
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(isNavigableUp)
}
override fun onHomePressed() {
drawerLayout.apply {
openDrawer(GravityCompat.START, true)
setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED)
}
}
override fun onMenuItemSelected(item: Int) {
val intent = ManagingActivity.newIntent(this@MainActivity, item)
if (item == FeedListFragment.ITEM_SETTINGS) {
startActivity(intent)
} else startActivityForResult(intent, REQUEST_CODE_ADD_FEED)
}
override fun onFeedSelected(feedId: String, activeFeedId: String?) {
if (feedId != activeFeedId) loadFeed(feedId) else drawerLayout.closeDrawers()
}
private fun loadFeed(feedId: String, blockAutoUpdate: Boolean = false) {
EntryListFragment.newInstance(feedId, blockAutoUpdate = blockAutoUpdate).let { fragment ->
Handler().postDelayed({ replaceMainFragment(fragment, false) }, 350)
}
drawerLayout.closeDrawers()
}
override fun onFeedLoaded(feedId: String) {
(getFragment(FRAGMENT_NAVIGATION) as? FeedListFragment)?.updateActiveFeedId(feedId)
}
override fun onFeedRemoved() {
replaceMainFragment(EntryListFragment.newInstance(null), false)
}
override fun onEntrySelected(entryId: String) {
replaceMainFragment(EntryFragment.newInstance(entryId), true)
drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
}
override fun onCategoriesNeeded(): Array<String> {
return (getFragment(FRAGMENT_NAVIGATION) as? FeedListFragment)?.getCategories() ?: emptyArray()
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
companion object {
private const val REQUEST_CODE_ADD_FEED = 0
private const val FRAGMENT_MAIN = 0
private const val FRAGMENT_NAVIGATION = 1
const val EXTRA_FEED_ID = "com.joshuacerdenia.android.nicefeed.feed_id"
const val EXTRA_ENTRY_ID = "com.joshuacerdenia.android.nicefeed.entry_id"
fun newIntent(context: Context, feedId: String, latestEntryId: String): Intent {
return Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_FEED_ID, feedId)
putExtra(EXTRA_ENTRY_ID, latestEntryId)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
}
}
}

View File

@@ -0,0 +1,155 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.activity
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.ui.fragment.*
import com.joshuacerdenia.android.nicefeed.util.Utils
class ManagingActivity : AppCompatActivity(),
ManageFeedsFragment.Callbacks,
FeedAddingFragment.Callbacks,
SettingsFragment.Callbacks {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_managing)
Utils.setStatusBarMode(this)
if (getCurrentFragment() == null) {
when (intent.getIntExtra(EXTRA_MANAGING, FeedListFragment.ITEM_ADD_FEEDS)) {
FeedListFragment.ITEM_ADD_FEEDS -> AddFeedsFragment.newInstance()
FeedListFragment.ITEM_MANAGE_FEEDS -> ManageFeedsFragment.newInstance()
FeedListFragment.ITEM_SETTINGS -> SettingsFragment.newInstance()
else -> throw IllegalArgumentException()
}.let { fragment ->
supportFragmentManager.beginTransaction()
.add(R.id.fragment_container, fragment)
.commit()
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != Activity.RESULT_OK) {
return
} else when (requestCode) {
REQUEST_CODE_READ_OPML -> {
data?.data?.let { uri ->
(getCurrentFragment() as? AddFeedsFragment)?.submitUriForImport(uri)
}
}
REQUEST_CODE_WRITE_OPML -> {
data?.data?.let { uri ->
(getCurrentFragment() as? ManageFeedsFragment)?.writeOpml(uri)
}
}
}
}
override fun onNewFeedAdded(feedId: String) {
Intent().apply {
putExtra(MainActivity.EXTRA_FEED_ID, feedId)
}.also { intent ->
setResult(Activity.RESULT_OK, intent)
}
finish()
}
override fun onQuerySubmitted(query: String) {
replaceFragment(SearchFeedsFragment.newInstance(query))
}
override fun onImportOpmlSelected() {
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = OPML_DOC_TYPE
addCategory(Intent.CATEGORY_OPENABLE)
}.also { intent ->
startActivityForResult(intent, REQUEST_CODE_READ_OPML)
}
}
override fun onAddFeedsSelected() {
replaceFragment(AddFeedsFragment.newInstance())
supportActionBar?.title = getString(R.string.add_feeds)
}
override fun onExportOpmlSelected() {
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = OPML_DOC_TYPE
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(
Intent.EXTRA_TITLE,
OPML_FILE_PREFIX + System.currentTimeMillis() + OPML_FILE_EXT
)
}.also {intent ->
startActivityForResult(intent, REQUEST_CODE_WRITE_OPML)
}
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
override fun onToolbarInflated(toolbar: Toolbar, isNavigableUp: Boolean) {
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(isNavigableUp)
}
override fun onFinished() {
finish()
}
private fun getCurrentFragment(): Fragment? {
return supportFragmentManager.findFragmentById(R.id.fragment_container)
}
private fun replaceFragment(fragment: Fragment, addToBackStack: Boolean = true) {
if (addToBackStack) {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, fragment)
.addToBackStack(null)
.commit()
} else {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, fragment)
.commit()
}
}
companion object {
private const val EXTRA_MANAGING = "com.joshuacerdenia.android.nicefeed.managing"
private const val REQUEST_CODE_READ_OPML = 0
private const val REQUEST_CODE_WRITE_OPML = 1
private const val OPML_DOC_TYPE ="*/*"
private const val OPML_FILE_PREFIX = "NiceFeed_"
private const val OPML_FILE_EXT = ".opml"
fun newIntent(packageContext: Context, item: Int): Intent {
return Intent(packageContext, ManagingActivity::class.java).apply {
putExtra(EXTRA_MANAGING, item)
}
}
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.adapter
import android.annotation.SuppressLint
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.text.HtmlCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.entry.EntryLight
import com.joshuacerdenia.android.nicefeed.util.extensions.hide
import com.joshuacerdenia.android.nicefeed.util.extensions.shortened
import com.joshuacerdenia.android.nicefeed.util.extensions.show
import com.squareup.picasso.Picasso
import java.text.DateFormat.*
import java.util.*
class EntryListAdapter(
private val listener: OnEntrySelected
) : ListAdapter<EntryLight, EntryListAdapter.EntryHolder>(DiffCallback()) {
interface OnEntrySelected {
fun onEntryClicked(entryId: String, view: View? = null)
fun onEntryLongClicked(entry: EntryLight, view: View?)
}
var lastClickedPosition = 0
private set
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EntryHolder {
val view = LayoutInflater.from(parent.context).inflate(
R.layout.list_item_entry,
parent,
false
)
return EntryHolder(view, listener)
}
override fun onBindViewHolder(holder: EntryHolder, position: Int) {
holder.bind(getItem(position))
}
inner class EntryHolder(
view: View,
private val listener: OnEntrySelected
) : RecyclerView.ViewHolder(view), View.OnClickListener, View.OnLongClickListener {
lateinit var entry: EntryLight
private val container: ConstraintLayout = itemView.findViewById(R.id.constraintLayout_container)
private val titleTextView: TextView = itemView.findViewById(R.id.textView_title)
private val infoTextView: TextView = itemView.findViewById(R.id.textView_info)
private val imageView: ImageView = itemView.findViewById(R.id.imageView_image)
private val starView: ImageView = itemView.findViewById(R.id.imageView_star)
init {
container.setOnClickListener(this)
container.setOnLongClickListener(this)
}
@SuppressLint("SetTextI18n")
fun bind(entry: EntryLight) {
this.entry = entry
val date = entry.date?.let {
if (getDateInstance().format(it) == getDateInstance().format(Date())) {
getTimeInstance(SHORT).format(it)
} else getDateInstance(SHORT).format(it)
} ?: ""
titleTextView.apply {
text = HtmlCompat.fromHtml(entry.title, 0)
setTextColor(if (entry.isRead) Color.GRAY else Color.BLACK)
}
infoTextView.text = "$date ${entry.website.shortened()}"
if (entry.isStarred) starView.show() else starView.hide()
Picasso.get().load(entry.image).fit().centerCrop()
.placeholder(R.drawable.vintage_newspaper).into(imageView)
}
override fun onClick(v: View) {
lastClickedPosition = adapterPosition
listener.onEntryClicked(entry.url)
}
override fun onLongClick(v: View?): Boolean {
lastClickedPosition = adapterPosition
listener.onEntryLongClicked(entry, v)
return true
}
}
private class DiffCallback : DiffUtil.ItemCallback<EntryLight>() {
override fun areItemsTheSame(oldItem: EntryLight, newItem: EntryLight): Boolean {
return oldItem.url == newItem.url
}
override fun areContentsTheSame(oldItem: EntryLight, newItem: EntryLight): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -0,0 +1,183 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.adapter
import android.content.Context
import android.view.Gravity.START
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.getColor
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.CategoryHeader
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedLight
import com.joshuacerdenia.android.nicefeed.data.model.FeedMenuItem
import com.joshuacerdenia.android.nicefeed.util.extensions.addRipple
import com.joshuacerdenia.android.nicefeed.util.extensions.hide
import com.joshuacerdenia.android.nicefeed.util.extensions.show
import com.squareup.picasso.Picasso
import kotlinx.android.synthetic.main.list_item_feed.view.*
class FeedListAdapter(
private val context: Context?,
private val listener: OnItemClickListener
) : ListAdapter<FeedMenuItem, RecyclerView.ViewHolder>(DiffCallback()) {
interface OnItemClickListener {
fun onFeedSelected(feedId: String)
fun onCategoryClicked(category: String)
}
private var activeFeedId: String? = null
fun setActiveFeedId(feedId: String?) {
activeFeedId = feedId
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
TYPE_ITEM -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_feed, parent, false)
FeedHolder(view)
}
TYPE_HEADER -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_category, parent, false)
CategoryHolder(view)
}
else -> throw IllegalArgumentException()
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is FeedHolder -> {
val isHighlighted = activeFeedId == (getItem(position).content as FeedLight).url
holder.bind(getItem(position).content as FeedLight, isHighlighted)
}
is CategoryHolder -> holder.bind(getItem(position).content as CategoryHeader)
}
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position).content) {
is FeedLight -> TYPE_ITEM
is CategoryHeader -> TYPE_HEADER
else -> throw IllegalArgumentException()
}
}
private inner class FeedHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener {
private lateinit var feed: FeedLight
private val titleTextView: TextView = itemView.findViewById(R.id.title_text_view)
private val countTextView: TextView = itemView.findViewById(R.id.item_count_text_view)
init {
itemView.setOnClickListener(this)
}
fun bind(feed: FeedLight, isHighlighted: Boolean) {
this.feed = feed
if (isHighlighted) {
context?.let { itemView.setBackgroundColor(getColor(it, R.color.colorSelect)) }
} else {
itemView.addRipple()
}
titleTextView.text = feed.title
countTextView.text = if (feed.unreadCount > 0) feed.unreadCount.toString() else null
Picasso.get().load(feed.imageUrl).fit().centerCrop(START)
.placeholder(R.drawable.feed_icon_small).into(itemView.image_view)
}
override fun onClick(v: View) {
listener.onFeedSelected(feed.url)
}
}
private inner class CategoryHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener {
private lateinit var category: String
private val categoryTextView: TextView = itemView.findViewById(R.id.category_text_view)
private val countTextView: TextView = itemView.findViewById(R.id.item_count_text_view)
init {
itemView.setOnClickListener(this)
}
fun bind(categoryHeader: CategoryHeader) {
this.category = categoryHeader.category
categoryTextView.text = categoryHeader.category
val drawableResId: Int
if (categoryHeader.isMinimized) {
drawableResId = R.drawable.ic_drop_down
if (categoryHeader.unreadCount > 0) {
countTextView.show()
countTextView.text = categoryHeader.unreadCount.toString()
} else {
countTextView.hide()
}
} else {
drawableResId = R.drawable.ic_drop_up
countTextView.hide()
}
context?.let { context ->
ContextCompat.getDrawable(context, drawableResId).also { drawable ->
categoryTextView.setCompoundDrawablesWithIntrinsicBounds(
drawable, null, null, null
)
}
}
}
override fun onClick(v: View?) {
listener.onCategoryClicked(category)
}
}
private class DiffCallback : DiffUtil.ItemCallback<FeedMenuItem>() {
override fun areItemsTheSame(oldItem: FeedMenuItem, newItem: FeedMenuItem): Boolean {
return when {
oldItem.content is FeedLight && newItem.content is FeedLight -> {
oldItem.content.url == newItem.content.url
}
oldItem.content is CategoryHeader && newItem.content is CategoryHeader -> {
oldItem.content.category == newItem.content.category
}
else -> false
}
}
override fun areContentsTheSame(oldItem: FeedMenuItem, newItem: FeedMenuItem): Boolean {
return oldItem == newItem
}
}
companion object {
private const val TYPE_ITEM = 0
private const val TYPE_HEADER = 1
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedManageable
import com.joshuacerdenia.android.nicefeed.util.extensions.pathified
class FeedManagerAdapter(
private val listener: ItemCheckBoxListener,
var selectedItems: List<FeedManageable>
) : ListAdapter<FeedManageable, FeedManagerAdapter.FeedHolder>(DiffCallback()) {
interface ItemCheckBoxListener {
fun onItemClicked(feed: FeedManageable, isChecked: Boolean)
fun onAllItemsChecked(isChecked: Boolean)
}
private val checkBoxes = mutableSetOf<CheckBox>()
fun toggleCheckBoxes(checkAll: Boolean) {
selectedItems = if (checkAll) currentList else emptyList()
checkBoxes.forEach { checkBox -> checkBox.isChecked = checkAll }
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FeedHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_feed_manageable, parent, false)
return FeedHolder(view)
}
override fun onBindViewHolder(holder: FeedHolder, position: Int) {
val itemIsChecked = selectedItems.contains(getItem(position))
holder.bind(getItem(position), itemIsChecked)
}
inner class FeedHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener {
private lateinit var feed: FeedManageable
private val checkBox: CheckBox = itemView.findViewById(R.id.check_box)
private val titleTextView: TextView = itemView.findViewById(R.id.title_text_view)
private val urlTextView: TextView = itemView.findViewById(R.id.url_text_view)
private val categoryTextView: TextView = itemView.findViewById(R.id.category_text_view)
init {
itemView.setOnClickListener(this)
}
fun bind(feed: FeedManageable, isChecked: Boolean) {
this.feed = feed
titleTextView.text = feed.title
urlTextView.text = feed.url.pathified()
categoryTextView.text = feed.category
checkBox.apply {
this.isChecked = isChecked
checkBoxes.add(this)
setOnClickListener { listener.onItemClicked(feed, this.isChecked) }
}
}
override fun onClick(v: View?) {
checkBox.isChecked = !checkBox.isChecked
listener.onItemClicked(feed, checkBox.isChecked)
}
}
private class DiffCallback : DiffUtil.ItemCallback<FeedManageable>() {
override fun areItemsTheSame(oldItem: FeedManageable, newItem: FeedManageable): Boolean {
return oldItem.url == newItem.url
}
override fun areContentsTheSame(oldItem: FeedManageable, newItem: FeedManageable): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.SearchResultItem
import com.joshuacerdenia.android.nicefeed.util.extensions.simplified
import com.squareup.picasso.Picasso
class FeedSearchAdapter(
private val listener: OnItemClickListener,
) : ListAdapter<SearchResultItem, FeedSearchAdapter.FeedHolder>(DiffCallback()) {
interface OnItemClickListener {
fun onItemClicked(searchResultItem: SearchResultItem)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FeedHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_search_result, parent, false)
return FeedHolder(view, listener)
}
override fun onBindViewHolder(holder: FeedHolder, position: Int) {
holder.bind(getItem(position))
}
inner class FeedHolder(
view: View,
private val listener: OnItemClickListener
) : RecyclerView.ViewHolder(view), View.OnClickListener {
private lateinit var searchResultItem: SearchResultItem
private val titleTextView: TextView = itemView.findViewById(R.id.textView_title)
private val infoTextView: TextView = itemView.findViewById(R.id.textView_info)
private val imageView: ImageView = itemView.findViewById(R.id.imageView_image)
init {
itemView.setOnClickListener(this)
}
fun bind(searchResultItem: SearchResultItem) {
this.searchResultItem = searchResultItem
titleTextView.text = searchResultItem.title
infoTextView.text = searchResultItem.website?.simplified()
Picasso.get().load(searchResultItem.imageUrl)
.placeholder(R.drawable.feed_icon).into(imageView)
}
override fun onClick(v: View) {
listener.onItemClicked(searchResultItem)
}
}
private class DiffCallback : DiffUtil.ItemCallback<SearchResultItem>() {
override fun areItemsTheSame(oldItem: SearchResultItem, newItem: SearchResultItem): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: SearchResultItem, newItem: SearchResultItem): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.TopicBlock
class TopicAdapter(
private val context: Context,
private val listener: OnItemClickListener,
) : ListAdapter<TopicBlock, TopicAdapter.TopicHolder>(DiffCallback()) {
var numOfItems = 0 // Initial value only
interface OnItemClickListener {
fun onTopicSelected(topic: String)
}
override fun submitList(list: MutableList<TopicBlock>?) {
super.submitList(list?.take(numOfItems))
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TopicHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.grid_item_topic, parent, false)
return TopicHolder(view)
}
override fun onBindViewHolder(holder: TopicHolder, position: Int) {
holder.bind(getItem(position))
}
inner class TopicHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener {
private lateinit var topicBlock: TopicBlock
private val topicTextView: TextView = itemView.findViewById(R.id.topic_text_view)
init {
itemView.setOnClickListener(this)
}
fun bind(topicBlock: TopicBlock) {
this.topicBlock = topicBlock
topicTextView.text = context.getString(R.string.hashtag, topicBlock.topic)
val color = ContextCompat.getColor(context, topicBlock.color)
itemView.setBackgroundColor(color)
}
override fun onClick(v: View) {
listener.onTopicSelected(topicTextView.text.toString())
}
}
private class DiffCallback : DiffUtil.ItemCallback<TopicBlock>() {
override fun areItemsTheSame(oldItem: TopicBlock, newItem: TopicBlock): Boolean {
return oldItem.topic == newItem.topic
}
override fun areContentsTheSame(oldItem: TopicBlock, newItem: TopicBlock): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.res.ResourcesCompat
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.util.extensions.addRipple
class AboutFragment: BottomSheetDialogFragment() {
interface Callback {
fun onGoToRepoClicked()
}
private lateinit var titleTextView: TextView
private lateinit var descriptionTextView: TextView
private lateinit var imageView: ImageView
private lateinit var goButton: Button
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_about, container, false)
titleTextView = view.findViewById(R.id.textView_title)
descriptionTextView = view.findViewById(R.id.textView_description)
imageView = view.findViewById(R.id.imageView_feed)
goButton = view.findViewById(R.id.button_positive)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
titleTextView.text = getString(R.string.about_nicefeed)
descriptionTextView.text = getString(R.string.about_PSMForums)
descriptionTextView.addRipple()
imageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.mipmap.ic_launcher_round, null))
goButton.apply {
text = getString(R.string.go)
setOnClickListener {
targetFragment?.let { (it as Callback).onGoToRepoClicked() }
dismiss()
}
}
}
companion object {
fun newInstance(): AboutFragment {
return AboutFragment()
}
}
}

View File

@@ -0,0 +1,130 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.TextView
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.util.extensions.hide
class ConfirmActionFragment : BottomSheetDialogFragment() {
interface OnRemoveConfirmed {
fun onRemoveConfirmed()
}
interface OnExportConfirmed {
fun onExportConfirmed()
}
interface OnImportConfirmed {
fun onImportConfirmed()
}
private lateinit var titleTextView: TextView
private lateinit var messageTextView: TextView
private lateinit var confirmButton: Button
private var titleStringRes: Int = 0
private var drawableRes: Int = 0
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_confirm_action, container, false)
titleTextView = view.findViewById(R.id.title_text_view)
messageTextView = view.findViewById(R.id.message_text_view)
confirmButton = view.findViewById(R.id.confirm_button)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val action = arguments?.getInt(ARG_ACTION)
val count = arguments?.getInt(ARG_COUNT) ?: 1
val title = arguments?.getString(ARG_TITLE)
val itemString = title ?: if (count == 1) {
getString(R.string.feed)
} else {
resources.getQuantityString(R.plurals.numberOfFeeds, count, count)
}
when (action) {
REMOVE -> {
drawableRes = R.drawable.ic_delete
titleStringRes = R.string.confirm_remove
messageTextView.text = getString(R.string.confirm_remove_dialog_message)
}
EXPORT -> {
drawableRes = R.drawable.ic_export
titleStringRes = R.string.confirm_export
messageTextView.hide()
}
IMPORT -> {
drawableRes = R.drawable.ic_import
titleStringRes = R.string.confirm_import_title
messageTextView.text = getString(R.string.confirm_import_message)
}
else -> throw throw IllegalArgumentException("Action not found!")
}
titleTextView.apply {
setCompoundDrawablesWithIntrinsicBounds(drawableRes, 0, 0, 0)
text = getString(titleStringRes, itemString)
}
confirmButton.setOnClickListener {
when (action) {
REMOVE -> (targetFragment as? OnRemoveConfirmed)?.onRemoveConfirmed()
EXPORT -> (targetFragment as? OnExportConfirmed)?.onExportConfirmed()
IMPORT -> (targetFragment as? OnImportConfirmed)?.onImportConfirmed()
}
dismiss()
}
}
companion object {
private const val ARG_TITLE = "ARG_TITLE"
private const val ARG_COUNT = "ARG_COUNT"
private const val ARG_ACTION = "ARG_ACTION"
const val REMOVE = 0
const val EXPORT = 1
const val IMPORT = 2
fun newInstance(action: Int, title: String?, count: Int = 1): ConfirmActionFragment {
val args = Bundle().apply {
putInt(ARG_ACTION, action)
putString(ARG_TITLE, title)
putInt(ARG_COUNT, count)
}
return ConfirmActionFragment().apply { arguments = args }
}
fun newInstance(action: Int, count: Int): ConfirmActionFragment {
val args = Bundle().apply {
putInt(ARG_ACTION, action)
putInt(ARG_COUNT, count)
}
return ConfirmActionFragment().apply { arguments = args }
}
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.dialog
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.Button
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.joshuacerdenia.android.nicefeed.R
class EditCategoryFragment : BottomSheetDialogFragment() {
interface Callbacks {
fun onEditCategoryConfirmed(category: String)
}
private lateinit var dialogMessage: TextView
private lateinit var categoryTextView: AutoCompleteTextView
private lateinit var confirmButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialogNoFloating)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_edit_category, container, false)
dialogMessage = view.findViewById(R.id.dialog_message)
categoryTextView = view.findViewById(R.id.category_edit_text)
confirmButton = view.findViewById(R.id.confirm_button)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val count = arguments?.getInt(ARG_COUNT) ?: 1
val title = arguments?.getString(ARG_TITLE)
val categories = arguments?.getStringArray(ARG_CATEGORIES)?.toList() ?: emptyList()
val adapter = context?.let { context ->
ArrayAdapter(context, android.R.layout.simple_list_item_1, categories)
}
val whatToEdit = title ?: resources.getQuantityString(R.plurals.numberOfFeeds, count, count)
dialogMessage.text = getString(R.string.edit_category_dialog_message, whatToEdit)
categoryTextView.apply {
setAdapter(adapter)
this.threshold = 1
addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
confirmButton.isEnabled = s?.length in 1..50
}
override fun afterTextChanged(s: Editable?) {}
})
setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE && confirmButton.isEnabled) submit()
true
}
}
confirmButton.apply {
isEnabled = false
setOnClickListener { submit() }
}
}
private fun submit() {
val category = categoryTextView.text.toString().trim()
targetFragment?.let { (it as Callbacks).onEditCategoryConfirmed(category) }
dismiss()
}
companion object {
private const val ARG_COUNT = "ARG_COUNT"
private const val ARG_TITLE = "ARG_TITLE"
private const val ARG_CATEGORIES = "ARG_CATEGORIES"
fun newInstance(categories: Array<String>,
title: String?,
count: Int = 1
): EditCategoryFragment {
val args = Bundle().apply {
putInt(ARG_COUNT, count)
putString(ARG_TITLE, title)
putStringArray(ARG_CATEGORIES, categories)
}
return EditCategoryFragment().apply { arguments = args }
}
}
}

View File

@@ -0,0 +1,134 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.*
import androidx.fragment.app.DialogFragment
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedManageable
import com.joshuacerdenia.android.nicefeed.util.Utils
import com.joshuacerdenia.android.nicefeed.util.extensions.addRipple
import com.joshuacerdenia.android.nicefeed.util.extensions.hide
import com.joshuacerdenia.android.nicefeed.util.extensions.toEditable
import com.squareup.picasso.Picasso
class EditFeedFragment : BottomSheetDialogFragment() {
interface Callback {
fun onFeedInfoSubmitted(title: String, category: String, isChanged: Boolean)
}
private lateinit var imageView: ImageView
private lateinit var titleEditText: EditText
private lateinit var urlTextView: TextView
private lateinit var categoryEditText: AutoCompleteTextView
private lateinit var descriptionTextView: TextView
private lateinit var undoButton: Button
private lateinit var doneButton: Button
private var callback: Callback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialogNoFloating)
callback = targetFragment as? Callback
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_edit_feed, container, false)
imageView = view.findViewById(R.id.image_view)
titleEditText = view.findViewById(R.id.title_edit_text)
urlTextView = view.findViewById(R.id.url_text_view)
categoryEditText = view.findViewById(R.id.category_edit_text)
descriptionTextView = view.findViewById(R.id.description_text_view)
undoButton = view.findViewById(R.id.undo_changes_button)
doneButton = view.findViewById(R.id.done_button)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val feed = arguments?.getSerializable(ARG_FEED) as FeedManageable?
val categories = arguments?.getStringArray(ARG_CATEGORIES) ?: emptyArray()
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, categories)
Picasso.get().load(feed?.imageUrl).placeholder(R.drawable.feed_icon).into(imageView)
fillEditables(feed?.title, feed?.category)
if (!feed?.description.isNullOrEmpty()) {
descriptionTextView.text = feed?.description
} else {
descriptionTextView.hide()
}
categoryEditText.apply {
setAdapter(adapter)
threshold = 1
setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) submit(feed)
true
}
}
urlTextView.apply {
text = feed?.url
addRipple()
setOnClickListener {
setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_green_check, 0, 0,0)
Utils.copyLinkToClipboard(context, this.text.toString())
}
}
undoButton.setOnClickListener { fillEditables(feed?.title, feed?.category) }
doneButton.setOnClickListener { submit(feed) }
}
private fun fillEditables(title: String?, category: String?) {
titleEditText.text = title.toEditable()
categoryEditText.text = category.toEditable()
}
private fun submit(feed: FeedManageable?) {
val inputTitle = titleEditText.text.toString().trim()
val newTitle = if (inputTitle.isNotEmpty()) inputTitle else feed?.title.toString()
val inputCategory = categoryEditText.text.toString().trim()
val newCategory = if (inputCategory.isNotEmpty()) inputCategory else "Uncategorized"
val isChanged = feed?.title != newTitle || feed.category != newCategory
callback?.onFeedInfoSubmitted(newTitle, newCategory, isChanged)
dismiss()
}
companion object {
private const val ARG_FEED = "ARG_FEED"
private const val ARG_CATEGORIES = "ARG_CATEGORIES"
fun newInstance(feed: FeedManageable, categories: Array<String>): EditFeedFragment {
val args = Bundle().apply {
putSerializable(ARG_FEED, feed)
putStringArray(ARG_CATEGORIES, categories)
}
return EditFeedFragment().apply { arguments = args }
}
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.RadioGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.joshuacerdenia.android.nicefeed.R
class FilterEntriesFragment: BottomSheetDialogFragment() {
interface Callbacks {
fun onFilterSelected(filter: Int)
}
private lateinit var filterRadioGroup: RadioGroup
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_filter_entries, container, false)
filterRadioGroup = view.findViewById(R.id.filter_radio_group)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val checkedItem = when (arguments?.getInt(ARG_CURRENT_FILTER)) {
FILTER_UNREAD -> R.id.unread_radio_button
FILTER_STARRED -> R.id.starred_radio_button
else -> R.id.all_entries_radio_button // Default
}
filterRadioGroup.apply {
check(checkedItem)
setOnCheckedChangeListener { _, checkedId ->
val filter = when (checkedId) {
R.id.unread_radio_button -> FILTER_UNREAD
R.id.starred_radio_button -> FILTER_STARRED
else -> FILTER_DEFAULT // Default
}
targetFragment?.let { fragment ->
(fragment as Callbacks).onFilterSelected(filter)
}
dismiss()
}
}
}
companion object {
private const val ARG_CURRENT_FILTER = "ARG_CURRENT_FILTER"
const val FILTER_DEFAULT = 0
const val FILTER_UNREAD = 1
const val FILTER_STARRED = 2
fun newInstance(currentFilter: Int): FilterEntriesFragment {
val args = Bundle().apply {
putInt(ARG_CURRENT_FILTER, currentFilter)
}
return FilterEntriesFragment().apply {
arguments = args
}
}
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import androidx.fragment.app.DialogFragment
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.ui.FeedRequestCallbacks
import com.joshuacerdenia.android.nicefeed.util.extensions.isVisible
import com.joshuacerdenia.android.nicefeed.util.extensions.show
import com.joshuacerdenia.android.nicefeed.util.extensions.toEditable
class InputUrlFragment : BottomSheetDialogFragment() {
private lateinit var urlEditText: EditText
private lateinit var subscribeButton: Button
private lateinit var progressBar: ProgressBar
private var callbacks: FeedRequestCallbacks? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialogNoFloating)
callbacks = targetFragment as? FeedRequestCallbacks
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_input_url, container, false)
urlEditText = view.findViewById(R.id.url_edit_text)
subscribeButton = view.findViewById(R.id.subscribe_button)
progressBar = view.findViewById(R.id.progress_bar)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
urlEditText.apply {
text = arguments?.getString(ARG_LAST_URL).toEditable()
addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
subscribeButton.isEnabled = s?.isNotEmpty() == true
}
override fun afterTextChanged(s: Editable?) {}
})
setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) submitFeedUrl(urlEditText.text.toString())
true
}
}
subscribeButton.apply{
isEnabled = urlEditText.text.isNotEmpty()
setOnClickListener {
submitFeedUrl(urlEditText.text.toString())
it.isEnabled = false
}
}
}
private fun submitFeedUrl(url: String) {
callbacks?.onRequestSubmitted(url)
progressBar.show()
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
if (progressBar.isVisible()) callbacks?.onRequestDismissed()
}
companion object {
const val TAG = "InputUrlFragment"
private const val ARG_LAST_URL = "ARG_LAST_URL"
fun newInstance(lastAttemptedUrl: String): InputUrlFragment {
val args = Bundle().apply { putString(ARG_LAST_URL, lastAttemptedUrl) }
return InputUrlFragment().apply { arguments = args }
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.RadioGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.joshuacerdenia.android.nicefeed.R
class SortFeedManagerFragment: BottomSheetDialogFragment() {
interface Callbacks {
fun onOrderSelected(order: Int)
}
private lateinit var radioGroupSortFeeds: RadioGroup
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_sort_feeds, container, false)
radioGroupSortFeeds = view.findViewById(R.id.radioGroup_sort)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val currentSelection = arguments?.getInt(ARG_CURRENT_ORDER) ?: 0
radioGroupSortFeeds.apply {
check(when (currentSelection) {
SORT_BY_CATEGORY -> R.id.radioButton_category
SORT_BY_TITLE -> R.id.radioButton_title
else -> R.id.radioButton_added // Default
})
setOnCheckedChangeListener { _, checkedId ->
val order = when (checkedId) {
R.id.radioButton_category -> SORT_BY_CATEGORY
R.id.radioButton_title -> SORT_BY_TITLE
else -> SORT_BY_ADDED // Default
}
targetFragment?.let { fragment ->
(fragment as Callbacks).onOrderSelected(order)
}
dismiss()
}
}
}
companion object {
private const val ARG_CURRENT_ORDER = "ARG_CURRENT_ORDER"
const val SORT_BY_ADDED = 0
const val SORT_BY_CATEGORY = 1
const val SORT_BY_TITLE = 2
fun newInstance(currentOrder: Int): SortFeedManagerFragment {
val args = Bundle().apply {
putInt(ARG_CURRENT_ORDER, currentOrder)
}
return SortFeedManagerFragment().apply {
arguments = args
}
}
}
}

View File

@@ -0,0 +1,131 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.text.HtmlCompat
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.SearchResultItem
import com.joshuacerdenia.android.nicefeed.ui.FeedRequestCallbacks
import com.joshuacerdenia.android.nicefeed.util.RssUrlTransformer
import com.joshuacerdenia.android.nicefeed.util.Utils
import com.joshuacerdenia.android.nicefeed.util.extensions.addRipple
import com.joshuacerdenia.android.nicefeed.util.extensions.hide
import com.joshuacerdenia.android.nicefeed.util.extensions.isVisible
import com.joshuacerdenia.android.nicefeed.util.extensions.show
import com.squareup.picasso.Picasso
import java.text.DateFormat
class SubscribeFragment: BottomSheetDialogFragment() {
private lateinit var titleTextView: TextView
private lateinit var urlTextView: TextView
private lateinit var descriptionTextView: TextView
private lateinit var updatedTextView: TextView
private lateinit var imageView: ImageView
private lateinit var subscribeButton: Button
private lateinit var progressBar: ProgressBar
private var callbacks: FeedRequestCallbacks? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_subscribe, container, false)
titleTextView = view.findViewById(R.id.title_text_view)
urlTextView = view.findViewById(R.id.url_text_view)
descriptionTextView = view.findViewById(R.id.description_text_view)
updatedTextView = view.findViewById(R.id.updated_text_view)
imageView = view.findViewById(R.id.image_view)
subscribeButton = view.findViewById(R.id.subscribe_button)
progressBar = view.findViewById(R.id.progress_bar)
return view
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
callbacks = targetFragment as? FeedRequestCallbacks
}
override fun onStart() {
super.onStart()
val searchResultItem = arguments?.getSerializable(ARG_SEARCH_RESULT_ITEM) as SearchResultItem
val lastUpdated = formatDate(searchResultItem.updated?.toLong())
Picasso.get().load(searchResultItem.imageUrl).placeholder(R.drawable.feed_icon).into(imageView)
titleTextView.text = searchResultItem.title
updatedTextView.text = getString(R.string.last_updated, lastUpdated)
urlTextView.apply {
text = searchResultItem.id?.substringAfter("feed/")
addRipple()
setOnClickListener {
setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_green_check, 0, 0,0)
Utils.copyLinkToClipboard(context, this.text.toString())
}
}
descriptionTextView.apply {
if (!searchResultItem.description.isNullOrEmpty()) {
this.show()
text = HtmlCompat.fromHtml(searchResultItem.description, 0)
} else this.hide()
}
subscribeButton.apply {
text = getString(R.string.subscribe)
setOnClickListener {
val url = searchResultItem.id?.let { RssUrlTransformer.getUrl(it) }.toString()
val backup = searchResultItem.website?.let { RssUrlTransformer.getUrl(it) }
// "website" property is also a usable URL
callbacks?.onRequestSubmitted(url, backup)
progressBar.show()
this.text = getString(R.string.loading)
this.isEnabled = false
}
}
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
if (progressBar.isVisible()) callbacks?.onRequestDismissed()
}
private fun formatDate(epoch: Long?): String? {
return if (epoch != null) {
DateFormat.getDateInstance(DateFormat.MEDIUM).format(epoch)
} else null
}
companion object {
const val TAG = "SubscribeFragment"
private const val ARG_SEARCH_RESULT_ITEM = "ARG_SEARCH_RESULT_ITEM"
fun newInstance(searchResultItem: SearchResultItem): SubscribeFragment {
val args = Bundle().apply { putSerializable(ARG_SEARCH_RESULT_ITEM, searchResultItem) }
return SubscribeFragment().apply { arguments = args }
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.RadioGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences.TEXT_SIZE_LARGE
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences.TEXT_SIZE_LARGER
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences.TEXT_SIZE_NORMAL
class TextSizeFragment: BottomSheetDialogFragment() {
interface Callbacks {
fun onTextSizeSelected(textSize: Int)
}
private lateinit var radioGroup: RadioGroup
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_text_size, container, false)
radioGroup = view.findViewById(R.id.radio_group_text_size)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val currentSelection = arguments?.getInt(ARG_TEXT_SIZE) ?: 0
radioGroup.apply {
when (currentSelection) {
TEXT_SIZE_LARGE -> R.id.radio_button_large
TEXT_SIZE_LARGER -> R.id.radio_button_larger
else -> R.id.radio_button_normal
}.let { selection ->
check(selection)
}
setOnCheckedChangeListener { _, checkedId ->
val textSize = when (checkedId) {
R.id.radio_button_large -> TEXT_SIZE_LARGE
R.id.radio_button_larger -> TEXT_SIZE_LARGER
else -> TEXT_SIZE_NORMAL
}
targetFragment?.let { fragment ->
(fragment as Callbacks).onTextSizeSelected(textSize)
}
dismiss()
}
}
}
companion object {
private const val ARG_TEXT_SIZE = "ARG_TEXT_SIZE"
fun newInstance(currentTextSize: Int): TextSizeFragment {
return TextSizeFragment().apply {
arguments = Bundle().apply {
putInt(ARG_TEXT_SIZE, currentTextSize)
}
}
}
}
}

View File

@@ -0,0 +1,200 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.fragment
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
import com.joshuacerdenia.android.nicefeed.ui.FeedRequestCallbacks
import com.joshuacerdenia.android.nicefeed.ui.adapter.TopicAdapter
import com.joshuacerdenia.android.nicefeed.ui.dialog.ConfirmActionFragment
import com.joshuacerdenia.android.nicefeed.ui.dialog.ConfirmActionFragment.Companion.IMPORT
import com.joshuacerdenia.android.nicefeed.ui.dialog.InputUrlFragment
import com.joshuacerdenia.android.nicefeed.ui.viewmodel.AddFeedsViewModel
import com.joshuacerdenia.android.nicefeed.util.OpmlImporter
import com.joshuacerdenia.android.nicefeed.util.Utils
import com.joshuacerdenia.android.nicefeed.util.work.BackgroundSyncWorker
import java.util.*
class AddFeedsFragment: FeedAddingFragment(),
OpmlImporter.OnOpmlParsedListener,
ConfirmActionFragment.OnImportConfirmed,
TopicAdapter.OnItemClickListener,
FeedRequestCallbacks
{
private lateinit var viewModel: AddFeedsViewModel
private lateinit var toolbar: Toolbar
private lateinit var linearLayout: LinearLayout
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: TopicAdapter
private lateinit var addUrlTextView: TextView
private lateinit var importOpmlTextView: TextView
private lateinit var searchView: SearchView
private val fragment = this@AddFeedsFragment
private var opmlImporter: OpmlImporter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this).get(AddFeedsViewModel::class.java)
viewModel.initDefaultTopics(viewModel.defaultTopicsResId.map { getString(it) })
opmlImporter = OpmlImporter(requireContext(), this)
adapter = TopicAdapter(requireContext(), this)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_add_feeds, container, false)
toolbar = view.findViewById(R.id.toolbar)
linearLayout = view.findViewById(R.id.linearLayout)
searchView = view.findViewById(R.id.searchView)
recyclerView = view.findViewById(R.id.recycler_view)
addUrlTextView = view.findViewById(R.id.add_url_text_view)
importOpmlTextView = view.findViewById(R.id.import_opml_text_view)
setupRecyclerView()
setupToolbar()
return view
}
private fun setupRecyclerView() {
val span = if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 3 else 5
recyclerView.layoutManager = GridLayoutManager(context, span)
recyclerView.adapter = adapter.apply { numOfItems = if (span == 3) 9 else 10 }
}
private fun setupToolbar() {
toolbar.title = getString(R.string.add_feeds)
callbacks?.onToolbarInflated(toolbar)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
resultManager = RequestResultManager(viewModel, linearLayout, R.string.failed_to_get_feed)
viewModel.feedIdsWithCategoriesLiveData.observe(viewLifecycleOwner, { data ->
viewModel.onFeedDataRetrieved(data)
})
viewModel.topicBlocksLiveData.observe(viewLifecycleOwner, { topics ->
adapter.submitList(topics.toMutableList())
})
viewModel.feedRequestLiveData.observe(viewLifecycleOwner, { feedWithEntries ->
// A little delay to prevent resulting snackbar from jumping:
Handler().postDelayed({ resultManager?.submitData(feedWithEntries) }, 250)
if (viewModel.isActiveRequest) {
parentFragmentManager.findFragmentByTag(InputUrlFragment.TAG).let { fragment ->
(fragment as? DialogFragment)?.dismiss()
viewModel.isActiveRequest = false
}
}
})
}
override fun onStart() {
super.onStart()
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(queryText: String): Boolean {
if (queryText.isNotBlank()) callbacks?.onQuerySubmitted(queryText)
return true
}
override fun onQueryTextChange(queryText: String?): Boolean {
return true
}
})
addUrlTextView.setOnClickListener {
InputUrlFragment.newInstance(viewModel.lastInputUrl).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager, InputUrlFragment.TAG)
}
}
importOpmlTextView.setOnClickListener {
callbacks?.onImportOpmlSelected()
}
}
override fun onRequestSubmitted(url: String, backup: String?) {
viewModel.lastInputUrl = url
val link = url.toLowerCase(Locale.ROOT).trim()
if (link.contains("://")) {
viewModel.requestFeed(url) // If scheme is provided, use as is
} else {
viewModel.requestFeed("https://$link", "http://$link")
}
}
override fun onRequestDismissed() {
// Wait for dialog to close fully to prevent snackbar from jumping
Handler().postDelayed({ resultManager?.onRequestDismissed() }, 250)
}
fun submitUriForImport(uri: Uri) {
opmlImporter?.submitUri(uri)
}
override fun onOpmlParsed(feeds: List<Feed>) {
viewModel.feedsToImport = feeds.filterNot { viewModel.currentFeedIds.contains(it.url) }
ConfirmActionFragment.newInstance(IMPORT, feeds.size).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager, "ConfirmImportFragment")
}
}
override fun onParseOpmlFailed() {
Utils.showErrorMessage(linearLayout, resources)
}
override fun onImportConfirmed() {
viewModel.feedsToImport.toTypedArray().run { viewModel.addFeeds(*this) }
viewModel.feedsToImport = emptyList()
Snackbar.make(linearLayout, getString(R.string.import_successful), Snackbar.LENGTH_SHORT)
.setAction(R.string.update_all) {
BackgroundSyncWorker.runOnce(requireContext().applicationContext)
callbacks?.onFinished()
}.show()
}
override fun onTopicSelected(topic: String) {
callbacks?.onQuerySubmitted(topic)
}
companion object {
fun newInstance(): AddFeedsFragment {
return AddFeedsFragment()
}
}
}

View File

@@ -0,0 +1,298 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.fragment
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.view.*
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.widget.Toolbar
import androidx.core.text.HtmlCompat
import androidx.core.widget.NestedScrollView
import androidx.lifecycle.ViewModelProvider
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
import com.joshuacerdenia.android.nicefeed.ui.OnToolbarInflated
import com.joshuacerdenia.android.nicefeed.ui.dialog.TextSizeFragment
import com.joshuacerdenia.android.nicefeed.ui.viewmodel.EntryViewModel
import com.joshuacerdenia.android.nicefeed.util.Utils
import com.joshuacerdenia.android.nicefeed.util.extensions.hide
import com.joshuacerdenia.android.nicefeed.util.extensions.shortened
import com.joshuacerdenia.android.nicefeed.util.extensions.show
import com.squareup.picasso.Picasso
import java.text.DateFormat
import java.util.*
class EntryFragment: VisibleFragment(), TextSizeFragment.Callbacks {
interface Callbacks: OnToolbarInflated
private lateinit var viewModel: EntryViewModel
private lateinit var nestedScrollView: NestedScrollView
private lateinit var toolbar: Toolbar
private lateinit var webView: WebView
private lateinit var progressBar: ProgressBar
private lateinit var imageView: ImageView
private lateinit var titleTextView: TextView
private lateinit var subtitleTextView: TextView
private var callbacks: Callbacks? = null
private var starItem: MenuItem? = null
private var textSizeItem: MenuItem? = null
private val fragment = this@EntryFragment
override fun onAttach(context: Context) {
super.onAttach(context)
callbacks = context as Callbacks?
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this).get(EntryViewModel::class.java)
viewModel.apply {
setTextSize(NiceFeedPreferences.getTextSize(requireContext()))
font = NiceFeedPreferences.getFont(requireContext())
bannerIsEnabled = NiceFeedPreferences.bannerIsEnabled(requireContext())
}
arguments?.getString(ARG_ENTRY_ID)?.let { entryId -> viewModel.getEntryById(entryId) }
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_entry, container, false)
toolbar = view.findViewById(R.id.toolbar)
nestedScrollView = view.findViewById(R.id.nested_scroll_view)
progressBar = view.findViewById(R.id.progress_bar)
imageView = view.findViewById(R.id.image_view)
titleTextView = view.findViewById(R.id.title_text_view)
subtitleTextView = view.findViewById(R.id.subtitle_text_view)
webView = view.findViewById(R.id.web_view)
webView.apply {
setBackgroundColor(Color.TRANSPARENT)
settings.apply {
javaScriptEnabled = true
builtInZoomControls = false
displayZoomControls = false
}
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
// Open all links with default browser
request?.url?.let { url -> Utils.openLink(requireActivity(), webView, url) }
return true
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
if (!viewModel.isInitialLoading) {
val position = viewModel.lastPosition
nestedScrollView.smoothScrollTo(position.first, position.second)
}
}
}
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
super.onProgressChanged(view, newProgress)
progressBar.progress = newProgress
if (newProgress == 100) progressBar.hide()
}
}
}
toolbar.title = getString(R.string.loading)
callbacks?.onToolbarInflated(toolbar)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.htmlLiveData.observe(viewLifecycleOwner, { data ->
if (data != null) {
webView.loadData(data, MIME_TYPE, ENCODING)
toggleBannerViews(viewModel.bannerIsEnabled)
setHasOptionsMenu(true)
toolbar.title = viewModel.entry?.website?.shortened()
if (viewModel.bannerIsEnabled) viewModel.entry?.let { entry ->
updateBanner(entry.title, entry.date, entry.author)
Picasso.get().load(entry.image).fit().centerCrop()
.placeholder(R.drawable.vintage_newspaper).into(imageView)
}
} else {
toggleBannerViews(false)
progressBar.hide()
toolbar.title = getString(R.string.app_name)
Utils.showErrorMessage(nestedScrollView, resources)
}
})
}
override fun onStart() {
super.onStart()
toolbar.setOnClickListener { nestedScrollView.smoothScrollTo(0, 0) }
imageView.setOnClickListener { handleViewInBrowser() }
}
private fun toggleBannerViews(isEnabled: Boolean) {
if (isEnabled) {
imageView.show()
titleTextView.show()
subtitleTextView.show()
} else {
imageView.hide()
titleTextView.hide()
subtitleTextView.hide()
}
}
private fun updateBanner(title: String, date: Date?, author: String?) {
titleTextView.text = HtmlCompat.fromHtml(title, 0)
val formattedDate = date?.let {
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(it)
}
subtitleTextView.text = when {
author.isNullOrEmpty() -> formattedDate
formattedDate.isNullOrEmpty() -> author
else -> "$formattedDate $author"
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.fragment_entry, menu)
starItem = menu.findItem(R.id.item_star)
textSizeItem = menu.findItem(R.id.item_text_size)
viewModel.entry?.let { entry -> toggleStarOptionItem(entry.isStarred) }
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.item_star -> handleStar()
R.id.item_share -> handleShare()
R.id.item_copy_link -> handleCopyLink()
R.id.item_view_in_browser -> handleViewInBrowser()
R.id.item_text_size -> handleChangeTextSize()
else -> super.onOptionsItemSelected(item)
}
}
private fun handleStar(): Boolean {
viewModel.entry?.let { entry ->
entry.isStarred = !entry.isStarred
toggleStarOptionItem(entry.isStarred)
return true
} ?: return false
}
private fun toggleStarOptionItem(isStarred: Boolean) {
starItem?.apply {
title = if (isStarred) {
setIcon(R.drawable.ic_star_yellow)
getString(R.string.unstar)
} else {
setIcon(R.drawable.ic_star_border)
getString(R.string.star)
}
}
}
private fun handleShare(): Boolean {
viewModel.entry?.let { entry ->
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, entry.title)
putExtra(Intent.EXTRA_TEXT, entry.url)
}.also { intent ->
val chooserIntent = Intent.createChooser(intent, getString(R.string.share_entry))
startActivity(chooserIntent)
}
return true
} ?: return false
}
private fun handleCopyLink(): Boolean {
viewModel.entry?.let { entry ->
Utils.copyLinkToClipboard(requireContext(), entry.url, webView)
return true
} ?: return false
}
private fun handleViewInBrowser(): Boolean {
Utils.openLink(requireActivity(), webView, Uri.parse(viewModel.entry?.url))
return true
}
private fun handleChangeTextSize(): Boolean {
saveScrollPosition()
TextSizeFragment.newInstance(viewModel.textSize).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager, "change text size")
}
return true
}
override fun onTextSizeSelected(textSize: Int) {
viewModel.setTextSize(textSize)
}
private fun saveScrollPosition() {
viewModel.lastPosition = Pair(nestedScrollView.scrollX, nestedScrollView.scrollY)
}
override fun onStop() {
super.onStop()
saveScrollPosition()
viewModel.isInitialLoading = false
viewModel.saveChanges()
context?.let { NiceFeedPreferences.saveTextSize(it, viewModel.textSize) }
}
override fun onDetach() {
super.onDetach()
callbacks = null
}
companion object {
private const val ARG_ENTRY_ID = "ARG_ENTRY_ID"
private const val MIME_TYPE = "text/html; charset=UTF-8"
private const val ENCODING = "base64"
fun newInstance(entryId: String): EntryFragment {
return EntryFragment().apply {
arguments = Bundle().apply {
putString(ARG_ENTRY_ID, entryId)
}
}
}
}
}

View File

@@ -0,0 +1,448 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.fragment
import android.content.Context
import android.content.res.Configuration.ORIENTATION_PORTRAIT
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.view.*
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
import com.joshuacerdenia.android.nicefeed.data.model.entry.EntryLight
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedManageable
import com.joshuacerdenia.android.nicefeed.ui.OnHomePressed
import com.joshuacerdenia.android.nicefeed.ui.OnToolbarInflated
import com.joshuacerdenia.android.nicefeed.ui.adapter.EntryListAdapter
import com.joshuacerdenia.android.nicefeed.ui.dialog.ConfirmActionFragment
import com.joshuacerdenia.android.nicefeed.ui.dialog.ConfirmActionFragment.Companion.REMOVE
import com.joshuacerdenia.android.nicefeed.ui.dialog.EditFeedFragment
import com.joshuacerdenia.android.nicefeed.ui.dialog.FilterEntriesFragment
import com.joshuacerdenia.android.nicefeed.ui.menu.EntryPopupMenu
import com.joshuacerdenia.android.nicefeed.ui.viewmodel.EntryListViewModel
import com.joshuacerdenia.android.nicefeed.util.Utils
import com.joshuacerdenia.android.nicefeed.util.extensions.hide
import com.joshuacerdenia.android.nicefeed.util.extensions.show
class EntryListFragment : VisibleFragment(),
EntryListAdapter.OnEntrySelected,
EntryPopupMenu.OnPopupMenuItemClicked,
FilterEntriesFragment.Callbacks,
EditFeedFragment.Callback,
ConfirmActionFragment.OnRemoveConfirmed
{
interface Callbacks: OnHomePressed, OnToolbarInflated {
fun onFeedLoaded(feedId: String)
fun onEntrySelected(entryId: String)
fun onCategoriesNeeded(): Array<String>
fun onFeedRemoved()
}
private lateinit var viewModel: EntryListViewModel
private lateinit var toolbar: Toolbar
private lateinit var noItemsTextView: TextView
private lateinit var masterProgressBar: ProgressBar
private lateinit var progressBar: ProgressBar
private lateinit var searchItem: MenuItem
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: EntryListAdapter
private var markAllOptionsItem: MenuItem? = null
private var starAllOptionsItem: MenuItem? = null
private var autoUpdateOnLaunch = true
private var feedId: String? = null
private var callbacks: Callbacks? = null
private val handler = Handler()
private val fragment = this@EntryListFragment
override fun onAttach(context: Context) {
super.onAttach(context)
callbacks = context as Callbacks?
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this).get(EntryListViewModel::class.java)
loadEntryOnStart()
viewModel.setOrder(NiceFeedPreferences.getEntriesOrder(requireContext()))
viewModel.keepOldUnreadEntries(NiceFeedPreferences.keepOldUnreadEntries(requireContext()))
autoUpdateOnLaunch = NiceFeedPreferences.getAutoUpdateSetting(requireContext())
adapter = EntryListAdapter(this)
feedId = arguments?.getString(ARG_FEED_ID)
setHasOptionsMenu(feedId != null)
val blockAutoUpdate = arguments?.getBoolean(ARG_BLOCK_AUTO_UPDATE) ?: false
if (blockAutoUpdate || !autoUpdateOnLaunch) viewModel.isAutoUpdating = false
}
private fun loadEntryOnStart() {
// If there is an entryID argument, load immediately and only once
arguments?.getString(ARG_ENTRY_ID)?.let { entryId ->
arguments?.remove(ARG_ENTRY_ID)
onEntryClicked(entryId, null)
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_entry_list, container, false)
toolbar = view.findViewById(R.id.toolbar)
noItemsTextView = view.findViewById(R.id.no_items_text_view)
masterProgressBar = view.findViewById(R.id.master_progress_bar)
progressBar = view.findViewById(R.id.progress_bar)
recyclerView = view.findViewById(R.id.recycler_view)
setupRecyclerView()
setupToolbar()
return view
}
private fun setupRecyclerView() {
val isPortrait = resources.configuration.orientation == ORIENTATION_PORTRAIT
val layoutManager = if (isPortrait) LinearLayoutManager(context) else GridLayoutManager(context, 2)
recyclerView.layoutManager = layoutManager
recyclerView.adapter = adapter
}
private fun setupToolbar() {
toolbar.title = getString(R.string.loading)
callbacks?.onToolbarInflated(toolbar, false)
toolbar.apply {
setNavigationIcon(R.drawable.ic_menu)
setNavigationOnClickListener { callbacks?.onHomePressed() }
setOnClickListener { recyclerView.smoothScrollToPosition(0) }
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.feedLiveData.observe(viewLifecycleOwner, { feed ->
progressBar.hide()
masterProgressBar.hide()
viewModel.onFeedRetrieved(feed)
restoreToolbar()
feed?.let { callbacks?.onFeedLoaded(it.url) } ?: run {
if (feedId?.startsWith(FOLDER) == false) {
callbacks?.onFeedRemoved()
}
}
})
viewModel.entriesLightLiveData.observe(viewLifecycleOwner, { entries ->
progressBar.hide()
adapter.submitList(entries)
showUpdateNotice()
toggleOptionsItems()
if (entries.isNullOrEmpty()) noItemsTextView.show() else noItemsTextView.hide()
if (adapter.lastClickedPosition == 0) {
handler.postDelayed({ recyclerView.scrollToPosition(0) }, 250)
}
})
viewModel.updateResultLiveData.observe(viewLifecycleOwner, { results ->
progressBar.hide()
results?.let { viewModel.onUpdatesDownloaded(results) }
restoreToolbar()
})
}
override fun onStart() {
super.onStart()
feedId?.let { feedId ->
viewModel.getFeedWithEntries(feedId)
if (feedId.startsWith(FOLDER)) callbacks?.onFeedLoaded(feedId)
if (viewModel.isAutoUpdating) { // Auto-update on launch:
handler.postDelayed({ handleCheckForUpdates(feedId) }, 750)
}
} ?: run { // If there is no feed to load:
masterProgressBar.hide()
noItemsTextView.show()
restoreToolbar()
}
}
private fun restoreToolbar() {
toolbar.title = when (feedId) {
FOLDER_NEW -> getString(R.string.new_entries)
FOLDER_STARRED -> getString(R.string.starred_entries)
null -> getString(R.string.app_name)
else -> viewModel.getCurrentFeed()?.title
}
}
override fun onResume() {
super.onResume()
context?.let { context ->
viewModel.setOrder(NiceFeedPreferences.getEntriesOrder(context))
viewModel.keepOldUnreadEntries(NiceFeedPreferences.keepOldUnreadEntries(context))
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.fragment_entry_list, menu)
searchItem = menu.findItem(R.id.menuItem_search)
markAllOptionsItem = menu.findItem(R.id.mark_all_item)
starAllOptionsItem = menu.findItem(R.id.star_all_item)
toggleOptionsItems()
if (feedId?.startsWith(FOLDER) == true) {
menu.findItem(R.id.update_item).isVisible = false
menu.findItem(R.id.visit_website_item).isVisible = false
menu.findItem(R.id.about_feed_item).isVisible = false
menu.findItem(R.id.remove_feed_item).isVisible = false
}
searchItem.setOnActionExpandListener(object: MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem?): Boolean = true
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
viewModel.clearQuery()
return true
}
})
(searchItem.actionView as SearchView).apply {
if (viewModel.query.isNotEmpty()) {
searchItem.expandActionView()
setQuery(viewModel.query, false)
clearFocus()
}
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextChange(queryText: String): Boolean = true
override fun onQueryTextSubmit(queryText: String): Boolean {
viewModel.submitQuery(queryText)
this@apply.clearFocus()
return true
}
})
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.update_item -> handleCheckForUpdates()
R.id.about_feed_item -> handleShowFeedInfo(viewModel.getCurrentFeed())
R.id.filter_item -> handleFilter()
R.id.mark_all_item -> handleMarkAll()
R.id.star_all_item -> handleStarAll()
R.id.visit_website_item -> handleVisitWebsite(viewModel.getCurrentFeed()?.website)
R.id.remove_feed_item -> handleRemoveFeed()
else -> super.onOptionsItemSelected(item)
}
}
private fun toggleOptionsItems() {
markAllOptionsItem?.apply {
if (viewModel.allIsRead()) {
title = getString(R.string.mark_all_as_unread)
setIcon(R.drawable.ic_check_circle_outline)
} else {
title = getString(R.string.mark_all_as_read)
setIcon(R.drawable.ic_check_circle)
}
}
starAllOptionsItem?.apply {
if (viewModel.allIsStarred()) {
title = getString(R.string.unstar_all)
setIcon(R.drawable.ic_star)
} else {
title = getString(R.string.star_all)
setIcon(R.drawable.ic_star_border)
}
}
}
private fun handleCheckForUpdates(
url: String? = viewModel.getCurrentFeed()?.url
): Boolean {
return if (url != null) {
searchItem.collapseActionView()
viewModel.clearQuery()
toolbar.title = getString(R.string.updating)
progressBar.show()
viewModel.requestUpdate(url)
true
} else false
}
private fun showUpdateNotice() {
val count = viewModel.updateValues
if (count.isEmpty()) return
val itemsAddedString = resources.getQuantityString(R.plurals.numberOfNewEntries, count.added, count.added)
val itemsUpdatedString = resources.getQuantityString(R.plurals.numberOfEntries, count.updated, count.updated)
val message = when {
count.added > 0 && count.updated == 0 -> getString(R.string.added, itemsAddedString)
count.added == 0 && count.updated > 0 -> getString(R.string.updated, itemsUpdatedString)
else -> getString(R.string.added_and_updated, itemsAddedString, count.updated)
}
Snackbar.make(recyclerView, message, Snackbar.LENGTH_SHORT).show()
viewModel.updateValues.clear()
}
private fun handleShowFeedInfo(feed: Feed?): Boolean {
return if (feed != null) {
val mFeed = FeedManageable(url = feed.url, title = feed.title, website = feed.website,
imageUrl = feed.imageUrl, description = feed.description, category = feed.category)
val categories = callbacks?.onCategoriesNeeded() ?: emptyArray()
EditFeedFragment.newInstance(mFeed, categories).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager, null)
}
true
} else false
}
override fun onFeedInfoSubmitted(title: String, category: String, isChanged: Boolean) {
if (!isChanged) return
viewModel.getCurrentFeed()?.let { currentFeed ->
val editedFeed = currentFeed.apply {
this.title = title
this.category = category
}
viewModel.updateFeed(editedFeed)
handler.postDelayed({
Snackbar.make(recyclerView, getString(R.string.saved_changes_to, title), Snackbar.LENGTH_SHORT).show()
}, 250)
}
}
private fun handleFilter(): Boolean {
FilterEntriesFragment.newInstance(viewModel.filter).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager, null)
}
return true
}
private fun handleMarkAll(): Boolean {
viewModel.markAllCurrentEntriesAsRead()
adapter.notifyDataSetChanged()
return true
}
private fun handleStarAll(): Boolean {
viewModel.starAllCurrentEntries()
adapter.notifyDataSetChanged()
return true
}
private fun handleVisitWebsite(website: String?): Boolean {
return if (website != null) {
Utils.openLink(requireActivity(), recyclerView, Uri.parse(website))
true
} else false
}
private fun handleRemoveFeed(): Boolean {
val feed = viewModel.getCurrentFeed()
return if (feed != null) {
ConfirmActionFragment.newInstance(REMOVE, feed.title).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager,null)
}
true
} else false
}
override fun onRemoveConfirmed() {
val title = viewModel.getCurrentFeed()?.title
Snackbar.make(recyclerView, getString(R.string.unsubscribed_message, title), Snackbar.LENGTH_SHORT).show()
viewModel.deleteFeedAndEntries()
callbacks?.onFeedRemoved()
}
override fun onEntryClicked(entryId: String, view: View?) {
if (NiceFeedPreferences.getBrowserSetting(requireContext())) {
Utils.openLink(requireContext(), view, Uri.parse(entryId))
viewModel.updateEntryIsRead(entryId, true)
} else {
callbacks?.onEntrySelected(entryId)
}
}
override fun onEntryLongClicked(entry: EntryLight, view: View?) {
view?.let { EntryPopupMenu(requireContext(), it, this, entry).show() }
}
override fun onPopupMenuItemClicked(entry: EntryLight, action: Int) {
val url = entry.url
when (action) {
EntryPopupMenu.ACTION_STAR -> viewModel.updateEntryIsStarred(url, !entry.isStarred)
EntryPopupMenu.ACTION_MARK_AS -> viewModel.updateEntryIsRead(url, !entry.isRead)
else -> {
onEntryClicked(entry.url, recyclerView)
return
}
}
adapter.notifyDataSetChanged()
}
override fun onFilterSelected(filter: Int) {
viewModel.setFilter(filter)
}
override fun onStop() {
super.onStop()
context?.let { NiceFeedPreferences.saveLastViewedFeedId(it, feedId) }
}
override fun onDetach() {
super.onDetach()
callbacks = null
}
companion object {
private const val TAG = "EntryListFragment"
private const val ARG_FEED_ID = "ARG_FEED_ID"
private const val ARG_ENTRY_ID = "ARG_ENTRY_ID"
private const val ARG_BLOCK_AUTO_UPDATE = "ARG_BLOCK_AUTO_UPDATE"
const val FOLDER = "FOLDER"
const val FOLDER_NEW = "FOLDER_NEW"
const val FOLDER_STARRED = "FOLDER_STARRED"
fun newInstance(
feedId: String?,
entryId: String? = null,
blockAutoUpdate: Boolean = false
): EntryListFragment {
return EntryListFragment().apply {
arguments = Bundle().apply {
putString(ARG_FEED_ID, feedId)
putString(ARG_ENTRY_ID, entryId)
putBoolean(ARG_BLOCK_AUTO_UPDATE, blockAutoUpdate)
}
}
}
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.fragment
import android.content.Context
import android.view.View
import com.google.android.material.snackbar.Snackbar
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.cross.FeedWithEntries
import com.joshuacerdenia.android.nicefeed.ui.OnFinished
import com.joshuacerdenia.android.nicefeed.ui.OnToolbarInflated
import com.joshuacerdenia.android.nicefeed.ui.viewmodel.FeedAddingViewModel
/* Gives ability to subscribe to new feeds, must be extended. */
abstract class FeedAddingFragment: VisibleFragment() {
interface Callbacks: OnToolbarInflated, OnFinished {
fun onNewFeedAdded(feedId: String)
fun onQuerySubmitted(query: String)
fun onImportOpmlSelected()
}
var callbacks: Callbacks? = null
var resultManager: RequestResultManager? = null
override fun onAttach(context: Context) {
super.onAttach(context)
callbacks = context as Callbacks?
}
override fun onDetach() {
super.onDetach()
callbacks = null
}
/* Retrieved data should be fed to this class; handles Snackbars and directing data to DB.
Must be initialized by extending class. */
inner class RequestResultManager(
private val viewModel: FeedAddingViewModel,
private val view: View,
private val negativeMessageRes: Int,
) {
fun submitData(feedWithEntries: FeedWithEntries?) {
feedWithEntries?.let { data ->
if (viewModel.currentFeedIds.size < SUBSCRIPTION_LIMIT) {
if (!isAlreadyAdded(data.feed.url)) {
viewModel.addFeedWithEntries(data)
showFeedAddedNotice(data.feed.url, data.feed.title)
} else {
showAlreadyAddedNotice()
}
viewModel.lastInputUrl = ""
} else {
showLimitReachedNotice()
}
} ?: showRequestFailedNotice()
}
fun onRequestDismissed() {
viewModel.requestFailedNoticeEnabled = false
viewModel.cancelRequest()
Snackbar.make(view, R.string.request_canceled, Snackbar.LENGTH_SHORT).show()
}
private fun isAlreadyAdded(feedId: String): Boolean {
return viewModel.currentFeedIds.contains(feedId)
}
private fun showFeedAddedNotice(feedId: String, title: String) {
Snackbar.make(view, getString(R.string.feed_added_message, title), Snackbar.LENGTH_LONG)
.setAction(R.string.view) { callbacks?.onNewFeedAdded(feedId) }.show()
viewModel.alreadyAddedNoticeEnabled = false
}
private fun showAlreadyAddedNotice() {
if (viewModel.alreadyAddedNoticeEnabled) {
Snackbar.make(view, getString(R.string.feed_already_added), Snackbar.LENGTH_SHORT).show()
viewModel.alreadyAddedNoticeEnabled = false
}
}
private fun showRequestFailedNotice() {
if (viewModel.requestFailedNoticeEnabled) {
Snackbar.make(view, getString(negativeMessageRes), Snackbar.LENGTH_SHORT).show()
viewModel.requestFailedNoticeEnabled = false
}
}
private fun showLimitReachedNotice() {
if (viewModel.subscriptionLimitNoticeEnabled) {
Snackbar.make(view, getString(R.string.subscription_limit_reached), Snackbar.LENGTH_SHORT).show()
viewModel.subscriptionLimitNoticeEnabled = false
}
}
}
companion object {
private const val SUBSCRIPTION_LIMIT = 1000 // Arbitrary
}
}

View File

@@ -0,0 +1,191 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.fragment
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
import com.joshuacerdenia.android.nicefeed.ui.adapter.FeedListAdapter
import com.joshuacerdenia.android.nicefeed.ui.viewmodel.FeedListViewModel
import com.joshuacerdenia.android.nicefeed.util.extensions.addRipple
import com.joshuacerdenia.android.nicefeed.util.extensions.hide
import com.joshuacerdenia.android.nicefeed.util.extensions.show
class FeedListFragment: VisibleFragment(), FeedListAdapter.OnItemClickListener {
interface Callbacks {
fun onMenuItemSelected(item: Int)
fun onFeedSelected(feedId: String, activeFeedId: String?)
}
private lateinit var viewModel: FeedListViewModel
private lateinit var manageButton: Button
private lateinit var addButton: Button
private lateinit var newEntriesButton: Button
private lateinit var starredEntriesButton: Button
private lateinit var settingsButton: Button
private lateinit var bottomDivider: View
private lateinit var recyclerView: RecyclerView
lateinit var adapter: FeedListAdapter
private var callbacks: Callbacks? = null
private val handler = Handler()
override fun onAttach(context: Context) {
super.onAttach(context)
callbacks = context as Callbacks?
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this).get(FeedListViewModel::class.java)
viewModel.setFeedOrder(NiceFeedPreferences.getFeedsOrder(requireContext()))
viewModel.setMinimizedCategories(NiceFeedPreferences.getMinimizedCategories(requireContext()))
adapter = FeedListAdapter(context, this)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_feed_list, container, false)
manageButton = view.findViewById(R.id.manage_button)
addButton = view.findViewById(R.id.add_button)
newEntriesButton = view.findViewById(R.id.recent_entries_button)
starredEntriesButton = view.findViewById(R.id.starred_entries_button)
settingsButton = view.findViewById(R.id.settings_button)
bottomDivider = view.findViewById(R.id.bottom_divider)
recyclerView = view.findViewById(R.id.recycler_view)
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.adapter = adapter
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.feedListLiveData.observe(viewLifecycleOwner, { list ->
adapter.submitList(list)
if (list.isNotEmpty()) {
newEntriesButton.show()
starredEntriesButton.show()
manageButton.show()
bottomDivider.show()
} else {
newEntriesButton.hide()
starredEntriesButton.hide()
manageButton.hide()
bottomDivider.hide()
updateActiveFeedId(null)
}
})
}
override fun onStart() {
super.onStart()
manageButton.setOnClickListener {
callbacks?.onMenuItemSelected(ITEM_MANAGE_FEEDS)
}
addButton.setOnClickListener {
callbacks?.onMenuItemSelected(ITEM_ADD_FEEDS)
}
newEntriesButton.setOnClickListener {
callbacks?.onFeedSelected(EntryListFragment.FOLDER_NEW, viewModel.activeFeedId)
}
starredEntriesButton.setOnClickListener {
callbacks?.onFeedSelected(EntryListFragment.FOLDER_STARRED, viewModel.activeFeedId)
}
settingsButton.setOnClickListener {
callbacks?.onMenuItemSelected(ITEM_SETTINGS)
}
}
override fun onResume() {
super.onResume()
viewModel.setFeedOrder(NiceFeedPreferences.getFeedsOrder(requireContext()))
}
override fun onFeedSelected(feedId: String) {
resetFolderHighlights()
callbacks?.onFeedSelected(feedId, viewModel.activeFeedId)
viewModel.activeFeedId = feedId
handler.postDelayed({ recyclerView.adapter = adapter }, 500)
}
override fun onCategoryClicked(category: String) {
viewModel.toggleCategoryDropDown(category)
}
fun updateActiveFeedId(feedId: String?) {
resetFolderHighlights()
viewModel.activeFeedId = feedId
adapter.setActiveFeedId(feedId)
recyclerView.adapter = adapter
context?.let { context ->
val color = ContextCompat.getColor(context, R.color.colorSelect)
if (feedId == EntryListFragment.FOLDER_NEW) {
newEntriesButton.setBackgroundColor(color)
} else if (feedId == EntryListFragment.FOLDER_STARRED) {
starredEntriesButton.setBackgroundColor(color)
}
}
}
private fun resetFolderHighlights() {
starredEntriesButton.addRipple()
newEntriesButton.addRipple()
}
fun getCategories(): Array<String> {
return viewModel.categories
}
override fun onStop() {
super.onStop()
context?.let { context ->
NiceFeedPreferences.saveMinimizedCategories(context, viewModel.minimizedCategories)
}
}
override fun onDetach() {
super.onDetach()
callbacks = null
}
companion object {
const val ITEM_MANAGE_FEEDS = 0
const val ITEM_ADD_FEEDS = 1
const val ITEM_SETTINGS = 2
fun newInstance(): FeedListFragment {
return FeedListFragment()
}
}
}

View File

@@ -0,0 +1,405 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.fragment
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.view.*
import android.widget.CheckBox
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedManageable
import com.joshuacerdenia.android.nicefeed.ui.OnFinished
import com.joshuacerdenia.android.nicefeed.ui.OnToolbarInflated
import com.joshuacerdenia.android.nicefeed.ui.adapter.FeedManagerAdapter
import com.joshuacerdenia.android.nicefeed.ui.dialog.ConfirmActionFragment
import com.joshuacerdenia.android.nicefeed.ui.dialog.ConfirmActionFragment.Companion.EXPORT
import com.joshuacerdenia.android.nicefeed.ui.dialog.ConfirmActionFragment.Companion.REMOVE
import com.joshuacerdenia.android.nicefeed.ui.dialog.EditCategoryFragment
import com.joshuacerdenia.android.nicefeed.ui.dialog.EditFeedFragment
import com.joshuacerdenia.android.nicefeed.ui.dialog.SortFeedManagerFragment
import com.joshuacerdenia.android.nicefeed.ui.viewmodel.ManageFeedsViewModel
import com.joshuacerdenia.android.nicefeed.util.OpmlExporter
import com.joshuacerdenia.android.nicefeed.util.extensions.hide
import com.joshuacerdenia.android.nicefeed.util.extensions.show
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
class ManageFeedsFragment: VisibleFragment(),
EditCategoryFragment.Callbacks,
EditFeedFragment.Callback,
ConfirmActionFragment.OnRemoveConfirmed,
ConfirmActionFragment.OnExportConfirmed,
SortFeedManagerFragment.Callbacks,
FeedManagerAdapter.ItemCheckBoxListener,
OpmlExporter.ExportResultListener {
interface Callbacks: OnToolbarInflated, OnFinished {
fun onAddFeedsSelected()
fun onExportOpmlSelected()
}
private lateinit var viewModel: ManageFeedsViewModel
private lateinit var toolbar: Toolbar
private lateinit var progressBar: ProgressBar
private lateinit var selectAllCheckBox: CheckBox
private lateinit var counterTextView: TextView
private lateinit var emptyMessageTextView: TextView
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: FeedManagerAdapter
private lateinit var speedDial: SpeedDialView
private lateinit var searchItem: MenuItem
private var opmlExporter: OpmlExporter? = null
private var callbacks: Callbacks? = null
private val fragment = this@ManageFeedsFragment
private val handler = Handler()
override fun onAttach(context: Context) {
super.onAttach(context)
callbacks = context as Callbacks?
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this).get(ManageFeedsViewModel::class.java)
viewModel.setOrder(NiceFeedPreferences.getFeedManagerOrder(requireContext()))
adapter = FeedManagerAdapter(this, viewModel.selectedItems)
opmlExporter = OpmlExporter(requireContext(), this)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_manage_feeds, container, false)
toolbar = view.findViewById(R.id.toolbar)
progressBar = view.findViewById(R.id.progress_bar)
selectAllCheckBox = view.findViewById(R.id.select_all_checkbox)
counterTextView = view.findViewById(R.id.counter_text_view)
emptyMessageTextView = view.findViewById(R.id.empty_message_text_view)
speedDial = view.findViewById(R.id.speed_dial)
recyclerView = view.findViewById(R.id.recycler_view)
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.adapter = adapter
toolbar.title = getString(R.string.manage_feeds)
callbacks?.onToolbarInflated(toolbar)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
progressBar.show()
setupSpeedDial()
viewModel.feedsManageableLiveData.observe(viewLifecycleOwner, { feeds ->
progressBar.hide()
adapter.submitList(feeds)
selectAllCheckBox.isChecked = feeds.size == viewModel.selectedItems.size
if (feeds.size > 1) selectAllCheckBox.show() else selectAllCheckBox.hide()
if (feeds.isEmpty()) emptyMessageTextView.show() else emptyMessageTextView.hide()
})
viewModel.anyIsSelected.observe(viewLifecycleOwner, { anyIsSelected ->
updateCounter()
if (anyIsSelected) {
speedDial.show()
speedDial.open()
} else {
speedDial.hide()
}
})
}
override fun onStart() {
super.onStart()
toolbar.setOnClickListener { recyclerView.smoothScrollToPosition(0) }
selectAllCheckBox.setOnClickListener { (it as CheckBox)
if (it.isChecked) viewModel.resetSelection(adapter.currentList) else viewModel.resetSelection()
adapter.toggleCheckBoxes(it.isChecked)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.fragment_manage_feeds, menu)
searchItem = menu.findItem(R.id.menu_item_search)
searchItem.setOnActionExpandListener(object: MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem?): Boolean = true
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
viewModel.clearQuery()
resetSelection()
return true
}
})
(searchItem.actionView as SearchView).apply {
if (viewModel.query.isNotEmpty()) {
searchItem.expandActionView()
setQuery(viewModel.query, false)
clearFocus()
}
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextChange(queryText: String): Boolean = true
override fun onQueryTextSubmit(queryText: String): Boolean {
viewModel.submitQuery(queryText)
clearFocus()
return true
}
})
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_item_sort -> handleSortFeeds()
R.id.menu_item_export -> handleExportAll()
R.id.menu_item_add_feeds -> {
callbacks?.onAddFeedsSelected()
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun setupSpeedDial() {
speedDial.apply {
addActionItem(defaultSpeedDialItem(R.id.fab_edit, R.drawable.ic_edit_light))
addActionItem(defaultSpeedDialItem(R.id.fab_remove, R.drawable.ic_delete_light))
addActionItem(defaultSpeedDialItem(R.id.fab_export, R.drawable.ic_export_light))
setOnChangeListener(object : SpeedDialView.OnChangeListener {
override fun onToggleChanged(isOpen: Boolean) { } // Blank on purpose
override fun onMainActionSelected(): Boolean {
resetSelection()
return true
}
})
setOnActionSelectedListener { actionItem ->
when (actionItem.id) {
R.id.fab_edit -> handleEditSelected()
R.id.fab_remove -> handleRemoveSelected()
R.id.fab_export -> handleExportSelected()
}
true
}
}
}
private fun defaultSpeedDialItem(id: Int, iconRes: Int): SpeedDialActionItem {
return SpeedDialActionItem.Builder(id, iconRes)
.setFabBackgroundColor(ContextCompat.getColor(requireContext(), R.color.colorAccent))
.create()
}
private fun updateCounter() {
val count = viewModel.selectedItems.size
if (count > 0) {
counterTextView.show()
counterTextView.text = getString(R.string.number_selected, count)
} else {
counterTextView.hide()
}
}
private fun handleEditSelected(): Boolean {
val count = viewModel.selectedItems.size
if (count > 1) {
EditCategoryFragment.newInstance(viewModel.getCategories(), null, count).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager, "EditCategoryFragment")
}
} else {
EditFeedFragment.newInstance(viewModel.selectedItems.first(), viewModel.getCategories()).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager, "EditFeedFragment")
}
}
return true
}
override fun onFeedInfoSubmitted(title: String, category: String, isChanged: Boolean) {
if (!isChanged) return
viewModel.updateFeedDetails(viewModel.selectedItems.first().url, title, category)
searchItem.collapseActionView()
resetSelection()
handler.postDelayed({ Snackbar.make(
recyclerView,
getString(R.string.saved_changes_to, title),
Snackbar.LENGTH_SHORT
).show() }, 250)
}
override fun onEditCategoryConfirmed(category: String) {
val ids = mutableListOf<String>()
for (feed in viewModel.selectedItems) ids.add(feed.url)
viewModel.updateCategoryByFeedIds(ids, category)
resetSelection()
searchItem.collapseActionView()
// Crude solution to Snackbar jumping: wait until keyboard is fully hidden
handler.postDelayed({ showFeedsCategorizedNotice(category, ids.size) }, 400)
}
private fun showFeedsCategorizedNotice(category: String, count: Int) {
val feedsUpdated = resources.getQuantityString(R.plurals.numberOfFeeds, count, count)
Snackbar.make(
recyclerView,
getString(R.string.category_assigned, category, feedsUpdated),
Snackbar.LENGTH_LONG
).setAction(R.string.done) { callbacks?.onFinished() }.show()
}
private fun handleRemoveSelected(): Boolean {
val count = viewModel.selectedItems.size
val title = if (count == 1) viewModel.selectedItems.first().title else null
ConfirmActionFragment.newInstance(REMOVE, title, count).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager,"ConfirmActionFragment")
}
return true
}
override fun onRemoveConfirmed() {
val feedIds = viewModel.selectedItems.map { feed -> feed.url }.toTypedArray()
viewModel.deleteItems(*feedIds)
if (feedIds.size == 1) {
showFeedsRemovedNotice(title = viewModel.selectedItems.first().title)
} else {
showFeedsRemovedNotice(feedIds.size)
// If last viewed feed was just deleted, prevent main page from loading it:
val lastViewedFeedId = NiceFeedPreferences.getLastViewedFeedId(requireContext())
if (feedIds.contains(lastViewedFeedId)) {
NiceFeedPreferences.saveLastViewedFeedId(requireContext(), null)
}
}
resetSelection()
}
private fun showFeedsRemovedNotice(count: Int = 1, title: String? = null) {
val feedsRemoved = title ?: resources.getQuantityString(R.plurals.numberOfFeeds, count, count)
Snackbar.make(
recyclerView, getString(R.string.unsubscribed_message, feedsRemoved), Snackbar.LENGTH_LONG)
.setAction(R.string.done) { callbacks?.onFinished() }.show()
}
private fun handleExportAll(): Boolean {
selectAllCheckBox.isChecked = true
viewModel.resetSelection(adapter.currentList)
adapter.toggleCheckBoxes(true)
return handleExportSelected()
}
private fun handleExportSelected(): Boolean {
val count = viewModel.selectedItems.size
val title = if (count == 1) viewModel.selectedItems.first().title else null
ConfirmActionFragment.newInstance(EXPORT, title, count).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager,"ConfirmActionFragment")
}
return true
}
override fun onExportConfirmed() {
val feeds = viewModel.selectedItems
opmlExporter?.submitFeeds(feeds)
callbacks?.onExportOpmlSelected()
}
fun writeOpml(uri: Uri) {
opmlExporter?.executeExport(uri)
}
override fun onExportAttempted(isSuccessful: Boolean, fileName: String?) {
val count = viewModel.selectedItems.size
val itemString = resources.getQuantityString(R.plurals.numberOfFeeds, count, count)
val message = if (isSuccessful) {
getString(R.string.exported_message, itemString)
} else {
getString(R.string.error_message)
}
resetSelection()
Snackbar.make(recyclerView, message, Snackbar.LENGTH_SHORT)
.setAction(R.string.done) { callbacks?.onFinished() }.show()
}
private fun handleSortFeeds(): Boolean {
SortFeedManagerFragment.newInstance(viewModel.order).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager, "SortFeedManagerFragment")
}
return true
}
override fun onOrderSelected(order: Int) {
viewModel.setOrder(order)
}
private fun resetSelection() {
viewModel.resetSelection()
selectAllCheckBox.isChecked = false
adapter.toggleCheckBoxes(false)
}
override fun onItemClicked(feed: FeedManageable, isChecked: Boolean) {
if (isChecked) {
viewModel.addSelection(feed)
selectAllCheckBox.isChecked = viewModel.selectedItems.size == adapter.currentList.size
} else {
viewModel.removeSelection(feed)
selectAllCheckBox.isChecked = false
}
adapter.selectedItems = viewModel.selectedItems
}
override fun onAllItemsChecked(isChecked: Boolean) {
selectAllCheckBox.isChecked = isChecked
}
override fun onStop() {
super.onStop()
context?.let { NiceFeedPreferences.saveFeedManagerOrder(it, viewModel.order) }
}
override fun onDetach() {
super.onDetach()
callbacks = null
}
companion object {
fun newInstance(): ManageFeedsFragment {
return ManageFeedsFragment()
}
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.fragment
import android.os.Bundle
import android.os.Handler
import android.view.*
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.SearchResultItem
import com.joshuacerdenia.android.nicefeed.ui.FeedRequestCallbacks
import com.joshuacerdenia.android.nicefeed.ui.adapter.FeedSearchAdapter
import com.joshuacerdenia.android.nicefeed.ui.dialog.SubscribeFragment
import com.joshuacerdenia.android.nicefeed.ui.viewmodel.SearchFeedsViewModel
import com.joshuacerdenia.android.nicefeed.util.Utils
import com.joshuacerdenia.android.nicefeed.util.extensions.hide
import com.joshuacerdenia.android.nicefeed.util.extensions.show
class SearchFeedsFragment : FeedAddingFragment(),
FeedSearchAdapter.OnItemClickListener,
FeedRequestCallbacks {
private lateinit var viewModel: SearchFeedsViewModel
private lateinit var toolbar: Toolbar
private lateinit var progressBar: ProgressBar
private lateinit var recyclerView: RecyclerView
private lateinit var noItemsTextView: TextView
private lateinit var searchView: SearchView
private lateinit var adapter: FeedSearchAdapter
private val fragment = this@SearchFeedsFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this).get(SearchFeedsViewModel::class.java)
adapter = FeedSearchAdapter(this)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_search_feeds, container, false)
progressBar = view.findViewById(R.id.search_progress_bar)
noItemsTextView = view.findViewById(R.id.empty_message_text_view)
recyclerView = view.findViewById(R.id.recycler_view)
toolbar = view.findViewById(R.id.toolbar)
setupRecyclerView()
setupToolbar()
return view
}
private fun setupRecyclerView() {
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.adapter = adapter
}
private fun setupToolbar() {
toolbar.title = getString(R.string.search_feeds)
callbacks?.onToolbarInflated(toolbar)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
resultManager = RequestResultManager(viewModel, recyclerView, R.string.failed_to_connect)
viewModel.feedIdsLiveData.observe(viewLifecycleOwner, { feedIds ->
viewModel.onFeedIdsRetrieved(feedIds)
})
viewModel.searchResultLiveData.observe(viewLifecycleOwner, { results ->
adapter.submitList(results)
progressBar.hide()
if (results.isEmpty()) noItemsTextView.show() else noItemsTextView.hide()
})
viewModel.feedRequestLiveData.observe(viewLifecycleOwner, { feedWithEntries ->
resultManager?.submitData(feedWithEntries)
if (viewModel.isActiveRequest) {
parentFragmentManager.findFragmentByTag(SubscribeFragment.TAG).let { fragment ->
(fragment as? DialogFragment)?.dismiss()
viewModel.isActiveRequest = false
}
}
})
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.fragment_feed_search, menu)
val initialQuery = arguments?.getString(ARG_INITIAL_QUERY) ?: ""
val searchItem: MenuItem = menu.findItem(R.id.menu_item_search)
searchView = searchItem.actionView as SearchView
searchView.apply {
isIconified = false
queryHint = getString(R.string.search_feeds___)
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(queryText: String): Boolean {
if (queryText.isNotEmpty()) {
progressBar.show()
viewModel.performSearch(queryText)
}
clearFocus()
Utils.hideSoftKeyBoard(requireActivity(), this@apply)
return true
}
override fun onQueryTextChange(queryText: String): Boolean {
viewModel.newQuery = queryText
return false
}
})
if (!viewModel.initialQueryIsMade) {
setQuery(initialQuery, true)
viewModel.initialQueryIsMade = true
} else {
setQuery(viewModel.newQuery, false)
clearFocus()
}
}
Utils.hideSoftKeyBoard(requireActivity(), searchView)
}
override fun onRequestSubmitted(url: String, backup: String?) {
viewModel.requestFeed(url, backup)
}
override fun onRequestDismissed() {
Handler().postDelayed({ resultManager?.onRequestDismissed() }, 250)
}
override fun onItemClicked(searchResultItem: SearchResultItem) {
searchView.clearFocus()
activity?.let { Utils.hideSoftKeyBoard(it, recyclerView) }
SubscribeFragment.newInstance(searchResultItem).apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager, SubscribeFragment.TAG)
}
}
companion object {
private const val ARG_INITIAL_QUERY = "ARG_INITIAL_QUERY"
fun newInstance(initialQuery: String?): SearchFeedsFragment {
return SearchFeedsFragment().apply {
arguments = Bundle().apply {
putString(ARG_INITIAL_QUERY, initialQuery)
}
}
}
}
}

View File

@@ -0,0 +1,237 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.fragment
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.view.*
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ScrollView
import android.widget.Spinner
import androidx.appcompat.widget.SwitchCompat
import androidx.appcompat.widget.Toolbar
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
import com.joshuacerdenia.android.nicefeed.ui.OnToolbarInflated
import com.joshuacerdenia.android.nicefeed.ui.dialog.AboutFragment
import com.joshuacerdenia.android.nicefeed.util.Utils
import com.joshuacerdenia.android.nicefeed.util.work.BackgroundSyncWorker
import com.joshuacerdenia.android.nicefeed.util.work.NewEntriesWorker
class SettingsFragment: VisibleFragment(), AboutFragment.Callback {
interface Callbacks: OnToolbarInflated
private lateinit var toolbar: Toolbar
private lateinit var scrollView: ScrollView
private lateinit var autoUpdateSwitch: SwitchCompat
private lateinit var browserSwitch: SwitchCompat
private lateinit var notificationSwitch: SwitchCompat
private lateinit var bannerSwitch: SwitchCompat
private lateinit var syncSwitch: SwitchCompat
private lateinit var keepEntriesSwitch: SwitchCompat
private lateinit var themeSpinner: Spinner
private lateinit var sortFeedsSpinner: Spinner
private lateinit var sortEntriesSpinner: Spinner
private lateinit var fontSpinner: Spinner
private val fragment = this@SettingsFragment
private var callbacks: Callbacks? = null
override fun onAttach(context: Context) {
super.onAttach(context)
callbacks = context as Callbacks?
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_settings, container, false)
toolbar = view.findViewById(R.id.toolbar)
scrollView = view.findViewById(R.id.scroll_view)
autoUpdateSwitch = view.findViewById(R.id.auto_update_switch)
browserSwitch = view.findViewById(R.id.browser_switch)
notificationSwitch = view.findViewById(R.id.notification_switch)
bannerSwitch = view.findViewById(R.id.banner_switch)
syncSwitch = view.findViewById(R.id.sync_switch)
keepEntriesSwitch = view.findViewById(R.id.keep_entries_switch)
themeSpinner = view.findViewById(R.id.theme_spinner)
sortFeedsSpinner = view.findViewById(R.id.sort_feeds_spinner)
sortEntriesSpinner = view.findViewById(R.id.sort_entries_spinner)
fontSpinner = view.findViewById(R.id.font_spinner)
setupToolbar()
setHasOptionsMenu(true)
return view
}
private fun setupToolbar() {
toolbar.title = getString(R.string.settings)
callbacks?.onToolbarInflated(toolbar)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
themeSpinner.apply {
adapter = arrayOf(
getString(R.string.system_default),
getString(R.string.light),
getString(R.string.dark)
).run { getDefaultAdapter(context, this)}
setSelection(NiceFeedPreferences.getTheme(context))
onItemSelectedListener = getSpinnerListener(context, ACTION_SAVE_THEME)
}
sortFeedsSpinner.apply {
adapter = arrayOf(
getString(R.string.title),
getString(R.string.unread_items)
).run { getDefaultAdapter(context, this) }
setSelection(NiceFeedPreferences.getFeedsOrder(context))
onItemSelectedListener = getSpinnerListener(context, ACTION_SAVE_FEEDS_ORDER)
}
sortEntriesSpinner.apply {
adapter = arrayOf(
getString(R.string.date_published),
getString(R.string.unread_on_top)
).run { getDefaultAdapter(context, this)}
setSelection(NiceFeedPreferences.getEntriesOrder(context))
onItemSelectedListener = getSpinnerListener(context, ACTION_SAVE_ENTRIES_ORDER)
}
fontSpinner.apply {
adapter = arrayOf(
getString(R.string.sans_serif),
getString(R.string.serif)
).run { getDefaultAdapter(context, this)}
setSelection(NiceFeedPreferences.getFont(context))
onItemSelectedListener = getSpinnerListener(context, ACTION_SAVE_FONT)
}
autoUpdateSwitch.apply {
isChecked = NiceFeedPreferences.getAutoUpdateSetting(context)
setOnCheckedChangeListener { _, isOn ->
NiceFeedPreferences.saveAutoUpdateSetting(context, isOn)
}
}
keepEntriesSwitch.apply {
isChecked = NiceFeedPreferences.keepOldUnreadEntries(context)
setOnCheckedChangeListener { _, isOn ->
NiceFeedPreferences.setKeepOldUnreadEntries(context, isOn)
}
}
syncSwitch.apply {
isChecked = NiceFeedPreferences.syncInBackground(context)
setOnCheckedChangeListener { _, isOn ->
NiceFeedPreferences.setSyncInBackground(context, isOn)
if (isOn) BackgroundSyncWorker.start(context) else BackgroundSyncWorker.cancel(context)
}
}
bannerSwitch.apply {
isChecked = NiceFeedPreferences.bannerIsEnabled(context)
setOnCheckedChangeListener { _, isOn ->
NiceFeedPreferences.setBannerIsEnabled(context, isOn)
}
}
browserSwitch.apply {
// Values are reversed on purpose
isChecked = !NiceFeedPreferences.getBrowserSetting(context)
setOnCheckedChangeListener { _, isOn ->
NiceFeedPreferences.setBrowserSetting(context, !isOn)
}
}
notificationSwitch.apply {
isChecked = NiceFeedPreferences.getPollingSetting(context)
setOnCheckedChangeListener { _, isOn ->
NiceFeedPreferences.savePollingSetting(context, isOn)
if (isOn) NewEntriesWorker.start(context) else NewEntriesWorker.cancel(context)
}
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.fragment_settings, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return if (item.itemId == R.id.about_menu_item) {
AboutFragment.newInstance().apply {
setTargetFragment(fragment, 0)
show(fragment.parentFragmentManager, "about")
}
true
} else super.onOptionsItemSelected(item)
}
private fun getDefaultAdapter(context: Context, items: Array<String>): ArrayAdapter<String> {
return ArrayAdapter(context, android.R.layout.simple_spinner_item, items).apply {
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
}
}
private fun getSpinnerListener(context: Context, action: Int): AdapterView.OnItemSelectedListener {
return object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
when (action) {
ACTION_SAVE_THEME -> {
NiceFeedPreferences.saveTheme(context, position)
Utils.setTheme(position)
}
ACTION_SAVE_FEEDS_ORDER -> NiceFeedPreferences.saveFeedsOrder(context, position)
ACTION_SAVE_ENTRIES_ORDER -> NiceFeedPreferences.saveEntriesOrder(context, position)
ACTION_SAVE_FONT -> NiceFeedPreferences.saveFont(context, position)
}
}
override fun onNothingSelected(parent: AdapterView<*>?) { } // Do nothing
}
}
override fun onGoToRepoClicked() {
Utils.openLink(requireActivity(), scrollView, Uri.parse(GITHUB_REPO))
}
override fun onDetach() {
super.onDetach()
callbacks = null
}
companion object {
private const val GITHUB_REPO = "https://www.psmforums.wordpress.com"
private const val ACTION_SAVE_THEME = 0
private const val ACTION_SAVE_FEEDS_ORDER = 1
private const val ACTION_SAVE_ENTRIES_ORDER = 2
private const val ACTION_SAVE_FONT = 3
fun newInstance(): SettingsFragment {
return SettingsFragment()
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.fragment
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.fragment.app.Fragment
import com.joshuacerdenia.android.nicefeed.util.work.NewEntriesWorker
// To be extended by all fragments
abstract class VisibleFragment : Fragment() {
private val onShowNotification = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
// Suppress notification when app is in the foreground
resultCode = Activity.RESULT_CANCELED
}
}
override fun onStart() {
super.onStart()
val filter = IntentFilter(NewEntriesWorker.ACTION_SHOW_NOTIFICATION)
requireActivity().registerReceiver(
onShowNotification,
filter,
NewEntriesWorker.PERM_PRIVATE,
null
)
}
override fun onStop() {
super.onStop()
requireActivity().unregisterReceiver(onShowNotification)
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.menu
import android.content.Context
import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.entry.EntryLight
import com.joshuacerdenia.android.nicefeed.util.extensions.addRipple
class EntryPopupMenu(
context: Context,
view: View,
private val listener: OnPopupMenuItemClicked,
private val entry: EntryLight
) : PopupMenu(context, view) {
interface OnPopupMenuItemClicked {
fun onPopupMenuItemClicked(entry: EntryLight, action: Int)
}
init {
view.setBackgroundColor(ContextCompat.getColor(context, R.color.colorSelect))
menuInflater.inflate(R.menu.popup_menu_entry, menu)
val starItem = menu.findItem(R.id.menuItem_star)
val markAsReadItem = menu.findItem(R.id.menuItem_mark_as_read)
// Default values for the following are set in XML
if (entry.isStarred) {
starItem.title = context.getString(R.string.unstar)
starItem.setIcon(R.drawable.ic_star)
}
if (entry.isRead) {
markAsReadItem.title = context.getString(R.string.mark_as_unread)
markAsReadItem.setIcon(R.drawable.ic_check_circle_outline)
}
// Force show icons
try {
val fieldMPopup = PopupMenu::class.java.getDeclaredField("mPopup")
fieldMPopup.isAccessible = true
val mPopup = fieldMPopup.get(this)
mPopup.javaClass
.getDeclaredMethod("setForceShowIcon", Boolean::class.java)
.invoke(mPopup, true)
} catch (e: Exception) { } // Do nothing
setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.menuItem_star -> listener.onPopupMenuItemClicked(entry, ACTION_STAR)
R.id.menuItem_mark_as_read -> listener.onPopupMenuItemClicked(entry, ACTION_MARK_AS)
R.id.menuItem_read -> listener.onPopupMenuItemClicked(entry, ACTION_OPEN)
}
true
}
setOnDismissListener { view.addRipple() }
}
companion object {
const val ACTION_MARK_AS = 0
const val ACTION_STAR = 1
const val ACTION_OPEN = 2
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.joshuacerdenia.android.nicefeed.R
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedIdWithCategory
import com.joshuacerdenia.android.nicefeed.data.model.TopicBlock
class AddFeedsViewModel: FeedAddingViewModel() {
val feedIdsWithCategoriesLiveData = repo.getFeedIdsWithCategories()
private val _topicBlocksLiveData = MutableLiveData<List<TopicBlock>>()
val topicBlocksLiveData: LiveData<List<TopicBlock>>
get() = _topicBlocksLiveData
var feedsToImport = listOf<Feed>()
var categories = listOf<String>()
private var isFirstTimeLoading = true
private val defaultTopics: MutableList<String> = mutableListOf()
val defaultTopicsResId: List<Int> = listOf(
R.string.news, R.string.politics, R.string.world, R.string.business, R.string.science,
R.string.tech, R.string.art, R.string.culture, R.string.books, R.string.entertainment
)
private val colorsResId: List<Int> = listOf(
R.color.topic1, R.color.topic2, R.color.topic3, R.color.topic4, R.color.topic5,
R.color.topic6, R.color.topic7, R.color.topic8, R.color.topic9, R.color.topic10
)
fun initDefaultTopics(topics: List<String>) {
topics.forEach { defaultTopics.add(it) }
}
fun onFeedDataRetrieved(data: List<FeedIdWithCategory>) {
currentFeedIds = data.map { it.url }
val categories = data.map { it.category }.distinct().filterNot { it == "Uncategorized"}
if (categories.sorted() != this.categories.sorted() || categories.isEmpty()) {
if (isFirstTimeLoading) _topicBlocksLiveData.value = getTopicBlocks(categories)
this.categories = categories
isFirstTimeLoading = false
}
}
private fun getTopicBlocks(categories: List<String>): List<TopicBlock> {
val topics = (categories + defaultTopics).distinct().shuffled()
val topicBlocks: MutableList<TopicBlock> = mutableListOf()
var index = 0
while (topicBlocks.size < MAX_TOPICS) {
topicBlocks.add(TopicBlock(topics[index], colorsResId[index]))
index += 1
}
return topicBlocks.shuffled()
}
fun addFeeds(vararg feed: Feed) {
repo.addFeeds(*feed)
}
companion object {
private const val MAX_TOPICS = 10
}
}

View File

@@ -0,0 +1,246 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.viewmodel
import androidx.lifecycle.*
import com.joshuacerdenia.android.nicefeed.data.NiceFeedRepository
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
import com.joshuacerdenia.android.nicefeed.data.model.*
import com.joshuacerdenia.android.nicefeed.data.model.cross.FeedWithEntries
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
import com.joshuacerdenia.android.nicefeed.data.model.entry.EntryLight
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
import com.joshuacerdenia.android.nicefeed.data.remote.FeedParser
import com.joshuacerdenia.android.nicefeed.ui.dialog.FilterEntriesFragment
import com.joshuacerdenia.android.nicefeed.ui.fragment.EntryListFragment
import com.joshuacerdenia.android.nicefeed.ui.fragment.EntryListFragment.Companion.FOLDER
import com.joshuacerdenia.android.nicefeed.util.UpdateManager
import com.joshuacerdenia.android.nicefeed.util.extensions.shortened
import com.joshuacerdenia.android.nicefeed.util.extensions.sortedByDate
import com.joshuacerdenia.android.nicefeed.util.extensions.sortedUnreadOnTop
import kotlinx.coroutines.launch
import java.util.*
class EntryListViewModel: ViewModel(), UpdateManager.UpdateReceiver {
private val repo = NiceFeedRepository.get()
private val parser = FeedParser(repo.networkMonitor)
private val updateManager = UpdateManager(this)
private val feedIdLiveData = MutableLiveData<String>()
val feedLiveData = Transformations.switchMap(feedIdLiveData) { feedId ->
repo.getFeed(feedId)
}
private val sourceEntriesLiveData = Transformations.switchMap(feedIdLiveData) { feedId ->
when (feedId) {
EntryListFragment.FOLDER_NEW -> repo.getNewEntries(MAX_NEW_ENTRIES)
EntryListFragment.FOLDER_STARRED -> repo.getStarredEntries()
else -> repo.getEntriesByFeed(feedId)
}
}
private val entriesLiveData = MediatorLiveData<List<Entry>>()
val entriesLightLiveData = MediatorLiveData<List<EntryLight>>()
val updateResultLiveData = parser.feedRequestLiveData
var query = ""
private set
private var order = 0
var filter = 0
private set
val updateValues = UpdateValues()
private var updateWasRequested = false
var isAutoUpdating = true
init {
entriesLiveData.addSource(sourceEntriesLiveData) { source ->
val filteredEntries = filterEntries(source, filter)
entriesLiveData.value = queryEntries(filteredEntries, query)
updateManager.setInitialEntries(source)
}
entriesLightLiveData.addSource(entriesLiveData) { entries ->
val list = entries.map { entry ->
EntryLight(url = entry.url, title = entry.title, website = entry.website, date = entry.date,
image = entry.image, isRead = entry.isRead, isStarred = entry.isStarred)
}
entriesLightLiveData.value = sortEntries(list, order)
}
}
fun getFeedWithEntries(feedId: String) {
if (feedId.startsWith(FOLDER)) isAutoUpdating = false
feedIdLiveData.value = feedId
}
fun requestUpdate(url: String) {
isAutoUpdating = false
updateWasRequested = true
viewModelScope.launch { parser.requestFeed(url) }
}
fun onFeedRetrieved(feed: Feed?) {
feed?.let { updateManager.setInitialFeed(feed) }
}
fun onUpdatesDownloaded(feedWithEntries: FeedWithEntries) {
if (updateWasRequested) {
updateManager.submitUpdates(feedWithEntries)
updateWasRequested = false
}
}
fun setFilter(filter: Int) {
this.filter = filter
sourceEntriesLiveData.value?.let { entries ->
val filteredEntries = filterEntries(entries, filter)
entriesLiveData.value = queryEntries(filteredEntries, query)
}
}
fun setOrder(order: Int) {
if (this.order != order) {
this.order = order
entriesLightLiveData.value?.let { entries ->
entriesLightLiveData.value = sortEntries(entries, order)
}
}
}
fun submitQuery(query: String) {
this.query = query.trim()
sourceEntriesLiveData.value?.let { source ->
val filteredEntries = filterEntries(source, filter)
entriesLiveData.value = if (this.query.isNotEmpty()) {
queryEntries(filteredEntries, this.query)
} else filteredEntries
}
}
fun clearQuery() {
submitQuery("")
}
fun starAllCurrentEntries() {
val entries = entriesLightLiveData.value ?: emptyList()
val isStarred = !allIsStarred(entries)
val entryIds = entries.map { entry -> entry.url }.toTypedArray()
repo.updateEntryIsStarred(*entryIds, isStarred = isStarred)
}
fun markAllCurrentEntriesAsRead() {
val entries = entriesLightLiveData.value ?: emptyList()
val isRead = !allIsRead(entries)
val entryIds = entries.map { entry -> entry.url }.toTypedArray()
repo.updateEntryIsRead(*entryIds, isRead = isRead)
}
fun keepOldUnreadEntries(isKeeping: Boolean) {
updateManager.keepOldUnreadEntries = isKeeping
}
fun allIsStarred(
entries: List<EntryLight> = entriesLightLiveData.value ?: emptyList()
): Boolean {
var count = 0
for (entry in entries) {
if (entry.isStarred) count += 1 else break
}
return count == entries.size
}
fun allIsRead(
entries: List<EntryLight> = entriesLightLiveData.value ?: emptyList()
): Boolean {
var count = 0
for (entry in entries) {
if (entry.isRead) count += 1 else break
}
return count == entries.size
}
private fun queryEntries(entries: List<Entry>, query: String): List<Entry> {
val results = mutableListOf<Entry>()
for (entry in entries) {
if (entry.title.toLowerCase(Locale.ROOT).contains(query) ||
entry.website.shortened().toLowerCase(Locale.ROOT).contains(query)) {
results.add(entry)
}
}
return results
}
private fun filterEntries(entries: List<Entry>, filter: Int): List<Entry> {
return when (filter) {
FilterEntriesFragment.FILTER_UNREAD -> entries.filter { !it.isRead }
FilterEntriesFragment.FILTER_STARRED -> entries.filter { it.isStarred }
else -> entries
}
}
private fun sortEntries(entries: List<EntryLight>, order: Int): List<EntryLight> {
return if (order == NiceFeedPreferences.ENTRY_ORDER_UNREAD) {
entries.sortedUnreadOnTop()
} else {
entries.sortedByDate()
}
}
override fun onUnreadEntriesCounted(feedId: String, unreadCount: Int) {
repo.updateFeedUnreadCount(feedId, unreadCount)
}
fun updateFeed(feed: Feed) {
updateManager.forceUpdateFeed(feed)
}
override fun onFeedNeedsUpdate(feed: Feed) {
repo.updateFeed(feed)
}
override fun onOldAndNewEntriesCompared(
feedId: String,
entriesToAdd: List<Entry>,
entriesToUpdate: List<Entry>,
entriesToDelete: List<Entry>,
) {
repo.handleEntryUpdates(feedId, entriesToAdd, entriesToUpdate, entriesToDelete)
if (entriesToAdd.size + entriesToUpdate.size > 0) {
updateValues.added = entriesToAdd.size
updateValues.updated = entriesToUpdate.size
} else {
updateValues.clear()
}
}
fun getCurrentFeed() = updateManager.currentFeed
fun updateEntryIsStarred(entryId: String, isStarred: Boolean) {
repo.updateEntryIsStarred(entryId, isStarred = isStarred)
}
fun updateEntryIsRead(entryId: String, isRead: Boolean) {
repo.updateEntryIsRead(entryId, isRead = isRead)
}
fun deleteFeedAndEntries() {
getCurrentFeed()?.url?.let { feedId -> repo.deleteFeedAndEntriesById(feedId) }
}
companion object {
private const val MAX_NEW_ENTRIES = 50
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.viewmodel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.joshuacerdenia.android.nicefeed.data.NiceFeedRepository
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
import com.joshuacerdenia.android.nicefeed.data.model.entry.EntryMinimal
import com.joshuacerdenia.android.nicefeed.data.remote.FeedParser
import com.joshuacerdenia.android.nicefeed.util.EntryToHtmlFormatter
class EntryViewModel : ViewModel() {
private val repo = NiceFeedRepository.get()
private val entryIdLiveData = MutableLiveData<String>()
private val entryLiveData = Transformations.switchMap(entryIdLiveData) { entryId ->
repo.getEntry(entryId)
}
val htmlLiveData = MediatorLiveData<String?>()
var lastPosition: Pair<Int, Int> = Pair(0, 0)
var textSize = 0
private set
var font = 0
var bannerIsEnabled = true
var isInitialLoading = true
var entry: Entry? = null
private set
private var isExcerpt = false // As of now, unused
init {
htmlLiveData.addSource(entryLiveData) { source ->
if (source != null) {
entry = source
isExcerpt = source.content?.startsWith(FeedParser.FLAG_EXCERPT) ?: false
drawHtml(source)
} else htmlLiveData.value = null
}
}
fun getEntryById(entryId: String) {
entryIdLiveData.value = entryId
}
fun setTextSize(textSize: Int) {
this.textSize = textSize
entryLiveData.value?.let { entry -> drawHtml(entry) }
}
private fun drawHtml(entry: Entry) {
EntryMinimal(
title = entry.title, date = entry.date, author = entry.author,
content = entry.content?.removePrefix(FeedParser.FLAG_EXCERPT) ?: ""
).let { htmlLiveData.value = EntryToHtmlFormatter(textSize, font, !bannerIsEnabled).getHtml(it)
}
}
fun saveChanges() {
entry?.let { repo.updateEntryAndFeedUnreadCount(it.url, true, it.isStarred) }
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.joshuacerdenia.android.nicefeed.data.NiceFeedRepository
import com.joshuacerdenia.android.nicefeed.data.model.cross.FeedWithEntries
import com.joshuacerdenia.android.nicefeed.data.remote.FeedParser
import kotlinx.coroutines.launch
abstract class FeedAddingViewModel: ViewModel() {
val repo = NiceFeedRepository.get()
private val parser = FeedParser(repo.networkMonitor)
val feedRequestLiveData = parser.feedRequestLiveData
var currentFeedIds = listOf<String>()
var isActiveRequest = false
var requestFailedNoticeEnabled = false
var alreadyAddedNoticeEnabled = false
var subscriptionLimitNoticeEnabled = false
var lastInputUrl = ""
fun requestFeed(url: String, backup: String? = null) {
onFeedRequested()
viewModelScope.launch {
parser.requestFeed(url, backup)
}
}
private fun onFeedRequested() {
isActiveRequest = true
requestFailedNoticeEnabled = true
alreadyAddedNoticeEnabled = true
subscriptionLimitNoticeEnabled = true
}
fun addFeedWithEntries(feedWithEntries: FeedWithEntries) {
repo.addFeedWithEntries(feedWithEntries)
}
fun cancelRequest() {
parser.cancelRequest()
}
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.viewmodel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import com.joshuacerdenia.android.nicefeed.data.NiceFeedRepository
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
import com.joshuacerdenia.android.nicefeed.data.model.CategoryHeader
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedLight
import com.joshuacerdenia.android.nicefeed.data.model.FeedMenuItem
import com.joshuacerdenia.android.nicefeed.util.extensions.sortedByTitle
import com.joshuacerdenia.android.nicefeed.util.extensions.sortedByUnreadCount
class FeedListViewModel: ViewModel() {
private val repo = NiceFeedRepository.get()
private val sourceFeedsLiveData = repo.getFeedsLight()
var activeFeedId: String? = null
var categories = arrayOf<String>()
private set
val minimizedCategories = mutableSetOf<String>()
private var feedOrder = 0
val feedListLiveData = MediatorLiveData<List<FeedMenuItem>>()
init {
feedListLiveData.addSource(sourceFeedsLiveData) { feeds ->
feedListLiveData.value = organizeFeedsAndCategories(feeds, minimizedCategories)
}
}
fun setMinimizedCategories(categories: Set<String>?) {
categories?.forEach { category ->
minimizedCategories.add(category)
}
}
fun toggleCategoryDropDown(category: String) {
if (minimizedCategories.contains(category)) {
minimizedCategories.remove(category)
} else minimizedCategories.add(category)
arrangeMenu()
}
fun setFeedOrder(order: Int) {
if (order != feedOrder) {
feedOrder = order
arrangeMenu()
}
}
private fun arrangeMenu() {
sourceFeedsLiveData.value?.let { feeds ->
feedListLiveData.value = organizeFeedsAndCategories(feeds, minimizedCategories)
}
}
private fun sortFeeds(feeds: List<FeedLight>, order: Int): List<FeedLight> {
return if (order == NiceFeedPreferences.FEED_ORDER_UNREAD) {
feeds.sortedByUnreadCount()
} else {
feeds.sortedByTitle()
}
}
private fun organizeFeedsAndCategories(
feeds: List<FeedLight>,
minimizedCategories: Set<String>
): List<FeedMenuItem> {
val categories = getOrderedCategories(feeds)
val arrangedMenu = mutableListOf<FeedMenuItem>()
for (category in categories) {
val isMinimized = minimizedCategories.contains(category)
val categoryHeader = CategoryHeader(category, isMinimized)
arrangedMenu.add(FeedMenuItem(categoryHeader))
sortFeeds(feeds, feedOrder).forEach { feed ->
if (feed.category == category) {
categoryHeader.unreadCount += feed.unreadCount
if (!isMinimized) {
arrangedMenu.add(FeedMenuItem(feed))
}
}
}
}
this.categories = categories.toTypedArray()
return arrangedMenu
}
private fun getOrderedCategories(feeds: List<FeedLight>): List<String> {
val categories = mutableSetOf<String>()
for (feed in feeds) {
categories.add(feed.category)
}
// Sort alphabetically:
return categories.toList().sorted()
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.joshuacerdenia.android.nicefeed.data.NiceFeedRepository
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedManageable
import com.joshuacerdenia.android.nicefeed.ui.dialog.SortFeedManagerFragment
import com.joshuacerdenia.android.nicefeed.util.extensions.pathified
import com.joshuacerdenia.android.nicefeed.util.extensions.sortedByCategory
import com.joshuacerdenia.android.nicefeed.util.extensions.sortedByTitle
import java.util.*
class ManageFeedsViewModel: ViewModel() {
private val repo = NiceFeedRepository.get()
private val sourceFeedsLiveData: LiveData<List<FeedManageable>> = repo.getFeedsManageable()
val feedsManageableLiveData = MediatorLiveData<List<FeedManageable>>()
private var _anyIsSelected: MutableLiveData<Boolean> = MutableLiveData(false)
val anyIsSelected: LiveData<Boolean>
get() = _anyIsSelected
private val _selectedItems = mutableListOf<FeedManageable>()
val selectedItems: List<FeedManageable>
get() = _selectedItems
var order = 0
private set
var query = ""
private set
init {
feedsManageableLiveData.addSource(sourceFeedsLiveData) { feeds ->
feedsManageableLiveData.value = sortFeeds(feeds, order)
}
}
private fun setObservableFeeds(feeds: List<FeedManageable>) {
val feedsQueried = queryFeeds(feeds, query.toLowerCase(Locale.ROOT))
feedsManageableLiveData.value = sortFeeds(feedsQueried, order)
}
fun addSelection(feed: FeedManageable) {
_selectedItems.add(feed)
_anyIsSelected.value = true
}
fun resetSelection(feeds: List<FeedManageable>? = null) {
_selectedItems.clear()
feeds?.forEach { _selectedItems.add(it) }
_anyIsSelected.value = _selectedItems.isNotEmpty()
}
fun removeSelection(vararg feed: FeedManageable) {
feed.forEach { _selectedItems.remove(it) }
_anyIsSelected.value = _selectedItems.isNotEmpty()
}
fun setOrder(order: Int) {
this.order = order
sourceFeedsLiveData.value?.let { setObservableFeeds(it) }
}
fun submitQuery(query: String) {
this.query = query.trim()
sourceFeedsLiveData.value?.let { setObservableFeeds(it) }
}
fun clearQuery() {
submitQuery("")
}
private fun sortFeeds(feeds: List<FeedManageable>, order: Int): List<FeedManageable> {
return when (order) {
SortFeedManagerFragment.SORT_BY_CATEGORY -> feeds.sortedByCategory()
SortFeedManagerFragment.SORT_BY_TITLE -> feeds.sortedByTitle()
else -> feeds.reversed() // Default
}
}
private fun queryFeeds(feeds: List<FeedManageable>, query: String): List<FeedManageable> {
val results = feeds.filter { feed ->
feed.title.toLowerCase(Locale.ROOT).contains(query)
|| feed.category.toLowerCase(Locale.ROOT).contains(query)
|| feed.url.pathified().contains(query)
}
// If current selected items contains items not returned by the query, remove them:
_selectedItems.filter { !results.contains(it) }.toTypedArray().run (::removeSelection)
return results
}
fun getCategories(): Array<String> {
val categories = mutableSetOf<String>()
sourceFeedsLiveData.value?.let { feeds ->
for (feed in feeds) categories.add(feed.category)
}
return categories.toTypedArray()
}
fun deleteItems(vararg feedId: String) {
repo.deleteFeedAndEntriesById(*feedId)
}
fun updateCategoryByFeedIds(ids: List<String>, category: String) {
repo.updateFeedCategory(*ids.toTypedArray(), category = category)
}
fun updateFeedDetails(feedId: String, title: String, category: String) {
repo.updateFeedTitleAndCategory(feedId, title, category)
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.ui.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import com.joshuacerdenia.android.nicefeed.data.model.SearchResultItem
import com.joshuacerdenia.android.nicefeed.data.remote.FeedSearcher
class SearchFeedsViewModel: FeedAddingViewModel() {
private val searcher = FeedSearcher(repo.networkMonitor)
var newQuery: String = ""
var initialQueryIsMade = false
val feedIdsLiveData = repo.getFeedIds()
private val mutableQuery = MutableLiveData<String>()
val searchResultLiveData: LiveData<List<SearchResultItem>> = Transformations.switchMap(mutableQuery) { query ->
searcher.getFeedList(query)
}
fun onFeedIdsRetrieved(feedIds: List<String>) {
currentFeedIds = feedIds
}
fun performSearch(query: String) {
mutableQuery.value = query.trim()
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.util
/* This object generates variations of a base URL to be used for requesting a feed,
in case the original URL doesn't work the first time.
*/
object BackupUrlManager {
private const val COUNTER_MAX = 4
private var attemptCount = 0
get() = if (field > COUNTER_MAX) 0 else field
private var url: String? = null // Base URL
private var urlPlusFeed: String? = null
private var urlPlusRss: String? = null
private var urlPlusRssXml: String? = null
fun setBase(url: String?) {
attemptCount = 0
this.url = url
if (url != null) {
urlPlusFeed = "$url/feed"
urlPlusRss = "$url/rss"
urlPlusRssXml = "$url/rss.xml"
// Expandable to other possible suffixes, just increase max count
} else resetValues()
}
fun getNextUrl(): String? {
attemptCount += 1
return when (attemptCount - 1) {
0 -> url
1 -> urlPlusFeed
2 -> urlPlusRss
3 -> urlPlusRssXml
else -> {
setBase(null)
null
}
}
}
fun reset() {
setBase(null)
}
private fun resetValues() {
urlPlusFeed = null
urlPlusRss = null
urlPlusRssXml = null
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (c) 2021 PSMForums. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.joshuacerdenia.android.nicefeed.util
import android.util.Base64
import android.util.Base64.encodeToString
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences.FONT_SERIF
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences.TEXT_SIZE_LARGE
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences.TEXT_SIZE_LARGER
import com.joshuacerdenia.android.nicefeed.data.model.entry.EntryMinimal
import java.text.DateFormat
// Prepares the contents of an Entry to be loaded into a WebView
class EntryToHtmlFormatter(
textSizeKey: Int,
font: Int,
private val includeHeader: Boolean
) {
private val fontFamily = if (font == FONT_SERIF) "serif" else "sans-serif"
private val textSize = when (textSizeKey) {
TEXT_SIZE_LARGE -> "large"
TEXT_SIZE_LARGER -> "x-large"
else -> "medium"
}
private val style = OPEN_STYLE_TAG +
"* {max-width:100%}" +
"body {font-size:$textSize; font-family:$fontFamily; word-wrap:break-word; line-height:1.4}" +
"h1, h2, h3, h4, h5, h6 {line-height:normal}" +
"#subtitle {color:gray}" +
"a:link, a:visited, a:hover, a:active {color:$LINK_COLOR; text-decoration:none; font-weight:bold}" +
"pre, code {white-space:pre-wrap; word-break:keep-all}" +
"img, figure {display:block; margin-left:auto; margin-right:auto; height:auto; max-width:100%}" +
"iframe {width:100%}" +
CLOSE_STYLE_TAG
private var title = ""
private var subtitle = ""
// Outputs an HTML string
fun getHtml(entry: EntryMinimal): String {
if (includeHeader) {
title = "<h2>${entry.title}</h2>"
val formattedDate = entry.date?.let { date ->
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date)
}
subtitle = when {
entry.author.isNullOrEmpty() -> "<p $ID_SUBTITLE>$formattedDate</p>"
formattedDate.isNullOrEmpty() -> "<p $ID_SUBTITLE>${entry.author}</p>"
else -> "<p $ID_SUBTITLE>$formattedDate ${entry.author}</p>"
}
}
val content = entry.content.run (::removeStyle)
val html = StringBuilder(style)
.append("<body>")
.append(title)
.append(subtitle)
.append(content)
.append("</body>")
.toString()
return encodeToString(html.toByteArray(), Base64.NO_PADDING)
}
private fun removeStyle(content: String): String {
var base = content
var editedContent = content
// Remove all <style></style> tags and content in between
while (base.contains(OPEN_STYLE_TAG)) {
editedContent = base.substringBefore(OPEN_STYLE_TAG) + base.substringAfter(CLOSE_STYLE_TAG)
base = editedContent
}
return editedContent
}
companion object {
private const val OPEN_STYLE_TAG = "<style>"
private const val CLOSE_STYLE_TAG = "</style>"
private const val LINK_COLOR = "#444E64"
private const val ID_SUBTITLE = "id=\"subtitle\""
}
}

Some files were not shown because too many files have changed in this diff Show More