commit 207ce82ff2580c8097498c5f2bae343bec73529d Author: psavarmattas Date: Wed Mar 10 18:17:24 2021 +0530 First Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..603b140 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..e2e56b4 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +PSMForums \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..3cc336b --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..61a9130 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/copyright/Android.xml b/.idea/copyright/Android.xml new file mode 100644 index 0000000..e95462c --- /dev/null +++ b/.idea/copyright/Android.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 0000000..324431d --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/Joshua.xml b/.idea/dictionaries/Joshua.xml new file mode 100644 index 0000000..00260b1 --- /dev/null +++ b/.idea/dictionaries/Joshua.xml @@ -0,0 +1,17 @@ + + + + feedless + feedly + joshuacerdenia + nicefeed + opml + pathified + rssifyer + sifyer + snackbar + supertitle + unstarred + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..23a89bb --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..2370474 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..d5d35ec --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/render.experimental.xml b/.idea/render.experimental.xml new file mode 100644 index 0000000..8ec256a --- /dev/null +++ b/.idea/render.experimental.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..7f68460 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9129441 --- /dev/null +++ b/LICENSE @@ -0,0 +1,10 @@ +Copyright (c) 2021 PSMForums. All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb17501 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ + +# **PSMForums** + +_`Last Updated: March 10' 2021`_ + +PSMForums is an RSS Reader for Android. This started out as a personal project while getting the +hang of Kotlin. RSS is an old technology and there are already many readers out there, but I find +many of them hard to navigate and jam-packed with features which are way overkill or simply not needed. + The aim is an attractive and intuitive app, lightweight but fully functional with not too many frills. + +### Early Access: + +PSMForums is now in Beta. Please note that until a stable release, some functionality may still be +limited. I'm constantly updating this app and trying to make it better. I would truly appreciate +any and all feedback, especially if you find any issues or bugs, or have ideas for new features +(please open an issue). I also welcome contributions.
+ + +## Screenshots:

+ + + +## Feature List v1.0.0-Beta: + + diff --git a/Screenshot (1).png b/Screenshot (1).png new file mode 100644 index 0000000..8995be8 Binary files /dev/null and b/Screenshot (1).png differ diff --git a/Screenshot (2).png b/Screenshot (2).png new file mode 100644 index 0000000..fdaf0bf Binary files /dev/null and b/Screenshot (2).png differ diff --git a/Screenshot (3).png b/Screenshot (3).png new file mode 100644 index 0000000..260f690 Binary files /dev/null and b/Screenshot (3).png differ diff --git a/Screenshot (4).png b/Screenshot (4).png new file mode 100644 index 0000000..9e5bd82 Binary files /dev/null and b/Screenshot (4).png differ diff --git a/Screenshot (5).png b/Screenshot (5).png new file mode 100644 index 0000000..805a9eb Binary files /dev/null and b/Screenshot (5).png differ diff --git a/Screenshot (6).png b/Screenshot (6).png new file mode 100644 index 0000000..f0e5d12 Binary files /dev/null and b/Screenshot (6).png differ diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..d0d397f --- /dev/null +++ b/app/build.gradle @@ -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' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/release/app-release.apk b/app/release/app-release.apk new file mode 100644 index 0000000..fc8bff3 Binary files /dev/null and b/app/release/app-release.apk differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..8bf473f --- /dev/null +++ b/app/release/output-metadata.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/joshuacerdenia/android/nicefeed/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/joshuacerdenia/android/nicefeed/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..0a3f81b --- /dev/null +++ b/app/src/androidTest/java/com/joshuacerdenia/android/nicefeed/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ef85e08 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..91723e4 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/NiceFeedApplication.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/NiceFeedApplication.kt new file mode 100644 index 0000000..77774e0 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/NiceFeedApplication.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/NiceFeedRepository.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/NiceFeedRepository.kt new file mode 100644 index 0000000..30092c7 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/NiceFeedRepository.kt @@ -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 = dao.getFeed(feedId) + + fun getFeedsLight(): LiveData> = dao.getFeedsLight() + + fun getFeedIds(): LiveData> = dao.getFeedIds() + + fun getFeedIdsWithCategories(): LiveData> = dao.getFeedIdsWithCategories() + + fun getFeedUrlsSynchronously(): List = dao.getFeedUrlsSynchronously() + + fun getFeedTitleWithEntriesToggleableSynchronously(feedId: String): FeedTitleWithEntriesToggleable { + return dao.getFeedTitleAndEntriesToggleableSynchronously(feedId) + } + + fun getFeedsManageable(): LiveData> = dao.getFeedsManageable() + + fun getEntry(entryId: String): LiveData = dao.getEntry(entryId) + + fun getEntriesByFeed(feedId: String): LiveData> = dao.getEntriesByFeed(feedId) + + fun getNewEntries(max: Int): LiveData> = dao.getNewEntries(max) + + fun getStarredEntries(): LiveData> = dao.getStarredEntries() + + fun getEntriesToggleableByFeedSynchronously(feedId: String): List { + 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, + entriesToUpdate: List, + entriesToDelete: List, + ) { + executor.execute { + dao.handleEntryUpdates(feedId, entriesToAdd, entriesToUpdate, entriesToDelete) + } + } + + fun handleBackgroundUpdate( + feedId: String, + newEntries: List, + oldEntries: List, + 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!") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/NiceFeedPreferences.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/NiceFeedPreferences.kt new file mode 100644 index 0000000..001ca9b --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/NiceFeedPreferences.kt @@ -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? { + return getPrefs(context).getStringSet(KEY_MIN_CATEGORIES, emptySet()) + } + + fun saveMinimizedCategories(context: Context, categories: Set) { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/CombinedDao.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/CombinedDao.kt new file mode 100644 index 0000000..405d9e7 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/CombinedDao.kt @@ -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) { + 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, + entriesToUpdate: List, + entriesToDelete: List, + ) { + addEntries(entriesToAdd) + addFeedEntryCrossRefs(feedId, entriesToAdd) + updateEntries(entriesToUpdate) + deleteFeedEntryCrossRefs(feedId, entriesToDelete.map { it.url }) + deleteEntries(entriesToDelete) + } + + @Transaction + fun handleBackgroundUpdate( + feedId: String, + newEntries: List, + oldEntries: List, + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/EntriesDao.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/EntriesDao.kt new file mode 100644 index 0000000..e95c228 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/EntriesDao.kt @@ -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) + + @Query("SELECT * FROM Entry WHERE url = :entryId") + fun getEntry(entryId: String): LiveData + + @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> + + @Query( + "SELECT url, title, website, date, image, isStarred, isRead " + + "FROM Entry WHERE isStarred = 1" + ) + @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) + fun getStarredEntries(): LiveData> + + @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> + + @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 + + @Update + fun updateEntries(entries: List) + + @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) + + @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) + + @Query("DELETE FROM Entry WHERE url NOT IN (SELECT entryUrl FROM FeedEntryCrossRef)") + fun deleteLeftoverEntries() +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/FeedEntryCrossRefsDao.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/FeedEntryCrossRefsDao.kt new file mode 100644 index 0000000..3c92fa5 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/FeedEntryCrossRefsDao.kt @@ -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) + + @Transaction + fun addFeedEntryCrossRefs(feedId: String, entries: List) { + addFeedEntryCrossRefs(entries.map { FeedEntryCrossRef(feedId, it.url) }) + } + + @Query("DELETE FROM FeedEntryCrossRef WHERE feedUrl = :feedId AND entryUrl IN (:entryIds)") + fun deleteFeedEntryCrossRefs(feedId: String, entryIds: List) + + @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() +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/FeedsDao.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/FeedsDao.kt new file mode 100644 index 0000000..d9f5a0d --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/FeedsDao.kt @@ -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 + + @Query("SELECT url, title, imageUrl, category, unreadCount FROM Feed") + fun getFeedsLight(): LiveData> + + @Query("SELECT url, title, website, imageUrl, description, category FROM Feed") + fun getFeedsManageable(): LiveData> + + @Query("SELECT url FROM Feed") + fun getFeedIds(): LiveData> + + @Query("SELECT url, category FROM Feed") + fun getFeedIdsWithCategories(): LiveData> + + @Query("SELECT url FROM Feed") + fun getFeedUrlsSynchronously(): List + + @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) +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/NiceFeedDatabase.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/NiceFeedDatabase.kt new file mode 100644 index 0000000..81f662c --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/NiceFeedDatabase.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/TypeConverters.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/TypeConverters.kt new file mode 100644 index 0000000..94e775a --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/local/database/TypeConverters.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/CategoryHeader.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/CategoryHeader.kt new file mode 100644 index 0000000..7449efa --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/CategoryHeader.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/FeedMenuItem.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/FeedMenuItem.kt new file mode 100644 index 0000000..b8c4da4 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/FeedMenuItem.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/SearchResultItem.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/SearchResultItem.kt new file mode 100644 index 0000000..3834fd4 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/SearchResultItem.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/TopicBlock.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/TopicBlock.kt new file mode 100644 index 0000000..c0344d6 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/TopicBlock.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/UpdateValues.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/UpdateValues.kt new file mode 100644 index 0000000..024a2be --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/UpdateValues.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedEntryCrossRef.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedEntryCrossRef.kt new file mode 100644 index 0000000..ebba8a6 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedEntryCrossRef.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedTitleWithEntriesToggleable.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedTitleWithEntriesToggleable.kt new file mode 100644 index 0000000..e2538cb --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedTitleWithEntriesToggleable.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedTitleWithEntryIds.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedTitleWithEntryIds.kt new file mode 100644 index 0000000..13ee9fe --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedTitleWithEntryIds.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedWithEntries.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedWithEntries.kt new file mode 100644 index 0000000..5061a4c --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/cross/FeedWithEntries.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/Entry.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/Entry.kt new file mode 100644 index 0000000..aade0bf --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/Entry.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/EntryLight.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/EntryLight.kt new file mode 100644 index 0000000..c8c268a --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/EntryLight.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/EntryMinimal.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/EntryMinimal.kt new file mode 100644 index 0000000..dc46db8 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/EntryMinimal.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/EntryToggleable.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/EntryToggleable.kt new file mode 100644 index 0000000..fe35b77 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/entry/EntryToggleable.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/Feed.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/Feed.kt new file mode 100644 index 0000000..bc3e837 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/Feed.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/FeedIdWithCategory.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/FeedIdWithCategory.kt new file mode 100644 index 0000000..c10257d --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/FeedIdWithCategory.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/FeedLight.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/FeedLight.kt new file mode 100644 index 0000000..158ba46 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/FeedLight.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/FeedManageable.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/FeedManageable.kt new file mode 100644 index 0000000..8a501b3 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/model/feed/FeedManageable.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/FeedParser.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/FeedParser.kt new file mode 100644 index 0000000..8d59493 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/FeedParser.kt @@ -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() + val feedRequestLiveData: LiveData + 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 { + val entries = mutableListOf() + 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 " + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/FeedSearcher.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/FeedSearcher.kt new file mode 100644 index 0000000..40c22c8 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/FeedSearcher.kt @@ -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> { + return if (networkMonitor.isOnline) { + val queryString = createQueryString(query) + val request: Call = 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 + ): MutableLiveData> { + val searchResultLiveData = MutableLiveData>() + val callback = object : Callback { + override fun onFailure(call: Call, t: Throwable) {} // Do nothing + + override fun onResponse( + call: Call, + response: Response + ) { + 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/" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/api/FeedlyApi.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/api/FeedlyApi.kt new file mode 100644 index 0000000..0e2ecea --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/api/FeedlyApi.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/api/SearchResult.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/api/SearchResult.kt new file mode 100644 index 0000000..8dc18cd --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/data/remote/api/SearchResult.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/FeedRequestCallbacks.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/FeedRequestCallbacks.kt new file mode 100644 index 0000000..be9d176 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/FeedRequestCallbacks.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/OnFinished.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/OnFinished.kt new file mode 100644 index 0000000..f49fa88 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/OnFinished.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/OnHomePressed.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/OnHomePressed.kt new file mode 100644 index 0000000..ae91bcf --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/OnHomePressed.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/OnToolbarInflated.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/OnToolbarInflated.kt new file mode 100644 index 0000000..b00b8ea --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/OnToolbarInflated.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/activity/MainActivity.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/activity/MainActivity.kt new file mode 100644 index 0000000..430ffd9 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/activity/MainActivity.kt @@ -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 { + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/activity/ManagingActivity.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/activity/ManagingActivity.kt new file mode 100644 index 0000000..bd5dae7 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/activity/ManagingActivity.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/EntryListAdapter.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/EntryListAdapter.kt new file mode 100644 index 0000000..310b240 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/EntryListAdapter.kt @@ -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(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() { + + override fun areItemsTheSame(oldItem: EntryLight, newItem: EntryLight): Boolean { + return oldItem.url == newItem.url + } + + override fun areContentsTheSame(oldItem: EntryLight, newItem: EntryLight): Boolean { + return oldItem == newItem + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/FeedListAdapter.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/FeedListAdapter.kt new file mode 100644 index 0000000..d30a734 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/FeedListAdapter.kt @@ -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(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() { + + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/FeedManagerAdapter.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/FeedManagerAdapter.kt new file mode 100644 index 0000000..3658e96 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/FeedManagerAdapter.kt @@ -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 +) : ListAdapter(DiffCallback()) { + + interface ItemCheckBoxListener { + fun onItemClicked(feed: FeedManageable, isChecked: Boolean) + fun onAllItemsChecked(isChecked: Boolean) + } + + private val checkBoxes = mutableSetOf() + + 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() { + + override fun areItemsTheSame(oldItem: FeedManageable, newItem: FeedManageable): Boolean { + return oldItem.url == newItem.url + } + + override fun areContentsTheSame(oldItem: FeedManageable, newItem: FeedManageable): Boolean { + return oldItem == newItem + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/FeedSearchAdapter.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/FeedSearchAdapter.kt new file mode 100644 index 0000000..ed42c96 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/FeedSearchAdapter.kt @@ -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(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() { + + override fun areItemsTheSame(oldItem: SearchResultItem, newItem: SearchResultItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: SearchResultItem, newItem: SearchResultItem): Boolean { + return oldItem == newItem + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/TopicAdapter.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/TopicAdapter.kt new file mode 100644 index 0000000..1be7f33 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/adapter/TopicAdapter.kt @@ -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(DiffCallback()) { + + var numOfItems = 0 // Initial value only + + interface OnItemClickListener { + fun onTopicSelected(topic: String) + } + + override fun submitList(list: MutableList?) { + 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() { + + override fun areItemsTheSame(oldItem: TopicBlock, newItem: TopicBlock): Boolean { + return oldItem.topic == newItem.topic + } + + override fun areContentsTheSame(oldItem: TopicBlock, newItem: TopicBlock): Boolean { + return oldItem == newItem + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/AboutFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/AboutFragment.kt new file mode 100644 index 0000000..35a684d --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/AboutFragment.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/ConfirmActionFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/ConfirmActionFragment.kt new file mode 100644 index 0000000..a11d5eb --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/ConfirmActionFragment.kt @@ -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 } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/EditCategoryFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/EditCategoryFragment.kt new file mode 100644 index 0000000..efde765 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/EditCategoryFragment.kt @@ -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, + 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 } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/EditFeedFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/EditFeedFragment.kt new file mode 100644 index 0000000..dbcc6b3 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/EditFeedFragment.kt @@ -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): EditFeedFragment { + val args = Bundle().apply { + putSerializable(ARG_FEED, feed) + putStringArray(ARG_CATEGORIES, categories) + } + return EditFeedFragment().apply { arguments = args } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/FilterEntriesFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/FilterEntriesFragment.kt new file mode 100644 index 0000000..c4f4cb2 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/FilterEntriesFragment.kt @@ -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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/InputUrlFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/InputUrlFragment.kt new file mode 100644 index 0000000..017ea54 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/InputUrlFragment.kt @@ -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 } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/SortFeedManagerFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/SortFeedManagerFragment.kt new file mode 100644 index 0000000..8d99ffe --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/SortFeedManagerFragment.kt @@ -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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/SubscribeFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/SubscribeFragment.kt new file mode 100644 index 0000000..571e72d --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/SubscribeFragment.kt @@ -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 } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/TextSizeFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/TextSizeFragment.kt new file mode 100644 index 0000000..c40452b --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/dialog/TextSizeFragment.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/AddFeedsFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/AddFeedsFragment.kt new file mode 100644 index 0000000..01f7929 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/AddFeedsFragment.kt @@ -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) { + 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/EntryFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/EntryFragment.kt new file mode 100644 index 0000000..b2895e9 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/EntryFragment.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/EntryListFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/EntryListFragment.kt new file mode 100644 index 0000000..af9c3a8 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/EntryListFragment.kt @@ -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 + 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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/FeedAddingFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/FeedAddingFragment.kt new file mode 100644 index 0000000..4c2813a --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/FeedAddingFragment.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/FeedListFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/FeedListFragment.kt new file mode 100644 index 0000000..0e9fa8e --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/FeedListFragment.kt @@ -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 { + 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/ManageFeedsFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/ManageFeedsFragment.kt new file mode 100644 index 0000000..1b14ece --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/ManageFeedsFragment.kt @@ -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() + 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/SearchFeedsFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/SearchFeedsFragment.kt new file mode 100644 index 0000000..2cf5bcc --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/SearchFeedsFragment.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/SettingsFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/SettingsFragment.kt new file mode 100644 index 0000000..ccc5937 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/SettingsFragment.kt @@ -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): ArrayAdapter { + 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/VisibleFragment.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/VisibleFragment.kt new file mode 100644 index 0000000..7acc552 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/fragment/VisibleFragment.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/menu/EntryPopupMenu.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/menu/EntryPopupMenu.kt new file mode 100644 index 0000000..a2a251d --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/menu/EntryPopupMenu.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/AddFeedsViewModel.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/AddFeedsViewModel.kt new file mode 100644 index 0000000..3912e85 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/AddFeedsViewModel.kt @@ -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>() + val topicBlocksLiveData: LiveData> + get() = _topicBlocksLiveData + + var feedsToImport = listOf() + var categories = listOf() + private var isFirstTimeLoading = true + + private val defaultTopics: MutableList = mutableListOf() + val defaultTopicsResId: List = 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 = 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) { + topics.forEach { defaultTopics.add(it) } + } + + fun onFeedDataRetrieved(data: List) { + 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): List { + val topics = (categories + defaultTopics).distinct().shuffled() + val topicBlocks: MutableList = 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/EntryListViewModel.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/EntryListViewModel.kt new file mode 100644 index 0000000..41d2f7a --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/EntryListViewModel.kt @@ -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() + 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>() + val entriesLightLiveData = MediatorLiveData>() + 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 = 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 = 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, query: String): List { + val results = mutableListOf() + 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, filter: Int): List { + return when (filter) { + FilterEntriesFragment.FILTER_UNREAD -> entries.filter { !it.isRead } + FilterEntriesFragment.FILTER_STARRED -> entries.filter { it.isStarred } + else -> entries + } + } + + private fun sortEntries(entries: List, order: Int): List { + 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, + entriesToUpdate: List, + entriesToDelete: List, + ) { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/EntryViewModel.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/EntryViewModel.kt new file mode 100644 index 0000000..7fded5d --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/EntryViewModel.kt @@ -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() + private val entryLiveData = Transformations.switchMap(entryIdLiveData) { entryId -> + repo.getEntry(entryId) + } + val htmlLiveData = MediatorLiveData() + + var lastPosition: Pair = 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) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/FeedAddingViewModel.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/FeedAddingViewModel.kt new file mode 100644 index 0000000..e81a4d0 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/FeedAddingViewModel.kt @@ -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() + + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/FeedListViewModel.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/FeedListViewModel.kt new file mode 100644 index 0000000..2cb9858 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/FeedListViewModel.kt @@ -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() + private set + val minimizedCategories = mutableSetOf() + private var feedOrder = 0 + val feedListLiveData = MediatorLiveData>() + + init { + feedListLiveData.addSource(sourceFeedsLiveData) { feeds -> + feedListLiveData.value = organizeFeedsAndCategories(feeds, minimizedCategories) + } + } + + fun setMinimizedCategories(categories: Set?) { + 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, order: Int): List { + return if (order == NiceFeedPreferences.FEED_ORDER_UNREAD) { + feeds.sortedByUnreadCount() + } else { + feeds.sortedByTitle() + } + } + + private fun organizeFeedsAndCategories( + feeds: List, + minimizedCategories: Set + ): List { + val categories = getOrderedCategories(feeds) + val arrangedMenu = mutableListOf() + + 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): List { + val categories = mutableSetOf() + for (feed in feeds) { + categories.add(feed.category) + } + // Sort alphabetically: + return categories.toList().sorted() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/ManageFeedsViewModel.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/ManageFeedsViewModel.kt new file mode 100644 index 0000000..38bc727 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/ManageFeedsViewModel.kt @@ -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> = repo.getFeedsManageable() + val feedsManageableLiveData = MediatorLiveData>() + + private var _anyIsSelected: MutableLiveData = MutableLiveData(false) + val anyIsSelected: LiveData + get() = _anyIsSelected + + private val _selectedItems = mutableListOf() + val selectedItems: List + 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) { + 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? = 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, order: Int): List { + return when (order) { + SortFeedManagerFragment.SORT_BY_CATEGORY -> feeds.sortedByCategory() + SortFeedManagerFragment.SORT_BY_TITLE -> feeds.sortedByTitle() + else -> feeds.reversed() // Default + } + } + + private fun queryFeeds(feeds: List, query: String): List { + 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 { + val categories = mutableSetOf() + 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, category: String) { + repo.updateFeedCategory(*ids.toTypedArray(), category = category) + } + + fun updateFeedDetails(feedId: String, title: String, category: String) { + repo.updateFeedTitleAndCategory(feedId, title, category) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/SearchFeedsViewModel.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/SearchFeedsViewModel.kt new file mode 100644 index 0000000..6de9eb1 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/ui/viewmodel/SearchFeedsViewModel.kt @@ -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() + val searchResultLiveData: LiveData> = Transformations.switchMap(mutableQuery) { query -> + searcher.getFeedList(query) + } + + fun onFeedIdsRetrieved(feedIds: List) { + currentFeedIds = feedIds + } + + fun performSearch(query: String) { + mutableQuery.value = query.trim() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/BackupUrlManager.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/BackupUrlManager.kt new file mode 100644 index 0000000..2e17e81 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/BackupUrlManager.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/EntryToHtmlFormatter.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/EntryToHtmlFormatter.kt new file mode 100644 index 0000000..9d012ec --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/EntryToHtmlFormatter.kt @@ -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 = "

${entry.title}

" + val formattedDate = entry.date?.let { date -> + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) + } + subtitle = when { + entry.author.isNullOrEmpty() -> "

$formattedDate

" + formattedDate.isNullOrEmpty() -> "

${entry.author}

" + else -> "

$formattedDate – ${entry.author}

" + } + } + + val content = entry.content.run (::removeStyle) + val html = StringBuilder(style) + .append("") + .append(title) + .append(subtitle) + .append(content) + .append("") + .toString() + + return encodeToString(html.toByteArray(), Base64.NO_PADDING) + } + + private fun removeStyle(content: String): String { + var base = content + var editedContent = content + // Remove all 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 = "" + private const val LINK_COLOR = "#444E64" + private const val ID_SUBTITLE = "id=\"subtitle\"" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/NetworkMonitor.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/NetworkMonitor.kt new file mode 100644 index 0000000..585c596 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/NetworkMonitor.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/NotificationReceiver.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/NotificationReceiver.kt new file mode 100644 index 0000000..194069b --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/NotificationReceiver.kt @@ -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) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/OpmlExporter.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/OpmlExporter.kt new file mode 100644 index 0000000..593f7cb --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/OpmlExporter.kt @@ -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() + get() = field.sortedByCategory() + private var categories = arrayOf() + + interface ExportResultListener { + fun onExportAttempted(isSuccessful: Boolean, fileName: String?) + } + + fun submitFeeds(feeds: List) { + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/OpmlImporter.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/OpmlImporter.kt new file mode 100644 index 0000000..102fcc5 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/OpmlImporter.kt @@ -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) + 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( + "".toRegex(), + "" + )) + parseOpml(fixedReader) + } catch (e: Exception) { + listener.onParseOpmlFailed() + } + } + } else { + listener.onParseOpmlFailed() + } + } + + private fun parseOpml(opmlReader: Reader) { + val feeds = mutableListOf() + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/RssUrlTransformer.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/RssUrlTransformer.kt new file mode 100644 index 0000000..2b2a742 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/RssUrlTransformer.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/UpdateManager.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/UpdateManager.kt new file mode 100644 index 0000000..735608b --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/UpdateManager.kt @@ -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, + entriesToUpdate: List, + entriesToDelete: List, + ) + } + + var keepOldUnreadEntries: Boolean = true + var currentFeed: Feed? = null + private set + private var currentEntries = listOf() + get() = field.sortedByDate() + + fun setInitialFeed(feed: Feed) { + currentFeed = feed + } + + fun setInitialEntries(entries: List) { + 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) { + val newEntryIds = getEntryIds(newEntries) + val currentEntryIds = getEntryIds(currentEntries) + val entriesToAdd = mutableListOf() + val entriesToUpdate = mutableListOf() + val entriesToDelete = mutableListOf() + + 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): List { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/Utils.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/Utils.kt new file mode 100644 index 0000000..78dc7a1 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/Utils.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/EntryListExtensions.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/EntryListExtensions.kt new file mode 100644 index 0000000..21f751d --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/EntryListExtensions.kt @@ -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.sortedByDate() = this.sortedByDescending { it.date } + +@JvmName("sortedByDateEntryLight") +fun List.sortedByDate() = this.sortedByDescending { it.date } + +fun List.sortedUnreadOnTop() = this.sortedByDate().sortedBy { it.isRead } \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/FeedListExtensions.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/FeedListExtensions.kt new file mode 100644 index 0000000..5f04338 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/FeedListExtensions.kt @@ -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.sortedByTitle() = this.sortedBy { it.title } + +fun List.sortedByUnreadCount() = this.sortedByDescending { it.unreadCount } + +fun List.sortedByTitle() = this.sortedBy { it.title } + +fun List.sortedByCategory() = this.sortedBy { it.category } \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/StringExtensions.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/StringExtensions.kt new file mode 100644 index 0000000..43ec787 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/StringExtensions.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/ViewExtensions.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/ViewExtensions.kt new file mode 100644 index 0000000..16b8f10 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/extensions/ViewExtensions.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/work/BackgroundSyncWorker.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/work/BackgroundSyncWorker.kt new file mode 100644 index 0000000..edd1d2e --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/work/BackgroundSyncWorker.kt @@ -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 = 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, + newEntries: List + ) { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/work/NewEntriesWorker.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/work/NewEntriesWorker.kt new file mode 100644 index 0000000..ebff3b8 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/work/NewEntriesWorker.kt @@ -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 = 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 + ): 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/work/SweeperWorker.kt b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/work/SweeperWorker.kt new file mode 100644 index 0000000..f3a9059 --- /dev/null +++ b/app/src/main/java/com/joshuacerdenia/android/nicefeed/util/work/SweeperWorker.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/ic_add.xml b/app/src/main/res/drawable-anydpi/ic_add.xml new file mode 100644 index 0000000..ea9b9ab --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_add.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/ic_browser.xml b/app/src/main/res/drawable-anydpi/ic_browser.xml new file mode 100644 index 0000000..a8cfb75 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_browser.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_check_circle.xml b/app/src/main/res/drawable-anydpi/ic_check_circle.xml new file mode 100644 index 0000000..21d9a07 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_check_circle.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_check_circle_outline.xml b/app/src/main/res/drawable-anydpi/ic_check_circle_outline.xml new file mode 100644 index 0000000..ada915f --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_check_circle_outline.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_close_light.xml b/app/src/main/res/drawable-anydpi/ic_close_light.xml new file mode 100644 index 0000000..6f10650 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_close_light.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_delete.xml b/app/src/main/res/drawable-anydpi/ic_delete.xml new file mode 100644 index 0000000..96d175c --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_delete.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_delete_light.xml b/app/src/main/res/drawable-anydpi/ic_delete_light.xml new file mode 100644 index 0000000..5e78c5a --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_delete_light.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_drop_down.xml b/app/src/main/res/drawable-anydpi/ic_drop_down.xml new file mode 100644 index 0000000..48badcc --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_drop_down.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_drop_up.xml b/app/src/main/res/drawable-anydpi/ic_drop_up.xml new file mode 100644 index 0000000..407d54b --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_drop_up.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_edit.xml b/app/src/main/res/drawable-anydpi/ic_edit.xml new file mode 100644 index 0000000..d28552a --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_edit.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_edit_light.xml b/app/src/main/res/drawable-anydpi/ic_edit_light.xml new file mode 100644 index 0000000..01fc611 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_edit_light.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_export.xml b/app/src/main/res/drawable-anydpi/ic_export.xml new file mode 100644 index 0000000..8e4bcc8 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_export.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_export_light.xml b/app/src/main/res/drawable-anydpi/ic_export_light.xml new file mode 100644 index 0000000..fe7fa6c --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_export_light.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_filter.xml b/app/src/main/res/drawable-anydpi/ic_filter.xml new file mode 100644 index 0000000..5dae6cb --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_filter.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_folder.xml b/app/src/main/res/drawable-anydpi/ic_folder.xml new file mode 100644 index 0000000..82b1c55 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_folder.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_folder_starred.xml b/app/src/main/res/drawable-anydpi/ic_folder_starred.xml new file mode 100644 index 0000000..0aa8bd8 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_folder_starred.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_green_check.xml b/app/src/main/res/drawable-anydpi/ic_green_check.xml new file mode 100644 index 0000000..5156ab7 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_green_check.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_import.xml b/app/src/main/res/drawable-anydpi/ic_import.xml new file mode 100644 index 0000000..d281ff8 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_import.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_info.xml b/app/src/main/res/drawable-anydpi/ic_info.xml new file mode 100644 index 0000000..c2a4d31 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_info.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_link.xml b/app/src/main/res/drawable-anydpi/ic_link.xml new file mode 100644 index 0000000..653e5a2 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_link.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_manage_feeds.xml b/app/src/main/res/drawable-anydpi/ic_manage_feeds.xml new file mode 100644 index 0000000..af80da5 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_manage_feeds.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_menu.xml b/app/src/main/res/drawable-anydpi/ic_menu.xml new file mode 100644 index 0000000..e06c6dc --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_menu.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_more_vert.xml b/app/src/main/res/drawable-anydpi/ic_more_vert.xml new file mode 100644 index 0000000..9ae45cf --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_more_vert.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_read.xml b/app/src/main/res/drawable-anydpi/ic_read.xml new file mode 100644 index 0000000..af4c6d8 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_read.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_search.xml b/app/src/main/res/drawable-anydpi/ic_search.xml new file mode 100644 index 0000000..f06793c --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_search.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_settings.xml b/app/src/main/res/drawable-anydpi/ic_settings.xml new file mode 100644 index 0000000..ca67c33 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_settings.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_share.xml b/app/src/main/res/drawable-anydpi/ic_share.xml new file mode 100644 index 0000000..0301978 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_share.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_sort.xml b/app/src/main/res/drawable-anydpi/ic_sort.xml new file mode 100644 index 0000000..7704630 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_sort.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_star.xml b/app/src/main/res/drawable-anydpi/ic_star.xml new file mode 100644 index 0000000..37fa8ce --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_star.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_star_border.xml b/app/src/main/res/drawable-anydpi/ic_star_border.xml new file mode 100644 index 0000000..672aee1 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_star_border.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_star_yellow.xml b/app/src/main/res/drawable-anydpi/ic_star_yellow.xml new file mode 100644 index 0000000..e1c3230 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_star_yellow.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_text_size.xml b/app/src/main/res/drawable-anydpi/ic_text_size.xml new file mode 100644 index 0000000..968fae6 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_text_size.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_update.xml b/app/src/main/res/drawable-anydpi/ic_update.xml new file mode 100644 index 0000000..878011a --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_update.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_add.png b/app/src/main/res/drawable-hdpi/ic_add.png new file mode 100644 index 0000000..be46ed1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_add.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_browser.png b/app/src/main/res/drawable-hdpi/ic_browser.png new file mode 100644 index 0000000..866befb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_browser.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_check_circle.png b/app/src/main/res/drawable-hdpi/ic_check_circle.png new file mode 100644 index 0000000..372abac Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_check_circle.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_check_circle_outline.png b/app/src/main/res/drawable-hdpi/ic_check_circle_outline.png new file mode 100644 index 0000000..88305c4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_check_circle_outline.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_close_light.png b/app/src/main/res/drawable-hdpi/ic_close_light.png new file mode 100644 index 0000000..1c8ce4f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_close_light.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_delete.png b/app/src/main/res/drawable-hdpi/ic_delete.png new file mode 100644 index 0000000..a37cacc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_delete.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_delete_light.png b/app/src/main/res/drawable-hdpi/ic_delete_light.png new file mode 100644 index 0000000..7ef1c72 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_delete_light.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_drop_down.png b/app/src/main/res/drawable-hdpi/ic_drop_down.png new file mode 100644 index 0000000..bd11b39 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_drop_down.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_drop_up.png b/app/src/main/res/drawable-hdpi/ic_drop_up.png new file mode 100644 index 0000000..5c91211 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_drop_up.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_edit.png b/app/src/main/res/drawable-hdpi/ic_edit.png new file mode 100644 index 0000000..f93cc0d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_edit.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_edit_light.png b/app/src/main/res/drawable-hdpi/ic_edit_light.png new file mode 100644 index 0000000..4104ec6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_edit_light.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_export.png b/app/src/main/res/drawable-hdpi/ic_export.png new file mode 100644 index 0000000..cc7a4fc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_export.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_export_light.png b/app/src/main/res/drawable-hdpi/ic_export_light.png new file mode 100644 index 0000000..70e4ca5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_export_light.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_filter.png b/app/src/main/res/drawable-hdpi/ic_filter.png new file mode 100644 index 0000000..68431cd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_filter.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_folder.png b/app/src/main/res/drawable-hdpi/ic_folder.png new file mode 100644 index 0000000..29f6aa1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_folder.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_folder_starred.png b/app/src/main/res/drawable-hdpi/ic_folder_starred.png new file mode 100644 index 0000000..1a8a505 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_folder_starred.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_green_check.png b/app/src/main/res/drawable-hdpi/ic_green_check.png new file mode 100644 index 0000000..59b8874 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_green_check.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_import.png b/app/src/main/res/drawable-hdpi/ic_import.png new file mode 100644 index 0000000..bb2d5fd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_import.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_info.png b/app/src/main/res/drawable-hdpi/ic_info.png new file mode 100644 index 0000000..3fc67ff Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_info.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_link.png b/app/src/main/res/drawable-hdpi/ic_link.png new file mode 100644 index 0000000..7c951d0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_link.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_manage_feeds.png b/app/src/main/res/drawable-hdpi/ic_manage_feeds.png new file mode 100644 index 0000000..ddb7307 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_manage_feeds.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu.png b/app/src/main/res/drawable-hdpi/ic_menu.png new file mode 100644 index 0000000..b884c2c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_more_vert.png b/app/src/main/res/drawable-hdpi/ic_more_vert.png new file mode 100644 index 0000000..501ddf8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_more_vert.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_nicefeed_notif.png b/app/src/main/res/drawable-hdpi/ic_nicefeed_notif.png new file mode 100644 index 0000000..1d05237 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_nicefeed_notif.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_read.png b/app/src/main/res/drawable-hdpi/ic_read.png new file mode 100644 index 0000000..44a4892 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_read.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_search.png b/app/src/main/res/drawable-hdpi/ic_search.png new file mode 100644 index 0000000..ecb475a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_search.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_settings.png b/app/src/main/res/drawable-hdpi/ic_settings.png new file mode 100644 index 0000000..b87e7d7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_settings.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_share.png b/app/src/main/res/drawable-hdpi/ic_share.png new file mode 100644 index 0000000..85ef8da Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_share.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_sort.png b/app/src/main/res/drawable-hdpi/ic_sort.png new file mode 100644 index 0000000..7087f3f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_sort.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_star.png b/app/src/main/res/drawable-hdpi/ic_star.png new file mode 100644 index 0000000..1f122fa Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_star.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_star_border.png b/app/src/main/res/drawable-hdpi/ic_star_border.png new file mode 100644 index 0000000..e8707c1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_star_border.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_star_yellow.png b/app/src/main/res/drawable-hdpi/ic_star_yellow.png new file mode 100644 index 0000000..0f488d3 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_star_yellow.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_text_size.png b/app/src/main/res/drawable-hdpi/ic_text_size.png new file mode 100644 index 0000000..db05af9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_text_size.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_update.png b/app/src/main/res/drawable-hdpi/ic_update.png new file mode 100644 index 0000000..2f3b351 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_update.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_add.png b/app/src/main/res/drawable-mdpi/ic_add.png new file mode 100644 index 0000000..d66dbde Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_add.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_browser.png b/app/src/main/res/drawable-mdpi/ic_browser.png new file mode 100644 index 0000000..a292ca6 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_browser.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_check_circle.png b/app/src/main/res/drawable-mdpi/ic_check_circle.png new file mode 100644 index 0000000..e5c77ae Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_check_circle.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_check_circle_outline.png b/app/src/main/res/drawable-mdpi/ic_check_circle_outline.png new file mode 100644 index 0000000..69a6e79 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_check_circle_outline.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_close_light.png b/app/src/main/res/drawable-mdpi/ic_close_light.png new file mode 100644 index 0000000..fcb840e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_close_light.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_delete.png b/app/src/main/res/drawable-mdpi/ic_delete.png new file mode 100644 index 0000000..04e26b3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_delete.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_delete_light.png b/app/src/main/res/drawable-mdpi/ic_delete_light.png new file mode 100644 index 0000000..d1a3912 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_delete_light.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_drop_down.png b/app/src/main/res/drawable-mdpi/ic_drop_down.png new file mode 100644 index 0000000..3ef90f4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_drop_down.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_drop_up.png b/app/src/main/res/drawable-mdpi/ic_drop_up.png new file mode 100644 index 0000000..fb1676f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_drop_up.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_edit.png b/app/src/main/res/drawable-mdpi/ic_edit.png new file mode 100644 index 0000000..aa7b716 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_edit.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_edit_light.png b/app/src/main/res/drawable-mdpi/ic_edit_light.png new file mode 100644 index 0000000..265ee8c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_edit_light.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_export.png b/app/src/main/res/drawable-mdpi/ic_export.png new file mode 100644 index 0000000..f37107b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_export.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_export_light.png b/app/src/main/res/drawable-mdpi/ic_export_light.png new file mode 100644 index 0000000..12fd65b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_export_light.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_filter.png b/app/src/main/res/drawable-mdpi/ic_filter.png new file mode 100644 index 0000000..e228f17 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_filter.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_folder.png b/app/src/main/res/drawable-mdpi/ic_folder.png new file mode 100644 index 0000000..a74a3b9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_folder.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_folder_starred.png b/app/src/main/res/drawable-mdpi/ic_folder_starred.png new file mode 100644 index 0000000..03cce29 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_folder_starred.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_green_check.png b/app/src/main/res/drawable-mdpi/ic_green_check.png new file mode 100644 index 0000000..63872d2 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_green_check.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_import.png b/app/src/main/res/drawable-mdpi/ic_import.png new file mode 100644 index 0000000..d1ca37d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_import.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_info.png b/app/src/main/res/drawable-mdpi/ic_info.png new file mode 100644 index 0000000..e8e9145 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_info.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_link.png b/app/src/main/res/drawable-mdpi/ic_link.png new file mode 100644 index 0000000..b0a542e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_link.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_manage_feeds.png b/app/src/main/res/drawable-mdpi/ic_manage_feeds.png new file mode 100644 index 0000000..13aef44 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_manage_feeds.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu.png b/app/src/main/res/drawable-mdpi/ic_menu.png new file mode 100644 index 0000000..a7dcb58 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_more_vert.png b/app/src/main/res/drawable-mdpi/ic_more_vert.png new file mode 100644 index 0000000..791bf36 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_more_vert.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_nicefeed_notif.png b/app/src/main/res/drawable-mdpi/ic_nicefeed_notif.png new file mode 100644 index 0000000..fe63e4b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_nicefeed_notif.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_read.png b/app/src/main/res/drawable-mdpi/ic_read.png new file mode 100644 index 0000000..a934196 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_read.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_search.png b/app/src/main/res/drawable-mdpi/ic_search.png new file mode 100644 index 0000000..fd1bd33 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_search.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_settings.png b/app/src/main/res/drawable-mdpi/ic_settings.png new file mode 100644 index 0000000..481d6a7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_settings.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_share.png b/app/src/main/res/drawable-mdpi/ic_share.png new file mode 100644 index 0000000..a4abcfb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_share.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_sort.png b/app/src/main/res/drawable-mdpi/ic_sort.png new file mode 100644 index 0000000..1fef08f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_sort.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_star.png b/app/src/main/res/drawable-mdpi/ic_star.png new file mode 100644 index 0000000..22ba83c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_star.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_star_border.png b/app/src/main/res/drawable-mdpi/ic_star_border.png new file mode 100644 index 0000000..7e19f68 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_star_border.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_star_yellow.png b/app/src/main/res/drawable-mdpi/ic_star_yellow.png new file mode 100644 index 0000000..2d6a7d3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_star_yellow.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_text_size.png b/app/src/main/res/drawable-mdpi/ic_text_size.png new file mode 100644 index 0000000..5c67fc9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_text_size.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_update.png b/app/src/main/res/drawable-mdpi/ic_update.png new file mode 100644 index 0000000..1b77a14 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_update.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_add.png b/app/src/main/res/drawable-xhdpi/ic_add.png new file mode 100644 index 0000000..fc2f290 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_add.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_browser.png b/app/src/main/res/drawable-xhdpi/ic_browser.png new file mode 100644 index 0000000..d82b24b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_browser.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_check_circle.png b/app/src/main/res/drawable-xhdpi/ic_check_circle.png new file mode 100644 index 0000000..1095062 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_check_circle.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_check_circle_outline.png b/app/src/main/res/drawable-xhdpi/ic_check_circle_outline.png new file mode 100644 index 0000000..830cc2a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_check_circle_outline.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_close_light.png b/app/src/main/res/drawable-xhdpi/ic_close_light.png new file mode 100644 index 0000000..a927ddb Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_close_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delete.png b/app/src/main/res/drawable-xhdpi/ic_delete.png new file mode 100644 index 0000000..fb6c1b9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_delete.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delete_light.png b/app/src/main/res/drawable-xhdpi/ic_delete_light.png new file mode 100644 index 0000000..8ad0c19 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_delete_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_drop_down.png b/app/src/main/res/drawable-xhdpi/ic_drop_down.png new file mode 100644 index 0000000..b77d3b4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_drop_down.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_drop_up.png b/app/src/main/res/drawable-xhdpi/ic_drop_up.png new file mode 100644 index 0000000..c865d68 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_drop_up.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_edit.png b/app/src/main/res/drawable-xhdpi/ic_edit.png new file mode 100644 index 0000000..a002ff0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_edit.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_edit_light.png b/app/src/main/res/drawable-xhdpi/ic_edit_light.png new file mode 100644 index 0000000..5baa117 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_edit_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_export.png b/app/src/main/res/drawable-xhdpi/ic_export.png new file mode 100644 index 0000000..396445d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_export.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_export_light.png b/app/src/main/res/drawable-xhdpi/ic_export_light.png new file mode 100644 index 0000000..ac56e86 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_export_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_filter.png b/app/src/main/res/drawable-xhdpi/ic_filter.png new file mode 100644 index 0000000..a98c60e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_filter.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_folder.png b/app/src/main/res/drawable-xhdpi/ic_folder.png new file mode 100644 index 0000000..53c495d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_folder.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_folder_starred.png b/app/src/main/res/drawable-xhdpi/ic_folder_starred.png new file mode 100644 index 0000000..56e3907 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_folder_starred.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_green_check.png b/app/src/main/res/drawable-xhdpi/ic_green_check.png new file mode 100644 index 0000000..fbd71f4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_green_check.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_import.png b/app/src/main/res/drawable-xhdpi/ic_import.png new file mode 100644 index 0000000..02f9d70 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_import.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_info.png b/app/src/main/res/drawable-xhdpi/ic_info.png new file mode 100644 index 0000000..110d0ad Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_info.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_link.png b/app/src/main/res/drawable-xhdpi/ic_link.png new file mode 100644 index 0000000..a15bb0b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_link.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_manage_feeds.png b/app/src/main/res/drawable-xhdpi/ic_manage_feeds.png new file mode 100644 index 0000000..b6e4bd0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_manage_feeds.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu.png b/app/src/main/res/drawable-xhdpi/ic_menu.png new file mode 100644 index 0000000..0c5d286 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_more_vert.png b/app/src/main/res/drawable-xhdpi/ic_more_vert.png new file mode 100644 index 0000000..7695752 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_more_vert.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_nicefeed_notif.png b/app/src/main/res/drawable-xhdpi/ic_nicefeed_notif.png new file mode 100644 index 0000000..b5d5902 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_nicefeed_notif.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_read.png b/app/src/main/res/drawable-xhdpi/ic_read.png new file mode 100644 index 0000000..bbce494 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_read.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_search.png b/app/src/main/res/drawable-xhdpi/ic_search.png new file mode 100644 index 0000000..4d89c21 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_search.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_settings.png b/app/src/main/res/drawable-xhdpi/ic_settings.png new file mode 100644 index 0000000..8fb6967 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_settings.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_share.png b/app/src/main/res/drawable-xhdpi/ic_share.png new file mode 100644 index 0000000..e13f014 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_share.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_sort.png b/app/src/main/res/drawable-xhdpi/ic_sort.png new file mode 100644 index 0000000..f10ffec Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_sort.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_star.png b/app/src/main/res/drawable-xhdpi/ic_star.png new file mode 100644 index 0000000..5e180bd Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_star.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_star_border.png b/app/src/main/res/drawable-xhdpi/ic_star_border.png new file mode 100644 index 0000000..dcc3cf3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_star_border.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_star_yellow.png b/app/src/main/res/drawable-xhdpi/ic_star_yellow.png new file mode 100644 index 0000000..79d997f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_star_yellow.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_text_size.png b/app/src/main/res/drawable-xhdpi/ic_text_size.png new file mode 100644 index 0000000..a56b3fc Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_text_size.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_update.png b/app/src/main/res/drawable-xhdpi/ic_update.png new file mode 100644 index 0000000..5da4cc0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_update.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_add.png b/app/src/main/res/drawable-xxhdpi/ic_add.png new file mode 100644 index 0000000..eb3ef73 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_add.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_browser.png b/app/src/main/res/drawable-xxhdpi/ic_browser.png new file mode 100644 index 0000000..ebb2e5a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_browser.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_check_circle.png b/app/src/main/res/drawable-xxhdpi/ic_check_circle.png new file mode 100644 index 0000000..941cbba Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_check_circle.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_check_circle_outline.png b/app/src/main/res/drawable-xxhdpi/ic_check_circle_outline.png new file mode 100644 index 0000000..3c4f728 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_check_circle_outline.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_light.png b/app/src/main/res/drawable-xxhdpi/ic_close_light.png new file mode 100644 index 0000000..c0e906c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_close_light.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delete.png b/app/src/main/res/drawable-xxhdpi/ic_delete.png new file mode 100644 index 0000000..75f41bb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delete.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delete_light.png b/app/src/main/res/drawable-xxhdpi/ic_delete_light.png new file mode 100644 index 0000000..f2d3ed0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delete_light.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_drop_down.png b/app/src/main/res/drawable-xxhdpi/ic_drop_down.png new file mode 100644 index 0000000..f337c9a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_drop_down.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_drop_up.png b/app/src/main/res/drawable-xxhdpi/ic_drop_up.png new file mode 100644 index 0000000..15e43cf Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_drop_up.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_edit.png b/app/src/main/res/drawable-xxhdpi/ic_edit.png new file mode 100644 index 0000000..25da310 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_edit.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_edit_light.png b/app/src/main/res/drawable-xxhdpi/ic_edit_light.png new file mode 100644 index 0000000..a586bf8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_edit_light.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_export.png b/app/src/main/res/drawable-xxhdpi/ic_export.png new file mode 100644 index 0000000..4244791 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_export.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_export_light.png b/app/src/main/res/drawable-xxhdpi/ic_export_light.png new file mode 100644 index 0000000..651cd77 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_export_light.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_filter.png b/app/src/main/res/drawable-xxhdpi/ic_filter.png new file mode 100644 index 0000000..b68251e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_filter.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_folder.png b/app/src/main/res/drawable-xxhdpi/ic_folder.png new file mode 100644 index 0000000..faa0687 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_folder.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_folder_starred.png b/app/src/main/res/drawable-xxhdpi/ic_folder_starred.png new file mode 100644 index 0000000..82d04b7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_folder_starred.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_green_check.png b/app/src/main/res/drawable-xxhdpi/ic_green_check.png new file mode 100644 index 0000000..6a6faa6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_green_check.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_import.png b/app/src/main/res/drawable-xxhdpi/ic_import.png new file mode 100644 index 0000000..0f27913 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_import.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_info.png b/app/src/main/res/drawable-xxhdpi/ic_info.png new file mode 100644 index 0000000..ea76085 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_info.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_link.png b/app/src/main/res/drawable-xxhdpi/ic_link.png new file mode 100644 index 0000000..70b853c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_link.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_manage_feeds.png b/app/src/main/res/drawable-xxhdpi/ic_manage_feeds.png new file mode 100644 index 0000000..40a5e46 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_manage_feeds.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu.png b/app/src/main/res/drawable-xxhdpi/ic_menu.png new file mode 100644 index 0000000..f7fa754 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_more_vert.png b/app/src/main/res/drawable-xxhdpi/ic_more_vert.png new file mode 100644 index 0000000..ec2c431 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_more_vert.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_nicefeed_notif.png b/app/src/main/res/drawable-xxhdpi/ic_nicefeed_notif.png new file mode 100644 index 0000000..a424b64 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_nicefeed_notif.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_read.png b/app/src/main/res/drawable-xxhdpi/ic_read.png new file mode 100644 index 0000000..5a2c4f0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_read.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_search.png b/app/src/main/res/drawable-xxhdpi/ic_search.png new file mode 100644 index 0000000..ac9848a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_search.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_settings.png b/app/src/main/res/drawable-xxhdpi/ic_settings.png new file mode 100644 index 0000000..01c79e3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_settings.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_share.png b/app/src/main/res/drawable-xxhdpi/ic_share.png new file mode 100644 index 0000000..7a75c3b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_share.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_sort.png b/app/src/main/res/drawable-xxhdpi/ic_sort.png new file mode 100644 index 0000000..6058560 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_sort.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_star.png b/app/src/main/res/drawable-xxhdpi/ic_star.png new file mode 100644 index 0000000..71791fd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_star.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_star_border.png b/app/src/main/res/drawable-xxhdpi/ic_star_border.png new file mode 100644 index 0000000..2911f25 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_star_border.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_star_yellow.png b/app/src/main/res/drawable-xxhdpi/ic_star_yellow.png new file mode 100644 index 0000000..966015b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_star_yellow.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_text_size.png b/app/src/main/res/drawable-xxhdpi/ic_text_size.png new file mode 100644 index 0000000..8dffc92 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_text_size.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_update.png b/app/src/main/res/drawable-xxhdpi/ic_update.png new file mode 100644 index 0000000..58d4530 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_update.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_nicefeed_notif.png b/app/src/main/res/drawable-xxxhdpi/ic_nicefeed_notif.png new file mode 100644 index 0000000..9580e4b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_nicefeed_notif.png differ diff --git a/app/src/main/res/drawable/feed_icon.png b/app/src/main/res/drawable/feed_icon.png new file mode 100644 index 0000000..49c4e23 Binary files /dev/null and b/app/src/main/res/drawable/feed_icon.png differ diff --git a/app/src/main/res/drawable/feed_icon_small.png b/app/src/main/res/drawable/feed_icon_small.png new file mode 100644 index 0000000..c80369b Binary files /dev/null and b/app/src/main/res/drawable/feed_icon_small.png differ diff --git a/app/src/main/res/drawable/vintage_newspaper.jpg b/app/src/main/res/drawable/vintage_newspaper.jpg new file mode 100644 index 0000000..467cef6 Binary files /dev/null and b/app/src/main/res/drawable/vintage_newspaper.jpg differ diff --git a/app/src/main/res/font/lato.xml b/app/src/main/res/font/lato.xml new file mode 100644 index 0000000..8e8639c --- /dev/null +++ b/app/src/main/res/font/lato.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/main/res/font/lato_bold.xml b/app/src/main/res/font/lato_bold.xml new file mode 100644 index 0000000..6d03467 --- /dev/null +++ b/app/src/main/res/font/lato_bold.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/main/res/layout-v23/fragment_entry.xml b/app/src/main/res/layout-v23/fragment_entry.xml new file mode 100644 index 0000000..493141d --- /dev/null +++ b/app/src/main/res/layout-v23/fragment_entry.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..be1fb8f --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_managing.xml b/app/src/main/res/layout/activity_managing.xml new file mode 100644 index 0000000..2bf6ee9 --- /dev/null +++ b/app/src/main/res/layout/activity_managing.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml new file mode 100644 index 0000000..8f81143 --- /dev/null +++ b/app/src/main/res/layout/fragment_about.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + +