First Commit
This commit is contained in:
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
100
app/build.gradle
Normal file
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
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
BIN
app/release/app-release.apk
Normal file
Binary file not shown.
20
app/release/output-metadata.json
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
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
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
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\""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.ConnectivityManager.NetworkCallback
|
||||
import android.net.Network
|
||||
import android.net.NetworkRequest
|
||||
|
||||
class NetworkMonitor(context: Context) {
|
||||
|
||||
var isOnline = false
|
||||
private set
|
||||
|
||||
private val conMan = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
// ^ Couldn't resist
|
||||
private val builder = NetworkRequest.Builder()
|
||||
private val callback = object : NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
isOnline = true
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
isOnline = false
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
conMan.registerNetworkCallback(builder.build(), callback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.app.Activity
|
||||
import android.app.Notification
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.joshuacerdenia.android.nicefeed.util.work.NewEntriesWorker
|
||||
|
||||
class NotificationReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (resultCode != Activity.RESULT_OK) {
|
||||
return
|
||||
} else {
|
||||
val requestCode = intent.getIntExtra(NewEntriesWorker.EXTRA_REQUEST_CODE, 0)
|
||||
val notification: Notification? = intent.getParcelableExtra(NewEntriesWorker.EXTRA_NOTIFICATION)
|
||||
notification?.let { NotificationManagerCompat.from(context).notify(requestCode, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedManageable
|
||||
import com.joshuacerdenia.android.nicefeed.util.extensions.sortedByCategory
|
||||
import com.rometools.opml.feed.opml.Opml
|
||||
import com.rometools.opml.feed.opml.Outline
|
||||
import com.rometools.opml.io.impl.OPML20Generator
|
||||
import com.rometools.rome.io.WireFeedOutput
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
|
||||
class OpmlExporter(
|
||||
context: Context,
|
||||
private val listener: ExportResultListener
|
||||
) {
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
private var feeds = listOf<FeedManageable>()
|
||||
get() = field.sortedByCategory()
|
||||
private var categories = arrayOf<String>()
|
||||
|
||||
interface ExportResultListener {
|
||||
fun onExportAttempted(isSuccessful: Boolean, fileName: String?)
|
||||
}
|
||||
|
||||
fun submitFeeds(feeds: List<FeedManageable>) {
|
||||
this.feeds = feeds
|
||||
categories = feeds.map { feed -> feed.category }.toSet().toTypedArray()
|
||||
}
|
||||
|
||||
fun executeExport(uri: Uri) {
|
||||
val outputStream = contentResolver.openOutputStream(uri)
|
||||
if (outputStream != null) {
|
||||
try {
|
||||
OutputStreamWriter(outputStream, Charsets.UTF_8).use { writer ->
|
||||
if (feeds.isNotEmpty()) writeOpml(writer)
|
||||
}
|
||||
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val fileName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
|
||||
listener.onExportAttempted(true, fileName)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
listener.onExportAttempted(false, null)
|
||||
}
|
||||
} else {
|
||||
listener.onExportAttempted(false, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeOpml(writer: OutputStreamWriter) {
|
||||
val opml = Opml().apply {
|
||||
feedType = OPML20Generator().type
|
||||
encoding = "utf-8"
|
||||
created = Date()
|
||||
outlines = categories.map { category ->
|
||||
Outline(category, null, null).apply {
|
||||
children = feeds.filter { feed -> feed.category == category }.map { feed ->
|
||||
Outline(feed.title, URL(feed.url), URL(feed.website))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WireFeedOutput().output(opml, writer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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.content.Context
|
||||
import android.net.Uri
|
||||
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
|
||||
import com.rometools.opml.feed.opml.Opml
|
||||
import com.rometools.rome.io.WireFeedInput
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.InputStreamReader
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
|
||||
class OpmlImporter(
|
||||
context: Context,
|
||||
private val listener: OnOpmlParsedListener
|
||||
) {
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
interface OnOpmlParsedListener {
|
||||
fun onOpmlParsed(feeds: List<Feed>)
|
||||
fun onParseOpmlFailed()
|
||||
}
|
||||
|
||||
fun submitUri(uri: Uri) {
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
if (inputStream != null) {
|
||||
try {
|
||||
InputStreamReader(inputStream).use { reader -> parseOpml(reader) }
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
val content = BufferedInputStream(inputStream).bufferedReader().readText()
|
||||
val fixedReader = StringReader(content.replace(
|
||||
"<opml version=['\"][0-9]\\.[0-9]['\"]>".toRegex(),
|
||||
"<opml>"
|
||||
))
|
||||
parseOpml(fixedReader)
|
||||
} catch (e: Exception) {
|
||||
listener.onParseOpmlFailed()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
listener.onParseOpmlFailed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseOpml(opmlReader: Reader) {
|
||||
val feeds = mutableListOf<Feed>()
|
||||
val opml = WireFeedInput().build(opmlReader) as Opml
|
||||
|
||||
for (outline in opml.outlines) {
|
||||
if (outline.xmlUrl != null) {
|
||||
val feed = Feed(
|
||||
url = outline.xmlUrl,
|
||||
website = outline.htmlUrl ?: outline.url ?: "",
|
||||
title = outline.title,
|
||||
unreadCount = 0
|
||||
)
|
||||
feeds.add(feed)
|
||||
} else {
|
||||
val category = outline.title
|
||||
if (outline.children.isNotEmpty()) {
|
||||
for (child in outline.children.filterNot { it.xmlUrl.isNullOrEmpty() }) {
|
||||
val feed = Feed(
|
||||
url = child.xmlUrl,
|
||||
website = child.htmlUrl ?: child.xmlUrl,
|
||||
title = child.title ?: child.xmlUrl.substringAfter("://"),
|
||||
category = category,
|
||||
unreadCount = 0
|
||||
)
|
||||
feeds.add(feed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listener.onOpmlParsed(feeds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 java.util.*
|
||||
|
||||
object RssUrlTransformer {
|
||||
|
||||
private const val BASE_NY_TIMES = "www.nytimes.com"
|
||||
private const val ATOM_XML = "atom.xml"
|
||||
|
||||
fun getUrl(link: String): String {
|
||||
val url = link.removePrefix("feed/").removeSuffix("/")
|
||||
// This is possibly not ideal, but it results in too many failed requests otherwise:
|
||||
.replace("http://", "https://")
|
||||
.toLowerCase(Locale.ROOT)
|
||||
|
||||
return when {
|
||||
url.contains(BASE_NY_TIMES) -> url.replace(BASE_NY_TIMES, "rss.nytimes.com")
|
||||
url.contains("feedproxy.google.com") -> "" // We don't want this
|
||||
url.contains("youtube.com") -> url
|
||||
url.contains("medium.com") -> url
|
||||
url.endsWith("blogspot.com") -> "$url/feeds/posts/default?alt=rss"
|
||||
url.endsWith("/feeds/posts/default") -> "$url?alt=rss"
|
||||
url.endsWith("wordpress.com") -> "$url/feed"
|
||||
url.endsWith("tumblr.com") -> "$url/rss"
|
||||
url.contains(ATOM_XML) -> url.replace(ATOM_XML, "rss.xml")
|
||||
url.endsWith(".xml") -> url
|
||||
url.endsWith("/feed") -> url
|
||||
url.endsWith("/rss") -> url
|
||||
else -> url
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* 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 com.joshuacerdenia.android.nicefeed.data.model.cross.FeedWithEntries
|
||||
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
|
||||
import com.joshuacerdenia.android.nicefeed.data.model.feed.Feed
|
||||
import com.joshuacerdenia.android.nicefeed.util.extensions.sortedByDate
|
||||
|
||||
/* This class compares recently requested data from the web with current data saved locally.
|
||||
It outputs which entries to add, update, and delete, as well as updated feed data, if any.
|
||||
*/
|
||||
class UpdateManager(private val receiver: UpdateReceiver) {
|
||||
|
||||
interface UpdateReceiver {
|
||||
fun onUnreadEntriesCounted(feedId: String, unreadCount: Int)
|
||||
fun onFeedNeedsUpdate(feed: Feed)
|
||||
fun onOldAndNewEntriesCompared(
|
||||
feedId: String,
|
||||
entriesToAdd: List<Entry>,
|
||||
entriesToUpdate: List<Entry>,
|
||||
entriesToDelete: List<Entry>,
|
||||
)
|
||||
}
|
||||
|
||||
var keepOldUnreadEntries: Boolean = true
|
||||
var currentFeed: Feed? = null
|
||||
private set
|
||||
private var currentEntries = listOf<Entry>()
|
||||
get() = field.sortedByDate()
|
||||
|
||||
fun setInitialFeed(feed: Feed) {
|
||||
currentFeed = feed
|
||||
}
|
||||
|
||||
fun setInitialEntries(entries: List<Entry>) {
|
||||
currentEntries = entries
|
||||
var unreadCount = 0
|
||||
for (entry in entries) {
|
||||
if (!entry.isRead) unreadCount += 1
|
||||
}
|
||||
currentFeed?.let { receiver.onUnreadEntriesCounted(it.url, unreadCount) }
|
||||
}
|
||||
|
||||
fun submitUpdates(feedWithEntries: FeedWithEntries) {
|
||||
handleFeedUpdate(feedWithEntries.feed)
|
||||
handleNewEntries(feedWithEntries.entries)
|
||||
}
|
||||
|
||||
fun forceUpdateFeed(feed: Feed) {
|
||||
currentFeed = feed
|
||||
receiver.onFeedNeedsUpdate(feed)
|
||||
}
|
||||
|
||||
private fun handleNewEntries(newEntries: List<Entry>) {
|
||||
val newEntryIds = getEntryIds(newEntries)
|
||||
val currentEntryIds = getEntryIds(currentEntries)
|
||||
val entriesToAdd = mutableListOf<Entry>()
|
||||
val entriesToUpdate = mutableListOf<Entry>()
|
||||
val entriesToDelete = mutableListOf<Entry>()
|
||||
|
||||
for (entry in newEntries) {
|
||||
// First, check if entry already exists unchanged
|
||||
if (isUnique(entry)) {
|
||||
// If entry is unique, look for existing older version
|
||||
if (currentEntryIds.contains(entry.url)) {
|
||||
val currentItemIndex = currentEntryIds.indexOf(entry.url)
|
||||
entry.isStarred = currentEntries[currentItemIndex].isStarred
|
||||
entriesToUpdate.add(entry)
|
||||
} else {
|
||||
entriesToAdd.add(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentEntries.filterNot { newEntryIds.contains(it.url) }.forEach { entry ->
|
||||
if (keepOldUnreadEntries) {
|
||||
if (!entry.isStarred && entry.isRead) entriesToDelete.add(entry)
|
||||
} else {
|
||||
if (!entry.isStarred) entriesToDelete.add(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if entries are changed at all
|
||||
if (entriesToAdd.size + entriesToUpdate.size + entriesToDelete.size > 0) {
|
||||
currentFeed?.let { feed->
|
||||
receiver.onOldAndNewEntriesCompared(
|
||||
feed.url,
|
||||
entriesToAdd,
|
||||
entriesToUpdate,
|
||||
entriesToDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check to see if there is any one old entry that is the same as the new entry
|
||||
private fun isUnique(newEntry: Entry): Boolean {
|
||||
var isUnique = true
|
||||
for (currentEntry in currentEntries.filter { it.url == newEntry.url }) {
|
||||
if (newEntry.isSameAs(currentEntry)) {
|
||||
isUnique = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return isUnique
|
||||
}
|
||||
|
||||
private fun getEntryIds(entries: List<Entry>): List<String> {
|
||||
return entries.map { it.url }
|
||||
}
|
||||
|
||||
private fun handleFeedUpdate(newFeed: Feed) {
|
||||
currentFeed?.let { oldFeed ->
|
||||
newFeed.title = oldFeed.title
|
||||
newFeed.category = oldFeed.category
|
||||
newFeed.unreadCount = oldFeed.unreadCount
|
||||
if (newFeed != oldFeed) receiver.onFeedNeedsUpdate(newFeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.joshuacerdenia.android.nicefeed.R
|
||||
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
|
||||
|
||||
// General methods needed in multiple places
|
||||
|
||||
object Utils {
|
||||
|
||||
fun openLink(context: Context, view: View?, url: Uri) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, url)
|
||||
// Check if activity is available to handle intent
|
||||
val resolvedActivity = context.packageManager
|
||||
.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
if (resolvedActivity != null) {
|
||||
startActivity(context, intent, null)
|
||||
} else {
|
||||
view?.let { showErrorMessage(it, context.resources) }
|
||||
}
|
||||
}
|
||||
|
||||
fun copyLinkToClipboard(context: Context, stringUrl: String, view: View? = null) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
ClipData.newPlainText("link", stringUrl).run { clipboard.setPrimaryClip(this) }
|
||||
view?.let { Snackbar.make(it, context.getString(R.string.copied_link), Snackbar.LENGTH_SHORT).show() }
|
||||
}
|
||||
|
||||
fun setTheme(theme: Int) {
|
||||
when (theme) {
|
||||
NiceFeedPreferences.THEME_LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
NiceFeedPreferences.THEME_DARK -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}.let { AppCompatDelegate.setDefaultNightMode(it) }
|
||||
}
|
||||
|
||||
fun setStatusBarMode(activity: Activity) {
|
||||
val window = activity.window
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val statusBarColor = if (
|
||||
activity.resources?.configuration?.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK)
|
||||
== Configuration.UI_MODE_NIGHT_YES
|
||||
) 0 else View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
|
||||
|
||||
window?.decorView?.systemUiVisibility = statusBarColor
|
||||
} else {
|
||||
window?.apply {
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
|
||||
statusBarColor = Color.GRAY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hideSoftKeyBoard(context: Context, view: View) {
|
||||
try {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun showErrorMessage(view: View, resources: Resources) {
|
||||
Snackbar.make(view, resources.getString(R.string.error_message), Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -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.util.extensions
|
||||
|
||||
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
|
||||
import com.joshuacerdenia.android.nicefeed.data.model.entry.EntryLight
|
||||
|
||||
fun List<Entry>.sortedByDate() = this.sortedByDescending { it.date }
|
||||
|
||||
@JvmName("sortedByDateEntryLight")
|
||||
fun List<EntryLight>.sortedByDate() = this.sortedByDescending { it.date }
|
||||
|
||||
fun List<EntryLight>.sortedUnreadOnTop() = this.sortedByDate().sortedBy { it.isRead }
|
||||
@@ -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.util.extensions
|
||||
|
||||
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedLight
|
||||
import com.joshuacerdenia.android.nicefeed.data.model.feed.FeedManageable
|
||||
|
||||
@JvmName("sortedByTitleFeedLight")
|
||||
fun List<FeedLight>.sortedByTitle() = this.sortedBy { it.title }
|
||||
|
||||
fun List<FeedLight>.sortedByUnreadCount() = this.sortedByDescending { it.unreadCount }
|
||||
|
||||
fun List<FeedManageable>.sortedByTitle() = this.sortedBy { it.title }
|
||||
|
||||
fun List<FeedManageable>.sortedByCategory() = this.sortedBy { it.category }
|
||||
@@ -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.util.extensions
|
||||
|
||||
import android.text.Editable
|
||||
|
||||
fun String.pathified() = this.substringAfter(
|
||||
"www.",
|
||||
this.substringAfter("://")
|
||||
).removeSuffix("/")
|
||||
|
||||
fun String.simplified() = this.pathified().substringBefore("?")
|
||||
|
||||
fun String.shortened() = this.simplified().substringBefore("/")
|
||||
|
||||
fun String?.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this)
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.extensions
|
||||
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
|
||||
fun View.addRipple() = with(TypedValue()) {
|
||||
context.theme.resolveAttribute(android.R.attr.selectableItemBackground, this, true)
|
||||
setBackgroundResource(resourceId)
|
||||
}
|
||||
|
||||
fun View.hide() = this.apply { visibility = View.GONE }
|
||||
|
||||
fun View.show() = this.apply { visibility = View.VISIBLE }
|
||||
|
||||
fun View.isVisible() = this.visibility == View.VISIBLE
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
package com.joshuacerdenia.android.nicefeed.util.work
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.*
|
||||
import com.joshuacerdenia.android.nicefeed.data.NiceFeedRepository
|
||||
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
|
||||
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.remote.FeedParser
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
open class BackgroundSyncWorker(
|
||||
private val context: Context,
|
||||
workerParams: WorkerParameters
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
|
||||
val repo = NiceFeedRepository.get()
|
||||
val parser = FeedParser(repo.networkMonitor)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val feedUrls = repo.getFeedUrlsSynchronously()
|
||||
if (feedUrls.isEmpty()) return Result.success()
|
||||
|
||||
for (url in feedUrls) {
|
||||
val storedEntries = repo.getEntriesToggleableByFeedSynchronously(url)
|
||||
val storedEntryIds: List<String> = storedEntries.map { it.url }
|
||||
val feedWithEntries: FeedWithEntries? = parser.getFeedSynchronously(url)
|
||||
|
||||
feedWithEntries?.let { fwe ->
|
||||
val newEntries = fwe.entries.filterNot { storedEntryIds.contains(it.url) }
|
||||
handleRetrievedData(fwe, storedEntries, newEntries)
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
fun handleRetrievedData(
|
||||
fwe: FeedWithEntries,
|
||||
storedEntries: List<EntryToggleable>,
|
||||
newEntries: List<Entry>
|
||||
) {
|
||||
val entryIds = fwe.entries.map { it.url }
|
||||
val oldEntries = storedEntries.filterNot { entryIds.contains(it.url) }
|
||||
val entriesToDelete = if (NiceFeedPreferences.keepOldUnreadEntries(context)) {
|
||||
oldEntries.filter { !it.isStarred && it.isRead }
|
||||
} else {
|
||||
oldEntries.filter { !it.isStarred }
|
||||
}
|
||||
|
||||
repo.handleBackgroundUpdate(fwe.feed.url, newEntries, entriesToDelete, fwe.feed.imageUrl)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val WORK_NAME = "com.joshuacerdenia.android.nicefeed.utils.work.BackgroundSyncWorker"
|
||||
|
||||
private val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build()
|
||||
|
||||
fun start(context: Context) {
|
||||
val request = PeriodicWorkRequest.Builder(
|
||||
BackgroundSyncWorker::class.java, 24, TimeUnit.HOURS
|
||||
).setConstraints(constraints).build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
fun runOnce(context: Context) {
|
||||
val request = OneTimeWorkRequest.Builder(BackgroundSyncWorker::class.java)
|
||||
.setConstraints(constraints).build()
|
||||
WorkManager.getInstance(context).enqueue(request)
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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.work
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.work.*
|
||||
import com.joshuacerdenia.android.nicefeed.NiceFeedApplication.Companion.NOTIFICATION_CHANNEL_ID
|
||||
import com.joshuacerdenia.android.nicefeed.R
|
||||
import com.joshuacerdenia.android.nicefeed.data.local.NiceFeedPreferences
|
||||
import com.joshuacerdenia.android.nicefeed.data.model.cross.FeedWithEntries
|
||||
import com.joshuacerdenia.android.nicefeed.data.model.entry.Entry
|
||||
import com.joshuacerdenia.android.nicefeed.ui.activity.MainActivity
|
||||
import com.joshuacerdenia.android.nicefeed.util.extensions.sortedByDate
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class NewEntriesWorker(
|
||||
private val context: Context,
|
||||
workerParams: WorkerParameters
|
||||
) : BackgroundSyncWorker(context, workerParams) {
|
||||
|
||||
private val resources = context.resources
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val feedUrls = repo.getFeedUrlsSynchronously()
|
||||
if (feedUrls.isEmpty()) return Result.success()
|
||||
val lastIndex = NiceFeedPreferences.getLastPolledIndex(context)
|
||||
val newIndex = if (lastIndex + 1 >= feedUrls.size) 0 else lastIndex + 1
|
||||
val url = feedUrls[newIndex]
|
||||
|
||||
val feedData = repo.getFeedTitleWithEntriesToggleableSynchronously(url)
|
||||
val title = feedData.feedTitle // Need user-set title saved in DB
|
||||
val storedEntries = feedData.entriesToggleable
|
||||
val storedEntryIds: List<String> = storedEntries.map { it.url }
|
||||
val feedWithEntries: FeedWithEntries? = parser.getFeedSynchronously(url)
|
||||
|
||||
feedWithEntries?.let { fwe ->
|
||||
val newEntries = fwe.entries.filterNot { storedEntryIds.contains(it.url) }
|
||||
handleRetrievedData(fwe, storedEntries, newEntries)
|
||||
|
||||
if (newEntries.isNotEmpty()) {
|
||||
val notification = createNotification(url, title, newEntries)
|
||||
Intent(ACTION_SHOW_NOTIFICATION).apply {
|
||||
putExtra(EXTRA_REQUEST_CODE, NOTIFICATION_ID)
|
||||
putExtra(EXTRA_NOTIFICATION, notification)
|
||||
}.also { intent -> context.sendOrderedBroadcast(intent, PERM_PRIVATE) }
|
||||
}
|
||||
}
|
||||
|
||||
NiceFeedPreferences.saveLastPolledIndex(context, newIndex)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun createNotification(
|
||||
feedId: String,
|
||||
feedTitle: String,
|
||||
entries: List<Entry>
|
||||
): Notification {
|
||||
val latestEntry = entries.sortedByDate().first()
|
||||
val intent = MainActivity.newIntent(context, feedId, latestEntry.url)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT
|
||||
)
|
||||
val text = if (entries.size > 1) {
|
||||
resources.getString(R.string.and_more, latestEntry.title)
|
||||
} else {
|
||||
latestEntry.title
|
||||
}.also { text -> HtmlCompat.fromHtml(text, 0) }
|
||||
|
||||
return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
|
||||
.setTicker(resources.getString(R.string.new_entries_notification_title, feedTitle))
|
||||
.setSmallIcon(R.drawable.ic_nicefeed_notif)
|
||||
.setContentTitle(resources.getString(R.string.new_entries_notification_title, feedTitle))
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentText(text)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val WORK_NAME = "com.joshuacerdenia.android.nicefeed.utils.work.NewEntriesWorker"
|
||||
const val ACTION_SHOW_NOTIFICATION = "com.joshuacerdenia.android.nicefeed.utils.work.SHOW_NOTIFICATION"
|
||||
const val NOTIFICATION_ID = 1
|
||||
const val PERM_PRIVATE = "com.joshuacerdenia.android.nicefeed.PRIVATE"
|
||||
const val EXTRA_REQUEST_CODE = "REQUEST_CODE"
|
||||
const val EXTRA_NOTIFICATION = "NOTIFICATION"
|
||||
|
||||
fun start(context: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build()
|
||||
val request = PeriodicWorkRequest.Builder(
|
||||
NewEntriesWorker::class.java, 20, TimeUnit.MINUTES
|
||||
).setConstraints(constraints).build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.work
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.*
|
||||
import com.joshuacerdenia.android.nicefeed.data.NiceFeedRepository
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SweeperWorker(
|
||||
context: Context,
|
||||
workerParams: WorkerParameters
|
||||
): Worker(context, workerParams) {
|
||||
|
||||
private val repo = NiceFeedRepository.get()
|
||||
|
||||
override fun doWork(): Result {
|
||||
repo.deleteLeftoverItems() // Just in case
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WORK_NAME = "com.joshuacerdenia.android.nicefeed.utils.work.SweeperWorker"
|
||||
|
||||
fun start(context: Context) {
|
||||
val request = PeriodicWorkRequest.Builder(
|
||||
SweeperWorker::class.java, 3, TimeUnit.DAYS
|
||||
).build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/src/main/res/drawable-anydpi/ic_add.xml
Normal file
24
app/src/main/res/drawable-anydpi/ic_add.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
~ Copyright (c) 2021 PSMForums. All rights reserved.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#333333"
|
||||
android:alpha="0.6">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
</vector>
|
||||
24
app/src/main/res/drawable-anydpi/ic_browser.xml
Normal file
24
app/src/main/res/drawable-anydpi/ic_browser.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
~ Copyright (c) 2021 PSMForums. All rights reserved.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#333333"
|
||||
android:alpha="0.6">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,4L5,4c-1.11,0 -2,0.9 -2,2v12c0,1.1 0.89,2 2,2h4v-2L5,18L5,8h14v10h-4v2h4c1.1,0 2,-0.9 2,-2L21,6c0,-1.1 -0.89,-2 -2,-2zM12,10l-4,4h3v6h2v-6h3l-4,-4z"/>
|
||||
</vector>
|
||||
24
app/src/main/res/drawable-anydpi/ic_check_circle.xml
Normal file
24
app/src/main/res/drawable-anydpi/ic_check_circle.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
~ Copyright (c) 2021 PSMForums. All rights reserved.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#333333"
|
||||
android:alpha="0.6">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
|
||||
</vector>
|
||||
24
app/src/main/res/drawable-anydpi/ic_check_circle_outline.xml
Normal file
24
app/src/main/res/drawable-anydpi/ic_check_circle_outline.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
~ Copyright (c) 2021 PSMForums. All rights reserved.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#333333"
|
||||
android:alpha="0.6">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M16.59,7.58L10,14.17l-3.59,-3.58L5,12l5,5 8,-8zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
|
||||
</vector>
|
||||
24
app/src/main/res/drawable-anydpi/ic_close_light.xml
Normal file
24
app/src/main/res/drawable-anydpi/ic_close_light.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
~ Copyright (c) 2021 PSMForums. All rights reserved.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF"
|
||||
android:alpha="0.8">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
|
||||
</vector>
|
||||
24
app/src/main/res/drawable-anydpi/ic_delete.xml
Normal file
24
app/src/main/res/drawable-anydpi/ic_delete.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
~ Copyright (c) 2021 PSMForums. All rights reserved.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#333333"
|
||||
android:alpha="0.6">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
|
||||
</vector>
|
||||
24
app/src/main/res/drawable-anydpi/ic_delete_light.xml
Normal file
24
app/src/main/res/drawable-anydpi/ic_delete_light.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
~ Copyright (c) 2021 PSMForums. All rights reserved.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF"
|
||||
android:alpha="0.8">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
|
||||
</vector>
|
||||
24
app/src/main/res/drawable-anydpi/ic_drop_down.xml
Normal file
24
app/src/main/res/drawable-anydpi/ic_drop_down.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
~ Copyright (c) 2021 PSMForums. All rights reserved.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#333333"
|
||||
android:alpha="0.6">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M7,10l5,5 5,-5z"/>
|
||||
</vector>
|
||||
24
app/src/main/res/drawable-anydpi/ic_drop_up.xml
Normal file
24
app/src/main/res/drawable-anydpi/ic_drop_up.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
~ Copyright (c) 2021 PSMForums. All rights reserved.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#333333"
|
||||
android:alpha="0.6">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M7,14l5,-5 5,5z"/>
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user