First Commit
14
.gitignore
vendored
Normal 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
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
1
.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
PSMForums
|
||||
138
.idea/codeStyles/Project.xml
generated
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="Copyright (c) &#36;today.year 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." />
|
||||
<option name="myName" value="Android" />
|
||||
</copyright>
|
||||
</component>
|
||||
7
.idea/copyright/profiles_settings.xml
generated
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 90 KiB |
BIN
Screenshot (2).png
Normal file
|
After Width: | Height: | Size: 280 KiB |
BIN
Screenshot (3).png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
Screenshot (4).png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
Screenshot (5).png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
Screenshot (6).png
Normal file
|
After Width: | Height: | Size: 776 KiB |
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
100
app/build.gradle
Normal 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
@@ -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
20
app/release/output-metadata.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
62
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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!")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 "
|
||||
}
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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\""
|
||||
}
|
||||
}
|
||||