From 624cff117a12d22d9544b54c50d8931388d7066e Mon Sep 17 00:00:00 2001 From: Matthieu <24-artectrex@users.noreply.shinice.net> Date: Fri, 27 Nov 2020 17:02:52 +0100 Subject: [PATCH] Cache feeds with database using the new paging3 API --- app/build.gradle | 16 +- app/licenses.yml | 9 +- .../java/com/h/pixeldroid/CameraTest.kt | 4 +- .../java/com/h/pixeldroid/DrawerMenuTest.kt | 4 +- .../java/com/h/pixeldroid/HomeFeedTest.kt | 53 +- .../java/com/h/pixeldroid/IntentTest.kt | 8 +- .../h/pixeldroid/LoginActivityOnlineTest.kt | 4 +- .../java/com/h/pixeldroid/MockedServerTest.kt | 32 - .../h/pixeldroid/PostCreationActivityTest.kt | 35 - .../h/pixeldroid/PostCreationFragmentTest.kt | 4 +- .../java/com/h/pixeldroid/PostTest.kt | 4 +- app/src/main/assets/licenses.html | 249 +++++- .../java/com/h/pixeldroid/FollowsActivity.kt | 8 +- .../java/com/h/pixeldroid/MainActivity.kt | 58 +- .../com/h/pixeldroid/PostCreationActivity.kt | 2 +- .../java/com/h/pixeldroid/ProfileActivity.kt | 5 +- .../java/com/h/pixeldroid/SearchActivity.kt | 9 +- .../java/com/h/pixeldroid/api/PixelfedAPI.kt | 36 +- .../java/com/h/pixeldroid/db/AppDatabase.kt | 17 +- .../java/com/h/pixeldroid/db/Converters.kt | 112 ++- .../main/java/com/h/pixeldroid/db/PostDao.kt | 28 - .../com/h/pixeldroid/db/PostDatabaseEntity.kt | 34 - .../h/pixeldroid/db/{ => dao}/InstanceDao.kt | 3 +- .../com/h/pixeldroid/db/{ => dao}/UserDao.kt | 3 +- .../db/dao/feedContent/FeedContentDao.kt | 17 + .../db/dao/feedContent/NotificationDao.kt | 17 + .../db/dao/feedContent/posts/HomePostDao.kt | 18 + .../db/dao/feedContent/posts/PublicPostDao.kt | 18 + .../db/entities/HomeStatusDatabaseEntity.kt | 95 +++ .../{ => entities}/InstanceDatabaseEntity.kt | 2 +- .../PublicFeedStatusDatabaseEntity.kt | 94 +++ .../db/{ => entities}/UserDatabaseEntity.kt | 3 +- .../h/pixeldroid/di/ApplicationComponent.kt | 12 +- .../h/pixeldroid/fragments/BaseFragment.kt | 26 + .../h/pixeldroid/fragments/ImageFragment.kt | 81 -- .../h/pixeldroid/fragments/PostFragment.kt | 47 +- .../pixeldroid/fragments/StatusViewHolder.kt | 731 ++++++++++++++++++ .../fragments/feeds/AccountListFragment.kt | 209 ----- .../feeds/CommonFeedFragmentUtils.kt | 123 +++ .../fragments/feeds/FeedFragment.kt | 165 ---- .../fragments/feeds/NotificationsFragment.kt | 269 ------- .../fragments/feeds/OfflineFeedFragment.kt | 211 ----- .../feeds/cachedFeeds/CachedFeedFragment.kt | 107 +++ .../cachedFeeds/FeedContentRepository.kt | 61 ++ .../feeds/cachedFeeds/FeedViewModel.kt | 43 ++ .../notifications/NotificationsFragment.kt | 208 +++++ .../NotificationsRemoteMediator.kt | 86 +++ .../postFeeds/HomeFeedRemoteMediator.kt | 71 ++ .../cachedFeeds/postFeeds/PostFeedFragment.kt | 97 +++ .../postFeeds/PublicFeedRemoteMediator.kt | 86 +++ .../feeds/postFeeds/HomeTimelineFragment.kt | 70 -- .../feeds/postFeeds/PostsFeedFragment.kt | 152 ---- .../feeds/postFeeds/PublicTimelineFragment.kt | 60 -- .../feeds/search/SearchAccountFragment.kt | 110 --- .../feeds/search/SearchHashtagFragment.kt | 170 ---- .../feeds/search/SearchPostsFragment.kt | 110 --- .../feeds/uncachedFeeds/FeedViewModel.kt | 34 + .../uncachedFeeds/UncachedFeedFragment.kt | 95 +++ .../accountLists/AccountListFragment.kt | 145 ++++ .../FollowersContentRepository.kt | 36 + .../accountLists/FollowersPagingSource.kt | 68 ++ .../search/SearchAccountFragment.kt | 57 ++ .../search/SearchContentRepository.kt | 43 ++ .../search/SearchHashtagFragment.kt | 133 ++++ .../search/SearchPagingSource.kt | 47 ++ .../search/SearchPostsFragment.kt | 89 +++ .../java/com/h/pixeldroid/objects/Account.kt | 4 +- .../com/h/pixeldroid/objects/FeedContent.kt | 13 +- .../com/h/pixeldroid/objects/Notification.kt | 28 +- .../java/com/h/pixeldroid/objects/Status.kt | 433 +---------- .../main/java/com/h/pixeldroid/objects/Tag.kt | 3 +- .../java/com/h/pixeldroid/utils/DBUtils.kt | 42 +- .../java/com/h/pixeldroid/utils/PostUtils.kt | 231 ------ app/src/main/res/layout/activity_post.xml | 6 +- ...ragment_image.xml => album_image_view.xml} | 5 +- app/src/main/res/layout/fragment_feed.xml | 56 +- .../layout/load_state_footer_view_item.xml | 45 ++ .../test/java/com/h/pixeldroid/APIUnitTest.kt | 27 +- .../java/com/h/pixeldroid/PostUnitTest.kt | 4 +- build.gradle | 4 +- gradle.properties | 1 + 81 files changed, 3333 insertions(+), 2622 deletions(-) delete mode 100644 app/src/main/java/com/h/pixeldroid/db/PostDao.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/db/PostDatabaseEntity.kt rename app/src/main/java/com/h/pixeldroid/db/{ => dao}/InstanceDao.kt (79%) rename app/src/main/java/com/h/pixeldroid/db/{ => dao}/UserDao.kt (89%) create mode 100644 app/src/main/java/com/h/pixeldroid/db/dao/feedContent/FeedContentDao.kt create mode 100644 app/src/main/java/com/h/pixeldroid/db/dao/feedContent/NotificationDao.kt create mode 100644 app/src/main/java/com/h/pixeldroid/db/dao/feedContent/posts/HomePostDao.kt create mode 100644 app/src/main/java/com/h/pixeldroid/db/dao/feedContent/posts/PublicPostDao.kt create mode 100644 app/src/main/java/com/h/pixeldroid/db/entities/HomeStatusDatabaseEntity.kt rename app/src/main/java/com/h/pixeldroid/db/{ => entities}/InstanceDatabaseEntity.kt (89%) create mode 100644 app/src/main/java/com/h/pixeldroid/db/entities/PublicFeedStatusDatabaseEntity.kt rename app/src/main/java/com/h/pixeldroid/db/{ => entities}/UserDatabaseEntity.kt (87%) create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/BaseFragment.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/fragments/ImageFragment.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/StatusViewHolder.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/AccountListFragment.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/CommonFeedFragmentUtils.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/FeedFragment.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/NotificationsFragment.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/OfflineFeedFragment.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/CachedFeedFragment.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/FeedContentRepository.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/FeedViewModel.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/notifications/NotificationsFragment.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/notifications/NotificationsRemoteMediator.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/HomeFeedRemoteMediator.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/PostFeedFragment.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/PublicFeedRemoteMediator.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/HomeTimelineFragment.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/PostsFeedFragment.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/PublicTimelineFragment.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchAccountFragment.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchHashtagFragment.kt delete mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchPostsFragment.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/FeedViewModel.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/UncachedFeedFragment.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/AccountListFragment.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/FollowersContentRepository.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/FollowersPagingSource.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchAccountFragment.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchContentRepository.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchHashtagFragment.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchPagingSource.kt create mode 100644 app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchPostsFragment.kt rename app/src/main/res/layout/{fragment_image.xml => album_image_view.xml} (81%) create mode 100644 app/src/main/res/layout/load_state_footer_view_item.xml diff --git a/app/build.gradle b/app/build.gradle index c97f1181..ccce5c97 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,13 +11,14 @@ apply plugin: 'jacoco' android { compileSdkVersion 30 - buildToolsVersion '30.0.1' + buildToolsVersion '30.0.2' compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" + freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"] } defaultConfig { applicationId "com.h.pixeldroid" @@ -53,6 +54,9 @@ android { animationsDisabled true } + buildFeatures { + viewBinding = true + } apply plugin: 'kotlin-kapt' } @@ -75,7 +79,7 @@ dependencies { implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation 'androidx.navigation:navigation-fragment-ktx:2.3.1' implementation 'androidx.navigation:navigation-ui-ktx:2.3.1' - implementation 'androidx.paging:paging-runtime-ktx:2.1.2' + implementation 'androidx.paging:paging-runtime-ktx:3.0.0-alpha09' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.2.0' implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0" @@ -83,16 +87,16 @@ dependencies { implementation 'androidx.gridlayout:gridlayout:1.0.0' // Use the most recent version of CameraX - def camerax_version = '1.0.0-beta11' + def camerax_version = '1.0.0-beta12' implementation "androidx.camera:camera-core:${camerax_version}" implementation "androidx.camera:camera-camera2:${camerax_version}" // CameraX Lifecycle library implementation "androidx.camera:camera-lifecycle:$camerax_version" // CameraX View class - implementation 'androidx.camera:camera-view:1.0.0-alpha18' + implementation 'androidx.camera:camera-view:1.0.0-alpha19' - def room_version = "2.2.5" + def room_version = "2.3.0-alpha03" implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" @@ -148,7 +152,7 @@ dependencies { implementation 'com.mikepenz:google-material-typeface:3.0.1.4.original-kotlin@aar' - implementation 'com.karumi:dexter:6.2.1' + implementation 'com.karumi:dexter:6.2.2' implementation 'com.github.ligi.tracedroid:lib:3.0' implementation 'com.github.ligi.tracedroid:supportemail:3.0' diff --git a/app/licenses.yml b/app/licenses.yml index c252599b..dc6196a1 100644 --- a/app/licenses.yml +++ b/app/licenses.yml @@ -630,8 +630,13 @@ license: The Apache Software License, Version 2.0 licenseUrl: https://www.apache.org/licenses/LICENSE-2.0.txt url: https://github.com/Kotlin/kotlinx.coroutines -- artifact: org.jetbrains.kotlinx:kotlinx-coroutines-core:+ - name: kotlinx-coroutines-core +- artifact: androidx.databinding:viewbinding:+ + name: viewbinding + copyrightHolder: Google Inc. + license: The Apache Software License, Version 2.0 + licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt +- artifact: org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:+ + name: kotlinx-coroutines-core-jvm copyrightHolder: JetBrains s.r.o. and contributors license: The Apache Software License, Version 2.0 licenseUrl: https://www.apache.org/licenses/LICENSE-2.0.txt diff --git a/app/src/androidTest/java/com/h/pixeldroid/CameraTest.kt b/app/src/androidTest/java/com/h/pixeldroid/CameraTest.kt index 6827b38b..b3110985 100644 --- a/app/src/androidTest/java/com/h/pixeldroid/CameraTest.kt +++ b/app/src/androidTest/java/com/h/pixeldroid/CameraTest.kt @@ -9,8 +9,8 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.rule.GrantPermissionRule -import com.h.pixeldroid.db.InstanceDatabaseEntity -import com.h.pixeldroid.db.UserDatabaseEntity +import com.h.pixeldroid.db.entities.InstanceDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity import com.h.pixeldroid.fragments.CameraFragment import com.h.pixeldroid.testUtility.clearData import com.h.pixeldroid.testUtility.initDB diff --git a/app/src/androidTest/java/com/h/pixeldroid/DrawerMenuTest.kt b/app/src/androidTest/java/com/h/pixeldroid/DrawerMenuTest.kt index 4f525839..ff42db9b 100644 --- a/app/src/androidTest/java/com/h/pixeldroid/DrawerMenuTest.kt +++ b/app/src/androidTest/java/com/h/pixeldroid/DrawerMenuTest.kt @@ -13,8 +13,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.uiautomator.UiDevice import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.InstanceDatabaseEntity -import com.h.pixeldroid.db.UserDatabaseEntity +import com.h.pixeldroid.db.entities.InstanceDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity import com.h.pixeldroid.testUtility.MockServer import com.h.pixeldroid.testUtility.clearData import com.h.pixeldroid.testUtility.initDB diff --git a/app/src/androidTest/java/com/h/pixeldroid/HomeFeedTest.kt b/app/src/androidTest/java/com/h/pixeldroid/HomeFeedTest.kt index 074a3817..a4938384 100644 --- a/app/src/androidTest/java/com/h/pixeldroid/HomeFeedTest.kt +++ b/app/src/androidTest/java/com/h/pixeldroid/HomeFeedTest.kt @@ -2,12 +2,9 @@ package com.h.pixeldroid import android.content.Context -import android.widget.TextView import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.action.ViewActions.swipeUp import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition @@ -15,9 +12,9 @@ import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.android.material.tabs.TabLayout import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.InstanceDatabaseEntity -import com.h.pixeldroid.db.UserDatabaseEntity -import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder +import com.h.pixeldroid.db.entities.InstanceDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity +import com.h.pixeldroid.fragments.StatusViewHolder import com.h.pixeldroid.testUtility.CustomMatchers.Companion.atPosition import com.h.pixeldroid.testUtility.CustomMatchers.Companion.clickChildViewWithId import com.h.pixeldroid.testUtility.CustomMatchers.Companion.first @@ -99,10 +96,10 @@ class HomeFeedTest { @Test fun clickingLikeButtonWorks() { onView(withId(R.id.list)).perform( - actionOnItemAtPosition(0, clickChildViewWithId(R.id.liker)) + actionOnItemAtPosition(0, clickChildViewWithId(R.id.liker)) ) onView(withId(R.id.list)).perform( - actionOnItemAtPosition(0, clickChildViewWithId(R.id.liker)) + actionOnItemAtPosition(0, clickChildViewWithId(R.id.liker)) ) onView(first(withId(R.id.nlikes))) .check(matches(withText(getText(first(withId(R.id.nlikes)))))) @@ -111,7 +108,7 @@ class HomeFeedTest { @Test fun clickingLikeButtonFails() { onView(withId(R.id.list)).perform( - actionOnItemAtPosition(2, clickChildViewWithId(R.id.liker)) + actionOnItemAtPosition(2, clickChildViewWithId(R.id.liker)) ) onView((withId(R.id.list))).check(matches(isDisplayed())) } @@ -119,7 +116,7 @@ class HomeFeedTest { @Test fun clickingUsernameOpensProfile() { onView(withId(R.id.list)).perform( - actionOnItemAtPosition(0, clickChildViewWithId(R.id.username)) + actionOnItemAtPosition(0, clickChildViewWithId(R.id.username)) ) onView(withId(R.id.accountNameTextView)).check(matches(isDisplayed())) } @@ -127,7 +124,7 @@ class HomeFeedTest { @Test fun clickingProfilePicOpensProfile() { onView(withId(R.id.list)).perform( - actionOnItemAtPosition(0, clickChildViewWithId(R.id.profilePic)) + actionOnItemAtPosition(0, clickChildViewWithId(R.id.profilePic)) ) onView(withId(R.id.accountNameTextView)).check(matches(isDisplayed())) } @@ -135,10 +132,10 @@ class HomeFeedTest { @Test fun clickingReblogButtonWorks() { onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (0, clickChildViewWithId(R.id.reblogger))) onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (0, clickChildViewWithId(R.id.reblogger))) onView(first(withId(R.id.nshares))) .check(matches(withText(getText(first(withId(R.id.nshares)))))) @@ -147,7 +144,7 @@ class HomeFeedTest { @Test fun clickingMentionOpensProfile() { onView(withId(R.id.list)).perform( - actionOnItemAtPosition(0, clickChildViewWithId(R.id.description)) + actionOnItemAtPosition(0, clickChildViewWithId(R.id.description)) ) onView(first(withId(R.id.username))).check(matches(isDisplayed())) } @@ -155,7 +152,7 @@ class HomeFeedTest { @Test fun clickingHashTagsWorks() { onView(withId(R.id.list)).perform( - actionOnItemAtPosition(1, clickChildViewWithId(R.id.description)) + actionOnItemAtPosition(1, clickChildViewWithId(R.id.description)) ) onView(withId(R.id.list)).check(matches(isDisplayed())) } @@ -164,7 +161,7 @@ class HomeFeedTest { @Test fun clickingCommentButtonOpensCommentSection() { onView(withId(R.id.list)).perform( - actionOnItemAtPosition(0, clickChildViewWithId(R.id.commenter)) + actionOnItemAtPosition(0, clickChildViewWithId(R.id.commenter)) ) onView(first(withId(R.id.commentIn))) .check(matches(hasDescendant(withId(R.id.editComment)))) @@ -174,7 +171,7 @@ class HomeFeedTest { fun clickingViewCommentShowsTheComments() { //Open the comment section onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (0, clickChildViewWithId(R.id.ViewComments))) Thread.sleep(1000) onView(first(withId(R.id.commentContainer))) @@ -185,7 +182,7 @@ class HomeFeedTest { fun clickingViewCommentFails() { //Open the comment section onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (2, clickChildViewWithId(R.id.ViewComments))) Thread.sleep(1000) onView(withId(R.id.list)).check(matches(isDisplayed())) @@ -195,17 +192,17 @@ class HomeFeedTest { fun postingACommentWorks() { //Open the comment section onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (0, clickChildViewWithId(R.id.commenter))) onView(withId(R.id.list)).perform(slowSwipeUp(false)) Thread.sleep(1000) onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (0, typeTextInViewWithId(R.id.editComment, "test"))) onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (0, clickChildViewWithId(R.id.submitComment))) Thread.sleep(1000) @@ -215,14 +212,14 @@ class HomeFeedTest { @Test fun performClickOnSensitiveWarning() { - onView(withId(R.id.list)).perform(scrollToPosition(1)) + onView(withId(R.id.list)).perform(scrollToPosition(1)) Thread.sleep(1000) onView(second(withId(R.id.sensitiveWarning))).check(matches(withEffectiveVisibility(Visibility.VISIBLE))) Thread.sleep(1000) onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (1, clickChildViewWithId(R.id.sensitiveWarning))) Thread.sleep(1000) @@ -232,14 +229,14 @@ class HomeFeedTest { @Test fun performClickOnSensitiveWarningTabs() { - onView(withId(R.id.list)).perform(scrollToPosition(0)) + onView(withId(R.id.list)).perform(scrollToPosition(0)) Thread.sleep(1000) onView(first(withId(R.id.sensitiveWarning))).check(matches(withEffectiveVisibility(Visibility.VISIBLE))) Thread.sleep(1000) onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (0, clickChildViewWithId(R.id.sensitiveWarning))) Thread.sleep(1000) @@ -254,16 +251,16 @@ class HomeFeedTest { //Remove sensitive media warning onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (0, clickChildViewWithId(R.id.sensitiveWarning))) Thread.sleep(100) //Like the post onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (0, clickChildViewWithId(R.id.postPicture))) onView(withId(R.id.list)) - .perform(actionOnItemAtPosition + .perform(actionOnItemAtPosition (0, clickChildViewWithId(R.id.postPicture))) //... Thread.sleep(100) diff --git a/app/src/androidTest/java/com/h/pixeldroid/IntentTest.kt b/app/src/androidTest/java/com/h/pixeldroid/IntentTest.kt index a411aea6..180c6274 100644 --- a/app/src/androidTest/java/com/h/pixeldroid/IntentTest.kt +++ b/app/src/androidTest/java/com/h/pixeldroid/IntentTest.kt @@ -24,9 +24,9 @@ import androidx.test.espresso.matcher.ViewMatchers import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.ActivityTestRule import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.InstanceDatabaseEntity -import com.h.pixeldroid.db.UserDatabaseEntity -import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder +import com.h.pixeldroid.db.entities.InstanceDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity +import com.h.pixeldroid.fragments.StatusViewHolder import com.h.pixeldroid.objects.Account import com.h.pixeldroid.objects.Account.Companion.ACCOUNT_TAG import com.h.pixeldroid.testUtility.MockServer @@ -112,7 +112,7 @@ class IntentTest { //Click the mention Espresso.onView(ViewMatchers.withId(R.id.list)) - .perform(RecyclerViewActions.actionOnItemAtPosition + .perform(RecyclerViewActions.actionOnItemAtPosition (0, clickClickableSpanInDescription("@Dobios"))) //Wait a bit diff --git a/app/src/androidTest/java/com/h/pixeldroid/LoginActivityOnlineTest.kt b/app/src/androidTest/java/com/h/pixeldroid/LoginActivityOnlineTest.kt index 1ebe5d33..5354544b 100644 --- a/app/src/androidTest/java/com/h/pixeldroid/LoginActivityOnlineTest.kt +++ b/app/src/androidTest/java/com/h/pixeldroid/LoginActivityOnlineTest.kt @@ -17,8 +17,8 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.InstanceDatabaseEntity -import com.h.pixeldroid.db.UserDatabaseEntity +import com.h.pixeldroid.db.entities.InstanceDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity import com.h.pixeldroid.testUtility.MockServer import com.h.pixeldroid.testUtility.clearData import com.h.pixeldroid.testUtility.initDB diff --git a/app/src/androidTest/java/com/h/pixeldroid/MockedServerTest.kt b/app/src/androidTest/java/com/h/pixeldroid/MockedServerTest.kt index 0133337c..789b44a3 100644 --- a/app/src/androidTest/java/com/h/pixeldroid/MockedServerTest.kt +++ b/app/src/androidTest/java/com/h/pixeldroid/MockedServerTest.kt @@ -1,38 +1,6 @@ package com.h.pixeldroid -import android.content.Context -import android.graphics.Color -import android.graphics.ColorMatrix -import androidx.test.core.app.ActivityScenario -import androidx.test.core.app.ApplicationProvider -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition -import androidx.test.espresso.matcher.ViewMatchers.* -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.android.material.tabs.TabLayout -import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.InstanceDatabaseEntity -import com.h.pixeldroid.db.UserDatabaseEntity -import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder -import com.h.pixeldroid.testUtility.CustomMatchers.Companion.clickChildViewWithId -import com.h.pixeldroid.testUtility.CustomMatchers.Companion.first -import com.h.pixeldroid.testUtility.CustomMatchers.Companion.second -import com.h.pixeldroid.testUtility.MockServer -import com.h.pixeldroid.testUtility.clearData -import com.h.pixeldroid.testUtility.initDB -import com.h.pixeldroid.utils.PostUtils.Companion.censorColorMatrix -import com.h.pixeldroid.utils.PostUtils.Companion.uncensorColorMatrix -import junit.framework.Assert.assertEquals -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.Timeout -import org.junit.runner.RunWith /* @RunWith(AndroidJUnit4::class) diff --git a/app/src/androidTest/java/com/h/pixeldroid/PostCreationActivityTest.kt b/app/src/androidTest/java/com/h/pixeldroid/PostCreationActivityTest.kt index de8177a3..e5de4444 100644 --- a/app/src/androidTest/java/com/h/pixeldroid/PostCreationActivityTest.kt +++ b/app/src/androidTest/java/com/h/pixeldroid/PostCreationActivityTest.kt @@ -1,40 +1,5 @@ package com.h.pixeldroid -import android.Manifest -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.Color -import android.util.Log -import android.view.View.VISIBLE -import androidx.core.net.toUri -import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.rule.GrantPermissionRule -import com.h.pixeldroid.adapters.ThumbnailAdapter -import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.InstanceDatabaseEntity -import com.h.pixeldroid.db.UserDatabaseEntity -import com.h.pixeldroid.testUtility.CustomMatchers -import com.h.pixeldroid.testUtility.MockServer -import com.h.pixeldroid.testUtility.clearData -import com.h.pixeldroid.testUtility.initDB -import kotlinx.android.synthetic.main.activity_post_creation.* -import org.hamcrest.Matchers.not -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.Timeout -import org.junit.runner.RunWith -import java.io.File /* @RunWith(AndroidJUnit4::class) class PostCreationActivityTest { diff --git a/app/src/androidTest/java/com/h/pixeldroid/PostCreationFragmentTest.kt b/app/src/androidTest/java/com/h/pixeldroid/PostCreationFragmentTest.kt index 06cd7171..f1e69c6a 100644 --- a/app/src/androidTest/java/com/h/pixeldroid/PostCreationFragmentTest.kt +++ b/app/src/androidTest/java/com/h/pixeldroid/PostCreationFragmentTest.kt @@ -17,8 +17,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.InstanceDatabaseEntity -import com.h.pixeldroid.db.UserDatabaseEntity +import com.h.pixeldroid.db.entities.InstanceDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity import com.h.pixeldroid.testUtility.MockServer import com.h.pixeldroid.testUtility.clearData import com.h.pixeldroid.testUtility.initDB diff --git a/app/src/androidTest/java/com/h/pixeldroid/PostTest.kt b/app/src/androidTest/java/com/h/pixeldroid/PostTest.kt index c76049d9..25e6fef3 100644 --- a/app/src/androidTest/java/com/h/pixeldroid/PostTest.kt +++ b/app/src/androidTest/java/com/h/pixeldroid/PostTest.kt @@ -14,8 +14,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.InstanceDatabaseEntity -import com.h.pixeldroid.db.UserDatabaseEntity +import com.h.pixeldroid.db.entities.InstanceDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity import com.h.pixeldroid.objects.* import com.h.pixeldroid.testUtility.MockServer import com.h.pixeldroid.testUtility.clearData diff --git a/app/src/main/assets/licenses.html b/app/src/main/assets/licenses.html index 57069594..0e70a313 100644 --- a/app/src/main/assets/licenses.html +++ b/app/src/main/assets/licenses.html @@ -25391,7 +25391,254 @@
-

kotlinx-coroutines-core

+

viewbinding

+

Copyright © Google Inc. All rights reserved.

+ + +
+

+ Apache License +
+ Version 2.0, January 2004 +
+ http://www.apache.org/licenses/ +

+

TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

+

1. Definitions.

+

+ "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. +

+

+ "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. +

+

+ "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. +

+

+ "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. +

+

+ "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. +

+

+ "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. +

+

+ "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). +

+

+ "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. +

+

+ "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." +

+

+ "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. +

+
+

2. Grant of Copyright License.

+

+ Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. +

+
+
+

3. Grant of Patent License.

+

+ Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. +

+
+
+

4. Redistribution.

+

+ You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: +

+
+
    +
  • + You must give any other recipients of the Work or + Derivative Works a copy of this License; and +
  • +
  • + You must cause any modified files to carry prominent notices + stating that You changed the files; and +
  • +
  • + You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of | + the Derivative Works; and +
  • +
  • + If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. +
  • +
+

+ You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. +

+
+

5. Submission of Contributions.

+

+ Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. +

+
+
+

6. Trademarks.

+

+ This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. +

+
+
+

7. Disclaimer of Warranty.

+

+ Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. +

+
+
+

8. Limitation of Liability.

+

+ In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. +

+
+
+

9. Accepting Warranty or Additional Liability.

+

+ While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. +

+
+

END OF TERMS AND CONDITIONS

+

APPENDIX: How to apply the Apache License to your work.

+

+ To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. +

+
Copyright [yyyy] [name of copyright owner]

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

  http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+
+
+
+ +

kotlinx-coroutines-core-jvm

Copyright © JetBrains s.r.o. and contributors. All rights reserved.

https://github.com/Kotlin/kotlinx.coroutines

diff --git a/app/src/main/java/com/h/pixeldroid/FollowsActivity.kt b/app/src/main/java/com/h/pixeldroid/FollowsActivity.kt index a08a6888..7ea31459 100644 --- a/app/src/main/java/com/h/pixeldroid/FollowsActivity.kt +++ b/app/src/main/java/com/h/pixeldroid/FollowsActivity.kt @@ -1,20 +1,16 @@ package com.h.pixeldroid import android.os.Bundle -import android.util.Log import androidx.appcompat.app.AppCompatActivity import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.di.PixelfedAPIHolder -import com.h.pixeldroid.fragments.feeds.AccountListFragment +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.accountLists.AccountListFragment import com.h.pixeldroid.objects.Account import com.h.pixeldroid.objects.Account.Companion.ACCOUNT_ID_TAG import com.h.pixeldroid.objects.Account.Companion.ACCOUNT_TAG import com.h.pixeldroid.objects.Account.Companion.FOLLOWERS_TAG -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response import javax.inject.Inject + class FollowsActivity : AppCompatActivity() { private var followsFragment = AccountListFragment() @Inject diff --git a/app/src/main/java/com/h/pixeldroid/MainActivity.kt b/app/src/main/java/com/h/pixeldroid/MainActivity.kt index 29c913c6..1f42571f 100644 --- a/app/src/main/java/com/h/pixeldroid/MainActivity.kt +++ b/app/src/main/java/com/h/pixeldroid/MainActivity.kt @@ -8,21 +8,24 @@ import android.os.Bundle import android.util.Log import android.view.View import android.widget.ImageView +import androidx.annotation.DrawableRes import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.core.view.GravityCompat import androidx.fragment.app.Fragment +import androidx.paging.ExperimentalPagingApi import androidx.viewpager2.adapter.FragmentStateAdapter import com.bumptech.glide.Glide import com.google.android.material.tabs.TabLayoutMediator import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.UserDatabaseEntity +import com.h.pixeldroid.db.entities.HomeStatusDatabaseEntity +import com.h.pixeldroid.db.entities.PublicFeedStatusDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity import com.h.pixeldroid.di.PixelfedAPIHolder import com.h.pixeldroid.fragments.CameraFragment import com.h.pixeldroid.fragments.SearchDiscoverFragment -import com.h.pixeldroid.fragments.feeds.NotificationsFragment -import com.h.pixeldroid.fragments.feeds.OfflineFeedFragment -import com.h.pixeldroid.fragments.feeds.postFeeds.HomeTimelineFragment -import com.h.pixeldroid.fragments.feeds.postFeeds.PublicTimelineFragment +import com.h.pixeldroid.fragments.feeds.cachedFeeds.postFeeds.PostFeedFragment +import com.h.pixeldroid.fragments.feeds.cachedFeeds.notifications.NotificationsFragment import com.h.pixeldroid.objects.Account import com.h.pixeldroid.utils.DBUtils import com.h.pixeldroid.utils.Utils.Companion.hasInternet @@ -40,11 +43,11 @@ import org.ligi.tracedroid.sending.TraceDroidEmailSender import retrofit2.Call import retrofit2.Callback import retrofit2.Response +import java.lang.IllegalArgumentException import javax.inject.Inject class MainActivity : AppCompatActivity() { - private val searchDiscoverFragment: SearchDiscoverFragment = SearchDiscoverFragment() @Inject lateinit var db: AppDatabase @Inject @@ -57,6 +60,7 @@ class MainActivity : AppCompatActivity() { const val ADD_ACCOUNT_IDENTIFIER: Long = -13 } + @ExperimentalPagingApi override fun onCreate(savedInstanceState: Bundle?) { setTheme(R.style.AppTheme_NoActionBar) super.onCreate(savedInstanceState) @@ -75,13 +79,19 @@ class MainActivity : AppCompatActivity() { launchActivity(LoginActivity(), firstTime = true) } else { setupDrawer() - val tabs = arrayOf( - if (hasInternet(applicationContext)) HomeTimelineFragment() - else OfflineFeedFragment(), - searchDiscoverFragment, + + val tabs: List = listOf( + PostFeedFragment() + .apply { + arguments = Bundle().apply { putBoolean("home", true) } + }, + SearchDiscoverFragment(), CameraFragment(), NotificationsFragment(), - PublicTimelineFragment() + PostFeedFragment() + .apply { + arguments = Bundle().apply { putBoolean("home", false) } + } ) setupTabs(tabs) } @@ -123,7 +133,7 @@ class MainActivity : AppCompatActivity() { override fun placeholder(ctx: Context, tag: String?): Drawable { if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) { - return ctx.getDrawable(R.drawable.ic_default_user)!! + return ContextCompat.getDrawable(ctx, R.drawable.ic_default_user)!! } return super.placeholder(ctx, tag) @@ -264,24 +274,30 @@ class MainActivity : AppCompatActivity() { } - private fun setupTabs(tab_array: Array){ + private fun setupTabs(tab_array: List){ view_pager.adapter = object : FragmentStateAdapter(this) { override fun createFragment(position: Int): Fragment { return tab_array[position] } override fun getItemCount(): Int { - return 5 + return tab_array.size } } + + //Keep the tabs active to prevent reloads and stutters + view_pager.offscreenPageLimit = tab_array.size - 1 + TabLayoutMediator(tabs, view_pager) { tab, position -> - when(position){ - 0 -> tab.icon = getDrawable(R.drawable.ic_home_white_24dp) - 1 -> tab.icon = getDrawable(R.drawable.ic_search_white_24dp) - 2 -> tab.icon = getDrawable(R.drawable.ic_photo_camera_white_24dp) - 3 -> tab.icon = getDrawable(R.drawable.ic_heart) - 4 -> tab.icon = getDrawable(R.drawable.ic_filter_black_24dp) - } + tab.icon = ContextCompat.getDrawable(applicationContext, + when(position){ + 0 -> R.drawable.ic_home_white_24dp + 1 -> R.drawable.ic_search_white_24dp + 2 -> R.drawable.ic_photo_camera_white_24dp + 3 -> R.drawable.ic_heart + 4 -> R.drawable.ic_filter_black_24dp + else -> throw IllegalArgumentException() + }) }.attach() } diff --git a/app/src/main/java/com/h/pixeldroid/PostCreationActivity.kt b/app/src/main/java/com/h/pixeldroid/PostCreationActivity.kt index c2a481bc..294fa2c1 100644 --- a/app/src/main/java/com/h/pixeldroid/PostCreationActivity.kt +++ b/app/src/main/java/com/h/pixeldroid/PostCreationActivity.kt @@ -19,7 +19,7 @@ import com.bumptech.glide.Glide import com.google.android.material.textfield.TextInputLayout import com.h.pixeldroid.api.PixelfedAPI import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.UserDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity import com.h.pixeldroid.di.PixelfedAPIHolder import com.h.pixeldroid.interfaces.PostCreationListener import com.h.pixeldroid.objects.Attachment diff --git a/app/src/main/java/com/h/pixeldroid/ProfileActivity.kt b/app/src/main/java/com/h/pixeldroid/ProfileActivity.kt index b5b4aa4f..ac186b68 100644 --- a/app/src/main/java/com/h/pixeldroid/ProfileActivity.kt +++ b/app/src/main/java/com/h/pixeldroid/ProfileActivity.kt @@ -17,14 +17,13 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.h.pixeldroid.adapters.ProfilePostsRecyclerViewAdapter import com.h.pixeldroid.api.PixelfedAPI import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.UserDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity import com.h.pixeldroid.di.PixelfedAPIHolder import com.h.pixeldroid.objects.Account import com.h.pixeldroid.objects.Relationship import com.h.pixeldroid.objects.Status import com.h.pixeldroid.utils.HtmlUtils.Companion.parseHTMLText import com.h.pixeldroid.utils.ImageConverter -import kotlinx.android.synthetic.main.fragment_search.* import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -145,9 +144,9 @@ class ProfileActivity : AppCompatActivity() { if(show){ motionLayout?.transitionToEnd() } else { - findViewById(R.id.profileProgressBar).visibility = View.GONE motionLayout?.transitionToStart() } + findViewById(R.id.profileProgressBar).visibility = View.GONE refreshLayout.isRefreshing = false } diff --git a/app/src/main/java/com/h/pixeldroid/SearchActivity.kt b/app/src/main/java/com/h/pixeldroid/SearchActivity.kt index ab332925..ca8d937a 100644 --- a/app/src/main/java/com/h/pixeldroid/SearchActivity.kt +++ b/app/src/main/java/com/h/pixeldroid/SearchActivity.kt @@ -10,9 +10,9 @@ import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator -import com.h.pixeldroid.fragments.feeds.search.SearchAccountFragment -import com.h.pixeldroid.fragments.feeds.search.SearchHashtagFragment -import com.h.pixeldroid.fragments.feeds.search.SearchPostsFragment +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.search.SearchAccountFragment +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.search.SearchHashtagFragment +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.search.SearchPostsFragment import com.h.pixeldroid.objects.Results class SearchActivity : AppCompatActivity() { @@ -54,8 +54,7 @@ class SearchActivity : AppCompatActivity() { private fun createSearchTabs(query: String): Array{ - val searchFeedFragment = - SearchPostsFragment() + val searchFeedFragment = SearchPostsFragment() val searchAccountListFragment = SearchAccountFragment() val searchHashtagFragment: Fragment = SearchHashtagFragment() diff --git a/app/src/main/java/com/h/pixeldroid/api/PixelfedAPI.kt b/app/src/main/java/com/h/pixeldroid/api/PixelfedAPI.kt index ef5a8063..b5dd7814 100644 --- a/app/src/main/java/com/h/pixeldroid/api/PixelfedAPI.kt +++ b/app/src/main/java/com/h/pixeldroid/api/PixelfedAPI.kt @@ -5,6 +5,7 @@ import io.reactivex.Observable import io.reactivex.Single import okhttp3.MultipartBody import retrofit2.Call +import retrofit2.Response import retrofit2.Retrofit import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory import retrofit2.converter.gson.GsonConverterFactory @@ -153,16 +154,16 @@ interface PixelfedAPI { ) : Call @GET("/api/v1/timelines/public") - fun timelinePublic( + suspend fun timelinePublic( @Query("local") local: Boolean? = null, @Query("max_id") max_id: String? = null, @Query("since_id") since_id: String? = null, @Query("min_id") min_id: String? = null, @Query("limit") limit: String? = null - ): Call> + ): List @GET("/api/v1/timelines/home") - fun timelineHome( + suspend fun timelineHome( //The authorization header needs to be of the form "Bearer " @Header("Authorization") authorization: String, @Query("max_id") max_id: String? = null, @@ -170,10 +171,10 @@ interface PixelfedAPI { @Query("min_id") min_id: String? = null, @Query("limit") limit: String? = null, @Query("local") local: Boolean? = null - ): Call> + ): List @GET("/api/v2/search") - fun search( + suspend fun search( //The authorization header needs to be of the form "Bearer " @Header("Authorization") authorization: String, @Query("account_id") account_id: String? = null, @@ -186,24 +187,19 @@ interface PixelfedAPI { @Query("limit") limit: String? = null, @Query("offset") offset: String? = null, @Query("following") following: Boolean? = null - ): Call + ): Results - /* - Note: as of 0.10.8, Pixelfed does not seem to respect the Mastodon API documentation, - you *need* to pass one of the so-called "optional" arguments. See: - https://github.com/pixelfed/pixelfed/blob/dev/app/Http/Controllers/Api/ApiV1Controller.php - An example that works: specify min_id as 1 (not 0 though) - */ @GET("/api/v1/notifications") - fun notifications( + suspend fun notifications( //The authorization header needs to be of the form "Bearer " @Header("Authorization") authorization: String, @Query("max_id") max_id: String? = null, @Query("since_id") since_id: String? = null, @Query("min_id") min_id: String? = null, - @Query("exclude_types") limit: String? = null, - @Query("account_id") exclude_types: Boolean? = null - ): Call> + @Query("limit") limit: String? = null, + @Query("exclude_types") exclude_types: List? = null, + @Query("account_id") account_id: Boolean? = null + ): List @GET("/api/v1/accounts/verify_credentials") fun verifyCredentials( @@ -224,24 +220,24 @@ interface PixelfedAPI { ) : Call> @GET("/api/v1/accounts/{id}/followers") - fun followers( + suspend fun followers( @Path("id") account_id: String, @Header("Authorization") authorization: String, @Query("max_id") max_id: String? = null, @Query("since_id") since_id: String? = null, @Query("limit") limit: Number? = null, @Query("page") page: String? = null - ) : Call> + ) : Response> @GET("/api/v1/accounts/{id}/following") - fun following( + suspend fun following( @Path("id") account_id: String, @Header("Authorization") authorization: String, @Query("max_id") max_id: String? = null, @Query("since_id") since_id: String? = null, @Query("limit") limit: Number? = 40, @Query("page") page: String? = null - ) : Call> + ) : Response> @GET("/api/v1/accounts/{id}") fun getAccount( diff --git a/app/src/main/java/com/h/pixeldroid/db/AppDatabase.kt b/app/src/main/java/com/h/pixeldroid/db/AppDatabase.kt index 3481cf57..7f687397 100644 --- a/app/src/main/java/com/h/pixeldroid/db/AppDatabase.kt +++ b/app/src/main/java/com/h/pixeldroid/db/AppDatabase.kt @@ -3,11 +3,22 @@ package com.h.pixeldroid.db import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import com.h.pixeldroid.db.dao.* +import com.h.pixeldroid.db.dao.feedContent.NotificationDao +import com.h.pixeldroid.db.dao.feedContent.posts.HomePostDao +import com.h.pixeldroid.db.dao.feedContent.posts.PublicPostDao +import com.h.pixeldroid.db.entities.HomeStatusDatabaseEntity +import com.h.pixeldroid.db.entities.InstanceDatabaseEntity +import com.h.pixeldroid.db.entities.PublicFeedStatusDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity +import com.h.pixeldroid.objects.Notification @Database(entities = [ InstanceDatabaseEntity::class, UserDatabaseEntity::class, - PostDatabaseEntity::class + HomeStatusDatabaseEntity::class, + PublicFeedStatusDatabaseEntity::class, + Notification::class ], version = 1 ) @@ -15,5 +26,7 @@ import androidx.room.TypeConverters abstract class AppDatabase : RoomDatabase() { abstract fun instanceDao(): InstanceDao abstract fun userDao(): UserDao - abstract fun postDao(): PostDao + abstract fun homePostDao(): HomePostDao + abstract fun publicPostDao(): PublicPostDao + abstract fun notificationDao(): NotificationDao } \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/db/Converters.kt b/app/src/main/java/com/h/pixeldroid/db/Converters.kt index b4d5cfaf..7e8ca43c 100644 --- a/app/src/main/java/com/h/pixeldroid/db/Converters.kt +++ b/app/src/main/java/com/h/pixeldroid/db/Converters.kt @@ -2,7 +2,9 @@ package com.h.pixeldroid.db import androidx.room.TypeConverter import com.google.gson.Gson -import java.util.Date +import com.google.gson.reflect.TypeToken +import com.h.pixeldroid.objects.* +import java.util.* class Converters { @TypeConverter @@ -16,7 +18,113 @@ class Converters { fun dateToJson(date: Date): String = Gson().toJson(date) @TypeConverter - fun jsontoDate(json: String): Date = Gson().fromJson(json, Date::class.java) + fun jsonToDate(json: String): Date = Gson().fromJson(json, Date::class.java) + + @TypeConverter + fun accountToJson(account: Account): String = Gson().toJson(account) + + @TypeConverter + fun jsonToAccount(json: String): Account = Gson().fromJson(json, Account::class.java) + + @TypeConverter + fun statusToJson(status: Status?): String = Gson().toJson(status) + + @TypeConverter + fun jsonToStatus(json: String): Status? = Gson().fromJson(json, Status::class.java) + + @TypeConverter + fun notificationTypeToJson(type: Notification.NotificationType?): String = Gson().toJson(type) + + @TypeConverter + fun jsonToNotificationType(json: String): Notification.NotificationType? = Gson().fromJson( + json, + Notification.NotificationType::class.java + ) + + @TypeConverter + fun applicationToJson(type: Application?): String = Gson().toJson(type) + + @TypeConverter + fun jsonToApplication(json: String): Application? = Gson().fromJson( + json, + Application::class.java + ) + + @TypeConverter + fun cardToJson(type: Card?): String = Gson().toJson(type) + + @TypeConverter + fun jsonToCard(json: String): Card? = Gson().fromJson(json, Card::class.java) + + @TypeConverter + fun attachmentToJson(type: Attachment?): String = Gson().toJson(type) + + @TypeConverter + fun jsonToAttachment(json: String): Attachment? = Gson().fromJson(json, Attachment::class.java) + + @TypeConverter + fun attachmentListToJson(type: List?): String { + val listType = object : TypeToken?>() {}.type + return Gson().toJson(type, listType) + } + + @TypeConverter + fun jsonToAttachmentList(json: String): List? { + val listType = object : TypeToken?>() {}.type + return Gson().fromJson(json, listType) + } + + @TypeConverter + fun mentionListToJson(type: List?): String { + val listType = object : TypeToken?>() {}.type + return Gson().toJson(type, listType) + } + + @TypeConverter + fun jsonToMentionList(json: String): List? { + val listType = object : TypeToken?>() {}.type + return Gson().fromJson(json, listType) + } + + @TypeConverter + fun emojiListToJson(type: List?): String { + val listType = object : TypeToken?>() {}.type + return Gson().toJson(type, listType) + } + + @TypeConverter + fun jsonToEmojiList(json: String): List? { + val listType = object : TypeToken?>() {}.type + return Gson().fromJson(json, listType) + } + + @TypeConverter + fun tagListToJson(type: List?): String { + val listType = object : TypeToken?>() {}.type + return Gson().toJson(type, listType) + } + + @TypeConverter + fun jsonToTagList(json: String): List? { + val listType = object : TypeToken?>() {}.type + return Gson().fromJson(json, listType) + } + + @TypeConverter + fun pollToJson(type: Poll?): String = Gson().toJson(type) + + @TypeConverter + fun jsonToPoll(json: String): Poll? = Gson().fromJson(json, Poll::class.java) + + @TypeConverter + fun visibilityToJson(type: Status.Visibility?): String = Gson().toJson(type) + + @TypeConverter + fun jsonToVisibility(json: String): Status.Visibility? = Gson().fromJson( + json, + Status.Visibility::class.java + ) + } \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/db/PostDao.kt b/app/src/main/java/com/h/pixeldroid/db/PostDao.kt deleted file mode 100644 index 425d24f6..00000000 --- a/app/src/main/java/com/h/pixeldroid/db/PostDao.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.h.pixeldroid.db - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query - -@Dao -interface PostDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertPost(post: PostDatabaseEntity) - - @Query("SELECT COUNT(*) FROM posts WHERE user_id=:userId AND instance_uri=:instanceUri") - fun numberOfPosts(userId: String, instanceUri: String): Int - - @Query("SELECT * FROM posts WHERE user_id=:user AND instance_uri=:instanceUri ORDER BY date DESC") - fun getAll(user: String, instanceUri: String): List - - @Query("SELECT COUNT(*) FROM posts WHERE uri=:uri AND user_id=:userId AND instance_uri=:instanceUri") - fun count(uri: String, userId: String, instanceUri: String): Int - - @Query( - """DELETE FROM posts WHERE uri IN ( - SELECT uri FROM posts ORDER BY date ASC LIMIT :nPosts - )""" - ) - fun removeOlderPosts(nPosts: Int) -} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/db/PostDatabaseEntity.kt b/app/src/main/java/com/h/pixeldroid/db/PostDatabaseEntity.kt deleted file mode 100644 index e3b52709..00000000 --- a/app/src/main/java/com/h/pixeldroid/db/PostDatabaseEntity.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.h.pixeldroid.db - -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import java.util.Date - -@Entity( - tableName = "posts", - primaryKeys = ["uri", "user_id", "instance_uri"], - foreignKeys = [ForeignKey( - entity = UserDatabaseEntity::class, - parentColumns = arrayOf("user_id", "instance_uri"), - childColumns = arrayOf("user_id", "instance_uri"), - onUpdate = ForeignKey.CASCADE, - onDelete = ForeignKey.CASCADE - )], - indices = [Index(value = ["user_id"])] -) -data class PostDatabaseEntity ( - var uri: String, - var user_id: String, - var instance_uri: String, - var account_profile_picture: String, - var account_name: String, - var media_urls: List, - var favourite_count: Int, - var reply_count: Int, - var share_count: Int, - var description: String, - var date: Date, - var likes: Int, - var shares: Int -) \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/db/InstanceDao.kt b/app/src/main/java/com/h/pixeldroid/db/dao/InstanceDao.kt similarity index 79% rename from app/src/main/java/com/h/pixeldroid/db/InstanceDao.kt rename to app/src/main/java/com/h/pixeldroid/db/dao/InstanceDao.kt index 362a287c..971db061 100644 --- a/app/src/main/java/com/h/pixeldroid/db/InstanceDao.kt +++ b/app/src/main/java/com/h/pixeldroid/db/dao/InstanceDao.kt @@ -1,9 +1,10 @@ -package com.h.pixeldroid.db +package com.h.pixeldroid.db.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import com.h.pixeldroid.db.entities.InstanceDatabaseEntity @Dao interface InstanceDao { diff --git a/app/src/main/java/com/h/pixeldroid/db/UserDao.kt b/app/src/main/java/com/h/pixeldroid/db/dao/UserDao.kt similarity index 89% rename from app/src/main/java/com/h/pixeldroid/db/UserDao.kt rename to app/src/main/java/com/h/pixeldroid/db/dao/UserDao.kt index 1e41e61e..78cdace8 100644 --- a/app/src/main/java/com/h/pixeldroid/db/UserDao.kt +++ b/app/src/main/java/com/h/pixeldroid/db/dao/UserDao.kt @@ -1,9 +1,10 @@ -package com.h.pixeldroid.db +package com.h.pixeldroid.db.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import com.h.pixeldroid.db.entities.UserDatabaseEntity @Dao interface UserDao { diff --git a/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/FeedContentDao.kt b/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/FeedContentDao.kt new file mode 100644 index 00000000..f594c30b --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/FeedContentDao.kt @@ -0,0 +1,17 @@ +package com.h.pixeldroid.db.dao.feedContent + +import androidx.paging.PagingSource +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import com.h.pixeldroid.objects.FeedContentDatabase + +interface FeedContentDao{ + + fun feedContent(userId: String, instanceUri: String): PagingSource + + suspend fun clearFeedContent() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(feedContent: List) + +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/NotificationDao.kt b/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/NotificationDao.kt new file mode 100644 index 00000000..73e5e683 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/NotificationDao.kt @@ -0,0 +1,17 @@ +package com.h.pixeldroid.db.dao.feedContent + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Query +import com.h.pixeldroid.objects.Notification + +@Dao +interface NotificationDao: FeedContentDao { + + @Query("DELETE FROM notifications") + override suspend fun clearFeedContent() + + @Query("""SELECT * FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri + ORDER BY CAST(created_at AS FLOAT) DESC""") + override fun feedContent(userId: String, instanceUri: String): PagingSource +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/posts/HomePostDao.kt b/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/posts/HomePostDao.kt new file mode 100644 index 00000000..afed0e1f --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/posts/HomePostDao.kt @@ -0,0 +1,18 @@ +package com.h.pixeldroid.db.dao.feedContent.posts + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Query +import com.h.pixeldroid.db.dao.feedContent.FeedContentDao +import com.h.pixeldroid.db.entities.HomeStatusDatabaseEntity + +@Dao +interface HomePostDao: FeedContentDao { + @Query("""SELECT * FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri + ORDER BY CAST(created_at AS FLOAT)""") + override fun feedContent(userId: String, instanceUri: String): PagingSource + + @Query("DELETE FROM homePosts") + override suspend fun clearFeedContent() + +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/posts/PublicPostDao.kt b/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/posts/PublicPostDao.kt new file mode 100644 index 00000000..f65fe896 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/db/dao/feedContent/posts/PublicPostDao.kt @@ -0,0 +1,18 @@ +package com.h.pixeldroid.db.dao.feedContent.posts + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Query +import com.h.pixeldroid.db.dao.feedContent.FeedContentDao +import com.h.pixeldroid.db.entities.PublicFeedStatusDatabaseEntity + +@Dao +interface PublicPostDao: FeedContentDao { + @Query("""SELECT * FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri + ORDER BY CAST(created_at AS FLOAT)""") + override fun feedContent(userId: String, instanceUri: String): PagingSource + + @Query("DELETE FROM publicPosts") + override suspend fun clearFeedContent() + +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/db/entities/HomeStatusDatabaseEntity.kt b/app/src/main/java/com/h/pixeldroid/db/entities/HomeStatusDatabaseEntity.kt new file mode 100644 index 00000000..47c5b113 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/db/entities/HomeStatusDatabaseEntity.kt @@ -0,0 +1,95 @@ +package com.h.pixeldroid.db.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import androidx.room.Index +import com.h.pixeldroid.objects.* +import java.util.* + +@Entity( + tableName = "homePosts", + primaryKeys = ["id", "user_id", "instance_uri"], + foreignKeys = [ForeignKey( + entity = UserDatabaseEntity::class, + parentColumns = arrayOf("user_id", "instance_uri"), + childColumns = arrayOf("user_id", "instance_uri"), + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["user_id", "instance_uri"])] +) +class HomeStatusDatabaseEntity( + override var user_id: String, + override var instance_uri: String, + status: Status +): Status( + status.id, + status.uri, + status.created_at, + status.account, + status.content, + status.visibility, + status.sensitive, + status.spoiler_text, + status.media_attachments, + status.application, + status.mentions, + status.tags, + status.emojis, + status.reblogs_count, + status.favourites_count, + status.replies_count, + status.url, + status.in_reply_to_id, + status.in_reply_to_account, + status.reblog, + status.poll, + status.card, + status.language, + status.text, + status.favourited, + status.reblogged, + status.muted, + status.bookmarked, + status.pinned +), FeedContentDatabase { + //Constructor to make Room happy. This sucks, and I know it. + constructor(id: String, + uri: String? = "", + created_at: Date? = Date(0), + account: Account?, + content: String? = "", + visibility: Visibility? = Visibility.public, + sensitive: Boolean? = false, + spoiler_text: String? = "", + media_attachments: List? = null, + application: Application? = null, + + mentions: List? = null, + tags: List? = null, + emojis: List? = null, + + reblogs_count: Int? = 0, + favourites_count: Int? = 0, + replies_count: Int? = 0, + + url: String? = null, + in_reply_to_id: String? = null, + in_reply_to_account: String? = null, + reblog: Status? = null, + poll: Poll? = null, + card: Card? = null, + language: String? = null, + text: String? = null, + + favourited: Boolean? = false, + reblogged: Boolean? = false, + muted: Boolean? = false, + bookmarked: Boolean? = false, + pinned: Boolean? = false, + user_id: String, + instance_uri: String): this(user_id, instance_uri, Status(id, uri, created_at, account, content, visibility, sensitive, spoiler_text, media_attachments, application, mentions, tags, emojis, reblogs_count, favourites_count, replies_count, url, in_reply_to_id, in_reply_to_account, reblog, poll, card, language, text, favourited, reblogged, muted, bookmarked, pinned) + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/db/InstanceDatabaseEntity.kt b/app/src/main/java/com/h/pixeldroid/db/entities/InstanceDatabaseEntity.kt similarity index 89% rename from app/src/main/java/com/h/pixeldroid/db/InstanceDatabaseEntity.kt rename to app/src/main/java/com/h/pixeldroid/db/entities/InstanceDatabaseEntity.kt index a8afd932..949e6bad 100644 --- a/app/src/main/java/com/h/pixeldroid/db/InstanceDatabaseEntity.kt +++ b/app/src/main/java/com/h/pixeldroid/db/entities/InstanceDatabaseEntity.kt @@ -1,4 +1,4 @@ -package com.h.pixeldroid.db +package com.h.pixeldroid.db.entities import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/app/src/main/java/com/h/pixeldroid/db/entities/PublicFeedStatusDatabaseEntity.kt b/app/src/main/java/com/h/pixeldroid/db/entities/PublicFeedStatusDatabaseEntity.kt new file mode 100644 index 00000000..69fe20c1 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/db/entities/PublicFeedStatusDatabaseEntity.kt @@ -0,0 +1,94 @@ +package com.h.pixeldroid.db.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import com.h.pixeldroid.objects.* +import java.util.* + +@Entity( + tableName = "publicPosts", + primaryKeys = ["id", "user_id", "instance_uri"], + foreignKeys = [ForeignKey( + entity = UserDatabaseEntity::class, + parentColumns = arrayOf("user_id", "instance_uri"), + childColumns = arrayOf("user_id", "instance_uri"), + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["user_id", "instance_uri"])] +) +class PublicFeedStatusDatabaseEntity( + override var user_id: String, + override var instance_uri: String, + status: Status +): Status( + status.id, + status.uri, + status.created_at, + status.account, + status.content, + status.visibility, + status.sensitive, + status.spoiler_text, + status.media_attachments, + status.application, + status.mentions, + status.tags, + status.emojis, + status.reblogs_count, + status.favourites_count, + status.replies_count, + status.url, + status.in_reply_to_id, + status.in_reply_to_account, + status.reblog, + status.poll, + status.card, + status.language, + status.text, + status.favourited, + status.reblogged, + status.muted, + status.bookmarked, + status.pinned +), FeedContentDatabase { + //Constructor to make Room happy. This sucks, and I know it. + constructor(id: String, + uri: String? = "", + created_at: Date? = Date(0), + account: Account?, + content: String? = "", + visibility: Visibility? = Visibility.public, + sensitive: Boolean? = false, + spoiler_text: String? = "", + media_attachments: List? = null, + application: Application? = null, + + mentions: List? = null, + tags: List? = null, + emojis: List? = null, + + reblogs_count: Int? = 0, + favourites_count: Int? = 0, + replies_count: Int? = 0, + + url: String? = null, + in_reply_to_id: String? = null, + in_reply_to_account: String? = null, + reblog: Status? = null, + poll: Poll? = null, + card: Card? = null, + language: String? = null, + text: String? = null, + + favourited: Boolean? = false, + reblogged: Boolean? = false, + muted: Boolean? = false, + bookmarked: Boolean? = false, + pinned: Boolean? = false, + user_id: String, + instance_uri: String): this(user_id, instance_uri, Status(id, uri, created_at, account, content, visibility, sensitive, spoiler_text, media_attachments, application, mentions, tags, emojis, reblogs_count, favourites_count, replies_count, url, in_reply_to_id, in_reply_to_account, reblog, poll, card, language, text, favourited, reblogged, muted, bookmarked, pinned) + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/db/UserDatabaseEntity.kt b/app/src/main/java/com/h/pixeldroid/db/entities/UserDatabaseEntity.kt similarity index 87% rename from app/src/main/java/com/h/pixeldroid/db/UserDatabaseEntity.kt rename to app/src/main/java/com/h/pixeldroid/db/entities/UserDatabaseEntity.kt index 1e64b115..8e1653f7 100644 --- a/app/src/main/java/com/h/pixeldroid/db/UserDatabaseEntity.kt +++ b/app/src/main/java/com/h/pixeldroid/db/entities/UserDatabaseEntity.kt @@ -1,8 +1,9 @@ -package com.h.pixeldroid.db +package com.h.pixeldroid.db.entities import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index +import com.h.pixeldroid.db.entities.InstanceDatabaseEntity @Entity( tableName = "users", diff --git a/app/src/main/java/com/h/pixeldroid/di/ApplicationComponent.kt b/app/src/main/java/com/h/pixeldroid/di/ApplicationComponent.kt index 71b2b731..6760cbc0 100644 --- a/app/src/main/java/com/h/pixeldroid/di/ApplicationComponent.kt +++ b/app/src/main/java/com/h/pixeldroid/di/ApplicationComponent.kt @@ -4,10 +4,10 @@ import android.app.Application import android.content.Context import com.h.pixeldroid.* import com.h.pixeldroid.db.AppDatabase +import com.h.pixeldroid.fragments.BaseFragment import com.h.pixeldroid.fragments.PostFragment import com.h.pixeldroid.fragments.SearchDiscoverFragment -import com.h.pixeldroid.fragments.feeds.FeedFragment -import com.h.pixeldroid.fragments.feeds.OfflineFeedFragment +import com.h.pixeldroid.fragments.feeds.cachedFeeds.notifications.NotificationsFragment import dagger.Component import javax.inject.Singleton @@ -18,8 +18,6 @@ import javax.inject.Singleton interface ApplicationComponent { fun inject(application: Pixeldroid?) fun inject(activity: LoginActivity?) - fun inject(feedFragment: FeedFragment) - fun inject(activity: FollowsActivity?) fun inject(activity: PostActivity?) fun inject(activity: PostCreationActivity?) fun inject(activity: ProfileActivity?) @@ -27,7 +25,11 @@ interface ApplicationComponent { fun inject(activity: ReportActivity?) fun inject(fragment: PostFragment) fun inject(fragment: SearchDiscoverFragment) - fun inject(fragment: OfflineFeedFragment) + + fun inject(fragment: NotificationsFragment) + fun inject(feedFragment: BaseFragment) + fun inject(followsActivity: FollowsActivity) + val context: Context? val application: Application? diff --git a/app/src/main/java/com/h/pixeldroid/fragments/BaseFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/BaseFragment.kt new file mode 100644 index 00000000..fc9f6369 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/BaseFragment.kt @@ -0,0 +1,26 @@ +package com.h.pixeldroid.fragments + +import android.os.Bundle +import androidx.fragment.app.Fragment +import com.h.pixeldroid.Pixeldroid +import com.h.pixeldroid.db.AppDatabase +import com.h.pixeldroid.di.PixelfedAPIHolder +import javax.inject.Inject + +/** + * Base Fragment, for dependency injection and other things common to a lot of the fragments + */ +open class BaseFragment: Fragment() { + + @Inject + lateinit var apiHolder: PixelfedAPIHolder + + @Inject + lateinit var db: AppDatabase + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (requireActivity().application as Pixeldroid).getAppComponent().inject(this) + } + +} diff --git a/app/src/main/java/com/h/pixeldroid/fragments/ImageFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/ImageFragment.kt deleted file mode 100644 index e41932c0..00000000 --- a/app/src/main/java/com/h/pixeldroid/fragments/ImageFragment.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.h.pixeldroid.fragments - -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.os.Bundle -import androidx.fragment.app.Fragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import com.bumptech.glide.Glide -import com.google.android.material.snackbar.Snackbar -import com.h.pixeldroid.R -import kotlinx.android.synthetic.main.fragment_image.* - -// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER -private const val IMG_URL = "imgurl" -private const val IMG_DESCRIPTION = "imgdescription" -private const val RQST_BLDR = "rqstbldr" - -/** - * A simple [Fragment] subclass. - * Use the [ImageFragment.newInstance] factory method to - * create an instance of this fragment. - */ -class ImageFragment : Fragment() { - private lateinit var imgUrl: String - private lateinit var imgDescription: String - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - imgUrl = it.getString(IMG_URL)!! - imgDescription = it.getString(IMG_DESCRIPTION)!!.ifEmpty { getString(R.string.no_description) } - } - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_image, container, false) - - view.findViewById(R.id.imageImageView).setOnLongClickListener { - Snackbar.make(it, imgDescription, Snackbar.LENGTH_SHORT).show() - true - } - - // Inflate the layout for this fragment - return view - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - //Load the image into to view - Glide.with(this) - .asDrawable().fitCenter() - .placeholder(ColorDrawable(Color.GRAY)) - .load(imgUrl) - .into(view.findViewById(R.id.imageImageView)!!) - imageImageView.contentDescription = imgDescription - } - - companion object { - /** - * Use this factory method to create a new instance of - * this fragment using the provided parameters. - * - * @param imageUrl the url of the image we want to create a fragment for - * @return A new instance of fragment ImageFragment. - */ - @JvmStatic - fun newInstance(imageUrl: String, imageDescription: String) = - ImageFragment().apply { - arguments = Bundle().apply { - putString(IMG_URL, imageUrl) - putString(IMG_DESCRIPTION, imageDescription) - } - } - } -} diff --git a/app/src/main/java/com/h/pixeldroid/fragments/PostFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/PostFragment.kt index 422e656a..74403ec6 100644 --- a/app/src/main/java/com/h/pixeldroid/fragments/PostFragment.kt +++ b/app/src/main/java/com/h/pixeldroid/fragments/PostFragment.kt @@ -1,59 +1,42 @@ package com.h.pixeldroid.fragments -import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment -import com.bumptech.glide.Glide -import com.h.pixeldroid.Pixeldroid import com.h.pixeldroid.R -import com.h.pixeldroid.api.PixelfedAPI -import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.di.PixelfedAPIHolder -import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder import com.h.pixeldroid.objects.Status import com.h.pixeldroid.objects.Status.Companion.DOMAIN_TAG import com.h.pixeldroid.objects.Status.Companion.POST_TAG -import javax.inject.Inject -class PostFragment : Fragment() { +class PostFragment : BaseFragment() { - @Inject - lateinit var db: AppDatabase + private lateinit var statusDomain: String + private var currentStatus: Status? = null - @Inject - lateinit var apiHolder: PixelfedAPIHolder + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + currentStatus = arguments?.getSerializable(POST_TAG) as Status? + statusDomain = arguments?.getString(DOMAIN_TAG)!! + + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - val currentStatus = arguments?.getSerializable(POST_TAG) as Status? - val statusDomain = arguments?.getString(DOMAIN_TAG)!! val root: View = inflater.inflate(R.layout.post_fragment, container, false) - val picRequest = Glide.with(this) - .asDrawable().fitCenter() - .placeholder(ColorDrawable(Color.GRAY)) - (requireActivity().application as Pixeldroid).getAppComponent().inject(this) + val user = db.userDao().getActiveUser()!! - val user = db.userDao().getActiveUser() + val accessToken = user.accessToken + val api = apiHolder.api ?: apiHolder.setDomain(user.instance_uri) - val accessToken = user?.accessToken.orEmpty() - val api = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db) + val holder = StatusViewHolder(root) - currentStatus?.setupPost(root, picRequest, this, statusDomain, true) - - val holder = PostViewHolder( - root, - root.context - ) - - currentStatus?.activateButtons(holder, api, "Bearer $accessToken") + holder.bind(currentStatus, statusDomain, api, "Bearer $accessToken") return root diff --git a/app/src/main/java/com/h/pixeldroid/fragments/StatusViewHolder.kt b/app/src/main/java/com/h/pixeldroid/fragments/StatusViewHolder.kt new file mode 100644 index 00000000..1a21ce6b --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/StatusViewHolder.kt @@ -0,0 +1,731 @@ +package com.h.pixeldroid.fragments + +import android.Manifest +import android.content.Intent +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.text.method.LinkMovementMethod +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import at.connyduck.sparkbutton.SparkButton +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayoutMediator +import com.h.pixeldroid.R +import com.h.pixeldroid.ReportActivity +import com.h.pixeldroid.api.PixelfedAPI +import com.h.pixeldroid.objects.Attachment +import com.h.pixeldroid.objects.Context +import com.h.pixeldroid.objects.Status +import com.h.pixeldroid.utils.HtmlUtils +import com.h.pixeldroid.utils.ImageConverter +import com.h.pixeldroid.utils.Utils +import com.karumi.dexter.Dexter +import com.karumi.dexter.listener.PermissionDeniedResponse +import com.karumi.dexter.listener.PermissionGrantedResponse +import com.karumi.dexter.listener.single.BasePermissionListener +import kotlinx.android.synthetic.main.comment.view.* +import kotlinx.android.synthetic.main.post_fragment.view.* +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + + +/** + * View Holder for a [Status] RecyclerView list item. + */ +class StatusViewHolder(val view: View) : RecyclerView.ViewHolder(view) { + val profilePic : ImageView = view.findViewById(R.id.profilePic) + val postPic : ImageView = view.findViewById(R.id.postPicture) + val username : TextView = view.findViewById(R.id.username) + val usernameDesc: TextView = view.findViewById(R.id.usernameDesc) + val description : TextView = view.findViewById(R.id.description) + val nlikes : TextView = view.findViewById(R.id.nlikes) + val nshares : TextView = view.findViewById(R.id.nshares) + + //Spark buttons + val liker : SparkButton = view.findViewById(R.id.liker) + val reblogger : SparkButton = view.findViewById(R.id.reblogger) + + val submitCmnt : ImageButton = view.findViewById(R.id.submitComment) + val commenter : ImageView = view.findViewById(R.id.commenter) + val comment : EditText = view.findViewById(R.id.editComment) + val commentCont : LinearLayout = view.findViewById(R.id.commentContainer) + val commentIn : LinearLayout = view.findViewById(R.id.commentIn) + val viewComment : TextView = view.findViewById(R.id.ViewComments) + val postDate : TextView = view.findViewById(R.id.postDate) + val postDomain : TextView = view.findViewById(R.id.postDomain) + val sensitiveW : TextView = view.findViewById(R.id.sensitiveWarning) + val postPager : ViewPager2 = view.findViewById(R.id.postPager) + val more : ImageButton = view.findViewById(R.id.status_more) + + private var status: Status? = null + + init { + itemView.setOnClickListener { + //notification?.openActivity() + } + } + + fun bind(status: Status?, instanceUri: String, pixelfedAPI: PixelfedAPI, credential: String) { + + this.status = status + + val metrics = itemView.context.resources.displayMetrics + //Limit the height of the different images + postPic.maxHeight = metrics.heightPixels * 3/4 + + //Setup the post layout + val picRequest = Glide.with(itemView) + .asDrawable().fitCenter() + .placeholder(ColorDrawable(Color.GRAY)) + + setupPost(itemView, picRequest, instanceUri, false) + + activateButtons(this, pixelfedAPI, credential) + + } + + fun setupPost( + rootView: View, + request: RequestBuilder, + //homeFragment: Fragment, + domain: String, + isActivity: Boolean + ) { + //Setup username as a button that opens the profile + rootView.findViewById(R.id.username).apply { + text = status?.account?.getDisplayName() ?: "" + setTypeface(null, Typeface.BOLD) + setOnClickListener { status?.account?.openProfile(rootView.context) } + } + + rootView.findViewById(R.id.usernameDesc).apply { + text = status?.account?.getDisplayName() ?: "" + setTypeface(null, Typeface.BOLD) + } + + rootView.findViewById(R.id.nlikes).apply { + text = status?.getNLikes(rootView.context) + setTypeface(null, Typeface.BOLD) + } + + rootView.findViewById(R.id.nshares).apply { + text = status?.getNShares(rootView.context) + setTypeface(null, Typeface.BOLD) + } + + //Convert the date to a readable string + Utils.setTextViewFromISO8601( + status?.created_at!!, + rootView.postDate, + isActivity, + rootView.context + ) + + rootView.postDomain.text = status?.getStatusDomain(domain) + + //Setup images + ImageConverter.setRoundImageFromURL( + rootView, + status?.getProfilePicUrl(), + rootView.profilePic + ) + rootView.profilePic.setOnClickListener { status?.account?.openProfile(rootView.context) } + + //Setup post pic only if there are media attachments + if(!status?.media_attachments.isNullOrEmpty()) { + setupPostPics(rootView, request) + } else { + rootView.postPicture.visibility = View.GONE + rootView.postPager.visibility = View.GONE + rootView.postTabs.visibility = View.GONE + } + + + //Set comment initial visibility + rootView.findViewById(R.id.commentIn).visibility = View.GONE + rootView.findViewById(R.id.commentContainer).visibility = View.GONE + } + + fun setupPostPics( + rootView: View, + request: RequestBuilder, + //homeFragment: Fragment + ) { + + // Standard layout + rootView.postPicture.visibility = View.VISIBLE + rootView.postPager.visibility = View.GONE + rootView.postTabs.visibility = View.GONE + + + if(status?.media_attachments?.size == 1) { + request.load(status?.getPostUrl()).into(rootView.postPicture) + val imgDescription = status?.media_attachments?.get(0)?.description.orEmpty().ifEmpty { rootView.context.getString( + R.string.no_description) } + rootView.postPicture.contentDescription = imgDescription + + rootView.postPicture.setOnLongClickListener { + Snackbar.make(it, imgDescription, Snackbar.LENGTH_SHORT).show() + true + } + + } else if(status?.media_attachments?.size!! > 1) { + setupTabsLayout(rootView, request) + } + + if (status?.sensitive!!) { + status?.setupSensitiveLayout(rootView) + } + } + + fun setupTabsLayout( + rootView: View, + request: RequestBuilder, + ) { + //Only show the viewPager and tabs + rootView.postPicture.visibility = View.GONE + rootView.postPager.visibility = View.VISIBLE + rootView.postTabs.visibility = View.VISIBLE + + //Attach the given tabs to the view pager + rootView.postPager.adapter = AlbumViewPagerAdapter(status?.media_attachments ?: emptyList()) + + TabLayoutMediator(rootView.postTabs, rootView.postPager) { tab, _ -> + tab.icon = ContextCompat.getDrawable(rootView.context, R.drawable.ic_dot_blue_12dp) + }.attach() + } + + fun setDescription(rootView: View, api: PixelfedAPI, credential: String) { + rootView.findViewById(R.id.description).apply { + if (status?.content.isNullOrBlank()) { + visibility = View.GONE + } else { + text = HtmlUtils.parseHTMLText(status?.content.orEmpty(), status?.mentions, api, rootView.context, credential) + movementMethod = LinkMovementMethod.getInstance() + } + } + } + + fun activateButtons(holder: StatusViewHolder, api: PixelfedAPI, credential: String){ + + //Set the special HTML text + setDescription(holder.view, api, credential) + + //Activate onclickListeners + activateLiker( + holder, api, credential, + status?.favourited ?: false + ) + activateReblogger( + holder, api, credential, + status?.reblogged ?: false + ) + activateCommenter(holder, api, credential) + + showComments(holder, api, credential) + + //Activate double tap liking + activateDoubleTapLiker(holder, api, credential) + + activateMoreButton(holder) + } + + fun activateReblogger( + holder: StatusViewHolder, + api: PixelfedAPI, + credential: String, + isReblogged: Boolean + ) { + holder.reblogger.apply { + //Set initial button state + isChecked = isReblogged + + //Activate the button + setEventListener { _, buttonState -> + if (buttonState) { + // Button is active + undoReblogPost(holder, api, credential) + } else { + // Button is inactive + reblogPost(holder, api, credential) + } + //show animation or not? + true + } + } + } + + fun reblogPost( + holder : StatusViewHolder, + api: PixelfedAPI, + credential: String + ) { + //Call the api function + status?.id?.let { + api.reblogStatus(credential, it).enqueue(object : Callback { + override fun onFailure(call: Call, t: Throwable) { + Log.e("REBLOG ERROR", t.toString()) + holder.reblogger.isChecked = false + } + + override fun onResponse(call: Call, response: Response) { + if(response.code() == 200) { + val resp = response.body()!! + + //Update shown share count + holder.nshares.text = resp.getNShares(holder.view.context) + holder.reblogger.isChecked = resp.reblogged!! + } else { + Log.e("RESPONSE_CODE", response.code().toString()) + holder.reblogger.isChecked = false + } + } + + }) + } + } + + fun undoReblogPost( + holder : StatusViewHolder, + api: PixelfedAPI, + credential: String, + ) { + //Call the api function + status?.id?.let { + api.undoReblogStatus(credential, it).enqueue(object : Callback { + override fun onFailure(call: Call, t: Throwable) { + Log.e("REBLOG ERROR", t.toString()) + holder.reblogger.isChecked = true + } + + override fun onResponse(call: Call, response: Response) { + if(response.code() == 200) { + val resp = response.body()!! + + //Update shown share count + holder.nshares.text = resp.getNShares(holder.view.context) + holder.reblogger.isChecked = resp.reblogged!! + } else { + Log.e("RESPONSE_CODE", response.code().toString()) + holder.reblogger.isChecked = true + } + } + + }) + } + } + + fun activateMoreButton(holder: StatusViewHolder){ + holder.more.setOnClickListener { + PopupMenu(it.context, it).apply { + setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.post_more_menu_report -> { + val intent = Intent(it.context, ReportActivity::class.java) + intent.putExtra(Status.POST_TAG, status) + ContextCompat.startActivity(it.context, intent, null) + true + } + R.id.post_more_menu_share_link -> { + val share = Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, status?.uri) + + type = "text/plain" + + putExtra(Intent.EXTRA_TITLE, status?.content) + }, null) + ContextCompat.startActivity(it.context, share, null) + + true + } + R.id.post_more_menu_save_to_gallery -> { + Dexter.withContext(holder.view.context) + .withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .withListener(object : BasePermissionListener() { + override fun onPermissionDenied(p0: PermissionDeniedResponse?) { + Toast.makeText( + holder.view.context, + holder.view.context.getString(R.string.write_permission_download_pic), + Toast.LENGTH_SHORT + ).show() + } + + override fun onPermissionGranted(p0: PermissionGrantedResponse?) { + status?.downloadImage( + holder.view.context, + status?.media_attachments?.get(holder.postPager.currentItem)?.url + ?: "", + holder.view + ) + } + }).check() + true + } + R.id.post_more_menu_share_picture -> { + Dexter.withContext(holder.view.context) + .withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .withListener(object : BasePermissionListener() { + override fun onPermissionDenied(p0: PermissionDeniedResponse?) { + Toast.makeText( + holder.view.context, + holder.view.context.getString(R.string.write_permission_share_pic), + Toast.LENGTH_SHORT + ).show() + } + + override fun onPermissionGranted(p0: PermissionGrantedResponse?) { + status?.downloadImage( + holder.view.context, + status?.media_attachments?.get(holder.postPager.currentItem)?.url + ?: "", + holder.view, + share = true, + ) + } + }).check() + true + } + else -> false + } + } + inflate(R.menu.post_more_menu) + if(status?.media_attachments.isNullOrEmpty()) { + //make sure to disable image-related things if there aren't any + menu.setGroupVisible(R.id.post_more_group_picture, false) + } + show() + } + } + } + + + fun activateDoubleTapLiker( + holder: StatusViewHolder, + api: PixelfedAPI, + credential: String + ) { + holder.apply { + var clicked = false + postPic.setOnClickListener { + //Check that the post isn't hidden + if(sensitiveW.visibility == View.GONE) { + //Check for double click + if(clicked) { + if (holder.liker.isChecked) { + // Button is active, unlike + holder.liker.isChecked = false + unLikePostCall(holder, api, credential) + } else { + // Button is inactive, like + holder.liker.playAnimation() + holder.liker.isChecked = true + likePostCall(holder, api, credential) + } + } else { + clicked = true + + //Reset clicked to false after 500ms + postPic.handler.postDelayed(fun() { clicked = false }, 500) + } + } + } + } + } + + fun activateLiker( + holder: StatusViewHolder, + api: PixelfedAPI, + credential: String, + isLiked: Boolean + ) { + + holder.liker.apply { + //Set initial state + isChecked = isLiked + + //Activate the liker + setEventListener { _, buttonState -> + if (buttonState) { + // Button is active, unlike + unLikePostCall(holder, api, credential) + } else { + // Button is inactive, like + likePostCall(holder, api, credential) + } + //show animation or not? + true + } + } + } + + fun likePostCall( + holder : StatusViewHolder, + api: PixelfedAPI, + credential: String, + ) { + //Call the api function + status?.id?.let { + api.likePost(credential, it).enqueue(object : Callback { + override fun onFailure(call: Call, t: Throwable) { + Log.e("LIKE ERROR", t.toString()) + holder.liker.isChecked = false + } + + override fun onResponse(call: Call, response: Response) { + if(response.code() == 200) { + val resp = response.body()!! + + //Update shown like count and internal like toggle + holder.nlikes.text = resp.getNLikes(holder.view.context) + holder.liker.isChecked = resp.favourited ?: false + } else { + Log.e("RESPONSE_CODE", response.code().toString()) + holder.liker.isChecked = false + } + } + + }) + } + } + + fun unLikePostCall( + holder : StatusViewHolder, + api: PixelfedAPI, + credential: String, + ) { + //Call the api function + status?.id?.let { + api.unlikePost(credential, it).enqueue(object : Callback { + override fun onFailure(call: Call, t: Throwable) { + Log.e("UNLIKE ERROR", t.toString()) + holder.liker.isChecked = true + } + + override fun onResponse(call: Call, response: Response) { + if(response.code() == 200) { + val resp = response.body()!! + + //Update shown like count and internal like toggle + holder.nlikes.text = resp.getNLikes(holder.view.context) + holder.liker.isChecked = resp.favourited ?: false + } else { + Log.e("RESPONSE_CODE", response.code().toString()) + holder.liker.isChecked = true + } + + } + + }) + } + } + + fun showComments( + holder: StatusViewHolder, + api: PixelfedAPI, + credential: String + ) { + //Show all comments of a post + if (status?.replies_count == 0) { + holder.viewComment.text = holder.view.context.getString(R.string.NoCommentsToShow) + } else { + holder.viewComment.apply { + text = "${status?.replies_count} ${holder.view.context.getString(R.string.CommentDisplay)}" + setOnClickListener { + visibility = View.GONE + + //Retrieve the comments + retrieveComments(holder, api, credential) + } + } + } + } + + fun activateCommenter( + holder: StatusViewHolder, + api: PixelfedAPI, + credential: String + ) { + //Toggle comment button + toggleCommentInput(holder) + + //Activate commenterpostPicture + holder.submitCmnt.setOnClickListener { + val textIn = holder.comment.text + //Open text input + if(textIn.isNullOrEmpty()) { + Toast.makeText( + holder.view.context, + holder.view.context.getString(R.string.empty_comment), + Toast.LENGTH_SHORT + ).show() + } else { + + //Post the comment + postComment(holder, api, credential) + } + } + } + + fun toggleCommentInput( + holder : StatusViewHolder + ) { + //Toggle comment button + holder.commenter.setOnClickListener { + when(holder.commentIn.visibility) { + View.VISIBLE -> { + holder.commentIn.visibility = View.GONE + ImageConverter.setImageFromDrawable( + holder.view, + holder.commenter, + R.drawable.ic_comment_empty + ) + } + View.GONE -> { + holder.commentIn.visibility = View.VISIBLE + ImageConverter.setImageFromDrawable( + holder.view, + holder.commenter, + R.drawable.ic_comment_blue + ) + } + } + } + } + + fun addComment(context: android.content.Context, commentContainer: LinearLayout, commentUsername: String, commentContent: String) { + + val view = LayoutInflater.from(context) + .inflate(R.layout.comment, commentContainer, true) + + view.user.text = commentUsername + view.commentText.text = commentContent + } + + fun retrieveComments( + holder : StatusViewHolder, + api: PixelfedAPI, + credential: String, + ) { + status?.id?.let { + api.statusComments(it, credential).enqueue(object : + Callback { + override fun onFailure(call: Call, t: Throwable) { + Log.e("COMMENT FETCH ERROR", t.toString()) + } + + override fun onResponse( + call: Call, + response: Response + ) { + if(response.code() == 200) { + val statuses = response.body()!!.descendants + + holder.commentCont.removeAllViews() + + //Create the new views for each comment + for (status in statuses) { + addComment(holder.view.context, holder.commentCont, status.account!!.username!!, + status.content!! + ) + } + holder.commentCont.visibility = View.VISIBLE + } else { + Log.e("COMMENT ERROR", "${response.code()} with body ${response.errorBody()}") + } + } + }) + } + } + + fun postComment( + holder : StatusViewHolder, + api: PixelfedAPI, + credential: String, + ) { + val textIn = holder.comment.text + val nonNullText = textIn.toString() + status?.id?.let { + api.postStatus(credential, nonNullText, it).enqueue(object : + Callback { + override fun onFailure(call: Call, t: Throwable) { + Log.e("COMMENT ERROR", t.toString()) + Toast.makeText( + holder.view.context, holder.view.context.getString(R.string.comment_error), + Toast.LENGTH_SHORT + ).show() + } + + override fun onResponse(call: Call, response: Response) { + //Check that the received response code is valid + if (response.code() == 200) { + val resp = response.body()!! + holder.commentIn.visibility = View.GONE + + //Add the comment to the comment section + addComment( + holder.view.context, holder.commentCont, resp.account!!.username!!, + resp.content!! + ) + + Toast.makeText( + holder.view.context, + holder.view.context.getString(R.string.comment_posted).format(textIn), + Toast.LENGTH_SHORT + ).show() + Log.e("COMMENT SUCCESS", "posted: $textIn") + } else { + Log.e("ERROR_CODE", response.code().toString()) + } + } + }) + } + } + + + companion object { + fun create(parent: ViewGroup): StatusViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.post_fragment, parent, false) + return StatusViewHolder(view) + } + } +} + +class AlbumViewPagerAdapter(private val media_attachments: List) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.album_image_view, parent, false)) + + override fun getItemCount() = media_attachments.size + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + Glide.with(holder.view) + .asDrawable().fitCenter().placeholder(ColorDrawable(Color.GRAY)) + .load(media_attachments[position].url).into(holder.image) + + val description = media_attachments[position].description + .orEmpty().ifEmpty{ holder.view.context.getString(R.string.no_description)} + + holder.image.setOnLongClickListener { + Snackbar.make(it, description, Snackbar.LENGTH_SHORT).show() + true + } + + holder.image.contentDescription = description + } + + class ViewHolder(val view: View) : RecyclerView.ViewHolder(view){ + val image: ImageView = view.findViewById(R.id.imageImageView) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/AccountListFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/AccountListFragment.kt deleted file mode 100644 index 6d84f7ca..00000000 --- a/app/src/main/java/com/h/pixeldroid/fragments/feeds/AccountListFragment.kt +++ /dev/null @@ -1,209 +0,0 @@ -package com.h.pixeldroid.fragments.feeds - -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.lifecycle.LiveData -import androidx.recyclerview.widget.RecyclerView -import androidx.lifecycle.Observer -import androidx.paging.LivePagedListBuilder -import androidx.paging.PagedList -import com.bumptech.glide.Glide -import com.bumptech.glide.ListPreloader -import com.bumptech.glide.RequestBuilder -import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader -import com.bumptech.glide.request.RequestOptions -import com.bumptech.glide.util.ViewPreloadSizeProvider -import com.h.pixeldroid.R -import com.h.pixeldroid.objects.Account -import com.h.pixeldroid.objects.Account.Companion.ACCOUNT_ID_TAG -import com.h.pixeldroid.objects.Account.Companion.FOLLOWERS_TAG -import kotlinx.android.synthetic.main.account_list_entry.view.* -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response - -open class AccountListFragment : FeedFragment() { - lateinit var profilePicRequest: RequestBuilder - protected lateinit var adapter : FeedsRecyclerViewAdapter - lateinit var factory: FeedDataSourceFactory - lateinit var content: LiveData> - - private var currentPage = 1 - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = super.onCreateView(inflater, container, savedInstanceState) - - //RequestBuilder that is re-used for every image - profilePicRequest = Glide.with(this) - .asDrawable().dontAnimate().apply(RequestOptions().circleCrop()) - .placeholder(R.drawable.ic_default_user) - - adapter = AccountsRecyclerViewAdapter() - list.adapter = adapter - - //Make Glide be aware of the recyclerview and pre-load images - val sizeProvider: ListPreloader.PreloadSizeProvider = ViewPreloadSizeProvider() - val preloader: RecyclerViewPreloader = RecyclerViewPreloader( - Glide.with(this), adapter as AccountListFragment.AccountsRecyclerViewAdapter, sizeProvider, 4 - ) - list.addOnScrollListener(preloader) - - return view - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - content = makeContent() - - content.observe(viewLifecycleOwner, - Observer { c -> - adapter.submitList(c) - //after a refresh is done we need to stop the pull to refresh spinner - swipeRefreshLayout.isRefreshing = false - }) - - swipeRefreshLayout.setOnRefreshListener { - showError(show = false) - - currentPage = 1 - //by invalidating data, loadInitial will be called again - factory.liveData.value!!.invalidate() - } - } - - internal open fun makeContent(): LiveData> { - val id = arguments?.getSerializable(ACCOUNT_ID_TAG) as String - val following = arguments?.getSerializable(FOLLOWERS_TAG) as Boolean - - val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build() - val dataSource = AccountListDataSource(following, id) - factory = FeedDataSourceFactory(dataSource) - return LivePagedListBuilder(factory, config).build() - } - - inner class AccountListDataSource(private val following: Boolean, private val id: String) : - FeedDataSource() { - - override fun newSource(): AccountListDataSource { - return AccountListDataSource(following, id) - } - - //We use the id as the key - override fun getKey(item: Account): String { - return currentPage.toString() - } - - override fun makeInitialCall(requestedLoadSize: Int): Call> { - return if (following) { - pixelfedAPI.followers( - id, "Bearer $accessToken", - limit = requestedLoadSize - ) - } else { - pixelfedAPI.following( - id, "Bearer $accessToken", - limit = requestedLoadSize - ) - } - } - - override fun makeAfterCall(requestedLoadSize: Int, key: String): Call> { - // Pixelfed and Mastodon don't implement this in the same fashion. Pixelfed uses - // Laravel's paging mechanism, while Mastodon uses the Link header for pagination. - // No need to know which is which, they should ignore the non-relevant argument - return if (following) { - pixelfedAPI.followers( - id, "Bearer $accessToken", - limit = requestedLoadSize, page = key, max_id = key - ) - } else { - pixelfedAPI.following( - id, "Bearer $accessToken", - limit = requestedLoadSize, page = key, max_id = key - ) - } - } - - override fun enqueueCall(call: Call>, callback: LoadCallback){ - - call.enqueue(object : Callback> { - override fun onResponse(call: Call>, response: Response>) { - if (response.isSuccessful && response.body() != null) { - val data = response.body()!! - if(response.headers()["Link"] != null){ - //Header is of the form: - // Link: ; rel="next", ; rel="prev" - // So we want the first max_id value. In case there are arguments after - // the max_id in the URL, we make sure to stop at the first '?' - currentPage = response.headers()["Link"] - .orEmpty() - .substringAfter("max_id=") - .substringBefore('?') - .substringBefore('>') - .toIntOrNull() ?: 0 - } else { - currentPage++ - } - callback.onResult(data) - } else{ - showError() - } - swipeRefreshLayout.isRefreshing = false - loadingIndicator.visibility = View.GONE - } - - override fun onFailure(call: Call>, t: Throwable) { - showError(errorText = R.string.feed_failed) - Log.e("AccountListFragment", t.toString()) - } - }) - } - } - - inner class AccountsRecyclerViewAdapter : FeedsRecyclerViewAdapter(), - ListPreloader.PreloadModelProvider { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.account_list_entry, parent, false) - context = view.context - return ViewHolder(view) - } - - override fun onBindViewHolder(holder : ViewHolder, position : Int) { - val account = getItem(position) ?: return - profilePicRequest.load(account.avatar).into(holder.avatar) - - holder.username.text = account.username - @SuppressLint("SetTextI18n") - holder.acct.text = "@${account.acct}" - - holder.mView.setOnClickListener { account.openProfile(context) } - } - - inner class ViewHolder(val mView : View) : RecyclerView.ViewHolder(mView) { - val avatar : ImageView = mView.account_entry_avatar - val username : TextView = mView.account_entry_username - val acct: TextView = mView.account_entry_acct - } - - override fun getPreloadItems(position : Int) : MutableList { - val account = getItem(position) ?: return mutableListOf() - return mutableListOf(account) - } - - override fun getPreloadRequestBuilder(item : Account) : RequestBuilder<*>? { - return profilePicRequest.load(item.avatar_static) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/CommonFeedFragmentUtils.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/CommonFeedFragmentUtils.kt new file mode 100644 index 00000000..0494fa8b --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/CommonFeedFragmentUtils.kt @@ -0,0 +1,123 @@ +package com.h.pixeldroid.fragments.feeds + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ProgressBar +import androidx.annotation.StringRes +import androidx.constraintlayout.motion.widget.MotionLayout +import androidx.core.view.isVisible +import androidx.core.view.size +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.RecyclerView +import com.h.pixeldroid.R +import com.h.pixeldroid.databinding.FragmentFeedBinding +import com.h.pixeldroid.databinding.LoadStateFooterViewItemBinding + +/** + * Shows or hides the error in the different FeedFragments + */ +private fun showError(errorText: String, show: Boolean = true, binding: FragmentFeedBinding){ + if(show){ + binding.motionLayout.transitionToEnd() + binding.errorLayout.errorText.text = errorText + } else if(binding.motionLayout.progress == 1F){ + binding.motionLayout.transitionToStart() + } +} + +/** + * Initialises the [RecyclerView] adapter for the different FeedFragments. + * + * Makes the UI respond to various [LoadState]s, including errors when an error message is shown. + */ +internal fun initAdapter(binding: FragmentFeedBinding, adapter: PagingDataAdapter) { + binding.list.adapter = adapter.withLoadStateFooter( + footer = ReposLoadStateAdapter { adapter.retry() } + ) + + adapter.addLoadStateListener { loadState -> + + if(!binding.progressBar.isVisible && binding.swipeRefreshLayout.isRefreshing) { + // Stop loading spinner when loading is done + binding.swipeRefreshLayout.isRefreshing = loadState.refresh is LoadState.Loading + } else { + // ProgressBar should stop showing as soon as the source stops loading ("source" + // meaning the database, so don't wait on the network) + val sourceLoading = loadState.source.refresh is LoadState.Loading + if(!sourceLoading && binding.list.size > 0){ + binding.list.isVisible = true + binding.progressBar.isVisible = false + } else if(binding.list.size == 0 + && loadState.append is LoadState.NotLoading + && loadState.append.endOfPaginationReached){ + binding.progressBar.isVisible = false + showError(binding = binding, errorText = "Nothing to see here :(") + } + } + + + // Toast on any error, regardless of whether it came from RemoteMediator or PagingSource + val errorState = loadState.source.append as? LoadState.Error + ?: loadState.source.prepend as? LoadState.Error + ?: loadState.source.refresh as? LoadState.Error + ?: loadState.append as? LoadState.Error + ?: loadState.prepend as? LoadState.Error + ?: loadState.refresh as? LoadState.Error + errorState?.let { + showError(binding = binding, errorText = it.error.toString()) + } + if (errorState == null) showError(binding = binding, show = false, errorText = "") + } + +} + +/** + * Adapter to the show the a [RecyclerView] item for a [LoadState], with a callback to retry if + * the retry button is pressed. + */ +class ReposLoadStateAdapter( + private val retry: () -> Unit +) : LoadStateAdapter() { + override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) { + holder.bind(loadState) + } + + override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder { + return ReposLoadStateViewHolder.create(parent, retry) + } +} + +/** + * [RecyclerView.ViewHolder] that is shown at the end of the feed to indicate loading or errors + * in the loading of appending values. + */ +class ReposLoadStateViewHolder( + private val binding: LoadStateFooterViewItemBinding, + retry: () -> Unit +) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.retryButton.setOnClickListener { retry.invoke() } + } + + fun bind(loadState: LoadState) { + if (loadState is LoadState.Error) { + binding.errorMsg.text = loadState.error.localizedMessage + } + binding.progressBar.isVisible = loadState is LoadState.Loading + binding.retryButton.isVisible = loadState !is LoadState.Loading + binding.errorMsg.isVisible = loadState !is LoadState.Loading + } + + companion object { + fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.load_state_footer_view_item, parent, false) + val binding = LoadStateFooterViewItemBinding.bind(view) + return ReposLoadStateViewHolder(binding, retry) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/FeedFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/FeedFragment.kt deleted file mode 100644 index d2d13657..00000000 --- a/app/src/main/java/com/h/pixeldroid/fragments/feeds/FeedFragment.kt +++ /dev/null @@ -1,165 +0,0 @@ - package com.h.pixeldroid.fragments.feeds - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import android.widget.ProgressBar -import android.widget.TextView -import androidx.annotation.StringRes -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.fragment.app.Fragment -import androidx.lifecycle.MutableLiveData -import androidx.paging.DataSource -import androidx.paging.ItemKeyedDataSource -import androidx.paging.PagedListAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.h.pixeldroid.Pixeldroid -import com.h.pixeldroid.R -import com.h.pixeldroid.api.PixelfedAPI -import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.UserDatabaseEntity -import com.h.pixeldroid.di.PixelfedAPIHolder -import com.h.pixeldroid.objects.FeedContent -import kotlinx.android.synthetic.main.fragment_feed.* -import kotlinx.android.synthetic.main.fragment_feed.view.* -import org.w3c.dom.Text -import retrofit2.Call -import javax.inject.Inject - -open class FeedFragment: Fragment() { - - protected var accessToken: String? = null - @Inject - lateinit var apiHolder: PixelfedAPIHolder - - protected lateinit var pixelfedAPI: PixelfedAPI - - protected lateinit var list : RecyclerView - protected lateinit var swipeRefreshLayout: SwipeRefreshLayout - internal lateinit var loadingIndicator: ProgressBar - var user: UserDatabaseEntity? = null - @Inject - lateinit var db: AppDatabase - - - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_feed, container, false) - - (requireActivity().application as Pixeldroid).getAppComponent().inject(this) - - //Initialize lateinit fields that are needed as soon as the view is created - swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout) - loadingIndicator = view.findViewById(R.id.progressBar) - list = swipeRefreshLayout.list - list.layoutManager = LinearLayoutManager(context) - user = db.userDao().getActiveUser() - - pixelfedAPI = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db) - accessToken = user?.accessToken.orEmpty() - - return view - } - - fun showError(@StringRes errorText: Int = R.string.loading_toast, show: Boolean = true){ - val errorLayout = view?.findViewById(R.id.errorLayout) - val progressBar = view?.findViewById(R.id.progressBar) - - if(show){ - view?.findViewById(R.id.error_text)?.setText(errorText) - errorLayout?.visibility = VISIBLE - progressBar?.visibility = GONE - } else { - errorLayout?.visibility = GONE - progressBar?.visibility = VISIBLE - } - } - - open inner class FeedDataSourceFactory( - private val dataSource: FeedDataSource - ): DataSource.Factory() { - internal lateinit var liveData: MutableLiveData> - - override fun create(): DataSource { - val dataSource = dataSource.newSource() - liveData = MutableLiveData() - liveData.postValue(dataSource) - return dataSource - } - } - abstract inner class FeedDataSource: ItemKeyedDataSource(){ - - /** - * Used in the initial call to initialize the list [loadInitial]. - * @param requestedLoadSize number of objects requested in a call - * @return [Call] that gets the list of [APIObject] - */ - abstract fun makeInitialCall(requestedLoadSize: Int): Call> - - /** - * Used in the subsequent calls to get more objects. - * @param requestedLoadSize number of objects requested in a call - * @param key of the last object we already have - * @return [Call] that gets the list of [APIObject] - */ - abstract fun makeAfterCall(requestedLoadSize: Int, key: ObjectId): Call> - - /** - * This is called to initialize the list, so we want some of the most recent objects. - * @param params holds the requestedLoadSize - * @param callback to call after network request completes - */ - override fun loadInitial( - params: LoadInitialParams, - callback: LoadInitialCallback - ) { - enqueueCall(makeInitialCall(params.requestedLoadSize), callback) - } - - /** - * This is called to when we get to the bottom of the loaded content, so we want objects - * older than the given key (params.key). - * @param params holds the requestedLoadSize - * @param callback to call after network request completes - */ - override fun loadAfter(params: LoadParams, callback: LoadCallback) { - enqueueCall(makeAfterCall(params.requestedLoadSize, params.key), callback) - } - - /** - * Do nothing here, it is expected to pull to refresh to load newer items - */ - override fun loadBefore(params: LoadParams, callback: LoadCallback) {} - - abstract fun enqueueCall(call: Call>, callback: LoadCallback) - - abstract fun newSource(): FeedDataSource - - } -} - -abstract class FeedsRecyclerViewAdapter: PagedListAdapter( - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: T, newItem: T): Boolean { - return oldItem.id === newItem.id - } - - override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { - return oldItem.equals(newItem) - } - } -){ - protected lateinit var context: Context -} - diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/NotificationsFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/NotificationsFragment.kt deleted file mode 100644 index 18544008..00000000 --- a/app/src/main/java/com/h/pixeldroid/fragments/feeds/NotificationsFragment.kt +++ /dev/null @@ -1,269 +0,0 @@ -package com.h.pixeldroid.fragments.feeds - -import android.content.Context -import android.content.Intent -import android.graphics.drawable.Drawable -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import androidx.paging.LivePagedListBuilder -import androidx.paging.PagedList -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.bumptech.glide.ListPreloader -import com.bumptech.glide.RequestBuilder -import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader -import com.bumptech.glide.request.RequestOptions -import com.bumptech.glide.util.ViewPreloadSizeProvider -import com.h.pixeldroid.PostActivity -import com.h.pixeldroid.ProfileActivity -import com.h.pixeldroid.R -import com.h.pixeldroid.objects.Account -import com.h.pixeldroid.objects.Notification -import com.h.pixeldroid.objects.Status -import com.h.pixeldroid.utils.HtmlUtils.Companion.parseHTMLText -import com.h.pixeldroid.utils.Utils.Companion.setTextViewFromISO8601 -import kotlinx.android.synthetic.main.fragment_notifications.view.* -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response - - -/** - * A fragment representing a list of Items. - */ -class NotificationsFragment : FeedFragment() { - - lateinit var profilePicRequest: RequestBuilder - protected lateinit var adapter : FeedsRecyclerViewAdapter - lateinit var factory: FeedDataSourceFactory - - - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - - val view = super.onCreateView(inflater, container, savedInstanceState) - - //RequestBuilder that is re-used for every image - profilePicRequest = Glide.with(this) - .asDrawable().apply(RequestOptions().circleCrop()) - .placeholder(R.drawable.ic_default_user) - - - adapter = NotificationsRecyclerViewAdapter() - list.adapter = adapter - - - //Make Glide be aware of the recyclerview and pre-load images - val sizeProvider: ListPreloader.PreloadSizeProvider = ViewPreloadSizeProvider() - val preloader: RecyclerViewPreloader = RecyclerViewPreloader( - Glide.with(this), adapter as NotificationsFragment.NotificationsRecyclerViewAdapter, sizeProvider, 4 - ) - list.addOnScrollListener(preloader) - - return view - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val content = makeContent() - - content.observe(viewLifecycleOwner, - Observer { c -> - adapter.submitList(c) - //after a refresh is done we need to stop the pull to refresh spinner - swipeRefreshLayout.isRefreshing = false - }) - - swipeRefreshLayout.setOnRefreshListener { - showError(show = false) - - //by invalidating data, loadInitial will be called again - factory.liveData.value!!.invalidate() - } - } - - private fun makeContent(): LiveData> { - val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build() - val dataSource = NotificationListDataSource() - factory = FeedDataSourceFactory(dataSource) - return LivePagedListBuilder(factory, config).build() - } - - inner class NotificationListDataSource: FeedDataSource() { - - override fun newSource(): NotificationListDataSource { - return NotificationListDataSource() - } - - //We use the id as the key - override fun getKey(item: Notification): String { - return item.id - } - - override fun makeInitialCall(requestedLoadSize: Int): Call> { - return pixelfedAPI - .notifications("Bearer $accessToken", limit="$requestedLoadSize") - } - override fun makeAfterCall(requestedLoadSize: Int, key: String): Call> { - return pixelfedAPI - .notifications("Bearer $accessToken", max_id=key, limit="$requestedLoadSize") - } - - override fun enqueueCall(call: Call>, callback: LoadCallback){ - - call.enqueue(object : Callback> { - override fun onResponse(call: Call>, response: Response>) { - if (response.isSuccessful && response.body() != null) { - val data = response.body()!! - callback.onResult(data) - } else { - showError() - } - swipeRefreshLayout.isRefreshing = false - loadingIndicator.visibility = View.GONE - } - - override fun onFailure(call: Call>, t: Throwable) { - showError(errorText = R.string.feed_failed) - Log.e("NotificationsFragment", t.toString()) - } - }) - } - } - - /** - * [RecyclerView.Adapter] that can display a [Notification] - */ - inner class NotificationsRecyclerViewAdapter: FeedsRecyclerViewAdapter(), - ListPreloader.PreloadModelProvider { - - private val mOnClickListener: View.OnClickListener - - init { - mOnClickListener = View.OnClickListener { v -> - val notification = v.tag as Notification - openActivity(notification) - } - } - - private fun openPostFromNotification(notification: Notification) : Intent { - val intent = Intent(context, PostActivity::class.java) - intent.putExtra(Status.POST_TAG, notification.status) - return intent - } - - private fun openActivity(notification: Notification){ - val intent: Intent - when (notification.type){ - Notification.NotificationType.mention, Notification.NotificationType.favourite, - Notification.NotificationType.poll, Notification.NotificationType.reblog -> { - intent = openPostFromNotification(notification) - } - Notification.NotificationType.follow -> { - intent = Intent(context, ProfileActivity::class.java) - intent.putExtra(Account.ACCOUNT_TAG, notification.account) - } - } - context.startActivity(intent) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.fragment_notifications, parent, false) - context = view.context - return ViewHolder(view) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val notification = getItem(position) ?: return - profilePicRequest.load(notification.account.avatar_static).into(holder.avatar) - - val previewUrl = notification.status?.media_attachments?.getOrNull(0)?.preview_url - if(!previewUrl.isNullOrBlank()){ - Glide.with(holder.mView).load(previewUrl) - .placeholder(R.drawable.ic_picture_fallback).into(holder.photoThumbnail) - } else{ - holder.photoThumbnail.visibility = View.GONE - } - - setNotificationType(notification.type, notification.account.username!!, holder.notificationType) - setTextViewFromISO8601(notification.created_at, holder.notificationTime, false, context) - - //Convert HTML to clickable text - holder.postDescription.text = - parseHTMLText( - notification.status?.content ?: "", - notification.status?.mentions, - pixelfedAPI, - context, - "Bearer $accessToken" - ) - - - with(holder.mView) { - tag = notification - setOnClickListener(mOnClickListener) - } - } - - private fun setNotificationType(type: Notification.NotificationType, username: String, - textView: TextView - ){ - val context = textView.context - val (format: String, drawable: Drawable?) = when(type) { - Notification.NotificationType.follow -> { - setNotificationTypeTextView(context, R.string.followed_notification, R.drawable.ic_follow) - } - Notification.NotificationType.mention -> { - setNotificationTypeTextView(context, R.string.mention_notification, R.drawable.ic_apenstaart) - } - - Notification.NotificationType.reblog -> { - setNotificationTypeTextView(context, R.string.shared_notification, R.drawable.ic_reblog_blue) - } - - Notification.NotificationType.favourite -> { - setNotificationTypeTextView(context, R.string.liked_notification, R.drawable.ic_like_full) - } - Notification.NotificationType.poll -> { - setNotificationTypeTextView(context, R.string.poll_notification, R.drawable.poll) - } - } - textView.text = format.format(username) - textView.setCompoundDrawablesWithIntrinsicBounds( - drawable,null,null,null - ) - } - private fun setNotificationTypeTextView(context: Context, format: Int, drawable: Int): Pair { - return Pair(context.getString(format), context.getDrawable(drawable)) - } - - - inner class ViewHolder(val mView: View) : RecyclerView.ViewHolder(mView) { - val notificationType: TextView = mView.notification_type - val notificationTime: TextView = mView.notification_time - val postDescription: TextView = mView.notification_post_description - val avatar: ImageView = mView.notification_avatar - val photoThumbnail: ImageView = mView.notification_photo_thumbnail - } - - override fun getPreloadItems(position: Int): MutableList { - val notification = getItem(position) ?: return mutableListOf() - return mutableListOf(notification) - } - - override fun getPreloadRequestBuilder(item: Notification): RequestBuilder<*>? { - return profilePicRequest.load(item.account.avatar_static) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/OfflineFeedFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/OfflineFeedFragment.kt deleted file mode 100644 index 0e009ba3..00000000 --- a/app/src/main/java/com/h/pixeldroid/fragments/feeds/OfflineFeedFragment.kt +++ /dev/null @@ -1,211 +0,0 @@ -package com.h.pixeldroid.fragments.feeds - -import android.content.Intent -import android.graphics.Color -import android.graphics.Typeface -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.os.Bundle -import androidx.fragment.app.Fragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.EditText -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestBuilder -import com.google.android.material.tabs.TabLayoutMediator -import com.h.pixeldroid.MainActivity -import com.h.pixeldroid.Pixeldroid -import com.h.pixeldroid.R -import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.PostDatabaseEntity -import com.h.pixeldroid.fragments.ImageFragment -import com.h.pixeldroid.utils.* -import com.h.pixeldroid.utils.Utils.Companion.setTextViewFromISO8601 -import kotlinx.android.synthetic.main.fragment_offline_feed.view.* -import kotlinx.android.synthetic.main.post_fragment.view.* -import javax.inject.Inject - - -class OfflineFeedFragment: Fragment() { - - private lateinit var recyclerView: RecyclerView - private lateinit var viewAdapter: RecyclerView.Adapter<*> - private lateinit var viewManager: RecyclerView.LayoutManager - lateinit var picRequest: RequestBuilder - @Inject - lateinit var db: AppDatabase - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - } - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - // Inflate the layout for this fragment - val view = inflater.inflate(R.layout.fragment_offline_feed, container, false) - val loadingAnimation = view.offline_feed_progress_bar - loadingAnimation.visibility = View.VISIBLE - picRequest = Glide.with(this) - .asDrawable().fitCenter() - .placeholder(ColorDrawable(Color.GRAY)) - - (requireActivity().application as Pixeldroid).getAppComponent().inject(this) - - val user = db.userDao().getActiveUser()!! - if (db.postDao().numberOfPosts(user.user_id, user.instance_uri) > 0) { - val posts = db.postDao().getAll(user.user_id, user.instance_uri) - viewManager = LinearLayoutManager(requireContext()) - viewAdapter = OfflinePostFeedAdapter(posts) - loadingAnimation.visibility = View.GONE - recyclerView = view.offline_feed_recyclerview.apply { - visibility = View.VISIBLE - // use this setting to improve performance if you know that changes - // in content do not change the layout size of the RecyclerView - setHasFixedSize(true) - // use a linear layout manager - layoutManager = viewManager - // specify an viewAdapter (see also next example) - adapter = viewAdapter - } - } else { - loadingAnimation.visibility = View.GONE - view.offline_feed_placeholder_text.visibility = View.VISIBLE - } - view.offline_feed_progress_bar.visibility = View.GONE - return view - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.swipeRefreshLayout.setOnRefreshListener { - if (Utils.hasInternet(requireContext())) { - onStop() - startActivity(Intent(requireContext(), MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - }) - } - view.swipeRefreshLayout.isRefreshing = false - } - } - - inner class OfflinePostFeedAdapter(private val posts: List) - : RecyclerView.Adapter() { - - inner class OfflinePostViewHolder(private val postView: View) - : RecyclerView.ViewHolder(postView) { - val profilePic : ImageView = postView.findViewById(R.id.profilePic) - val postPic : ImageView = postView.findViewById(R.id.postPicture) - val username : TextView = postView.findViewById(R.id.username) - val description : TextView = postView.findViewById(R.id.description) - val comment : EditText = postView.findViewById(R.id.editComment) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OfflinePostViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.post_fragment, parent, false) - .apply { - commenter.visibility = View.GONE - postDomain.visibility = View.GONE - commentIn.visibility = View.GONE - liker.apply { - //de-activate the liker - setEventListener { _, _ -> - false - } - } - reblogger.apply { - //de-activate the reblogger - setEventListener { _, _ -> - false - } - } - } - return OfflinePostViewHolder(view) - } - - override fun onBindViewHolder(holder: OfflinePostViewHolder, position: Int) { - val post = posts[position] - val metrics = requireContext().resources.displayMetrics - //Limit the height of the different images - holder.profilePic.maxHeight = metrics.heightPixels - holder.postPic.maxHeight = metrics.heightPixels - //Setup username as a button that opens the profile - holder.itemView.username.apply { - text = post.account_name - setTypeface(null, Typeface.BOLD) - } - //Convert the date to a readable string - setTextViewFromISO8601(post.date, holder.itemView.postDate, false, requireContext()) - - //Setup images - ImageConverter.setRoundImageFromURL( - holder.itemView, - post.account_profile_picture, - holder.profilePic - ) - - //Setup post pic only if there are media attachments - if(!post.media_urls.isNullOrEmpty()) { - // Standard layout - holder.postPic.visibility = View.VISIBLE - holder.itemView.postPager.visibility = View.GONE - holder.itemView.postTabs.visibility = View.GONE - holder.itemView.sensitiveWarning.visibility = View.GONE - - if(post.media_urls.size == 1) { - picRequest.load(post.media_urls[0]).into(holder.postPic) - } else { - //Only show the viewPager and tabs - holder.postPic.visibility = View.GONE - holder.itemView.postPager.visibility = View.VISIBLE - holder.itemView.postTabs.visibility = View.VISIBLE - val tabs : ArrayList = ArrayList() - //Fill the tabs with each mediaAttachment - for((index, media) in post.media_urls.withIndex()) { - tabs.add(ImageFragment.newInstance(media, "Photo $index")) - } - holder.itemView.postPager.adapter = object : FragmentStateAdapter(this@OfflineFeedFragment) { - override fun createFragment(position: Int): Fragment { - return tabs[position] - } - - override fun getItemCount(): Int { - return post.media_urls.size - } - } - TabLayoutMediator(holder.itemView.postTabs, holder.itemView.postPager) { tab, _ -> - tab.icon = holder.itemView.context.getDrawable(R.drawable.ic_dot_blue_12dp) - }.attach() - } - } - - holder.description.apply { - if(post.description.isBlank()) { - visibility = View.GONE - } else { - text = HtmlUtils.fromHtml(post.description) - } - } - - holder.itemView.nlikes.text = post.likes.toString() - holder.itemView.nshares.text = post.shares.toString() - } - - override fun getItemCount(): Int { - return posts.size - } - - } -} - diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/CachedFeedFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/CachedFeedFragment.kt new file mode 100644 index 00000000..13e76fec --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/CachedFeedFragment.kt @@ -0,0 +1,107 @@ +package com.h.pixeldroid.fragments.feeds.cachedFeeds + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.paging.* +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch + +import com.h.pixeldroid.databinding.FragmentFeedBinding +import com.h.pixeldroid.db.AppDatabase +import com.h.pixeldroid.db.dao.feedContent.FeedContentDao +import com.h.pixeldroid.fragments.BaseFragment +import com.h.pixeldroid.fragments.feeds.initAdapter +import com.h.pixeldroid.objects.FeedContentDatabase + + +/** + * A fragment representing a list of [FeedContentDatabase] items that are cached by the database. + */ +open class CachedFeedFragment : BaseFragment() { + + internal lateinit var viewModel: FeedViewModel + internal lateinit var adapter: PagingDataAdapter + + private lateinit var binding: FragmentFeedBinding + + + private var job: Job? = null + + + internal fun launch() { + // Make sure we cancel the previous job before creating a new one + job?.cancel() + job = lifecycleScope.launch { + viewModel.flow().collectLatest { + adapter.submitData(it) + } + } + } + + internal fun initSearch() { + // Scroll to top when the list is refreshed from network. + lifecycleScope.launch { + adapter.loadStateFlow + // Only emit when REFRESH LoadState for RemoteMediator changes. + .distinctUntilChangedBy { it.refresh } + // Only react to cases where Remote REFRESH completes i.e., NotLoading. + .filter { it.refresh is LoadState.NotLoading } + .collect { binding.list.scrollToPosition(0) } + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + super.onCreateView(inflater, container, savedInstanceState) + + binding = FragmentFeedBinding.inflate(layoutInflater) + + initAdapter(binding, adapter) + + //binding.progressBar.visibility = View.GONE + binding.swipeRefreshLayout.setOnRefreshListener { + //It shouldn't be necessary to also retry() in addition to refresh(), + //but if we don't do this, reloads after an error fail immediately... + // https://issuetracker.google.com/issues/173438474 + adapter.retry() + adapter.refresh() + } + + return binding.root + } +} + + +/** + * Factory that creates ViewModel from a [FeedContentRepository], to be used in cached feeds to + * fetch the ViewModel that is responsible for preparing and managing the data for + * an Activity or a Fragment + */ +class ViewModelFactory @ExperimentalPagingApi constructor(private val db: AppDatabase?, + private val dao: FeedContentDao?, + private val remoteMediator: RemoteMediator?, + private val feedContentRepository: FeedContentRepository = FeedContentRepository(db!!, dao!!, remoteMediator!!) +) : ViewModelProvider.Factory { + + @ExperimentalPagingApi + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(FeedViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return FeedViewModel(feedContentRepository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/FeedContentRepository.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/FeedContentRepository.kt new file mode 100644 index 00000000..32acabaa --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/FeedContentRepository.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.h.pixeldroid.fragments.feeds.cachedFeeds + +import androidx.paging.* +import com.h.pixeldroid.db.AppDatabase +import com.h.pixeldroid.db.dao.feedContent.FeedContentDao +import com.h.pixeldroid.objects.FeedContentDatabase +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * Repository class that works with local and remote data sources. + */ +class FeedContentRepository @ExperimentalPagingApi +@Inject constructor( + private val db: AppDatabase, + private val dao: FeedContentDao, + private val mediator: RemoteMediator +) { + + /** + * [FeedContentDatabase], exposed as a stream of data that will emit + * every time we get more data from the network. + */ + fun stream(): Flow> { + + val pagingSourceFactory = { + val user = db.userDao().getActiveUser()!! + dao.feedContent(user.user_id, user.instance_uri) + } + + return Pager( + config = PagingConfig(initialLoadSize = NETWORK_PAGE_SIZE, + pageSize = NETWORK_PAGE_SIZE, + enablePlaceholders = false, + prefetchDistance = 50 + ), + remoteMediator = mediator, + pagingSourceFactory = pagingSourceFactory + ).flow + } + + companion object { + private const val NETWORK_PAGE_SIZE = 20 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/FeedViewModel.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/FeedViewModel.kt new file mode 100644 index 00000000..b8bc9775 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/FeedViewModel.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.h.pixeldroid.fragments.feeds.cachedFeeds + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.* +import com.h.pixeldroid.objects.FeedContentDatabase +import kotlinx.coroutines.flow.Flow + +/** + * ViewModel for the cached feeds. + * The ViewModel works with the [FeedContentRepository] to get the data. + */ +class FeedViewModel(private val repository: FeedContentRepository) : ViewModel() { + + private var currentResult: Flow>? = null + + fun flow(): Flow> { + val lastResult = currentResult + if (lastResult != null) { + return lastResult + } + val newResult: Flow> = repository.stream() + .cachedIn(viewModelScope) + currentResult = newResult + return newResult + } +} diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/notifications/NotificationsFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/notifications/NotificationsFragment.kt new file mode 100644 index 00000000..7dd464dc --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/notifications/NotificationsFragment.kt @@ -0,0 +1,208 @@ +package com.h.pixeldroid.fragments.feeds.cachedFeeds.notifications + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModelProvider +import androidx.paging.ExperimentalPagingApi +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.h.pixeldroid.PostActivity +import com.h.pixeldroid.ProfileActivity +import com.h.pixeldroid.R +import com.h.pixeldroid.api.PixelfedAPI +import com.h.pixeldroid.db.AppDatabase +import com.h.pixeldroid.di.PixelfedAPIHolder +import com.h.pixeldroid.objects.Account +import com.h.pixeldroid.objects.Notification +import com.h.pixeldroid.objects.Status +import com.h.pixeldroid.utils.Utils.Companion.setTextViewFromISO8601 +import kotlinx.android.synthetic.main.fragment_notifications.view.* + +import com.h.pixeldroid.fragments.feeds.cachedFeeds.CachedFeedFragment +import com.h.pixeldroid.fragments.feeds.cachedFeeds.FeedViewModel +import com.h.pixeldroid.fragments.feeds.cachedFeeds.ViewModelFactory +import com.h.pixeldroid.utils.HtmlUtils.Companion.parseHTMLText + + +/** + * Fragment for the notifications tab. + */ +class NotificationsFragment : CachedFeedFragment() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + adapter = NotificationsAdapter(apiHolder, db) + } + + @ExperimentalPagingApi + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = super.onCreateView(inflater, container, savedInstanceState) + + // get the view model + @Suppress("UNCHECKED_CAST") + viewModel = ViewModelProvider(this, ViewModelFactory(db, db.notificationDao(), NotificationsRemoteMediator(apiHolder, db))) + .get(FeedViewModel::class.java) as FeedViewModel + + launch() + initSearch() + + return view + } + +} + +/** + * View Holder for a [Notification] RecyclerView list item. + */ +class NotificationViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val notificationType: TextView = view.notification_type + private val notificationTime: TextView = view.notification_time + private val postDescription: TextView = view.notification_post_description + private val avatar: ImageView = view.notification_avatar + private val photoThumbnail: ImageView = view.notification_photo_thumbnail + + private var notification: Notification? = null + + init { + itemView.setOnClickListener { + notification?.openActivity() + } + } + + private fun Notification.openActivity() { + val intent: Intent + when (type){ + Notification.NotificationType.mention, Notification.NotificationType.favourite, + Notification.NotificationType.poll, Notification.NotificationType.reblog -> { + intent = openPostFromNotification() + } + Notification.NotificationType.follow -> { + intent = Intent(itemView.context, ProfileActivity::class.java) + intent.putExtra(Account.ACCOUNT_TAG, account) + } + } + itemView.context.startActivity(intent) + } + + private fun Notification.openPostFromNotification(): Intent { + val intent = Intent(itemView.context, PostActivity::class.java) + intent.putExtra(Status.POST_TAG, status) + return intent + } + + + private fun setNotificationType(type: Notification.NotificationType, username: String, + textView: TextView + ){ + val context = textView.context + val (format: String, drawable: Drawable?) = when(type) { + Notification.NotificationType.follow -> { + setNotificationTypeTextView(context, R.string.followed_notification, R.drawable.ic_follow) + } + Notification.NotificationType.mention -> { + setNotificationTypeTextView(context, R.string.mention_notification, R.drawable.ic_apenstaart) + } + + Notification.NotificationType.reblog -> { + setNotificationTypeTextView(context, R.string.shared_notification, R.drawable.ic_reblog_blue) + } + + Notification.NotificationType.favourite -> { + setNotificationTypeTextView(context, R.string.liked_notification, R.drawable.ic_like_full) + } + Notification.NotificationType.poll -> { + setNotificationTypeTextView(context, R.string.poll_notification, R.drawable.poll) + } + } + textView.text = format.format(username) + textView.setCompoundDrawablesWithIntrinsicBounds( + drawable,null,null,null + ) + } + private fun setNotificationTypeTextView(context: Context, format: Int, drawable: Int): Pair { + return Pair(context.getString(format), ContextCompat.getDrawable(context, drawable)) + } + + + + fun bind(notification: Notification?, api: PixelfedAPI, accessToken: String) { + + this.notification = notification + + Glide.with(itemView).load(notification?.account?.avatar_static).circleCrop().into(avatar) + + val previewUrl = notification?.status?.media_attachments?.getOrNull(0)?.preview_url + if(!previewUrl.isNullOrBlank()){ + Glide.with(itemView).load(previewUrl) + .placeholder(R.drawable.ic_picture_fallback).into(photoThumbnail) + } else{ + photoThumbnail.visibility = View.GONE + } + + notification?.type?.let { setNotificationType(it, notification.account.username!!, notificationType) } + notification?.created_at?.let { setTextViewFromISO8601(it, notificationTime, false, itemView.context) } + + //Convert HTML to clickable text + postDescription.text = + parseHTMLText( + notification?.status?.content ?: "", + notification?.status?.mentions, + api, + itemView.context, + "Bearer $accessToken" + ) + } + + companion object { + fun create(parent: ViewGroup): NotificationViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.fragment_notifications, parent, false) + return NotificationViewHolder(view) + } + } +} + + +class NotificationsAdapter(private val apiHolder: PixelfedAPIHolder, private val db: AppDatabase) : PagingDataAdapter( + UIMODEL_COMPARATOR +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return NotificationViewHolder.create(parent) + } + + override fun getItemViewType(position: Int): Int { + return R.layout.fragment_notifications + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val uiModel = getItem(position) + uiModel.let { + (holder as NotificationViewHolder).bind(it, apiHolder.setDomainToCurrentUser(db), db.userDao().getActiveUser()!!.accessToken) + } + } + + companion object { + private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean = + oldItem == newItem + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/notifications/NotificationsRemoteMediator.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/notifications/NotificationsRemoteMediator.kt new file mode 100644 index 00000000..324f222a --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/notifications/NotificationsRemoteMediator.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.h.pixeldroid.fragments.feeds.cachedFeeds.notifications + +import androidx.paging.* +import androidx.room.withTransaction +import com.h.pixeldroid.db.AppDatabase +import com.h.pixeldroid.di.PixelfedAPIHolder +import com.h.pixeldroid.objects.Notification +import retrofit2.HttpException +import java.io.IOException +import javax.inject.Inject + +/** + * RemoteMediator for the notifications. + * + * A [RemoteMediator] defines a set of callbacks used to incrementally load data from a remote + * source into a local source wrapped by a [PagingSource], e.g., loading data from network into + * a local db cache. + */ +@OptIn(ExperimentalPagingApi::class) +class NotificationsRemoteMediator @Inject constructor( + private val apiHolder: PixelfedAPIHolder, + private val db: AppDatabase +) : RemoteMediator() { + + override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { + + val (max_id, min_id) = when (loadType) { + LoadType.REFRESH -> { + Pair(null, null) + } + LoadType.PREPEND -> { + //No prepend for the moment, might be nice to add later + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + Pair(state.lastItemOrNull()?.id, null) + } + + } + + try { + val user = db.userDao().getActiveUser()!! + val api = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db) + val accessToken = user.accessToken.orEmpty() + + val apiResponse = api.notifications("Bearer $accessToken", + max_id = max_id, + min_id = min_id, + limit = state.config.pageSize.toString(), + ) + + apiResponse.forEach{it.user_id = user.user_id; it.instance_uri = user.instance_uri} + + val endOfPaginationReached = apiResponse.isEmpty() + + db.withTransaction { + // clear table in the database + if (loadType == LoadType.REFRESH) { + db.notificationDao().clearFeedContent() + } + db.notificationDao().insertAll(apiResponse) + } + return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) + } catch (exception: IOException) { + return MediatorResult.Error(exception) + } catch (exception: HttpException) { + return MediatorResult.Error(exception) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/HomeFeedRemoteMediator.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/HomeFeedRemoteMediator.kt new file mode 100644 index 00000000..6bdfbee6 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/HomeFeedRemoteMediator.kt @@ -0,0 +1,71 @@ +package com.h.pixeldroid.fragments.feeds.cachedFeeds.postFeeds + +import androidx.paging.* +import androidx.room.withTransaction +import com.h.pixeldroid.db.AppDatabase +import com.h.pixeldroid.di.PixelfedAPIHolder +import com.h.pixeldroid.db.entities.HomeStatusDatabaseEntity +import retrofit2.HttpException +import java.io.IOException +import javax.inject.Inject + + +/** + * RemoteMediator for the home feed. + * + * A [RemoteMediator] defines a set of callbacks used to incrementally load data from a remote + * source into a local source wrapped by a [PagingSource], e.g., loading data from network into + * a local db cache. + */ +@OptIn(ExperimentalPagingApi::class) +class HomeFeedRemoteMediator @Inject constructor( + private val apiHolder: PixelfedAPIHolder, + private val db: AppDatabase, +) : RemoteMediator() { + + override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { + + val (max_id, min_id) = when (loadType) { + LoadType.REFRESH -> { + Pair(null, null) + } + LoadType.PREPEND -> { + //No prepend for the moment, might be nice to add later + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + Pair(state.lastItemOrNull()?.id, null) + } + + } + + try { + val user = db.userDao().getActiveUser()!! + val api = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db) + val accessToken = user.accessToken.orEmpty() + + val apiResponse = api.timelineHome( "Bearer $accessToken", + max_id= max_id, min_id = min_id, + limit = state.config.pageSize.toString()) + + val dbObjects = apiResponse.map{ + HomeStatusDatabaseEntity(user.user_id, user.instance_uri, it) + } + + val endOfPaginationReached = apiResponse.isEmpty() + + db.withTransaction { + // clear table in the database + if (loadType == LoadType.REFRESH) { + db.homePostDao().clearFeedContent() + } + db.homePostDao().insertAll(dbObjects) + } + return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) + } catch (exception: IOException) { + return MediatorResult.Error(exception) + } catch (exception: HttpException) { + return MediatorResult.Error(exception) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/PostFeedFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/PostFeedFragment.kt new file mode 100644 index 00000000..521d4dd7 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/PostFeedFragment.kt @@ -0,0 +1,97 @@ +package com.h.pixeldroid.fragments.feeds.cachedFeeds.postFeeds + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.ViewModelProvider +import androidx.paging.ExperimentalPagingApi +import androidx.paging.PagingDataAdapter +import androidx.paging.RemoteMediator +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.h.pixeldroid.R +import com.h.pixeldroid.db.dao.feedContent.FeedContentDao +import com.h.pixeldroid.fragments.StatusViewHolder +import com.h.pixeldroid.fragments.feeds.cachedFeeds.FeedViewModel +import com.h.pixeldroid.fragments.feeds.cachedFeeds.CachedFeedFragment +import com.h.pixeldroid.fragments.feeds.cachedFeeds.ViewModelFactory +import com.h.pixeldroid.objects.FeedContentDatabase +import com.h.pixeldroid.objects.Status + + +/** + * Fragment for the home feed or public feed tabs. + * + * Takes a "home" boolean in its arguments [Bundle] to determine which + */ +@ExperimentalPagingApi +class PostFeedFragment: CachedFeedFragment() { + + private lateinit var mediator: RemoteMediator + private lateinit var dao: FeedContentDao + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + adapter = PostsAdapter() + + @Suppress("UNCHECKED_CAST") + if (requireArguments().get("home") as Boolean){ + mediator = HomeFeedRemoteMediator(apiHolder, db) as RemoteMediator + dao = db.homePostDao() as FeedContentDao + } + else { + mediator = PublicFeedRemoteMediator(apiHolder, db) as RemoteMediator + dao = db.publicPostDao() as FeedContentDao + } + } + + @ExperimentalPagingApi + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + val view = super.onCreateView(inflater, container, savedInstanceState) + + // get the view model + @Suppress("UNCHECKED_CAST") + viewModel = ViewModelProvider(this, ViewModelFactory(db, dao, mediator)) + .get(FeedViewModel::class.java) as FeedViewModel + + launch() + initSearch() + + return view + } + + inner class PostsAdapter : PagingDataAdapter( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: T, newItem: T): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = + oldItem.id == newItem.id + } + ) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return StatusViewHolder.create(parent) + } + + override fun getItemViewType(position: Int): Int { + return R.layout.post_fragment + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val uiModel = getItem(position) as Status + uiModel.let { + val instanceUri = db.userDao().getActiveUser()!!.instance_uri + val accessToken = db.userDao().getActiveUser()!!.accessToken + (holder as StatusViewHolder).bind(it, instanceUri, apiHolder.setDomain(instanceUri), "Bearer $accessToken") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/PublicFeedRemoteMediator.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/PublicFeedRemoteMediator.kt new file mode 100644 index 00000000..0d65a5eb --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/cachedFeeds/postFeeds/PublicFeedRemoteMediator.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.h.pixeldroid.fragments.feeds.cachedFeeds.postFeeds + +import androidx.paging.* +import androidx.room.withTransaction +import com.h.pixeldroid.db.AppDatabase +import com.h.pixeldroid.db.entities.PublicFeedStatusDatabaseEntity +import com.h.pixeldroid.di.PixelfedAPIHolder +import retrofit2.HttpException +import java.io.IOException +import javax.inject.Inject + +/** + * RemoteMediator for the public feed. + * + * A [RemoteMediator] defines a set of callbacks used to incrementally load data from a remote + * source into a local source wrapped by a [PagingSource], e.g., loading data from network into + * a local db cache. + */ +@OptIn(ExperimentalPagingApi::class) +class PublicFeedRemoteMediator @Inject constructor( + private val apiHolder: PixelfedAPIHolder, + private val db: AppDatabase +) : RemoteMediator() { + + override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { + + val (max_id, min_id) = when (loadType) { + LoadType.REFRESH -> { + Pair(null, null) + } + LoadType.PREPEND -> { + //No prepend for the moment, might be nice to add later + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + Pair(state.lastItemOrNull()?.id, null) + } + + } + + try { + val user = db.userDao().getActiveUser()!! + val api = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db) + + val apiResponse = api.timelinePublic( + max_id = max_id, + min_id = min_id, + limit = state.config.pageSize.toString(), + ) + + val dbObjects = apiResponse.map{ + PublicFeedStatusDatabaseEntity(user.user_id, user.instance_uri, it) + } + val endOfPaginationReached = apiResponse.isEmpty() + + db.withTransaction { + // clear table in the database + if (loadType == LoadType.REFRESH) { + db.publicPostDao().clearFeedContent() + } + db.publicPostDao().insertAll(dbObjects) + } + return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) + } catch (exception: IOException) { + return MediatorResult.Error(exception) + } catch (exception: HttpException) { + return MediatorResult.Error(exception) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/HomeTimelineFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/HomeTimelineFragment.kt deleted file mode 100644 index 99152f88..00000000 --- a/app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/HomeTimelineFragment.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.h.pixeldroid.fragments.feeds.postFeeds - -import android.util.Log -import android.view.View -import android.widget.Toast -import androidx.lifecycle.LiveData -import androidx.paging.LivePagedListBuilder -import androidx.paging.PagedList -import com.h.pixeldroid.R -import com.h.pixeldroid.objects.Status -import com.h.pixeldroid.utils.DBUtils -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response - -class HomeTimelineFragment: PostsFeedFragment() { - - override fun makeContent(): LiveData> { - val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build() - val dataSource = PostFeedDataSource() - factory = FeedDataSourceFactory(dataSource) - return LivePagedListBuilder(factory, config).build() - } - - - inner class PostFeedDataSource: FeedDataSource() { - - override fun newSource(): PostFeedDataSource { - return PostFeedDataSource() - } - - override fun makeInitialCall(requestedLoadSize: Int): Call> { - return pixelfedAPI - .timelineHome("Bearer $accessToken", limit="$requestedLoadSize") - } - - override fun makeAfterCall(requestedLoadSize: Int, key: String): Call> { - return pixelfedAPI - .timelineHome("Bearer $accessToken", max_id=key, - limit="$requestedLoadSize") - } - - //We use the id as the key - override fun getKey(item: Status): String { - return item.id!! - } - - override fun enqueueCall(call: Call>, callback: LoadCallback){ - - call.enqueue(object : Callback> { - override fun onResponse(call: Call>, response: Response>) { - if (response.isSuccessful && response.body() != null) { - val notifications = response.body()!! - callback.onResult(notifications) - DBUtils.storePosts(db, notifications, user!!) - } else { - showError() - } - swipeRefreshLayout.isRefreshing = false - loadingIndicator.visibility = View.GONE - } - - override fun onFailure(call: Call>, t: Throwable) { - showError(errorText = R.string.feed_failed) - Log.e("PostsFeedFragment", t.toString()) - } - }) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/PostsFeedFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/PostsFeedFragment.kt deleted file mode 100644 index 087816b1..00000000 --- a/app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/PostsFeedFragment.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.h.pixeldroid.fragments.feeds.postFeeds - -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.* -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import androidx.paging.PagedList -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.widget.ViewPager2 -import at.connyduck.sparkbutton.SparkButton -import com.bumptech.glide.Glide -import com.bumptech.glide.ListPreloader -import com.bumptech.glide.RequestBuilder -import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader -import com.bumptech.glide.util.ViewPreloadSizeProvider -import com.h.pixeldroid.R -import com.h.pixeldroid.fragments.feeds.FeedFragment -import com.h.pixeldroid.fragments.feeds.FeedsRecyclerViewAdapter -import com.h.pixeldroid.objects.Status - -abstract class PostsFeedFragment : FeedFragment() { - - lateinit var picRequest: RequestBuilder - lateinit var domain : String - protected lateinit var adapter : FeedsRecyclerViewAdapter - lateinit var factory: FeedDataSourceFactory - - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = super.onCreateView(inflater, container, savedInstanceState) - - domain = user?.instance_uri.orEmpty() - //RequestBuilder that is re-used for every image - picRequest = Glide.with(this) - .asDrawable().fitCenter() - .placeholder(ColorDrawable(Color.GRAY)) - - adapter = PostsFeedRecyclerViewAdapter() - list.adapter = adapter - - //Make Glide be aware of the recyclerview and pre-load images - val sizeProvider: ListPreloader.PreloadSizeProvider = ViewPreloadSizeProvider() - val preloader: RecyclerViewPreloader = RecyclerViewPreloader( - Glide.with(this), adapter as PostsFeedRecyclerViewAdapter, sizeProvider, 4 - ) - list.addOnScrollListener(preloader) - - return view - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val content = makeContent() - content.observe(viewLifecycleOwner, - { c -> - adapter.submitList(c) - //after a refresh is done we need to stop the pull to refresh spinner - swipeRefreshLayout.isRefreshing = false - }) - - swipeRefreshLayout.setOnRefreshListener { - showError(show = false) - - //by invalidating data, loadInitial will be called again - factory.liveData.value!!.invalidate() - } - } - - internal abstract fun makeContent(): LiveData> - - /** - * [RecyclerView.Adapter] that can display a list of Statuses - */ - inner class PostsFeedRecyclerViewAdapter - : FeedsRecyclerViewAdapter(), - ListPreloader.PreloadModelProvider { - private val api = pixelfedAPI - private val credential = "Bearer $accessToken" - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.post_fragment, parent, false) - context = view.context - return PostViewHolder( - view, - context - ) - } - - /** - * Binds the different elements of the Post Model to the view holder - */ - override fun onBindViewHolder(holder: PostViewHolder, position: Int) { - val post = getItem(position) ?: return - val metrics = context.resources.displayMetrics - //Limit the height of the different images - holder.postPic.maxHeight = metrics.heightPixels * 3/4 - - //Setup the post layout - post.setupPost(holder.postView, picRequest, this@PostsFeedFragment, domain, false) - - post.activateButtons(holder, api, credential) - } - - override fun getPreloadItems(position: Int): MutableList { - val status = getItem(position) ?: return mutableListOf() - return mutableListOf(status) - } - - override fun getPreloadRequestBuilder(item: Status): RequestBuilder<*>? { - return picRequest.load(item.getPostUrl()) - } - } -} - -/** - * Represents the posts that will be contained within the feed - */ -class PostViewHolder(val postView: View, val context: android.content.Context) : RecyclerView.ViewHolder(postView) { - val profilePic : ImageView = postView.findViewById(R.id.profilePic) - val postPic : ImageView = postView.findViewById(R.id.postPicture) - val username : TextView = postView.findViewById(R.id.username) - val usernameDesc: TextView = postView.findViewById(R.id.usernameDesc) - val description : TextView = postView.findViewById(R.id.description) - val nlikes : TextView = postView.findViewById(R.id.nlikes) - val nshares : TextView = postView.findViewById(R.id.nshares) - - //Spark buttons - val liker : SparkButton = postView.findViewById(R.id.liker) - val reblogger : SparkButton = postView.findViewById(R.id.reblogger) - - val submitCmnt : ImageButton = postView.findViewById(R.id.submitComment) - val commenter : ImageView = postView.findViewById(R.id.commenter) - val comment : EditText = postView.findViewById(R.id.editComment) - val commentCont : LinearLayout = postView.findViewById(R.id.commentContainer) - val commentIn : LinearLayout = postView.findViewById(R.id.commentIn) - val viewComment : TextView = postView.findViewById(R.id.ViewComments) - val postDate : TextView = postView.findViewById(R.id.postDate) - val postDomain : TextView = postView.findViewById(R.id.postDomain) - val sensitiveW : TextView = postView.findViewById(R.id.sensitiveWarning) - val postPager : ViewPager2 = postView.findViewById(R.id.postPager) - - val more : ImageButton = postView.findViewById(R.id.status_more) -} diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/PublicTimelineFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/PublicTimelineFragment.kt deleted file mode 100644 index a78921a7..00000000 --- a/app/src/main/java/com/h/pixeldroid/fragments/feeds/postFeeds/PublicTimelineFragment.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.h.pixeldroid.fragments.feeds.postFeeds - -import android.util.Log -import android.view.View -import android.widget.Toast -import androidx.lifecycle.LiveData -import androidx.paging.LivePagedListBuilder -import androidx.paging.PagedList -import com.h.pixeldroid.R -import com.h.pixeldroid.objects.Status -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response - -class PublicTimelineFragment: PostsFeedFragment() { - - inner class PublicFeedDataSource : FeedDataSource(){ - - override fun newSource(): PublicFeedDataSource { - return PublicFeedDataSource() - } - - override fun makeInitialCall(requestedLoadSize: Int): Call> { - return pixelfedAPI.timelinePublic(limit="$requestedLoadSize") - } - override fun makeAfterCall(requestedLoadSize: Int, key: String): Call> { - return pixelfedAPI.timelinePublic( max_id=key, limit="$requestedLoadSize") - } - - override fun enqueueCall(call: Call>, callback: LoadCallback) { - call.enqueue(object : Callback> { - override fun onResponse(call: Call>, response: Response>) { - if (response.isSuccessful && response.body() != null) { - val notifications = response.body()!! - callback.onResult(notifications) - } else{ - showError() - } - swipeRefreshLayout.isRefreshing = false - loadingIndicator.visibility = View.GONE - } - - override fun onFailure(call: Call>, t: Throwable) { - showError(errorText = R.string.feed_failed) - Log.e("PublicTimelineFragment", t.toString()) - } - }) - } - - override fun getKey(item: Status): String { - return item.id!! - } - } - - override fun makeContent(): LiveData> { - val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build() - factory = FeedDataSourceFactory(PublicFeedDataSource()) - return LivePagedListBuilder(factory, config).build() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchAccountFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchAccountFragment.kt deleted file mode 100644 index bc8dbf2d..00000000 --- a/app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchAccountFragment.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.h.pixeldroid.fragments.feeds.search - -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.lifecycle.LiveData -import androidx.paging.LivePagedListBuilder -import androidx.paging.PagedList -import com.h.pixeldroid.R -import com.h.pixeldroid.fragments.feeds.AccountListFragment -import com.h.pixeldroid.fragments.feeds.FeedFragment -import com.h.pixeldroid.objects.Account -import com.h.pixeldroid.objects.Results -import com.h.pixeldroid.objects.Tag -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response - -class SearchAccountFragment: AccountListFragment(){ - - private lateinit var query: String - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = super.onCreateView(inflater, container, savedInstanceState) - - query = arguments?.getSerializable("searchFeed") as String - - return view - } - - inner class SearchAccountListDataSource: FeedDataSource(){ - - override fun newSource(): SearchAccountListDataSource { - return SearchAccountListDataSource() - } - - override fun getKey(item: Account): String { - return content.value?.loadedCount.toString() - } - - private fun searchMakeInitialCall(requestedLoadSize: Int): Call { - return pixelfedAPI - .search("Bearer $accessToken", - limit="$requestedLoadSize", q = query, - type = Results.SearchType.accounts) - } - private fun searchMakeAfterCall(requestedLoadSize: Int, key: String): Call { - return pixelfedAPI - .search("Bearer $accessToken", offset = key, - limit="$requestedLoadSize", q = query, - type = Results.SearchType.accounts) - } - - override fun loadInitial( - params: LoadInitialParams, - callback: LoadInitialCallback - ) { - searchEnqueueCall(searchMakeInitialCall(params.requestedLoadSize), callback) - } - - override fun loadAfter(params: LoadParams, callback: LoadCallback) { - searchEnqueueCall(searchMakeAfterCall(params.requestedLoadSize, params.key), callback) - } - private fun searchEnqueueCall(call: Call, callback: LoadCallback) { - - call.enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.code() == 200) { - val notifications = response.body()!!.accounts as ArrayList - callback.onResult(notifications as List) - } else{ - showError() - } - swipeRefreshLayout.isRefreshing = false - loadingIndicator.visibility = View.GONE - } - - override fun onFailure(call: Call, t: Throwable) { - showError(errorText = R.string.feed_failed) - Log.e("FeedFragment", t.toString()) - } - }) - } - - override fun makeInitialCall(requestedLoadSize: Int): Call> { - throw NotImplementedError("Should not be called, reimplemented for Search fragment") - } - - override fun makeAfterCall(requestedLoadSize: Int, key: String): Call> { - throw NotImplementedError("Should not be called, reimplemented for Search fragment") - } - - override fun enqueueCall(call: Call>, callback: LoadCallback) { - throw NotImplementedError("Should not be called, reimplemented for Search fragment") - } - } - - override fun makeContent(): LiveData> { - val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build() - factory = FeedFragment().FeedDataSourceFactory(SearchAccountListDataSource()) - return LivePagedListBuilder(factory, config).build() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchHashtagFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchHashtagFragment.kt deleted file mode 100644 index 156c2a6c..00000000 --- a/app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchHashtagFragment.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.h.pixeldroid.fragments.feeds.search - -import android.annotation.SuppressLint -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import android.widget.Toast -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import androidx.paging.LivePagedListBuilder -import androidx.paging.PagedList -import androidx.recyclerview.widget.RecyclerView -import com.h.pixeldroid.R -import com.h.pixeldroid.fragments.feeds.AccountListFragment -import com.h.pixeldroid.fragments.feeds.FeedFragment -import com.h.pixeldroid.fragments.feeds.FeedsRecyclerViewAdapter -import com.h.pixeldroid.objects.Account -import com.h.pixeldroid.objects.Results -import com.h.pixeldroid.objects.Tag -import kotlinx.android.synthetic.main.fragment_tags.view.* -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response - -class SearchHashtagFragment: FeedFragment(){ - - private lateinit var query: String - private lateinit var content: LiveData> - private lateinit var adapter : TagsRecyclerViewAdapter - lateinit var factory: FeedDataSourceFactory - - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = super.onCreateView(inflater, container, savedInstanceState) - - query = arguments?.getSerializable("searchFeed") as String - - adapter = TagsRecyclerViewAdapter() - list.adapter = adapter - - return view - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - content = makeContent() - - content.observe(viewLifecycleOwner, - Observer { c -> - adapter.submitList(c) - //after a refresh is done we need to stop the pull to refresh spinner - swipeRefreshLayout.isRefreshing = false - }) - - swipeRefreshLayout.setOnRefreshListener { - showError(show = false) - - //by invalidating data, loadInitial will be called again - factory.liveData.value!!.invalidate() - } - } - - inner class SearchTagsListDataSource: FeedDataSource(){ - - override fun newSource(): SearchTagsListDataSource { - return SearchTagsListDataSource() - } - - private fun searchMakeInitialCall(requestedLoadSize: Int): Call { - return pixelfedAPI - .search("Bearer $accessToken", - limit="$requestedLoadSize", q=query, - type = Results.SearchType.hashtags) - } - private fun searchMakeAfterCall(requestedLoadSize: Int, key: Int): Call { - return pixelfedAPI - .search("Bearer $accessToken", offset = key.toString(), - limit="$requestedLoadSize", q = query, - type = Results.SearchType.hashtags) - } - - override fun getKey(item: Tag): Int { - val value = content.value - return value?.loadedCount ?: 0 - } - override fun loadInitial( - params: LoadInitialParams, - callback: LoadInitialCallback - ) { - searchEnqueueCall(searchMakeInitialCall(params.requestedLoadSize), callback) - } - - override fun loadAfter(params: LoadParams, callback: LoadCallback) { - searchEnqueueCall(searchMakeAfterCall(params.requestedLoadSize, params.key), callback) - } - - private fun searchEnqueueCall(call: Call, callback: LoadCallback){ - - call.enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.code() == 200) { - val notifications = response.body()!!.hashtags as ArrayList - callback.onResult(notifications as List) - - } else{ - showError() - } - swipeRefreshLayout.isRefreshing = false - loadingIndicator.visibility = View.GONE - } - - override fun onFailure(call: Call, t: Throwable) { - showError(errorText = R.string.feed_failed) - Log.e("FeedFragment", t.toString()) - } - }) - } - - override fun makeInitialCall(requestedLoadSize: Int): Call> { - throw NotImplementedError("Should not be called, reimplemented for Search fragment") - } - - override fun makeAfterCall(requestedLoadSize: Int, key: Int): Call> { - throw NotImplementedError("Should not be called, reimplemented for Search fragment") - } - - override fun enqueueCall(call: Call>, callback: LoadCallback) { - throw NotImplementedError("Should not be called, reimplemented for Search fragment") - } - } - - private fun makeContent(): LiveData> { - val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build() - factory = - FeedFragment() - .FeedDataSourceFactory( - SearchTagsListDataSource() - ) - return LivePagedListBuilder(factory, config).build() - } - - inner class TagsRecyclerViewAdapter : FeedsRecyclerViewAdapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.fragment_tags, parent, false) - context = view.context - return ViewHolder(view) - } - - override fun onBindViewHolder(holder : ViewHolder, position : Int) { - val tag = getItem(position) ?: return - - @SuppressLint("SetTextI18n") - holder.name.text = "#" + tag.name - - holder.mView.setOnClickListener { Log.e("Tag: ", tag.name) } - } - - inner class ViewHolder(val mView : View) : RecyclerView.ViewHolder(mView) { - val name : TextView = mView.tag_name - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchPostsFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchPostsFragment.kt deleted file mode 100644 index 76a50c64..00000000 --- a/app/src/main/java/com/h/pixeldroid/fragments/feeds/search/SearchPostsFragment.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.h.pixeldroid.fragments.feeds.search - -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.lifecycle.LiveData -import androidx.paging.LivePagedListBuilder -import androidx.paging.PagedList -import com.h.pixeldroid.R -import com.h.pixeldroid.fragments.feeds.FeedFragment -import com.h.pixeldroid.fragments.feeds.postFeeds.PostsFeedFragment -import com.h.pixeldroid.objects.Results -import com.h.pixeldroid.objects.Status -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response - -class SearchPostsFragment: PostsFeedFragment(){ - - private lateinit var query: String - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = super.onCreateView(inflater, container, savedInstanceState) - - query = arguments?.getSerializable("searchFeed") as String - - return view - } - - inner class SearchFeedDataSource : FeedDataSource(){ - - override fun newSource(): SearchFeedDataSource { - return SearchFeedDataSource() - } - - private fun searchMakeInitialCall(requestedLoadSize: Int): Call { - return pixelfedAPI - .search("Bearer $accessToken", - limit="$requestedLoadSize", q=query, - type = Results.SearchType.statuses) - } - private fun searchMakeAfterCall(requestedLoadSize: Int, key: String): Call { - return pixelfedAPI - .search("Bearer $accessToken", max_id=key, - limit="$requestedLoadSize", q = query, - type = Results.SearchType.statuses) - } - override fun loadInitial( - params: LoadInitialParams, - callback: LoadInitialCallback - ) { - searchEnqueueCall(searchMakeInitialCall(params.requestedLoadSize), callback) - } - - override fun loadAfter(params: LoadParams, callback: LoadCallback) { - searchEnqueueCall(searchMakeAfterCall(params.requestedLoadSize, params.key), callback) - } - - private fun searchEnqueueCall(call: Call, callback: LoadCallback){ - - call.enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.code() == 200) { - val notifications = response.body()!!.statuses as ArrayList - callback.onResult(notifications as List) - } else { - showError() - } - swipeRefreshLayout.isRefreshing = false - loadingIndicator.visibility = View.GONE - } - - override fun onFailure(call: Call, t: Throwable) { - showError(errorText = R.string.feed_failed) - Log.e("FeedFragment", t.toString()) - } - }) - } - override fun makeInitialCall(requestedLoadSize: Int): Call> { - throw NotImplementedError("Should not be called, reimplemented for Search fragment") - } - - override fun makeAfterCall(requestedLoadSize: Int, key: String): Call> { - throw NotImplementedError("Should not be called, reimplemented for Search fragment") - } - - override fun enqueueCall(call: Call>, callback: LoadCallback) { - throw NotImplementedError("Should not be called, reimplemented for Search fragment") - } - - override fun getKey(item: Status): String { - return item.id!! - } - - - } - - override fun makeContent(): LiveData> { - val config: PagedList.Config = PagedList.Config.Builder().setPageSize(10).build() - factory = FeedFragment() - .FeedDataSourceFactory(SearchFeedDataSource()) - return LivePagedListBuilder(factory, config).build() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/FeedViewModel.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/FeedViewModel.kt new file mode 100644 index 00000000..8f8be2af --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/FeedViewModel.kt @@ -0,0 +1,34 @@ +package com.h.pixeldroid.fragments.feeds.uncachedFeeds + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.* +import com.h.pixeldroid.objects.FeedContent +import kotlinx.coroutines.flow.Flow + +/** + * ViewModel for the uncached feeds. + * The ViewModel works with the different [UncachedContentRepository]s to get the data. + */ +class FeedViewModel(private val repository: UncachedContentRepository) : ViewModel() { + + private var currentResult: Flow>? = null + + fun flow(): Flow> { + val lastResult = currentResult + if (lastResult != null) { + return lastResult + } + val newResult: Flow> = repository.getStream() + .cachedIn(viewModelScope) + currentResult = newResult + return newResult + } +} + +/** + * Common interface for the different uncached feeds + */ +interface UncachedContentRepository{ + fun getStream(): Flow> +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/UncachedFeedFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/UncachedFeedFragment.kt new file mode 100644 index 00000000..027fe514 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/UncachedFeedFragment.kt @@ -0,0 +1,95 @@ +package com.h.pixeldroid.fragments.feeds.uncachedFeeds + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.paging.* +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch + +import com.h.pixeldroid.databinding.FragmentFeedBinding +import com.h.pixeldroid.fragments.BaseFragment +import com.h.pixeldroid.fragments.feeds.initAdapter +import com.h.pixeldroid.objects.FeedContent + + +/** + * A fragment representing a list of [FeedContent], not backed by a db cache. + */ +open class UncachedFeedFragment : BaseFragment() { + + internal lateinit var viewModel: FeedViewModel + internal lateinit var adapter: PagingDataAdapter + + private lateinit var binding: FragmentFeedBinding + + + private var job: Job? = null + + + internal fun launch() { + // Make sure we cancel the previous job before creating a new one + job?.cancel() + job = lifecycleScope.launch { + viewModel.flow().collectLatest { + adapter.submitData(it) + } + } + } + + internal fun initSearch() { + // Scroll to top when the list is refreshed from network. + lifecycleScope.launch { + adapter.loadStateFlow + // Only emit when REFRESH LoadState for RemoteMediator changes. + .distinctUntilChangedBy { it.refresh } + // Only react to cases where Remote REFRESH completes i.e., NotLoading. + .filter { it.refresh is LoadState.NotLoading } + .collect { binding.list.scrollToPosition(0) } + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + super.onCreateView(inflater, container, savedInstanceState) + + binding = FragmentFeedBinding.inflate(layoutInflater) + + initAdapter(binding, adapter) + + binding.swipeRefreshLayout.setOnRefreshListener { + //It shouldn't be necessary to also retry() in addition to refresh(), + //but if we don't do this, reloads after an error fail immediately... + adapter.retry() + adapter.refresh() + } + + return binding.root + } +} + +class ViewModelFactory @ExperimentalPagingApi constructor( + private val searchContentRepository: UncachedContentRepository +) : ViewModelProvider.Factory { + + @ExperimentalPagingApi + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(FeedViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return FeedViewModel(searchContentRepository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/AccountListFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/AccountListFragment.kt new file mode 100644 index 00000000..cea125c6 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/AccountListFragment.kt @@ -0,0 +1,145 @@ +package com.h.pixeldroid.fragments.feeds.uncachedFeeds.accountLists + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.lifecycle.ViewModelProvider +import androidx.paging.ExperimentalPagingApi +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.h.pixeldroid.R +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.FeedViewModel +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.UncachedFeedFragment +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.ViewModelFactory +import com.h.pixeldroid.objects.Account +import com.h.pixeldroid.objects.Account.Companion.ACCOUNT_ID_TAG +import com.h.pixeldroid.objects.Account.Companion.FOLLOWERS_TAG +import kotlinx.android.synthetic.main.account_list_entry.view.* + + +/** + * Fragment to show a list of [Account]s, for a list of followers or following + */ +class AccountListFragment : UncachedFeedFragment() { + + private lateinit var id: String + private var following: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + id = arguments?.getSerializable(ACCOUNT_ID_TAG) as String + following = arguments?.getSerializable(FOLLOWERS_TAG) as Boolean + + adapter = AccountAdapter() + + } + + @ExperimentalPagingApi + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + + val view = super.onCreateView(inflater, container, savedInstanceState) + + // get the view model + @Suppress("UNCHECKED_CAST") + viewModel = ViewModelProvider(this, ViewModelFactory( + FollowersContentRepository( + apiHolder.setDomainToCurrentUser(db), + db.userDao().getActiveUser()!!.accessToken, + id, + following + ) + ) + ) + .get(FeedViewModel::class.java) as FeedViewModel + + launch() + initSearch() + + return view + } + +} + + +/** + * View Holder for an [Account] RecyclerView list item. + */ +class AccountViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val avatar : ImageView = view.account_entry_avatar + private val username : TextView = view.account_entry_username + private val acct: TextView = view.account_entry_acct + + private var account: Account? = null + + init { + itemView.setOnClickListener { + account?.openProfile(itemView.context) + } + } + + fun bind(account: Account?) { + + this.account = account + + Glide.with(itemView) + .load(account?.avatar_static ?: account?.avatar) + .circleCrop().placeholder(R.drawable.ic_default_user) + .into(avatar) + + username.text = account?.username + @SuppressLint("SetTextI18n") + acct.text = "@${account?.acct}" + } + + companion object { + fun create(parent: ViewGroup): AccountViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.account_list_entry, parent, false) + return AccountViewHolder(view) + } + } +} + + + +class AccountAdapter : PagingDataAdapter( + UIMODEL_COMPARATOR +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return AccountViewHolder.create(parent) + } + + override fun getItemViewType(position: Int): Int { + return R.layout.account_list_entry + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val uiModel = getItem(position) + uiModel.let { + (holder as AccountViewHolder).bind(it) + } + } + + companion object { + private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean = + oldItem == newItem + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/FollowersContentRepository.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/FollowersContentRepository.kt new file mode 100644 index 00000000..333259d4 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/FollowersContentRepository.kt @@ -0,0 +1,36 @@ +package com.h.pixeldroid.fragments.feeds.uncachedFeeds.accountLists + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.h.pixeldroid.api.PixelfedAPI +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.UncachedContentRepository +import com.h.pixeldroid.objects.Account +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + + +class FollowersContentRepository @ExperimentalPagingApi +@Inject constructor( + private val api: PixelfedAPI, + private val accessToken: String, + private val accountId: String, + private val following: Boolean, +): UncachedContentRepository { + override fun getStream(): Flow> { + return Pager( + config = PagingConfig( + initialLoadSize = NETWORK_PAGE_SIZE, + pageSize = NETWORK_PAGE_SIZE, + enablePlaceholders = false), + pagingSourceFactory = { + FollowersPagingSource(api, accessToken, accountId, following) + } + ).flow + } + + companion object { + private const val NETWORK_PAGE_SIZE = 20 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/FollowersPagingSource.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/FollowersPagingSource.kt new file mode 100644 index 00000000..aa93920b --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/accountLists/FollowersPagingSource.kt @@ -0,0 +1,68 @@ +package com.h.pixeldroid.fragments.feeds.uncachedFeeds.accountLists + +import androidx.paging.PagingSource +import com.h.pixeldroid.api.PixelfedAPI +import com.h.pixeldroid.objects.Account +import retrofit2.HttpException +import java.io.IOException + +class FollowersPagingSource( + private val api: PixelfedAPI, + private val accessToken: String, + private val accountId: String, + private val following: Boolean +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + val position = params.key + return try { + val response = + // Pixelfed and Mastodon don't implement this in the same fashion. Pixelfed uses + // Laravel's paging mechanism, while Mastodon uses the Link header for pagination. + // No need to know which is which, they should ignore the non-relevant argument + if(following) { + api.followers(account_id = accountId, + authorization = "Bearer $accessToken", + limit = params.loadSize, + page = position?.toString(), + max_id = position?.toString()) + } else { + api.following(account_id = accountId, + authorization = "Bearer $accessToken", + limit = params.loadSize, + page = position?.toString(), + max_id = position?.toString()) + } + + val accounts = if(response.isSuccessful){ + response.body().orEmpty() + } else { + throw HttpException(response) + } + + val nextPosition = if(response.headers()["Link"] != null){ + //Header is of the form: + // Link: ; rel="next", ; rel="prev" + // So we want the first max_id value. In case there are arguments after + // the max_id in the URL, we make sure to stop at the first '?' + response.headers()["Link"] + .orEmpty() + .substringAfter("max_id=") + .substringBefore('?') + .substringBefore('>') + .toIntOrNull() ?: 0 + } else { + params.key?.plus(1) ?: 2 + } + + LoadResult.Page( + data = accounts, + prevKey = null, + nextKey = if (accounts.isEmpty()) null else nextPosition + ) + } catch (exception: IOException) { + LoadResult.Error(exception) + } catch (exception: HttpException) { + LoadResult.Error(exception) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchAccountFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchAccountFragment.kt new file mode 100644 index 00000000..dfe5de6b --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchAccountFragment.kt @@ -0,0 +1,57 @@ +package com.h.pixeldroid.fragments.feeds.uncachedFeeds.search + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.ViewModelProvider +import androidx.paging.ExperimentalPagingApi +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.* +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.accountLists.AccountAdapter +import com.h.pixeldroid.objects.Account +import com.h.pixeldroid.objects.Results + +/** + * Fragment to show a list of [Account]s, as a result of a search. + */ +class SearchAccountFragment : UncachedFeedFragment() { + + private lateinit var query: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + adapter = AccountAdapter() + + query = arguments?.getSerializable("searchFeed") as String + } + + @ExperimentalPagingApi + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + + val view = super.onCreateView(inflater, container, savedInstanceState) + + // get the view model + @Suppress("UNCHECKED_CAST") + viewModel = ViewModelProvider(this, ViewModelFactory( + SearchContentRepository( + apiHolder.setDomainToCurrentUser(db), + Results.SearchType.accounts, + db.userDao().getActiveUser()!!.accessToken, + query + ) + ) + ) + .get(FeedViewModel::class.java) as FeedViewModel + + launch() + initSearch() + + return view + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchContentRepository.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchContentRepository.kt new file mode 100644 index 00000000..4eec7ef7 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchContentRepository.kt @@ -0,0 +1,43 @@ +package com.h.pixeldroid.fragments.feeds.uncachedFeeds.search + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.h.pixeldroid.api.PixelfedAPI +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.UncachedContentRepository +import com.h.pixeldroid.objects.FeedContent +import com.h.pixeldroid.objects.Results +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * Repository class to perform searches + * + * The type argument [T] and the [Results.SearchType][type] argument should always + * be in agreement, e.g. if [T] is a [com.h.pixeldroid.objects.Account] then + * [type] should be [Results.SearchType.accounts]. + */ +class SearchContentRepository @ExperimentalPagingApi +@Inject constructor( + private val api: PixelfedAPI, + private val type: Results.SearchType, + private val accessToken: String, + private val query: String, +): UncachedContentRepository { + override fun getStream(): Flow> { + return Pager( + config = PagingConfig( + initialLoadSize = NETWORK_PAGE_SIZE, + pageSize = NETWORK_PAGE_SIZE, + enablePlaceholders = false), + pagingSourceFactory = { + SearchPagingSource(api, query, type, accessToken) + } + ).flow + } + + companion object { + private const val NETWORK_PAGE_SIZE = 20 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchHashtagFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchHashtagFragment.kt new file mode 100644 index 00000000..b5a80463 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchHashtagFragment.kt @@ -0,0 +1,133 @@ +package com.h.pixeldroid.fragments.feeds.uncachedFeeds.search + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.lifecycle.ViewModelProvider +import androidx.paging.ExperimentalPagingApi +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.h.pixeldroid.R +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.UncachedFeedFragment +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.FeedViewModel +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.ViewModelFactory +import com.h.pixeldroid.objects.Account +import com.h.pixeldroid.objects.Results +import com.h.pixeldroid.objects.Tag +import kotlinx.android.synthetic.main.fragment_tags.view.* + +/** + * Fragment to show a list of [hashtag][Tag]s, as a result of a search. + */ +class SearchHashtagFragment : UncachedFeedFragment() { + + private lateinit var query: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + adapter = HashTagAdapter() + + query = arguments?.getSerializable("searchFeed") as String + + } + + @ExperimentalPagingApi + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = super.onCreateView(inflater, container, savedInstanceState) + + // get the view model + @Suppress("UNCHECKED_CAST") + viewModel = ViewModelProvider(this, ViewModelFactory( + SearchContentRepository( + apiHolder.setDomainToCurrentUser(db), + Results.SearchType.hashtags, + db.userDao().getActiveUser()!!.accessToken, + query + ) + ) + ) + .get(FeedViewModel::class.java) as FeedViewModel + + launch() + initSearch() + + return view + } + +} + + + +class HashTagAdapter : PagingDataAdapter( + UIMODEL_COMPARATOR +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return HashTagViewHolder.create(parent) + } + + override fun getItemViewType(position: Int): Int { + return R.layout.fragment_tags + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val uiModel = getItem(position) + uiModel.let { + (holder as HashTagViewHolder).bind(it) + } + } + + companion object { + private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Tag, newItem: Tag): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: Tag, newItem: Tag): Boolean = + oldItem == newItem + } + } +} + + +/** + * View Holder for a [Tag] RecyclerView list item. + */ +class HashTagViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + private val name : TextView = view.tag_name + + private var tag: Tag? = null + + init { + itemView.setOnClickListener { + //TODO + } + } + + + fun bind(tag: Tag?) { + + this.tag = tag + + @SuppressLint("SetTextI18n") + name.text = "#" + tag?.name + + } + + companion object { + fun create(parent: ViewGroup): HashTagViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.fragment_tags, parent, false) + return HashTagViewHolder(view) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchPagingSource.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchPagingSource.kt new file mode 100644 index 00000000..84d29369 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchPagingSource.kt @@ -0,0 +1,47 @@ +package com.h.pixeldroid.fragments.feeds.uncachedFeeds.search + +import androidx.paging.PagingSource +import com.h.pixeldroid.api.PixelfedAPI +import com.h.pixeldroid.objects.FeedContent +import com.h.pixeldroid.objects.Results +import retrofit2.HttpException +import java.io.IOException + +/** + * Provides the PagingSource for search feeds. Is used in [SearchContentRepository] + */ +class SearchPagingSource( + private val api: PixelfedAPI, + private val query: String, + private val type: Results.SearchType, + private val accessToken: String, +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + val position = params.key + return try { + val response = api.search(authorization = "Bearer $accessToken", + offset = position?.toString(), + q = query, + type = type, + limit = params.loadSize.toString()) + + + @Suppress("UNCHECKED_CAST") + val repos = when(type){ + Results.SearchType.accounts -> response.accounts + Results.SearchType.hashtags -> response.hashtags + Results.SearchType.statuses -> response.statuses + } as List + + LoadResult.Page( + data = repos, + prevKey = null, + nextKey = if (repos.isEmpty()) null else (position ?: 0) + repos.size + ) + } catch (exception: IOException) { + LoadResult.Error(exception) + } catch (exception: HttpException) { + LoadResult.Error(exception) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchPostsFragment.kt b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchPostsFragment.kt new file mode 100644 index 00000000..741f0513 --- /dev/null +++ b/app/src/main/java/com/h/pixeldroid/fragments/feeds/uncachedFeeds/search/SearchPostsFragment.kt @@ -0,0 +1,89 @@ +package com.h.pixeldroid.fragments.feeds.uncachedFeeds.search + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.ViewModelProvider +import androidx.paging.ExperimentalPagingApi +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.h.pixeldroid.R +import com.h.pixeldroid.fragments.StatusViewHolder +import com.h.pixeldroid.fragments.feeds.uncachedFeeds.* +import com.h.pixeldroid.objects.Account +import com.h.pixeldroid.objects.Results +import com.h.pixeldroid.objects.Status + +/** + * Fragment to show a list of [Status]es, as a result of a search. + */ +class SearchPostsFragment : UncachedFeedFragment() { + + private lateinit var query: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + adapter = PostsAdapter() + + query = arguments?.getSerializable("searchFeed") as String + + } + + @ExperimentalPagingApi + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = super.onCreateView(inflater, container, savedInstanceState) + + // get the view model + @Suppress("UNCHECKED_CAST") + viewModel = ViewModelProvider(this, ViewModelFactory( + SearchContentRepository( + apiHolder.setDomainToCurrentUser(db), + Results.SearchType.statuses, + db.userDao().getActiveUser()!!.accessToken, + query + ) + ) + ) + .get(FeedViewModel::class.java) as FeedViewModel + + launch() + initSearch() + + return view + } + + inner class PostsAdapter : PagingDataAdapter( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean = + oldItem.id == newItem.id + } + ) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return StatusViewHolder.create(parent) + } + + override fun getItemViewType(position: Int): Int { + return R.layout.post_fragment + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val uiModel = getItem(position) as Status + uiModel.let { + val instanceUri = db.userDao().getActiveUser()!!.instance_uri + val accessToken = db.userDao().getActiveUser()!!.accessToken + (holder as StatusViewHolder).bind(it, instanceUri, apiHolder.setDomain(instanceUri), "Bearer $accessToken") + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/objects/Account.kt b/app/src/main/java/com/h/pixeldroid/objects/Account.kt index 8a0a6b32..e65ea18d 100644 --- a/app/src/main/java/com/h/pixeldroid/objects/Account.kt +++ b/app/src/main/java/com/h/pixeldroid/objects/Account.kt @@ -41,8 +41,8 @@ data class Account( val moved: Account? = null, val fields: List? = emptyList(), val bot: Boolean? = false, - val source: Source? = null -) : Serializable, FeedContent() { + val source: Source? = null, +) : Serializable, FeedContent { companion object { const val ACCOUNT_TAG = "AccountTag" const val ACCOUNT_ID_TAG = "AccountIdTag" diff --git a/app/src/main/java/com/h/pixeldroid/objects/FeedContent.kt b/app/src/main/java/com/h/pixeldroid/objects/FeedContent.kt index 80c350a7..ee4b9667 100644 --- a/app/src/main/java/com/h/pixeldroid/objects/FeedContent.kt +++ b/app/src/main/java/com/h/pixeldroid/objects/FeedContent.kt @@ -1,10 +1,13 @@ package com.h.pixeldroid.objects -abstract class FeedContent { - abstract val id: String? +interface FeedContent { + val id: String? +} - override fun hashCode(): Int { - return id.hashCode() - } +interface FeedContentDatabase { + val id: String? + var user_id: String + + var instance_uri: String } \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/objects/Notification.kt b/app/src/main/java/com/h/pixeldroid/objects/Notification.kt index d35026b1..a0caf2be 100644 --- a/app/src/main/java/com/h/pixeldroid/objects/Notification.kt +++ b/app/src/main/java/com/h/pixeldroid/objects/Notification.kt @@ -1,11 +1,28 @@ package com.h.pixeldroid.objects +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import com.h.pixeldroid.db.entities.UserDatabaseEntity +import java.io.Serializable import java.util.Date /* Represents a notification of an event relevant to the user. https://docs.joinmastodon.org/entities/notification/ */ +@Entity( + tableName = "notifications", + primaryKeys = ["id", "user_id", "instance_uri"], + foreignKeys = [ForeignKey( + entity = UserDatabaseEntity::class, + parentColumns = arrayOf("user_id", "instance_uri"), + childColumns = arrayOf("user_id", "instance_uri"), + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["user_id", "instance_uri"])] +) data class Notification( //Required attributes override val id: String, @@ -13,9 +30,14 @@ data class Notification( val created_at: Date, //ISO 8601 Datetime val account: Account, //Optional attributes - val status: Status? = null -): FeedContent() { - enum class NotificationType { + val status: Status? = null, + + //Database values (not from API) + //TODO do we find this approach acceptable? Preferable to a semi-duplicate NotificationDataBaseEntity? + override var user_id: String, + override var instance_uri: String, + ): FeedContent, FeedContentDatabase { + enum class NotificationType: Serializable { follow, mention, reblog, favourite, poll } } \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/objects/Status.kt b/app/src/main/java/com/h/pixeldroid/objects/Status.kt index 27896da5..10546882 100644 --- a/app/src/main/java/com/h/pixeldroid/objects/Status.kt +++ b/app/src/main/java/com/h/pixeldroid/objects/Status.kt @@ -1,63 +1,43 @@ package com.h.pixeldroid.objects -import android.Manifest import android.app.DownloadManager import android.content.Context import android.content.Intent import android.database.Cursor import android.graphics.ColorMatrixColorFilter -import android.graphics.Typeface -import android.graphics.drawable.Drawable import android.net.Uri import android.os.Environment import android.text.Spanned -import android.text.method.LinkMovementMethod import android.view.View import android.view.View.GONE import android.view.View.VISIBLE -import android.widget.* -import androidx.core.content.ContextCompat.startActivity +import android.widget.ImageView +import android.widget.TextView import androidx.core.net.toUri -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.bumptech.glide.RequestBuilder +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index import com.google.android.material.snackbar.Snackbar -import com.google.android.material.tabs.TabLayoutMediator import com.h.pixeldroid.R -import com.h.pixeldroid.ReportActivity import com.h.pixeldroid.api.PixelfedAPI -import com.h.pixeldroid.fragments.ImageFragment -import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder +import com.h.pixeldroid.db.entities.UserDatabaseEntity import com.h.pixeldroid.utils.HtmlUtils.Companion.getDomain import com.h.pixeldroid.utils.HtmlUtils.Companion.parseHTMLText -import com.h.pixeldroid.utils.ImageConverter import com.h.pixeldroid.utils.PostUtils.Companion.censorColorMatrix -import com.h.pixeldroid.utils.PostUtils.Companion.likePostCall -import com.h.pixeldroid.utils.PostUtils.Companion.postComment -import com.h.pixeldroid.utils.PostUtils.Companion.reblogPost -import com.h.pixeldroid.utils.PostUtils.Companion.retrieveComments -import com.h.pixeldroid.utils.PostUtils.Companion.toggleCommentInput -import com.h.pixeldroid.utils.PostUtils.Companion.unLikePostCall import com.h.pixeldroid.utils.PostUtils.Companion.uncensorColorMatrix -import com.h.pixeldroid.utils.PostUtils.Companion.undoReblogPost -import com.h.pixeldroid.utils.Utils.Companion.setTextViewFromISO8601 -import com.karumi.dexter.Dexter -import com.karumi.dexter.listener.PermissionDeniedResponse -import com.karumi.dexter.listener.PermissionGrantedResponse -import com.karumi.dexter.listener.single.BasePermissionListener import kotlinx.android.synthetic.main.post_fragment.view.* import java.io.File import java.io.Serializable import java.util.* -import kotlin.collections.ArrayList -/* -Represents a status posted by an account. -https://docs.joinmastodon.org/entities/status/ +/** + Represents a status posted by an account. + https://docs.joinmastodon.org/entities/status/ */ -data class Status( + +open class Status( //Base attributes - override val id: String?, + override val id: String, val uri: String? = "", val created_at: Date? = Date(0), //ISO 8601 Datetime val account: Account?, @@ -89,10 +69,9 @@ data class Status( val reblogged: Boolean? = false, val muted: Boolean? = false, val bookmarked: Boolean? = false, - val pinned: Boolean? = false -) : Serializable, FeedContent() + val pinned: Boolean? = false, +) : Serializable, FeedContent { - companion object { const val POST_TAG = "postTag" const val DOMAIN_TAG = "domainTag" @@ -117,45 +96,14 @@ data class Status( return context.getString(R.string.shares).format(reblogs_count.toString()) } - private fun getStatusDomain(domain: String) : String { + fun getStatusDomain(domain: String) : String { val accountDomain = getDomain(account!!.url) return if(getDomain(domain) == accountDomain) "" else " from $accountDomain" } - private fun setupPostPics( - rootView: View, - request: RequestBuilder, - homeFragment: Fragment - ) { - - // Standard layout - rootView.postPicture.visibility = VISIBLE - rootView.postPager.visibility = GONE - rootView.postTabs.visibility = GONE - - - if(media_attachments?.size == 1) { - request.load(this.getPostUrl()).into(rootView.postPicture) - val imgDescription = media_attachments[0].description.orEmpty().ifEmpty { rootView.context.getString(R.string.no_description) } - rootView.postPicture.contentDescription = imgDescription - - rootView.postPicture.setOnLongClickListener { - Snackbar.make(it, imgDescription, Snackbar.LENGTH_SHORT).show() - true - } - - } else if(media_attachments?.size!! > 1) { - setupTabsLayout(rootView, request, homeFragment) - } - - if (sensitive!!) { - setupSensitiveLayout(rootView) - } - } - - private fun setupSensitiveLayout(view: View) { + fun setupSensitiveLayout(view: View) { // Set dark layout and warning message view.sensitiveWarning.visibility = VISIBLE @@ -176,161 +124,6 @@ data class Status( } } - private fun setupTabsLayout( - rootView: View, - request: RequestBuilder, - homeFragment: Fragment - ) { - //Only show the viewPager and tabs - rootView.postPicture.visibility = GONE - rootView.postPager.visibility = VISIBLE - rootView.postTabs.visibility = VISIBLE - - val tabs : ArrayList = ArrayList() - - //Fill the tabs with each mediaAttachment - for(media in media_attachments!!) { - tabs.add(ImageFragment.newInstance(media.url!!, media.description.orEmpty())) - } - - setupTabs(tabs, rootView, homeFragment) - } - - - private fun setupTabs(tabs: ArrayList, rootView: View, homeFragment: Fragment) { - //Attach the given tabs to the view pager - rootView.postPager.adapter = object : FragmentStateAdapter(homeFragment) { - override fun createFragment(position: Int): Fragment { - return tabs[position] - } - - override fun getItemCount(): Int { - return media_attachments?.size ?: 0 - } - } - - TabLayoutMediator(rootView.postTabs, rootView.postPager) { tab, _ -> - tab.icon = rootView.context.getDrawable(R.drawable.ic_dot_blue_12dp) - }.attach() - } - - fun setupPost( - rootView: View, - request: RequestBuilder, - homeFragment: Fragment, - domain: String, - isActivity: Boolean - ) { - //Setup username as a button that opens the profile - rootView.findViewById(R.id.username).apply { - text = this@Status.account?.getDisplayName() ?: "" - setTypeface(null, Typeface.BOLD) - setOnClickListener { account?.openProfile(rootView.context) } - } - - rootView.findViewById(R.id.usernameDesc).apply { - text = this@Status.account?.getDisplayName() ?: "" - setTypeface(null, Typeface.BOLD) - } - - rootView.findViewById(R.id.nlikes).apply { - text = this@Status.getNLikes(rootView.context) - setTypeface(null, Typeface.BOLD) - } - - rootView.findViewById(R.id.nshares).apply { - text = this@Status.getNShares(rootView.context) - setTypeface(null, Typeface.BOLD) - } - - //Convert the date to a readable string - setTextViewFromISO8601(created_at!!, rootView.postDate, isActivity, rootView.context) - - rootView.postDomain.text = getStatusDomain(domain) - - //Setup images - ImageConverter.setRoundImageFromURL( - rootView, - this.getProfilePicUrl(), - rootView.profilePic - ) - rootView.profilePic.setOnClickListener { account?.openProfile(rootView.context) } - - //Setup post pic only if there are media attachments - if(!media_attachments.isNullOrEmpty()) { - setupPostPics(rootView, request, homeFragment) - } else { - rootView.postPicture.visibility = GONE - rootView.postPager.visibility = GONE - rootView.postTabs.visibility = GONE - } - - - //Set comment initial visibility - rootView.findViewById(R.id.commentIn).visibility = GONE - rootView.findViewById(R.id.commentContainer).visibility = GONE - } - - fun setDescription(rootView: View, api: PixelfedAPI, credential: String) { - rootView.findViewById(R.id.description).apply { - if (content.isNullOrBlank()) { - visibility = GONE - } else { - text = parseHTMLText(content, mentions, api, rootView.context, credential) - movementMethod = LinkMovementMethod.getInstance() - } - } - } - - fun activateButtons(holder: PostViewHolder, api: PixelfedAPI, credential: String){ - - //Set the special HTML text - setDescription(holder.postView, api, credential) - - //Activate onclickListeners - activateLiker( - holder, api, credential, - this.favourited ?: false - ) - activateReblogger( - holder, api, credential, - this.reblogged ?: false - ) - activateCommenter(holder, api, credential) - - showComments(holder, api, credential) - - //Activate double tap liking - activateDoubleTapLiker(holder, api, credential) - - activateMoreButton(holder) - } - - fun activateReblogger( - holder: PostViewHolder, - api: PixelfedAPI, - credential: String, - isReblogged: Boolean - ) { - holder.reblogger.apply { - //Set initial button state - isChecked = isReblogged - - //Activate the button - setEventListener { _, buttonState -> - if (buttonState) { - // Button is active - undoReblogPost(holder, api, credential, this@Status) - } else { - // Button is inactive - reblogPost(holder, api, credential, this@Status) - } - //show animation or not? - true - } - } - } - fun downloadImage(context: Context, url: String, view: View, share: Boolean = false) { val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager @@ -403,199 +196,7 @@ data class Status( }.start() } - fun activateMoreButton(holder: PostViewHolder){ - holder.more.setOnClickListener { - PopupMenu(it.context, it).apply { - setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.post_more_menu_report -> { - val intent = Intent(it.context, ReportActivity::class.java) - intent.putExtra(POST_TAG, this@Status) - startActivity(it.context, intent, null) - true - } - R.id.post_more_menu_share_link -> { - val share = Intent.createChooser(Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, uri) - - type = "text/plain" - - putExtra(Intent.EXTRA_TITLE, content) - }, null) - startActivity(it.context, share, null) - - true - } - R.id.post_more_menu_save_to_gallery -> { - Dexter.withContext(holder.context) - .withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) - .withListener(object : BasePermissionListener() { - override fun onPermissionDenied(p0: PermissionDeniedResponse?) { - Toast.makeText( - holder.context, - holder.context.getString(R.string.write_permission_download_pic), - Toast.LENGTH_SHORT - ).show() - } - - override fun onPermissionGranted(p0: PermissionGrantedResponse?) { - downloadImage( - holder.context, - media_attachments?.get(holder.postPager.currentItem)?.url - ?: "", - holder.postView - ) - } - }).check() - true - } - R.id.post_more_menu_share_picture -> { - Dexter.withContext(holder.context) - .withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) - .withListener(object : BasePermissionListener() { - override fun onPermissionDenied(p0: PermissionDeniedResponse?) { - Toast.makeText( - holder.context, - holder.context.getString(R.string.write_permission_share_pic), - Toast.LENGTH_SHORT - ).show() - } - - override fun onPermissionGranted(p0: PermissionGrantedResponse?) { - downloadImage( - holder.context, - media_attachments?.get(holder.postPager.currentItem)?.url - ?: "", - holder.postView, - share = true, - ) - } - }).check() - true - } - else -> false - } - } - inflate(R.menu.post_more_menu) - if(media_attachments.isNullOrEmpty()) { - //make sure to disable image-related things if there aren't any - menu.setGroupVisible(R.id.post_more_group_picture, false) - } - show() - } - } - } - - - fun activateDoubleTapLiker( - holder: PostViewHolder, - api: PixelfedAPI, - credential: String - ) { - holder.apply { - var clicked = false - postPic.setOnClickListener { - //Check that the post isn't hidden - if(sensitiveW.visibility == GONE) { - //Check for double click - if(clicked) { - if (holder.liker.isChecked) { - // Button is active, unlike - holder.liker.isChecked = false - unLikePostCall(holder, api, credential, this@Status) - } else { - // Button is inactive, like - holder.liker.playAnimation() - holder.liker.isChecked = true - likePostCall(holder, api, credential, this@Status) - } - } else { - clicked = true - - //Reset clicked to false after 500ms - postPic.handler.postDelayed(fun() { clicked = false }, 500) - } - } - } - } - } - - fun activateLiker( - holder: PostViewHolder, - api: PixelfedAPI, - credential: String, - isLiked: Boolean - ) { - - holder.liker.apply { - //Set initial state - isChecked = isLiked - - //Activate the liker - setEventListener { _, buttonState -> - if (buttonState) { - // Button is active, unlike - unLikePostCall(holder, api, credential, this@Status) - } else { - // Button is inactive, like - likePostCall(holder, api, credential, this@Status) - } - //show animation or not? - true - } - } - } - - - fun showComments( - holder: PostViewHolder, - api: PixelfedAPI, - credential: String - ) { - //Show all comments of a post - if (replies_count == 0) { - holder.viewComment.text = holder.context.getString(R.string.NoCommentsToShow) - } else { - holder.viewComment.apply { - text = "$replies_count ${holder.context.getString(R.string.CommentDisplay)}" - setOnClickListener { - visibility = GONE - - //Retrieve the comments - retrieveComments(holder, api, credential, this@Status) - } - } - } - } - - fun activateCommenter( - holder: PostViewHolder, - api: PixelfedAPI, - credential: String - ) { - //Toggle comment button - toggleCommentInput(holder) - - //Activate commenterpostPicture - holder.submitCmnt.setOnClickListener { - val textIn = holder.comment.text - //Open text input - if(textIn.isNullOrEmpty()) { - Toast.makeText( - holder.context, - holder.context.getString(R.string.empty_comment), - Toast.LENGTH_SHORT - ).show() - } else { - - //Post the comment - postComment(holder, api, credential, this) - } - } - } - - enum class Visibility : Serializable { + enum class Visibility: Serializable { public, unlisted, private, direct } } \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/objects/Tag.kt b/app/src/main/java/com/h/pixeldroid/objects/Tag.kt index e3bce90a..2414c252 100644 --- a/app/src/main/java/com/h/pixeldroid/objects/Tag.kt +++ b/app/src/main/java/com/h/pixeldroid/objects/Tag.kt @@ -7,10 +7,9 @@ data class Tag( val name: String, val url: String, //Optional attributes - val history: List? = emptyList()) : Serializable, FeedContent() { + val history: List? = emptyList()) : Serializable, FeedContent { //needed to be a FeedContent, this inheritance is a bit fickle. Do not use. override val id: String get() = "tag" - } diff --git a/app/src/main/java/com/h/pixeldroid/utils/DBUtils.kt b/app/src/main/java/com/h/pixeldroid/utils/DBUtils.kt index a9648224..14b261e5 100644 --- a/app/src/main/java/com/h/pixeldroid/utils/DBUtils.kt +++ b/app/src/main/java/com/h/pixeldroid/utils/DBUtils.kt @@ -1,14 +1,11 @@ package com.h.pixeldroid.utils import com.h.pixeldroid.db.AppDatabase -import com.h.pixeldroid.db.InstanceDatabaseEntity -import com.h.pixeldroid.db.PostDatabaseEntity -import com.h.pixeldroid.db.UserDatabaseEntity +import com.h.pixeldroid.db.entities.InstanceDatabaseEntity +import com.h.pixeldroid.db.entities.UserDatabaseEntity import com.h.pixeldroid.objects.Account import com.h.pixeldroid.objects.Instance -import com.h.pixeldroid.objects.Status import com.h.pixeldroid.utils.Utils.Companion.normalizeDomain -import java.util.Date class DBUtils { companion object { @@ -48,40 +45,5 @@ class DBUtils { ) db.instanceDao().insertInstance(dbInstance) } - - fun storePosts( - db: AppDatabase, - data: List<*>, - user: UserDatabaseEntity - ) { - val dao = db.postDao() - data.forEach { post -> - if (post is Status - && !post.media_attachments.isNullOrEmpty() - && dao.count(post.uri ?: "", user.user_id, user.instance_uri) == 0) { - val nPosts = dao.numberOfPosts(user.user_id, user.instance_uri) - MAX_NUMBER_OF_STORED_POSTS - if (nPosts > 0) { - dao.removeOlderPosts(nPosts) - } - dao.insertPost(PostDatabaseEntity( - user_id = user.user_id, - instance_uri = user.instance_uri, - uri = post.uri ?: "", - account_profile_picture = post.getProfilePicUrl() ?: "", - account_name = (post.account?.getDisplayName() ?: "").toString(), - media_urls = post.media_attachments.map { - attachment -> attachment.url ?: "" - }, - favourite_count = post.favourites_count ?: 0, - reply_count = post.replies_count ?: 0, - share_count = post.reblogs_count ?: 0, - description = post.content ?: "", - date = post.created_at ?: Date(0), - likes = post.favourites_count ?: 0, - shares = post.reblogs_count ?: 0 - )) - } - } - } } } \ No newline at end of file diff --git a/app/src/main/java/com/h/pixeldroid/utils/PostUtils.kt b/app/src/main/java/com/h/pixeldroid/utils/PostUtils.kt index 6be4c22c..5d8a5b0e 100644 --- a/app/src/main/java/com/h/pixeldroid/utils/PostUtils.kt +++ b/app/src/main/java/com/h/pixeldroid/utils/PostUtils.kt @@ -1,240 +1,9 @@ package com.h.pixeldroid.utils import android.graphics.ColorMatrix -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.widget.LinearLayout -import android.widget.Toast -import com.h.pixeldroid.R -import com.h.pixeldroid.api.PixelfedAPI -import com.h.pixeldroid.fragments.feeds.postFeeds.PostViewHolder -import com.h.pixeldroid.objects.Context -import com.h.pixeldroid.objects.Status -import com.h.pixeldroid.utils.ImageConverter.Companion.setImageFromDrawable -import kotlinx.android.synthetic.main.comment.view.* -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response abstract class PostUtils { companion object { - fun toggleCommentInput( - holder : PostViewHolder - ) { - //Toggle comment button - holder.commenter.setOnClickListener { - when(holder.commentIn.visibility) { - View.VISIBLE -> { - holder.commentIn.visibility = View.GONE - setImageFromDrawable(holder.postView, holder.commenter, R.drawable.ic_comment_empty) - } - View.GONE -> { - holder.commentIn.visibility = View.VISIBLE - setImageFromDrawable(holder.postView, holder.commenter, R.drawable.ic_comment_blue) - } - } - } - } - - fun reblogPost( - holder : PostViewHolder, - api: PixelfedAPI, - credential: String, - post : Status - ) { - //Call the api function - api.reblogStatus(credential, post.id!!).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - Log.e("REBLOG ERROR", t.toString()) - holder.reblogger.isChecked = false - } - - override fun onResponse(call: Call, response: Response) { - if(response.code() == 200) { - val resp = response.body()!! - - //Update shown share count - holder.nshares.text = resp.getNShares(holder.context) - holder.reblogger.isChecked = resp.reblogged!! - } else { - Log.e("RESPONSE_CODE", response.code().toString()) - holder.reblogger.isChecked = false - } - } - - }) - } - - fun undoReblogPost( - holder : PostViewHolder, - api: PixelfedAPI, - credential: String, - post : Status - ) { - //Call the api function - api.undoReblogStatus(credential, post.id!!).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - Log.e("REBLOG ERROR", t.toString()) - holder.reblogger.isChecked = true - } - - override fun onResponse(call: Call, response: Response) { - if(response.code() == 200) { - val resp = response.body()!! - - //Update shown share count - holder.nshares.text = resp.getNShares(holder.context) - holder.reblogger.isChecked = resp.reblogged!! - } else { - Log.e("RESPONSE_CODE", response.code().toString()) - holder.reblogger.isChecked = true - } - } - - }) - } - - fun likePostCall( - holder : PostViewHolder, - api: PixelfedAPI, - credential: String, - post : Status - ) { - //Call the api function - api.likePost(credential, post.id!!).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - Log.e("LIKE ERROR", t.toString()) - holder.liker.isChecked = false - } - - override fun onResponse(call: Call, response: Response) { - if(response.code() == 200) { - val resp = response.body()!! - - //Update shown like count and internal like toggle - holder.nlikes.text = resp.getNLikes(holder.context) - holder.liker.isChecked = resp.favourited ?: false - } else { - Log.e("RESPONSE_CODE", response.code().toString()) - holder.liker.isChecked = false - } - } - - }) - } - - fun unLikePostCall( - holder : PostViewHolder, - api: PixelfedAPI, - credential: String, - post : Status - ) { - //Call the api function - api.unlikePost(credential, post.id!!).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - Log.e("UNLIKE ERROR", t.toString()) - holder.liker.isChecked = true - } - - override fun onResponse(call: Call, response: Response) { - if(response.code() == 200) { - val resp = response.body()!! - - //Update shown like count and internal like toggle - holder.nlikes.text = resp.getNLikes(holder.context) - holder.liker.isChecked = resp.favourited ?: false - } else { - Log.e("RESPONSE_CODE", response.code().toString()) - holder.liker.isChecked = true - } - - } - - }) - } - - fun postComment( - holder : PostViewHolder, - api: PixelfedAPI, - credential: String, - post : Status - ) { - val textIn = holder.comment.text - val nonNullText = textIn.toString() - api.postStatus(credential, nonNullText, post.id).enqueue(object : - Callback { - override fun onFailure(call: Call, t: Throwable) { - Log.e("COMMENT ERROR", t.toString()) - Toast.makeText(holder.context, holder.context.getString(R.string.comment_error), - Toast.LENGTH_SHORT).show() - } - - override fun onResponse(call: Call, response: Response) { - //Check that the received response code is valid - if(response.code() == 200) { - val resp = response.body()!! - holder.commentIn.visibility = View.GONE - - //Add the comment to the comment section - addComment(holder.context, holder.commentCont, resp.account!!.username!!, - resp.content!! - ) - - Toast.makeText(holder.context, - holder.context.getString(R.string.comment_posted).format(textIn), - Toast.LENGTH_SHORT).show() - Log.e("COMMENT SUCCESS", "posted: $textIn") - } else { - Log.e("ERROR_CODE", response.code().toString()) - } - } - }) - } - - fun addComment(context: android.content.Context, commentContainer: LinearLayout, commentUsername: String, commentContent: String) { - - val view = LayoutInflater.from(context) - .inflate(R.layout.comment, commentContainer, true) - - view.user.text = commentUsername - view.commentText.text = commentContent - } - - fun retrieveComments( - holder : PostViewHolder, - api: PixelfedAPI, - credential: String, - post : Status - ) { - api.statusComments(post.id!!, credential).enqueue(object : - Callback { - override fun onFailure(call: Call, t: Throwable) { - Log.e("COMMENT FETCH ERROR", t.toString()) - } - - override fun onResponse( - call: Call, - response: Response - ) { - if(response.code() == 200) { - val statuses = response.body()!!.descendants - - holder.commentCont.removeAllViews() - - //Create the new views for each comment - for (status in statuses) { - addComment(holder.context, holder.commentCont, status.account!!.username!!, - status.content!! - ) - } - holder.commentCont.visibility = View.VISIBLE - } else { - Log.e("COMMENT ERROR", "${response.code()} with body ${response.errorBody()}") - } - } - }) - } fun censorColorMatrix(): ColorMatrix { val array: FloatArray = floatArrayOf( 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 1f, 0f ) diff --git a/app/src/main/res/layout/activity_post.xml b/app/src/main/res/layout/activity_post.xml index 634404be..c83fc99c 100644 --- a/app/src/main/res/layout/activity_post.xml +++ b/app/src/main/res/layout/activity_post.xml @@ -20,6 +20,7 @@ + - + app:layout_constraintTop_toTopOf="parent" + tools:context=".fragments.PostFragment" + tools:visibility="visible"/> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_image.xml b/app/src/main/res/layout/album_image_view.xml similarity index 81% rename from app/src/main/res/layout/fragment_image.xml rename to app/src/main/res/layout/album_image_view.xml index dbc1cf71..09f1cb9a 100644 --- a/app/src/main/res/layout/fragment_image.xml +++ b/app/src/main/res/layout/album_image_view.xml @@ -1,9 +1,9 @@ + + android:layout_height="match_parent"> - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml index 8cb2963a..7ab966f2 100644 --- a/app/src/main/res/layout/fragment_feed.xml +++ b/app/src/main/res/layout/fragment_feed.xml @@ -6,6 +6,17 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + - + + + + + + + + + - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/load_state_footer_view_item.xml b/app/src/main/res/layout/load_state_footer_view_item.xml new file mode 100644 index 00000000..36d313c6 --- /dev/null +++ b/app/src/main/res/layout/load_state_footer_view_item.xml @@ -0,0 +1,45 @@ + + + + + + +