From c859669ac1c585f6e40f49010bbb97f4b3fef5b7 Mon Sep 17 00:00:00 2001 From: Ammar Githam Date: Wed, 14 Apr 2021 00:17:23 +0900 Subject: [PATCH] Add search fragment with recent searches --- app/build.gradle | 20 + .../awais.instagrabber.db.AppDatabase/6.json | 227 +++++++++++ .../awais/instagrabber/db/MigrationTest.java | 51 +++ .../db/dao/RecentSearchDaoTest.java | 82 ++++ .../instagrabber/activities/MainActivity.java | 310 +++++---------- .../adapters/FavoritesAdapter.java | 4 +- .../adapters/SearchCategoryAdapter.java | 33 ++ .../adapters/SearchItemsAdapter.java | 215 +++++++++++ .../adapters/SuggestionsAdapter.java | 77 ---- .../viewholder/FavoriteViewHolder.java | 20 +- .../viewholder/SearchItemViewHolder.java | 80 ++++ .../awais/instagrabber/db/AppDatabase.java | 25 +- .../instagrabber/db/dao/RecentSearchDao.java | 37 ++ .../datasources/RecentSearchDataSource.java | 57 +++ .../db/entities/RecentSearch.java | 185 +++++++++ .../repositories/RecentSearchRepository.java | 124 ++++++ .../fragments/FavoritesFragment.java | 4 +- .../search/SearchCategoryFragment.java | 195 ++++++++++ .../fragments/search/SearchFragment.java | 245 ++++++++++++ .../models/enums/FavoriteType.java | 3 +- .../repositories/responses/Hashtag.java | 18 + .../repositories/responses/Place.java | 19 + .../responses/search/SearchItem.java | 236 ++++++++++++ .../viewmodels/AppStateViewModel.java | 9 +- .../viewmodels/SearchFragmentViewModel.java | 352 ++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 24 +- .../main/res/layout/fragment_favorites.xml | 2 +- app/src/main/res/layout/fragment_search.xml | 18 + ..._suggestion.xml => item_search_result.xml} | 41 +- app/src/main/res/menu/main_menu.xml | 19 +- .../navigation/direct_messages_nav_graph.xml | 10 + .../res/navigation/discover_nav_graph.xml | 9 + .../res/navigation/favorites_nav_graph.xml | 13 +- .../main/res/navigation/feed_nav_graph.xml | 10 +- .../main/res/navigation/hashtag_nav_graph.xml | 9 + .../res/navigation/location_nav_graph.xml | 9 + .../main/res/navigation/profile_nav_graph.xml | 37 +- app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/styles.xml | 9 + 39 files changed, 2455 insertions(+), 386 deletions(-) create mode 100644 app/schemas/awais.instagrabber.db.AppDatabase/6.json create mode 100644 app/src/androidTest/java/awais/instagrabber/db/MigrationTest.java create mode 100644 app/src/androidTest/java/awais/instagrabber/db/dao/RecentSearchDaoTest.java create mode 100644 app/src/main/java/awais/instagrabber/adapters/SearchCategoryAdapter.java create mode 100644 app/src/main/java/awais/instagrabber/adapters/SearchItemsAdapter.java delete mode 100755 app/src/main/java/awais/instagrabber/adapters/SuggestionsAdapter.java create mode 100644 app/src/main/java/awais/instagrabber/adapters/viewholder/SearchItemViewHolder.java create mode 100644 app/src/main/java/awais/instagrabber/db/dao/RecentSearchDao.java create mode 100644 app/src/main/java/awais/instagrabber/db/datasources/RecentSearchDataSource.java create mode 100644 app/src/main/java/awais/instagrabber/db/entities/RecentSearch.java create mode 100644 app/src/main/java/awais/instagrabber/db/repositories/RecentSearchRepository.java create mode 100644 app/src/main/java/awais/instagrabber/fragments/search/SearchCategoryFragment.java create mode 100644 app/src/main/java/awais/instagrabber/fragments/search/SearchFragment.java create mode 100644 app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.java create mode 100644 app/src/main/res/layout/fragment_search.xml rename app/src/main/res/layout/{item_suggestion.xml => item_search_result.xml} (65%) mode change 100755 => 100644 diff --git a/app/build.gradle b/app/build.gradle index 871f8cc5..044c8f41 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -33,6 +33,12 @@ android { arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] } } + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } compileOptions { @@ -95,6 +101,13 @@ android { outputFileName = "barinsta_${suffix}.apk" } } + + packagingOptions { + // Exclude file to avoid + // Error: Duplicate files during packaging of APK + exclude 'META-INF/LICENSE.md' + exclude 'META-INF/LICENSE-notice.md' + } } configurations.all { @@ -165,4 +178,11 @@ dependencies { githubImplementation 'io.sentry:sentry-android:4.3.0' testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1' + + androidTestImplementation 'org.junit.jupiter:junit-jupiter:5.7.1' + androidTestImplementation 'androidx.test:core:1.3.0' + androidTestImplementation 'com.android.support:support-annotations:28.0.0' + androidTestImplementation 'com.android.support.test:runner:1.0.2' + androidTestImplementation "androidx.room:room-testing:2.2.6" + } diff --git a/app/schemas/awais.instagrabber.db.AppDatabase/6.json b/app/schemas/awais.instagrabber.db.AppDatabase/6.json new file mode 100644 index 00000000..4a2f5199 --- /dev/null +++ b/app/schemas/awais.instagrabber.db.AppDatabase/6.json @@ -0,0 +1,227 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "232e618b3bfcb4661336b359d036c455", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uid` TEXT, `username` TEXT, `cookie` TEXT, `full_name` TEXT, `profile_pic` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fullName", + "columnName": "full_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePic", + "columnName": "profile_pic", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query_text` TEXT, `type` TEXT, `display_name` TEXT, `pic_url` TEXT, `date_added` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "picUrl", + "columnName": "pic_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dm_last_notified", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `thread_id` TEXT, `last_notified_msg_ts` INTEGER, `last_notified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "thread_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastNotifiedMsgTs", + "columnName": "last_notified_msg_ts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastNotifiedAt", + "columnName": "last_notified_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_dm_last_notified_thread_id", + "unique": true, + "columnNames": [ + "thread_id" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dm_last_notified_thread_id` ON `${TABLE_NAME}` (`thread_id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "recent_searches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ig_id` TEXT NOT NULL, `name` TEXT NOT NULL, `username` TEXT, `pic_url` TEXT, `type` TEXT NOT NULL, `last_searched_on` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "igId", + "columnName": "ig_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "picUrl", + "columnName": "pic_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSearchedOn", + "columnName": "last_searched_on", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_recent_searches_ig_id_type", + "unique": true, + "columnNames": [ + "ig_id", + "type" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_recent_searches_ig_id_type` ON `${TABLE_NAME}` (`ig_id`, `type`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '232e618b3bfcb4661336b359d036c455')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/awais/instagrabber/db/MigrationTest.java b/app/src/androidTest/java/awais/instagrabber/db/MigrationTest.java new file mode 100644 index 00000000..5156c5ff --- /dev/null +++ b/app/src/androidTest/java/awais/instagrabber/db/MigrationTest.java @@ -0,0 +1,51 @@ +package awais.instagrabber.db; + +import androidx.room.Room; +import androidx.room.migration.Migration; +import androidx.room.testing.MigrationTestHelper; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +import static awais.instagrabber.db.AppDatabase.MIGRATION_4_5; +import static awais.instagrabber.db.AppDatabase.MIGRATION_5_6; + +@RunWith(AndroidJUnit4.class) +public class MigrationTest { + private static final String TEST_DB = "migration-test"; + private static final Migration[] ALL_MIGRATIONS = new Migration[]{MIGRATION_4_5, MIGRATION_5_6}; + + @Rule + public MigrationTestHelper helper; + + public MigrationTest() { + final String canonicalName = AppDatabase.class.getCanonicalName(); + assert canonicalName != null; + helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(), + canonicalName, + new FrameworkSQLiteOpenHelperFactory()); + } + + @Test + public void migrateAll() throws IOException { + // Create earliest version of the database. Have to start with 4 since that is the version we migrated to Room. + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 4); + db.close(); + + // Open latest version of the database. Room will validate the schema + // once all migrations execute. + AppDatabase appDb = Room.databaseBuilder(InstrumentationRegistry.getInstrumentation().getTargetContext(), + AppDatabase.class, + TEST_DB) + .addMigrations(ALL_MIGRATIONS).build(); + appDb.getOpenHelper().getWritableDatabase(); + appDb.close(); + } +} diff --git a/app/src/androidTest/java/awais/instagrabber/db/dao/RecentSearchDaoTest.java b/app/src/androidTest/java/awais/instagrabber/db/dao/RecentSearchDaoTest.java new file mode 100644 index 00000000..c8a48775 --- /dev/null +++ b/app/src/androidTest/java/awais/instagrabber/db/dao/RecentSearchDaoTest.java @@ -0,0 +1,82 @@ +package awais.instagrabber.db.dao; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.room.Room; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.runner.AndroidJUnit4; + +import com.google.common.collect.ImmutableList; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.runner.RunWith; + +import java.time.LocalDateTime; +import java.util.List; + +import awais.instagrabber.db.AppDatabase; +import awais.instagrabber.db.entities.RecentSearch; +import awais.instagrabber.models.enums.FavoriteType; + +@RunWith(AndroidJUnit4.class) +public class RecentSearchDaoTest { + private static final String TAG = RecentSearchDaoTest.class.getSimpleName(); + + private RecentSearchDao dao; + private AppDatabase db; + + @Before + public void createDb() { + final Context context = ApplicationProvider.getApplicationContext(); + db = Room.inMemoryDatabaseBuilder(context, AppDatabase.class).build(); + dao = db.recentSearchDao(); + } + + @After + public void closeDb() { + db.close(); + } + + @Test + public void writeQueryDelete() { + final RecentSearch recentSearch = insertRecentSearch("1", "test1", FavoriteType.HASHTAG); + final RecentSearch byIgIdAndType = dao.getRecentSearchByIgIdAndType("1", FavoriteType.HASHTAG); + Assertions.assertEquals(recentSearch, byIgIdAndType); + dao.deleteRecentSearch(byIgIdAndType); + final RecentSearch deleted = dao.getRecentSearchByIgIdAndType("1", FavoriteType.HASHTAG); + Assertions.assertNull(deleted); + } + + @Test + public void queryAllOrdered() { + final List insertListReversed = ImmutableList + .builder() + .add(insertRecentSearch("1", "test1", FavoriteType.HASHTAG)) + .add(insertRecentSearch("2", "test2", FavoriteType.LOCATION)) + .add(insertRecentSearch("3", "test3", FavoriteType.USER)) + .add(insertRecentSearch("4", "test4", FavoriteType.USER)) + .add(insertRecentSearch("5", "test5", FavoriteType.USER)) + .build() + .reverse(); // important + final List fromDb = dao.getAllRecentSearches(); + Assertions.assertIterableEquals(insertListReversed, fromDb); + } + + @NonNull + private RecentSearch insertRecentSearch(final String igId, final String name, final FavoriteType type) { + final RecentSearch recentSearch = new RecentSearch( + igId, + name, + null, + null, + type, + LocalDateTime.now() + ); + dao.insertRecentSearch(recentSearch); + return recentSearch; + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/activities/MainActivity.java b/app/src/main/java/awais/instagrabber/activities/MainActivity.java index 8882bed9..cc833e97 100644 --- a/app/src/main/java/awais/instagrabber/activities/MainActivity.java +++ b/app/src/main/java/awais/instagrabber/activities/MainActivity.java @@ -8,19 +8,18 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; -import android.database.MatrixCursor; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; -import android.provider.BaseColumns; +import android.text.Editable; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.WindowManager; -import android.widget.AutoCompleteTextView; +import android.widget.EditText; import android.widget.Toast; import androidx.annotation.IdRes; @@ -28,7 +27,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.widget.SearchView; import androidx.appcompat.widget.Toolbar; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.app.NotificationManagerCompat; @@ -50,10 +48,10 @@ import com.google.android.material.appbar.CollapsingToolbarLayout; import com.google.android.material.badge.BadgeDrawable; import com.google.android.material.behavior.HideBottomViewOnScrollBehavior; import com.google.android.material.bottomnavigation.BottomNavigationView; +import com.google.android.material.textfield.TextInputLayout; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterators; -import java.util.ArrayList; import java.util.Collections; import java.util.Deque; import java.util.List; @@ -62,9 +60,9 @@ import java.util.stream.Collectors; import awais.instagrabber.BuildConfig; import awais.instagrabber.R; -import awais.instagrabber.adapters.SuggestionsAdapter; import awais.instagrabber.asyncs.PostFetcher; import awais.instagrabber.customviews.emoji.EmojiVariantManager; +import awais.instagrabber.customviews.helpers.TextWatcherAdapter; import awais.instagrabber.databinding.ActivityMainBinding; import awais.instagrabber.fragments.PostViewV2Fragment; import awais.instagrabber.fragments.directmessages.DirectMessageInboxFragmentDirections; @@ -72,9 +70,6 @@ import awais.instagrabber.fragments.main.FeedFragment; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.models.IntentModel; import awais.instagrabber.models.Tab; -import awais.instagrabber.models.enums.SuggestionType; -import awais.instagrabber.repositories.responses.search.SearchItem; -import awais.instagrabber.repositories.responses.search.SearchResponse; import awais.instagrabber.services.ActivityCheckerService; import awais.instagrabber.services.DMSyncAlarmReceiver; import awais.instagrabber.utils.AppExecutors; @@ -88,10 +83,6 @@ import awais.instagrabber.utils.emoji.EmojiParser; import awais.instagrabber.viewmodels.AppStateViewModel; import awais.instagrabber.viewmodels.DirectInboxViewModel; import awais.instagrabber.webservices.RetrofitFactory; -import awais.instagrabber.webservices.SearchService; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; import static awais.instagrabber.utils.NavigationExtensions.setupWithNavController; import static awais.instagrabber.utils.Utils.settingsHelper; @@ -100,16 +91,19 @@ public class MainActivity extends BaseLanguageActivity implements FragmentManage private static final String TAG = "MainActivity"; private static final String FIRST_FRAGMENT_GRAPH_INDEX_KEY = "firstFragmentGraphIndex"; private static final String LAST_SELECT_NAV_MENU_ID = "lastSelectedNavMenuId"; + private static final List SEARCH_VISIBLE_DESTINATIONS = ImmutableList.of( + R.id.feedFragment, + R.id.profileFragment, + R.id.directMessagesInboxFragment, + R.id.discoverFragment, + R.id.favoritesFragment, + R.id.hashTagFragment, + R.id.locationFragment + ); private ActivityMainBinding binding; private LiveData currentNavControllerLiveData; private MenuItem searchMenuItem; - private SuggestionsAdapter suggestionAdapter; - private AutoCompleteTextView searchAutoComplete; - private SearchView searchView; - private SearchService searchService; - private boolean showSearch = true; - private Handler suggestionsFetchHandler; private int firstFragmentGraphIndex; private int lastSelectedNavMenuId; private boolean isActivityCheckerServiceBound = false; @@ -155,7 +149,6 @@ public class MainActivity extends BaseLanguageActivity implements FragmentManage if (savedInstanceState == null) { setupBottomNavigationBar(true); } - setupSuggestions(); if (!BuildConfig.isPre) { final boolean checkUpdates = settingsHelper.getBoolean(Constants.CHECK_UPDATES); if (checkUpdates) FlavorTown.updateCheck(this); @@ -174,9 +167,9 @@ public class MainActivity extends BaseLanguageActivity implements FragmentManage EmojiVariantManager.getInstance(); }); initEmojiCompat(); - searchService = SearchService.getInstance(); // initDmService(); initDmUnreadCount(); + initSearchInput(); } private void setupCookie() { @@ -216,25 +209,74 @@ public class MainActivity extends BaseLanguageActivity implements FragmentManage }); } + private void initSearchInput() { + binding.searchInputLayout.setEndIconOnClickListener(v -> { + final EditText editText = binding.searchInputLayout.getEditText(); + if (editText == null) return; + editText.setText(""); + }); + binding.searchInputLayout.addOnEditTextAttachedListener(textInputLayout -> { + textInputLayout.setEndIconVisible(false); + final EditText editText = textInputLayout.getEditText(); + if (editText == null) return; + editText.addTextChangedListener(new TextWatcherAdapter() { + @Override + public void afterTextChanged(final Editable s) { + binding.searchInputLayout.setEndIconVisible(!TextUtils.isEmpty(s)); + } + }); + }); + } + @Override public boolean onCreateOptionsMenu(final Menu menu) { getMenuInflater().inflate(R.menu.main_menu, menu); searchMenuItem = menu.findItem(R.id.search); - if (showSearch && currentNavControllerLiveData != null) { - final NavController navController = currentNavControllerLiveData.getValue(); - if (navController != null) { - final NavDestination currentDestination = navController.getCurrentDestination(); - if (currentDestination != null) { - final int destinationId = currentDestination.getId(); - showSearch = destinationId == R.id.profileFragment; - } + final NavController navController = currentNavControllerLiveData.getValue(); + if (navController != null) { + final NavDestination currentDestination = navController.getCurrentDestination(); + if (currentDestination != null) { + @SuppressLint("RestrictedApi") final Deque backStack = navController.getBackStack(); + setupMenu(backStack.size(), currentDestination.getId()); } } - if (!showSearch) { - searchMenuItem.setVisible(false); - return true; + // if (binding.searchInputLayout.getVisibility() == View.VISIBLE) { + // searchMenuItem.setVisible(false).setEnabled(false); + // return true; + // } + // searchMenuItem.setVisible(true).setEnabled(true); + // if (showSearch && currentNavControllerLiveData != null) { + // final NavController navController = currentNavControllerLiveData.getValue(); + // if (navController != null) { + // final NavDestination currentDestination = navController.getCurrentDestination(); + // if (currentDestination != null) { + // final int destinationId = currentDestination.getId(); + // showSearch = destinationId == R.id.profileFragment; + // } + // } + // } + // if (!showSearch) { + // searchMenuItem.setVisible(false); + // return true; + // } + // return setupSearchView(); + return true; + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getItemId() == R.id.search) { + final NavController navController = currentNavControllerLiveData.getValue(); + if (navController == null) return false; + try { + navController.navigate(R.id.action_global_search); + return true; + } catch (Exception e) { + Log.e(TAG, "onOptionsItemSelected: ", e); + } + return false; } - return setupSearchView(); + return super.onOptionsItemSelected(item); } @Override @@ -336,176 +378,6 @@ public class MainActivity extends BaseLanguageActivity implements FragmentManage notificationManager.createNotificationChannel(silentNotificationChannel); } - private void setupSuggestions() { - suggestionsFetchHandler = new Handler(); - suggestionAdapter = new SuggestionsAdapter(this, (type, query) -> { - if (searchMenuItem != null) searchMenuItem.collapseActionView(); - if (searchView != null && !searchView.isIconified()) searchView.setIconified(true); - if (currentNavControllerLiveData == null) return; - final NavController navController = currentNavControllerLiveData.getValue(); - if (navController == null) return; - final Bundle bundle = new Bundle(); - switch (type) { - case TYPE_LOCATION: - bundle.putLong("locationId", Long.parseLong(query)); - navController.navigate(R.id.action_global_locationFragment, bundle); - break; - case TYPE_HASHTAG: - bundle.putString("hashtag", query); - navController.navigate(R.id.action_global_hashTagFragment, bundle); - break; - case TYPE_USER: - bundle.putString("username", query); - navController.navigate(R.id.action_global_profileFragment, bundle); - break; - } - }); - } - - private boolean setupSearchView() { - final View actionView = searchMenuItem.getActionView(); - if (!(actionView instanceof SearchView)) return false; - searchView = (SearchView) actionView; - searchView.setSuggestionsAdapter(suggestionAdapter); - searchView.setMaxWidth(Integer.MAX_VALUE); - final View searchText = searchView.findViewById(R.id.search_src_text); - if (searchText instanceof AutoCompleteTextView) { - searchAutoComplete = (AutoCompleteTextView) searchText; - } - searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { - private boolean searchUser; - private boolean searchHash; - private Call prevSuggestionAsync; - private final String[] COLUMNS = { - BaseColumns._ID, - Constants.EXTRAS_USERNAME, - Constants.EXTRAS_NAME, - Constants.EXTRAS_TYPE, - "query", - "pfp", - "verified" - }; - private String currentSearchQuery; - - private final Callback cb = new Callback() { - @Override - public void onResponse(@NonNull final Call call, - @NonNull final Response response) { - final MatrixCursor cursor; - final SearchResponse body = response.body(); - if (body == null) { - cursor = null; - return; - } - final List result = new ArrayList<>(); - if (isLoggedIn) { - if (body.getList() != null) { - result.addAll(searchHash ? body.getList() - .stream() - .filter(i -> i.getUser() == null) - .collect(Collectors.toList()) - : body.getList()); - } - } else { - if (body.getUsers() != null && !searchHash) result.addAll(body.getUsers()); - if (body.getHashtags() != null) result.addAll(body.getHashtags()); - if (body.getPlaces() != null) result.addAll(body.getPlaces()); - } - cursor = new MatrixCursor(COLUMNS, 0); - for (int i = 0; i < result.size(); i++) { - final SearchItem suggestionModel = result.get(i); - if (suggestionModel != null) { - Object[] objects = null; - if (suggestionModel.getUser() != null) - objects = new Object[]{ - suggestionModel.getPosition(), - suggestionModel.getUser().getUsername(), - suggestionModel.getUser().getFullName(), - SuggestionType.TYPE_USER, - suggestionModel.getUser().getUsername(), - suggestionModel.getUser().getProfilePicUrl(), - suggestionModel.getUser().isVerified()}; - else if (suggestionModel.getHashtag() != null) - objects = new Object[]{ - suggestionModel.getPosition(), - suggestionModel.getHashtag().getName(), - suggestionModel.getHashtag().getSubtitle(), - SuggestionType.TYPE_HASHTAG, - suggestionModel.getHashtag().getName(), - "res:/" + R.drawable.ic_hashtag, - false}; - else if (suggestionModel.getPlace() != null) - objects = new Object[]{ - suggestionModel.getPosition(), - suggestionModel.getPlace().getTitle(), - suggestionModel.getPlace().getSubtitle(), - SuggestionType.TYPE_LOCATION, - suggestionModel.getPlace().getLocation().getPk(), - "res:/" + R.drawable.ic_location, - false}; - cursor.addRow(objects); - } - } - suggestionAdapter.changeCursor(cursor); - } - - @Override - public void onFailure(@NonNull final Call call, - @NonNull Throwable t) { - if (!call.isCanceled()) { - Log.e(TAG, "Exception on search:", t); - } - } - }; - - private final Runnable runnable = () -> { - cancelSuggestionsAsync(); - if (TextUtils.isEmpty(currentSearchQuery)) { - suggestionAdapter.changeCursor(null); - return; - } - searchUser = currentSearchQuery.charAt(0) == '@'; - searchHash = currentSearchQuery.charAt(0) == '#'; - if (currentSearchQuery.length() == 1 && (searchHash || searchUser)) { - if (searchAutoComplete != null) { - searchAutoComplete.setThreshold(2); - } - } else { - if (searchAutoComplete != null) { - searchAutoComplete.setThreshold(1); - } - prevSuggestionAsync = searchService.search(isLoggedIn, - searchUser || searchHash ? currentSearchQuery.substring(1) - : currentSearchQuery, - searchUser ? "user" : (searchHash ? "hashtag" : "blended")); - suggestionAdapter.changeCursor(null); - prevSuggestionAsync.enqueue(cb); - } - }; - - private void cancelSuggestionsAsync() { - if (prevSuggestionAsync != null) - try { - prevSuggestionAsync.cancel(); - } catch (final Exception ignored) {} - } - - @Override - public boolean onQueryTextSubmit(final String query) { - return onQueryTextChange(query); - } - - @Override - public boolean onQueryTextChange(final String query) { - suggestionsFetchHandler.removeCallbacks(runnable); - currentSearchQuery = query; - suggestionsFetchHandler.postDelayed(runnable, 800); - return true; - } - }); - return true; - } - private void setupBottomNavigationBar(final boolean setDefaultTabFromSettings) { currentTabs = !isLoggedIn ? setupAnonBottomNav() : setupMainBottomNav(); final List mainNavList = currentTabs.stream() @@ -606,20 +478,6 @@ public class MainActivity extends BaseLanguageActivity implements FragmentManage binding.bottomNavView.setSelectedItemId(navGraphRootId); } - // @NonNull - // private List getMainNavList(final int main_nav_ids) { - // final TypedArray navIds = getResources().obtainTypedArray(main_nav_ids); - // final List mainNavList = new ArrayList<>(navIds.length()); - // final int length = navIds.length(); - // for (int i = 0; i < length; i++) { - // final int resourceId = navIds.getResourceId(i, -1); - // if (resourceId < 0) continue; - // mainNavList.add(resourceId); - // } - // navIds.recycle(); - // return mainNavList; - // } - private void setupNavigation(final Toolbar toolbar, final NavController navController) { if (navController == null) return; NavigationUI.setupWithNavController(toolbar, navController); @@ -651,12 +509,10 @@ public class MainActivity extends BaseLanguageActivity implements FragmentManage private void setupMenu(final int backStackSize, final int destinationId) { if (searchMenuItem == null) return; - if (backStackSize >= 2 && destinationId == R.id.profileFragment) { - showSearch = true; + if (backStackSize >= 2 && SEARCH_VISIBLE_DESTINATIONS.contains(destinationId)) { searchMenuItem.setVisible(true); return; } - showSearch = false; searchMenuItem.setVisible(false); } @@ -935,10 +791,6 @@ public class MainActivity extends BaseLanguageActivity implements FragmentManage return currentTabs; } - // public boolean isNavRootInCurrentTabs(@IdRes final int navRootId) { - // return showBottomViewDestinations.stream().anyMatch(id -> id == navRootId); - // } - private void setNavBarDMUnreadCountBadge(final int unseenCount) { final BadgeDrawable badge = binding.bottomNavView.getOrCreateBadge(R.id.direct_messages_nav_graph); if (badge == null) return; @@ -953,4 +805,14 @@ public class MainActivity extends BaseLanguageActivity implements FragmentManage badge.setNumber(unseenCount); badge.setVisible(true); } + + @NonNull + public TextInputLayout showSearchView() { + binding.searchInputLayout.setVisibility(View.VISIBLE); + return binding.searchInputLayout; + } + + public void hideSearchView() { + binding.searchInputLayout.setVisibility(View.GONE); + } } \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/FavoritesAdapter.java b/app/src/main/java/awais/instagrabber/adapters/FavoritesAdapter.java index d697343a..b9d89ce7 100644 --- a/app/src/main/java/awais/instagrabber/adapters/FavoritesAdapter.java +++ b/app/src/main/java/awais/instagrabber/adapters/FavoritesAdapter.java @@ -19,7 +19,7 @@ import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.viewholder.FavoriteViewHolder; import awais.instagrabber.databinding.ItemFavSectionHeaderBinding; -import awais.instagrabber.databinding.ItemSuggestionBinding; +import awais.instagrabber.databinding.ItemSearchResultBinding; import awais.instagrabber.db.entities.Favorite; import awais.instagrabber.models.enums.FavoriteType; @@ -73,7 +73,7 @@ public class FavoritesAdapter extends RecyclerView.Adapter categories; + + public SearchCategoryAdapter(@NonNull final Fragment fragment, + @NonNull final List categories) { + super(fragment); + this.categories = categories; + + } + + @NonNull + @Override + public Fragment createFragment(final int position) { + return SearchCategoryFragment.newInstance(categories.get(position)); + } + + @Override + public int getItemCount() { + return categories.size(); + } +} diff --git a/app/src/main/java/awais/instagrabber/adapters/SearchItemsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/SearchItemsAdapter.java new file mode 100644 index 00000000..e26059de --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/SearchItemsAdapter.java @@ -0,0 +1,215 @@ +package awais.instagrabber.adapters; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.AdapterListUpdateCallback; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.R; +import awais.instagrabber.adapters.viewholder.SearchItemViewHolder; +import awais.instagrabber.databinding.ItemFavSectionHeaderBinding; +import awais.instagrabber.databinding.ItemSearchResultBinding; +import awais.instagrabber.fragments.search.SearchCategoryFragment.OnSearchItemClickListener; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.search.SearchItem; + +public final class SearchItemsAdapter extends RecyclerView.Adapter { + private static final String TAG = SearchItemsAdapter.class.getSimpleName(); + private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull final SearchItemOrHeader oldItem, @NonNull final SearchItemOrHeader newItem) { + return Objects.equals(oldItem, newItem); + } + + @Override + public boolean areContentsTheSame(@NonNull final SearchItemOrHeader oldItem, @NonNull final SearchItemOrHeader newItem) { + return Objects.equals(oldItem, newItem); + } + }; + private static final String RECENT = "recent"; + private static final String FAVORITE = "favorite"; + private static final int VIEW_TYPE_HEADER = 0; + private static final int VIEW_TYPE_ITEM = 1; + + private final OnSearchItemClickListener onSearchItemClickListener; + private final AsyncListDiffer differ; + + public SearchItemsAdapter(final OnSearchItemClickListener onSearchItemClickListener) { + differ = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), + new AsyncDifferConfig.Builder<>(DIFF_CALLBACK).build()); + this.onSearchItemClickListener = onSearchItemClickListener; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + if (viewType == VIEW_TYPE_HEADER) { + return new HeaderViewHolder(ItemFavSectionHeaderBinding.inflate(layoutInflater, parent, false)); + } + final ItemSearchResultBinding binding = ItemSearchResultBinding.inflate(layoutInflater, parent, false); + return new SearchItemViewHolder(binding, onSearchItemClickListener); + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { + if (getItemViewType(position) == VIEW_TYPE_HEADER) { + final SearchItemOrHeader searchItemOrHeader = getItem(position); + if (!searchItemOrHeader.isHeader()) return; + ((HeaderViewHolder) holder).bind(searchItemOrHeader.header); + return; + } + ((SearchItemViewHolder) holder).bind(getItem(position).searchItem); + } + + protected SearchItemOrHeader getItem(int position) { + return differ.getCurrentList().get(position); + } + + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + @Override + public int getItemViewType(final int position) { + return getItem(position).isHeader() ? VIEW_TYPE_HEADER : VIEW_TYPE_ITEM; + } + + public void submitList(@Nullable final List list) { + if (list == null) { + differ.submitList(null); + return; + } + differ.submitList(sectionAndSort(list)); + } + + public void submitList(@Nullable final List list, @Nullable final Runnable commitCallback) { + if (list == null) { + differ.submitList(null, commitCallback); + return; + } + differ.submitList(sectionAndSort(list), commitCallback); + } + + @NonNull + private List sectionAndSort(@NonNull final List list) { + final boolean containsRecentOrFavorite = list.stream().anyMatch(searchItem -> searchItem.isRecent() || searchItem.isFavorite()); + // Don't do anything if not showing recent results + if (!containsRecentOrFavorite) { + return list.stream() + .map(SearchItemOrHeader::new) + .collect(Collectors.toList()); + } + final List listCopy = new ArrayList<>(list); + Collections.sort(listCopy, (o1, o2) -> { + final boolean bothRecent = o1.isRecent() && o2.isRecent(); + if (bothRecent) { + // Don't sort + return 0; + } + final boolean bothFavorite = o1.isFavorite() && o2.isFavorite(); + if (bothFavorite) { + if (o1.getType() == o2.getType()) return 0; + // keep users at top + if (o1.getType() == FavoriteType.USER) return -1; + if (o2.getType() == FavoriteType.USER) return 1; + // keep locations at bottom + if (o1.getType() == FavoriteType.LOCATION) return 1; + if (o2.getType() == FavoriteType.LOCATION) return -1; + } + // keep recents at top + if (o1.isRecent()) return -1; + if (o2.isRecent()) return 1; + return 0; + }); + final List itemOrHeaders = new ArrayList<>(); + for (int i = 0; i < listCopy.size(); i++) { + final SearchItem searchItem = listCopy.get(i); + final SearchItemOrHeader prev = itemOrHeaders.isEmpty() ? null : itemOrHeaders.get(itemOrHeaders.size() - 1); + boolean prevWasSameType = prev != null && ((prev.searchItem.isRecent() && searchItem.isRecent()) + || (prev.searchItem.isFavorite() && searchItem.isFavorite())); + if (prevWasSameType) { + // just add the item + itemOrHeaders.add(new SearchItemOrHeader(searchItem)); + continue; + } + // add header and item + // add header only if search item is recent or favorite + if (searchItem.isRecent() || searchItem.isFavorite()) { + itemOrHeaders.add(new SearchItemOrHeader(searchItem.isRecent() ? RECENT : FAVORITE)); + } + itemOrHeaders.add(new SearchItemOrHeader(searchItem)); + } + return itemOrHeaders; + } + + private static class SearchItemOrHeader { + String header; + SearchItem searchItem; + + public SearchItemOrHeader(final SearchItem searchItem) { + this.searchItem = searchItem; + } + + public SearchItemOrHeader(final String header) { + this.header = header; + } + + boolean isHeader() { + return header != null; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final SearchItemOrHeader that = (SearchItemOrHeader) o; + return Objects.equals(header, that.header) && + Objects.equals(searchItem, that.searchItem); + } + + @Override + public int hashCode() { + return Objects.hash(header, searchItem); + } + } + + public static class HeaderViewHolder extends RecyclerView.ViewHolder { + private final ItemFavSectionHeaderBinding binding; + + public HeaderViewHolder(@NonNull final ItemFavSectionHeaderBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + public void bind(final String header) { + if (header == null) return; + final int headerText; + switch (header) { + case RECENT: + headerText = R.string.recent; + break; + case FAVORITE: + headerText = R.string.title_favorites; + break; + default: + headerText = R.string.unknown; + break; + } + binding.getRoot().setText(headerText); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/SuggestionsAdapter.java b/app/src/main/java/awais/instagrabber/adapters/SuggestionsAdapter.java deleted file mode 100755 index 6c51f1f7..00000000 --- a/app/src/main/java/awais/instagrabber/adapters/SuggestionsAdapter.java +++ /dev/null @@ -1,77 +0,0 @@ -package awais.instagrabber.adapters; - -import android.content.Context; -import android.database.Cursor; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.cursoradapter.widget.CursorAdapter; - -import awais.instagrabber.databinding.ItemSuggestionBinding; -import awais.instagrabber.models.enums.SuggestionType; - -public final class SuggestionsAdapter extends CursorAdapter { - private static final String TAG = "SuggestionsAdapter"; - - private final OnSuggestionClickListener onSuggestionClickListener; - - public SuggestionsAdapter(final Context context, - final OnSuggestionClickListener onSuggestionClickListener) { - super(context, null, FLAG_REGISTER_CONTENT_OBSERVER); - this.onSuggestionClickListener = onSuggestionClickListener; - } - - @Override - public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { - final LayoutInflater layoutInflater = LayoutInflater.from(context); - final ItemSuggestionBinding binding = ItemSuggestionBinding.inflate(layoutInflater, parent, false); - return binding.getRoot(); - // return layoutInflater.inflate(R.layout.item_suggestion, parent, false); - } - - @Override - public void bindView(@NonNull final View view, final Context context, @NonNull final Cursor cursor) { - // i, username, fullname, type, query, picUrl, verified - // 0, 1 , 2 , 3 , 4 , 5 , 6 - final String fullName = cursor.getString(2); - String username = cursor.getString(1); - String picUrl = cursor.getString(5); - final boolean verified = cursor.getString(6).charAt(0) == 't'; - - final String type = cursor.getString(3); - SuggestionType suggestionType = null; - try { - suggestionType = SuggestionType.valueOf(type); - } catch (IllegalArgumentException e) { - Log.e(TAG, "Unknown suggestion type: " + type, e); - } - if (suggestionType == null) return; - String query = cursor.getString(4); - switch (suggestionType) { - case TYPE_USER: - username = '@' + username; - break; - case TYPE_HASHTAG: - username = '#' + username; - break; - } - - if (onSuggestionClickListener != null) { - final SuggestionType finalSuggestionType = suggestionType; - view.setOnClickListener(v -> onSuggestionClickListener.onSuggestionClick(finalSuggestionType, query)); - } - final ItemSuggestionBinding binding = ItemSuggestionBinding.bind(view); - binding.isVerified.setVisibility(verified ? View.VISIBLE : View.GONE); - binding.tvUsername.setText(username); - binding.tvFullName.setVisibility(View.VISIBLE); - binding.tvFullName.setText(fullName); - binding.ivProfilePic.setImageURI(picUrl); - } - - public interface OnSuggestionClickListener { - void onSuggestionClick(final SuggestionType type, final String query); - } -} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/FavoriteViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/FavoriteViewHolder.java index e16824ea..bf9da891 100644 --- a/app/src/main/java/awais/instagrabber/adapters/viewholder/FavoriteViewHolder.java +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/FavoriteViewHolder.java @@ -6,7 +6,7 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.adapters.FavoritesAdapter; -import awais.instagrabber.databinding.ItemSuggestionBinding; +import awais.instagrabber.databinding.ItemSearchResultBinding; import awais.instagrabber.db.entities.Favorite; import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.utils.Constants; @@ -14,12 +14,12 @@ import awais.instagrabber.utils.Constants; public class FavoriteViewHolder extends RecyclerView.ViewHolder { private static final String TAG = "FavoriteViewHolder"; - private final ItemSuggestionBinding binding; + private final ItemSearchResultBinding binding; - public FavoriteViewHolder(@NonNull final ItemSuggestionBinding binding) { + public FavoriteViewHolder(@NonNull final ItemSearchResultBinding binding) { super(binding.getRoot()); this.binding = binding; - binding.isVerified.setVisibility(View.GONE); + binding.verified.setVisibility(View.GONE); } public void bind(final Favorite model, @@ -36,12 +36,12 @@ public class FavoriteViewHolder extends RecyclerView.ViewHolder { return longClickListener.onLongClick(model); }); if (model.getType() == FavoriteType.HASHTAG) { - binding.ivProfilePic.setImageURI(Constants.DEFAULT_HASH_TAG_PIC); + binding.profilePic.setImageURI(Constants.DEFAULT_HASH_TAG_PIC); } else { - binding.ivProfilePic.setImageURI(model.getPicUrl()); + binding.profilePic.setImageURI(model.getPicUrl()); } - binding.tvFullName.setText(model.getDisplayName()); - binding.tvUsername.setVisibility(View.VISIBLE); + binding.title.setVisibility(View.VISIBLE); + binding.subtitle.setText(model.getDisplayName()); String query = model.getQuery(); switch (model.getType()) { case HASHTAG: @@ -51,11 +51,11 @@ public class FavoriteViewHolder extends RecyclerView.ViewHolder { query = "@" + query; break; case LOCATION: - binding.tvUsername.setVisibility(View.GONE); + binding.title.setVisibility(View.GONE); break; default: // do nothing } - binding.tvUsername.setText(query); + binding.title.setText(query); } } diff --git a/app/src/main/java/awais/instagrabber/adapters/viewholder/SearchItemViewHolder.java b/app/src/main/java/awais/instagrabber/adapters/viewholder/SearchItemViewHolder.java new file mode 100644 index 00000000..4eb93fed --- /dev/null +++ b/app/src/main/java/awais/instagrabber/adapters/viewholder/SearchItemViewHolder.java @@ -0,0 +1,80 @@ +package awais.instagrabber.adapters.viewholder; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import awais.instagrabber.R; +import awais.instagrabber.databinding.ItemSearchResultBinding; +import awais.instagrabber.fragments.search.SearchCategoryFragment.OnSearchItemClickListener; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.Hashtag; +import awais.instagrabber.repositories.responses.Place; +import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.repositories.responses.search.SearchItem; + +public class SearchItemViewHolder extends RecyclerView.ViewHolder { + + private final ItemSearchResultBinding binding; + private final OnSearchItemClickListener onSearchItemClickListener; + + public SearchItemViewHolder(@NonNull final ItemSearchResultBinding binding, + final OnSearchItemClickListener onSearchItemClickListener) { + super(binding.getRoot()); + this.binding = binding; + this.onSearchItemClickListener = onSearchItemClickListener; + } + + public void bind(final SearchItem searchItem) { + if (searchItem == null) return; + final FavoriteType type = searchItem.getType(); + if (type == null) return; + String title; + String subtitle; + String picUrl; + boolean isVerified = false; + switch (type) { + case USER: + final User user = searchItem.getUser(); + title = "@" + user.getUsername(); + subtitle = user.getFullName(); + picUrl = user.getProfilePicUrl(); + isVerified = user.isVerified(); + break; + case HASHTAG: + final Hashtag hashtag = searchItem.getHashtag(); + title = "#" + hashtag.getName(); + subtitle = hashtag.getSubtitle(); + picUrl = "res:/" + R.drawable.ic_hashtag; + break; + case LOCATION: + final Place place = searchItem.getPlace(); + title = place.getTitle(); + subtitle = place.getSubtitle(); + picUrl = "res:/" + R.drawable.ic_location; + break; + default: + return; + } + itemView.setOnClickListener(v -> { + if (onSearchItemClickListener != null) { + onSearchItemClickListener.onSearchItemClick(searchItem); + } + }); + binding.delete.setVisibility(searchItem.isRecent() ? View.VISIBLE : View.GONE); + if (searchItem.isRecent()) { + binding.delete.setEnabled(true); + binding.delete.setOnClickListener(v -> { + if (onSearchItemClickListener != null) { + binding.delete.setEnabled(false); + onSearchItemClickListener.onSearchItemDelete(searchItem); + } + }); + } + binding.title.setText(title); + binding.subtitle.setText(subtitle); + binding.profilePic.setImageURI(picUrl); + binding.verified.setVisibility(isVerified ? View.VISIBLE : View.GONE); + } +} diff --git a/app/src/main/java/awais/instagrabber/db/AppDatabase.java b/app/src/main/java/awais/instagrabber/db/AppDatabase.java index 63e2ce43..37446d9d 100644 --- a/app/src/main/java/awais/instagrabber/db/AppDatabase.java +++ b/app/src/main/java/awais/instagrabber/db/AppDatabase.java @@ -22,14 +22,16 @@ import java.util.List; import awais.instagrabber.db.dao.AccountDao; import awais.instagrabber.db.dao.DMLastNotifiedDao; import awais.instagrabber.db.dao.FavoriteDao; +import awais.instagrabber.db.dao.RecentSearchDao; import awais.instagrabber.db.entities.Account; import awais.instagrabber.db.entities.DMLastNotified; import awais.instagrabber.db.entities.Favorite; +import awais.instagrabber.db.entities.RecentSearch; import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.utils.Utils; -@Database(entities = {Account.class, Favorite.class, DMLastNotified.class}, - version = 5) +@Database(entities = {Account.class, Favorite.class, DMLastNotified.class, RecentSearch.class}, + version = 6) @TypeConverters({Converters.class}) public abstract class AppDatabase extends RoomDatabase { private static final String TAG = AppDatabase.class.getSimpleName(); @@ -42,12 +44,14 @@ public abstract class AppDatabase extends RoomDatabase { public abstract DMLastNotifiedDao dmLastNotifiedDao(); + public abstract RecentSearchDao recentSearchDao(); + public static AppDatabase getDatabase(final Context context) { if (INSTANCE == null) { synchronized (AppDatabase.class) { if (INSTANCE == null) { INSTANCE = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "cookiebox.db") - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) .build(); } } @@ -156,6 +160,21 @@ public abstract class AppDatabase extends RoomDatabase { } }; + static final Migration MIGRATION_5_6 = new Migration(5, 6) { + @Override + public void migrate(@NonNull final SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE IF NOT EXISTS `recent_searches` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`ig_id` TEXT NOT NULL, " + + "`name` TEXT NOT NULL, " + + "`username` TEXT, " + + "`pic_url` TEXT, " + + "`type` TEXT NOT NULL, " + + "`last_searched_on` INTEGER NOT NULL)"); + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_recent_searches_ig_id_type` ON `recent_searches` (`ig_id`, `type`)"); + } + }; + @NonNull private static List backupOldFavorites(@NonNull final SupportSQLiteDatabase db) { // check if old favorites table had the column query_display diff --git a/app/src/main/java/awais/instagrabber/db/dao/RecentSearchDao.java b/app/src/main/java/awais/instagrabber/db/dao/RecentSearchDao.java new file mode 100644 index 00000000..94266524 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/dao/RecentSearchDao.java @@ -0,0 +1,37 @@ +package awais.instagrabber.db.dao; + +import androidx.room.Dao; +import androidx.room.Delete; +import androidx.room.Insert; +import androidx.room.Query; +import androidx.room.Update; + +import java.util.List; + +import awais.instagrabber.db.entities.RecentSearch; +import awais.instagrabber.models.enums.FavoriteType; + +@Dao +public interface RecentSearchDao { + + @Query("SELECT * FROM recent_searches ORDER BY last_searched_on DESC") + List getAllRecentSearches(); + + @Query("SELECT * FROM recent_searches WHERE `ig_id` = :igId AND `type` = :type") + RecentSearch getRecentSearchByIgIdAndType(String igId, FavoriteType type); + + @Query("SELECT * FROM recent_searches WHERE instr(`name`, :query) > 0") + List findRecentSearchesWithNameContaining(String query); + + @Insert + Long insertRecentSearch(RecentSearch recentSearch); + + @Update + void updateRecentSearch(RecentSearch recentSearch); + + @Delete + void deleteRecentSearch(RecentSearch recentSearch); + + // @Query("DELETE from recent_searches") + // void deleteAllRecentSearches(); +} diff --git a/app/src/main/java/awais/instagrabber/db/datasources/RecentSearchDataSource.java b/app/src/main/java/awais/instagrabber/db/datasources/RecentSearchDataSource.java new file mode 100644 index 00000000..9fd950a8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/datasources/RecentSearchDataSource.java @@ -0,0 +1,57 @@ +package awais.instagrabber.db.datasources; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import java.util.List; + +import awais.instagrabber.db.AppDatabase; +import awais.instagrabber.db.dao.RecentSearchDao; +import awais.instagrabber.db.entities.RecentSearch; +import awais.instagrabber.models.enums.FavoriteType; + +public class RecentSearchDataSource { + private static final String TAG = RecentSearchDataSource.class.getSimpleName(); + + private static RecentSearchDataSource INSTANCE; + + private final RecentSearchDao recentSearchDao; + + private RecentSearchDataSource(final RecentSearchDao recentSearchDao) { + this.recentSearchDao = recentSearchDao; + } + + public static synchronized RecentSearchDataSource getInstance(@NonNull Context context) { + if (INSTANCE == null) { + synchronized (RecentSearchDataSource.class) { + if (INSTANCE == null) { + final AppDatabase database = AppDatabase.getDatabase(context); + INSTANCE = new RecentSearchDataSource(database.recentSearchDao()); + } + } + } + return INSTANCE; + } + + public RecentSearch getRecentSearchByIgIdAndType(@NonNull final String igId, @NonNull final FavoriteType type) { + return recentSearchDao.getRecentSearchByIgIdAndType(igId, type); + } + + @NonNull + public final List getAllRecentSearches() { + return recentSearchDao.getAllRecentSearches(); + } + + public final void insertOrUpdateRecentSearch(@NonNull final RecentSearch recentSearch) { + if (recentSearch.getId() != 0) { + recentSearchDao.updateRecentSearch(recentSearch); + return; + } + recentSearchDao.insertRecentSearch(recentSearch); + } + + public final void deleteRecentSearch(@NonNull final RecentSearch recentSearch) { + recentSearchDao.deleteRecentSearch(recentSearch); + } +} diff --git a/app/src/main/java/awais/instagrabber/db/entities/RecentSearch.java b/app/src/main/java/awais/instagrabber/db/entities/RecentSearch.java new file mode 100644 index 00000000..95bd368e --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/entities/RecentSearch.java @@ -0,0 +1,185 @@ +package awais.instagrabber.db.entities; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Ignore; +import androidx.room.Index; +import androidx.room.PrimaryKey; + +import java.time.LocalDateTime; +import java.util.Objects; + +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.search.SearchItem; + +@Entity(tableName = RecentSearch.TABLE_NAME, indices = {@Index(value = {RecentSearch.COL_IG_ID, RecentSearch.COL_TYPE}, unique = true)}) +public class RecentSearch { + private static final String TAG = RecentSearch.class.getSimpleName(); + + public static final String TABLE_NAME = "recent_searches"; + private static final String COL_ID = "id"; + public static final String COL_IG_ID = "ig_id"; + private static final String COL_NAME = "name"; + private static final String COL_USERNAME = "username"; + private static final String COL_PIC_URL = "pic_url"; + public static final String COL_TYPE = "type"; + private static final String COL_LAST_SEARCHED_ON = "last_searched_on"; + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = COL_ID) + private final int id; + + @ColumnInfo(name = COL_IG_ID) + @NonNull + private final String igId; + + @ColumnInfo(name = COL_NAME) + @NonNull + private final String name; + + @ColumnInfo(name = COL_USERNAME) + private final String username; + + @ColumnInfo(name = COL_PIC_URL) + private final String picUrl; + + @ColumnInfo(name = COL_TYPE) + @NonNull + private final FavoriteType type; + + @ColumnInfo(name = COL_LAST_SEARCHED_ON) + @NonNull + private final LocalDateTime lastSearchedOn; + + @Ignore + public RecentSearch(final String igId, + final String name, + final String username, + final String picUrl, + final FavoriteType type, + final LocalDateTime lastSearchedOn) { + this(0, igId, name, username, picUrl, type, lastSearchedOn); + } + + public RecentSearch(final int id, + @NonNull final String igId, + @NonNull final String name, + final String username, + final String picUrl, + @NonNull final FavoriteType type, + @NonNull final LocalDateTime lastSearchedOn) { + this.id = id; + this.igId = igId; + this.name = name; + this.username = username; + this.picUrl = picUrl; + this.type = type; + this.lastSearchedOn = lastSearchedOn; + } + + public int getId() { + return id; + } + + @NonNull + public String getIgId() { + return igId; + } + + @NonNull + public String getName() { + return name; + } + + public String getUsername() { + return username; + } + + public String getPicUrl() { + return picUrl; + } + + @NonNull + public FavoriteType getType() { + return type; + } + + @NonNull + public LocalDateTime getLastSearchedOn() { + return lastSearchedOn; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final RecentSearch that = (RecentSearch) o; + return Objects.equals(igId, that.igId) && + Objects.equals(name, that.name) && + Objects.equals(username, that.username) && + Objects.equals(picUrl, that.picUrl) && + type == that.type && + Objects.equals(lastSearchedOn, that.lastSearchedOn); + } + + @Override + public int hashCode() { + return Objects.hash(igId, name, username, picUrl, type, lastSearchedOn); + } + + @NonNull + @Override + public String toString() { + return "RecentSearch{" + + "id=" + id + + ", igId='" + igId + '\'' + + ", name='" + name + '\'' + + ", username='" + username + '\'' + + ", picUrl='" + picUrl + '\'' + + ", type=" + type + + ", lastSearchedOn=" + lastSearchedOn + + '}'; + } + + @Nullable + public static RecentSearch fromSearchItem(@NonNull final SearchItem searchItem) { + final FavoriteType type = searchItem.getType(); + if (type == null) return null; + try { + final String igId; + final String name; + final String username; + final String picUrl; + switch (type) { + case USER: + igId = String.valueOf(searchItem.getUser().getPk()); + name = searchItem.getUser().getFullName(); + username = searchItem.getUser().getUsername(); + picUrl = searchItem.getUser().getProfilePicUrl(); + break; + case HASHTAG: + igId = searchItem.getHashtag().getId(); + name = searchItem.getHashtag().getName(); + username = null; + picUrl = null; + break; + case LOCATION: + igId = String.valueOf(searchItem.getPlace().getLocation().getPk()); + name = searchItem.getPlace().getTitle(); + username = null; + picUrl = null; + break; + default: + return null; + } + return new RecentSearch(igId, name, username, picUrl, type, LocalDateTime.now()); + } catch (Exception e) { + Log.e(TAG, "fromSearchItem: ", e); + } + return null; + } +} diff --git a/app/src/main/java/awais/instagrabber/db/repositories/RecentSearchRepository.java b/app/src/main/java/awais/instagrabber/db/repositories/RecentSearchRepository.java new file mode 100644 index 00000000..0832a109 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/db/repositories/RecentSearchRepository.java @@ -0,0 +1,124 @@ +package awais.instagrabber.db.repositories; + +import androidx.annotation.NonNull; + +import java.time.LocalDateTime; +import java.util.List; + +import awais.instagrabber.db.datasources.RecentSearchDataSource; +import awais.instagrabber.db.entities.RecentSearch; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.utils.AppExecutors; + +public class RecentSearchRepository { + private static final String TAG = RecentSearchRepository.class.getSimpleName(); + + private static RecentSearchRepository instance; + + private final AppExecutors appExecutors; + private final RecentSearchDataSource recentSearchDataSource; + + private RecentSearchRepository(final AppExecutors appExecutors, final RecentSearchDataSource recentSearchDataSource) { + this.appExecutors = appExecutors; + this.recentSearchDataSource = recentSearchDataSource; + } + + public static RecentSearchRepository getInstance(final RecentSearchDataSource recentSearchDataSource) { + if (instance == null) { + instance = new RecentSearchRepository(AppExecutors.getInstance(), recentSearchDataSource); + } + return instance; + } + + public void getRecentSearch(@NonNull final String igId, + @NonNull final FavoriteType type, + final RepositoryCallback callback) { + // request on the I/O thread + appExecutors.diskIO().execute(() -> { + final RecentSearch recentSearch = recentSearchDataSource.getRecentSearchByIgIdAndType(igId, type); + // notify on the main thread + appExecutors.mainThread().execute(() -> { + if (callback == null) return; + if (recentSearch == null) { + callback.onDataNotAvailable(); + return; + } + callback.onSuccess(recentSearch); + }); + }); + } + + public void getAllRecentSearches(final RepositoryCallback> callback) { + // request on the I/O thread + appExecutors.diskIO().execute(() -> { + final List recentSearches = recentSearchDataSource.getAllRecentSearches(); + // notify on the main thread + appExecutors.mainThread().execute(() -> { + if (callback == null) return; + callback.onSuccess(recentSearches); + }); + }); + } + + public void insertOrUpdateRecentSearch(@NonNull final RecentSearch recentSearch, + final RepositoryCallback callback) { + insertOrUpdateRecentSearch(recentSearch.getIgId(), recentSearch.getName(), recentSearch.getUsername(), recentSearch.getPicUrl(), + recentSearch.getType(), callback); + } + + public void insertOrUpdateRecentSearch(@NonNull final String igId, + @NonNull final String name, + final String username, + final String picUrl, + @NonNull final FavoriteType type, + final RepositoryCallback callback) { + // request on the I/O thread + appExecutors.diskIO().execute(() -> { + RecentSearch recentSearch = recentSearchDataSource.getRecentSearchByIgIdAndType(igId, type); + recentSearch = recentSearch == null + ? new RecentSearch(igId, name, username, picUrl, type, LocalDateTime.now()) + : new RecentSearch(recentSearch.getId(), igId, name, username, picUrl, type, LocalDateTime.now()); + recentSearchDataSource.insertOrUpdateRecentSearch(recentSearch); + // notify on the main thread + appExecutors.mainThread().execute(() -> { + if (callback == null) return; + callback.onSuccess(null); + }); + }); + } + + public void deleteRecentSearchByIgIdAndType(@NonNull final String igId, + @NonNull final FavoriteType type, + final RepositoryCallback callback) { + // request on the I/O thread + appExecutors.diskIO().execute(() -> { + final RecentSearch recentSearch = recentSearchDataSource.getRecentSearchByIgIdAndType(igId, type); + if (recentSearch != null) { + recentSearchDataSource.deleteRecentSearch(recentSearch); + } + // notify on the main thread + appExecutors.mainThread().execute(() -> { + if (callback == null) return; + if (recentSearch == null) { + callback.onDataNotAvailable(); + return; + } + callback.onSuccess(null); + }); + }); + } + + public void deleteRecentSearch(@NonNull final RecentSearch recentSearch, + final RepositoryCallback callback) { + // request on the I/O thread + appExecutors.diskIO().execute(() -> { + + recentSearchDataSource.deleteRecentSearch(recentSearch); + // notify on the main thread + appExecutors.mainThread().execute(() -> { + if (callback == null) return; + callback.onSuccess(null); + }); + }); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/FavoritesFragment.java b/app/src/main/java/awais/instagrabber/fragments/FavoritesFragment.java index 7be4156c..975357e8 100644 --- a/app/src/main/java/awais/instagrabber/fragments/FavoritesFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/FavoritesFragment.java @@ -41,7 +41,9 @@ public class FavoritesFragment extends Fragment { @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); - favoriteRepository = FavoriteRepository.getInstance(FavoriteDataSource.getInstance(getContext())); + final Context context = getContext(); + if (context == null) return; + favoriteRepository = FavoriteRepository.getInstance(FavoriteDataSource.getInstance(context)); } @NonNull diff --git a/app/src/main/java/awais/instagrabber/fragments/search/SearchCategoryFragment.java b/app/src/main/java/awais/instagrabber/fragments/search/SearchCategoryFragment.java new file mode 100644 index 00000000..89a2c7e9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/search/SearchCategoryFragment.java @@ -0,0 +1,195 @@ +package awais.instagrabber.fragments.search; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import awais.instagrabber.adapters.SearchItemsAdapter; +import awais.instagrabber.models.Resource; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.search.SearchItem; +import awais.instagrabber.viewmodels.SearchFragmentViewModel; + +public class SearchCategoryFragment extends Fragment { + private static final String TAG = SearchCategoryFragment.class.getSimpleName(); + private static final String ARG_TYPE = "type"; + + + @Nullable + private SwipeRefreshLayout swipeRefreshLayout; + @Nullable + private RecyclerView list; + private SearchFragmentViewModel viewModel; + private FavoriteType type; + private SearchItemsAdapter searchItemsAdapter; + @Nullable + private OnSearchItemClickListener onSearchItemClickListener; + private boolean skipViewRefresh; + private String prevQuery; + + @NonNull + public static SearchCategoryFragment newInstance(@NonNull final FavoriteType type) { + final SearchCategoryFragment fragment = new SearchCategoryFragment(); + final Bundle args = new Bundle(); + args.putSerializable(ARG_TYPE, type); + fragment.setArguments(args); + return fragment; + } + + public SearchCategoryFragment() {} + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + final Fragment parentFragment = getParentFragment(); + if (!(parentFragment instanceof OnSearchItemClickListener)) return; + onSearchItemClickListener = (OnSearchItemClickListener) parentFragment; + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final FragmentActivity fragmentActivity = getActivity(); + if (fragmentActivity == null) return; + viewModel = new ViewModelProvider(fragmentActivity).get(SearchFragmentViewModel.class); + final Bundle args = getArguments(); + if (args == null) { + Log.e(TAG, "onCreate: arguments are null"); + return; + } + final Serializable typeSerializable = args.getSerializable(ARG_TYPE); + if (!(typeSerializable instanceof FavoriteType)) { + Log.e(TAG, "onCreate: type not a FavoriteType"); + return; + } + type = (FavoriteType) typeSerializable; + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + final Context context = getContext(); + if (context == null) return null; + skipViewRefresh = false; + if (swipeRefreshLayout != null) { + skipViewRefresh = true; + return swipeRefreshLayout; + } + swipeRefreshLayout = new SwipeRefreshLayout(context); + swipeRefreshLayout.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + list = new RecyclerView(context); + list.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + swipeRefreshLayout.addView(list); + return swipeRefreshLayout; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (skipViewRefresh) return; + setupList(); + } + + @Override + public void onResume() { + super.onResume(); + // Log.d(TAG, "onResume: type: " + type); + setupObservers(); + final String currentQuery = viewModel.getQuery().getValue(); + if (prevQuery != null && currentQuery != null && !Objects.equals(prevQuery, currentQuery)) { + viewModel.search(currentQuery, type); + } + prevQuery = null; + } + + private void setupList() { + if (list == null || swipeRefreshLayout == null) return; + final Context context = getContext(); + if (context == null) return; + list.setLayoutManager(new LinearLayoutManager(context)); + searchItemsAdapter = new SearchItemsAdapter(onSearchItemClickListener); + list.setAdapter(searchItemsAdapter); + swipeRefreshLayout.setOnRefreshListener(() -> { + String currentQuery = viewModel.getQuery().getValue(); + if (currentQuery == null) currentQuery = ""; + viewModel.search(currentQuery, type); + }); + } + + private void setupObservers() { + viewModel.getQuery().observe(getViewLifecycleOwner(), q -> { + if (!isVisible() || Objects.equals(prevQuery, q)) return; + viewModel.search(q, type); + prevQuery = q; + }); + final LiveData>> resultsLiveData = getResultsLiveData(); + if (resultsLiveData != null) { + resultsLiveData.observe(getViewLifecycleOwner(), this::onResults); + } + } + + private void onResults(final Resource> listResource) { + if (listResource == null) return; + switch (listResource.status) { + case SUCCESS: + if (searchItemsAdapter != null) { + searchItemsAdapter.submitList(listResource.data); + } + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setRefreshing(false); + } + break; + case ERROR: + if (searchItemsAdapter != null) { + searchItemsAdapter.submitList(Collections.emptyList()); + } + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setRefreshing(false); + } + break; + case LOADING: + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setRefreshing(true); + } + break; + } + } + + @Nullable + private LiveData>> getResultsLiveData() { + switch (type) { + case TOP: + return viewModel.getTopResults(); + case USER: + return viewModel.getUserResults(); + case HASHTAG: + return viewModel.getHashtagResults(); + case LOCATION: + return viewModel.getLocationResults(); + } + return null; + } + + public interface OnSearchItemClickListener { + void onSearchItemClick(SearchItem searchItem); + + void onSearchItemDelete(SearchItem searchItem); + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/search/SearchFragment.java b/app/src/main/java/awais/instagrabber/fragments/search/SearchFragment.java new file mode 100644 index 00000000..91ca1519 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/search/SearchFragment.java @@ -0,0 +1,245 @@ +package awais.instagrabber.fragments.search; + +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.LinearLayoutCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavController; +import androidx.navigation.fragment.NavHostFragment; + +import com.google.android.material.snackbar.Snackbar; +import com.google.android.material.tabs.TabLayoutMediator; +import com.google.android.material.textfield.TextInputLayout; + +import java.util.Arrays; +import java.util.List; + +import awais.instagrabber.R; +import awais.instagrabber.activities.MainActivity; +import awais.instagrabber.adapters.SearchCategoryAdapter; +import awais.instagrabber.customviews.helpers.TextWatcherAdapter; +import awais.instagrabber.databinding.FragmentSearchBinding; +import awais.instagrabber.models.Resource; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.search.SearchItem; +import awais.instagrabber.viewmodels.SearchFragmentViewModel; + +public class SearchFragment extends Fragment implements SearchCategoryFragment.OnSearchItemClickListener { + private static final String TAG = SearchFragment.class.getSimpleName(); + private static final String QUERY = "query"; + + private FragmentSearchBinding binding; + private LinearLayoutCompat root; + private boolean shouldRefresh = true; + @Nullable + private TextInputLayout searchInputLayout; + @Nullable + private EditText searchInput; + @Nullable + private MainActivity mainActivity; + private SearchFragmentViewModel viewModel; + + private final TextWatcherAdapter textWatcher = new TextWatcherAdapter() { + @Override + public void afterTextChanged(final Editable s) { + if (s == null) return; + viewModel.submitQuery(s.toString().trim()); + } + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final FragmentActivity fragmentActivity = getActivity(); + if (!(fragmentActivity instanceof MainActivity)) return; + mainActivity = (MainActivity) fragmentActivity; + viewModel = new ViewModelProvider(mainActivity).get(SearchFragmentViewModel.class); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + if (root != null) { + shouldRefresh = false; + return root; + } + binding = FragmentSearchBinding.inflate(inflater, container, false); + root = binding.getRoot(); + return root; + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + if (!shouldRefresh) return; + init(savedInstanceState); + shouldRefresh = false; + } + + @Override + public void onSaveInstanceState(@NonNull final Bundle outState) { + super.onSaveInstanceState(outState); + final String current = viewModel.getQuery().getValue(); + if (TextUtils.isEmpty(current)) return; + outState.putString(QUERY, current); + } + + @Override + public void onPause() { + super.onPause(); + if (mainActivity != null) { + mainActivity.hideSearchView(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (mainActivity != null) { + mainActivity.hideSearchView(); + } + if (searchInput != null) { + searchInput.removeTextChangedListener(textWatcher); + searchInput.setText(""); + } + } + + @Override + public void onResume() { + super.onResume(); + if (mainActivity != null) { + mainActivity.showSearchView(); + } + if (searchInputLayout != null) { + searchInputLayout.requestFocus(); + } + } + + private void init(@Nullable final Bundle savedInstanceState) { + if (mainActivity == null) return; + searchInputLayout = mainActivity.showSearchView(); + searchInput = searchInputLayout.getEditText(); + setupObservers(); + setupViewPager(); + setupSearchInput(savedInstanceState); + } + + private void setupObservers() { + viewModel.getQuery().observe(getViewLifecycleOwner(), q -> {}); // need to observe, so that getQuery returns proper value + } + + private void setupSearchInput(@Nullable final Bundle savedInstanceState) { + if (searchInput == null) return; + searchInput.removeTextChangedListener(textWatcher); // make sure we add only 1 instance of textWatcher + searchInput.addTextChangedListener(textWatcher); + boolean triggerEmptyQuery = true; + if (savedInstanceState != null) { + final String savedQuery = savedInstanceState.getString(QUERY); + if (TextUtils.isEmpty(savedQuery)) return; + searchInput.setText(savedQuery); + triggerEmptyQuery = false; + } + searchInput.requestFocus(); + if (triggerEmptyQuery) { + viewModel.submitQuery(""); + } + } + + private void setupViewPager() { + binding.pager.setSaveEnabled(false); + final List categories = Arrays.asList(FavoriteType.values()); + binding.pager.setAdapter(new SearchCategoryAdapter(this, categories)); + final TabLayoutMediator mediator = new TabLayoutMediator(binding.tabLayout, binding.pager, (tab, position) -> { + try { + final FavoriteType type = categories.get(position); + final int resId; + switch (type) { + case TOP: + resId = R.string.top; + break; + case USER: + resId = R.string.accounts; + break; + case HASHTAG: + resId = R.string.hashtags; + break; + case LOCATION: + resId = R.string.locations; + break; + default: + throw new IllegalStateException("Unexpected value: " + type); + } + tab.setText(resId); + } catch (Exception e) { + Log.e(TAG, "setupViewPager: ", e); + } + }); + mediator.attach(); + } + + @Override + public void onSearchItemClick(final SearchItem searchItem) { + if (searchItem == null) return; + final FavoriteType type = searchItem.getType(); + if (type == null) return; + try { + if (!searchItem.isFavorite()) { + viewModel.saveToRecentSearches(searchItem); // insert or update recent + } + final NavController navController = NavHostFragment.findNavController(this); + final Bundle bundle = new Bundle(); + switch (type) { + case USER: + bundle.putString("username", searchItem.getUser().getUsername()); + navController.navigate(R.id.action_global_profileFragment, bundle); + break; + case HASHTAG: + bundle.putString("hashtag", searchItem.getHashtag().getName()); + navController.navigate(R.id.action_global_hashTagFragment, bundle); + break; + case LOCATION: + bundle.putLong("locationId", searchItem.getPlace().getLocation().getPk()); + navController.navigate(R.id.action_global_locationFragment, bundle); + break; + } + } catch (Exception e) { + Log.e(TAG, "onSearchItemClick: ", e); + } + } + + @Override + public void onSearchItemDelete(final SearchItem searchItem) { + final LiveData> liveData = viewModel.deleteRecentSearch(searchItem); + if (liveData == null) return; + liveData.observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(final Resource resource) { + if (resource == null) return; + switch (resource.status) { + case SUCCESS: + viewModel.search("", FavoriteType.TOP); + liveData.removeObserver(this); + break; + case ERROR: + Snackbar.make(binding.getRoot(), R.string.error, Snackbar.LENGTH_SHORT); + liveData.removeObserver(this); + break; + case LOADING: + break; + } + } + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/models/enums/FavoriteType.java b/app/src/main/java/awais/instagrabber/models/enums/FavoriteType.java index cdf926a9..536ac05f 100644 --- a/app/src/main/java/awais/instagrabber/models/enums/FavoriteType.java +++ b/app/src/main/java/awais/instagrabber/models/enums/FavoriteType.java @@ -1,7 +1,8 @@ package awais.instagrabber.models.enums; public enum FavoriteType { + TOP, // used just for searching USER, HASHTAG, - LOCATION + LOCATION, } diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/Hashtag.java b/app/src/main/java/awais/instagrabber/repositories/responses/Hashtag.java index 2ce08eb5..f1ef47b3 100755 --- a/app/src/main/java/awais/instagrabber/repositories/responses/Hashtag.java +++ b/app/src/main/java/awais/instagrabber/repositories/responses/Hashtag.java @@ -1,6 +1,7 @@ package awais.instagrabber.repositories.responses; import java.io.Serializable; +import java.util.Objects; import awais.instagrabber.models.enums.FollowingType; @@ -42,4 +43,21 @@ public final class Hashtag implements Serializable { public String getSubtitle() { return searchResultSubtitle; } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Hashtag hashtag = (Hashtag) o; + return mediaCount == hashtag.mediaCount && + following == hashtag.following && + Objects.equals(id, hashtag.id) && + Objects.equals(name, hashtag.name) && + Objects.equals(searchResultSubtitle, hashtag.searchResultSubtitle); + } + + @Override + public int hashCode() { + return Objects.hash(following, mediaCount, id, name, searchResultSubtitle); + } } \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/Place.java b/app/src/main/java/awais/instagrabber/repositories/responses/Place.java index c72e1c41..3f10ffd4 100644 --- a/app/src/main/java/awais/instagrabber/repositories/responses/Place.java +++ b/app/src/main/java/awais/instagrabber/repositories/responses/Place.java @@ -1,5 +1,7 @@ package awais.instagrabber.repositories.responses; +import java.util.Objects; + public class Place { private final Location location; // for search @@ -40,4 +42,21 @@ public class Place { public String getStatus() { return status; } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Place place = (Place) o; + return Objects.equals(location, place.location) && + Objects.equals(title, place.title) && + Objects.equals(subtitle, place.subtitle) && + Objects.equals(slug, place.slug) && + Objects.equals(status, place.status); + } + + @Override + public int hashCode() { + return Objects.hash(location, title, subtitle, slug, status); + } } diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/search/SearchItem.java b/app/src/main/java/awais/instagrabber/repositories/responses/search/SearchItem.java index 4aca79da..296e9c84 100644 --- a/app/src/main/java/awais/instagrabber/repositories/responses/search/SearchItem.java +++ b/app/src/main/java/awais/instagrabber/repositories/responses/search/SearchItem.java @@ -1,15 +1,34 @@ package awais.instagrabber.repositories.responses.search; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.db.entities.Favorite; +import awais.instagrabber.db.entities.RecentSearch; +import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.repositories.responses.Hashtag; +import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Place; import awais.instagrabber.repositories.responses.User; public class SearchItem { + private static final String TAG = SearchItem.class.getSimpleName(); + private final User user; private final Place place; private final Hashtag hashtag; private final int position; + private boolean isRecent = false; + private boolean isFavorite = false; + public SearchItem(final User user, final Place place, final Hashtag hashtag, @@ -35,4 +54,221 @@ public class SearchItem { public int getPosition() { return position; } + + public boolean isRecent() { + return isRecent; + } + + public void setRecent(final boolean recent) { + isRecent = recent; + } + + public boolean isFavorite() { + return isFavorite; + } + + public void setFavorite(final boolean favorite) { + isFavorite = favorite; + } + + @Nullable + public FavoriteType getType() { + if (user != null) { + return FavoriteType.USER; + } + if (hashtag != null) { + return FavoriteType.HASHTAG; + } + if (place != null) { + return FavoriteType.LOCATION; + } + return null; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final SearchItem that = (SearchItem) o; + return Objects.equals(user, that.user) && + Objects.equals(place, that.place) && + Objects.equals(hashtag, that.hashtag); + } + + @Override + public int hashCode() { + return Objects.hash(user, place, hashtag); + } + + @NonNull + @Override + public String toString() { + return "SearchItem{" + + "user=" + user + + ", place=" + place + + ", hashtag=" + hashtag + + ", position=" + position + + ", isRecent=" + isRecent + + '}'; + } + + @NonNull + public static List fromRecentSearch(final List recentSearches) { + if (recentSearches == null) return Collections.emptyList(); + return recentSearches.stream() + .map(SearchItem::fromRecentSearch) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + @Nullable + private static SearchItem fromRecentSearch(final RecentSearch recentSearch) { + if (recentSearch == null) return null; + try { + final FavoriteType type = recentSearch.getType(); + final SearchItem searchItem; + switch (type) { + case USER: + searchItem = new SearchItem(getUser(recentSearch), null, null, 0); + break; + case HASHTAG: + searchItem = new SearchItem(null, null, getHashtag(recentSearch), 0); + break; + case LOCATION: + searchItem = new SearchItem(null, getPlace(recentSearch), null, 0); + break; + default: + return null; + } + searchItem.setRecent(true); + return searchItem; + } catch (Exception e) { + Log.e(TAG, "fromRecentSearch: ", e); + } + return null; + } + + @NonNull + private static User getUser(@NonNull final RecentSearch recentSearch) { + return new User( + Long.parseLong(recentSearch.getIgId()), + recentSearch.getUsername(), + recentSearch.getName(), + false, + recentSearch.getPicUrl(), + null, null, false, false, false, false, false, + null, null, 0, 0, 0, 0, null, null, + 0, null, null, null, null, null, null + ); + } + + @NonNull + private static Hashtag getHashtag(@NonNull final RecentSearch recentSearch) { + return new Hashtag( + recentSearch.getIgId(), + recentSearch.getName(), + 0, + null, + null + ); + } + + @NonNull + private static Place getPlace(@NonNull final RecentSearch recentSearch) { + final Location location = new Location( + Long.parseLong(recentSearch.getIgId()), + recentSearch.getName(), + recentSearch.getName(), + null, null, 0, 0 + ); + return new Place( + location, + recentSearch.getName(), + null, + null, + null + ); + } + + public static List fromFavorite(final List favorites) { + if (favorites == null) { + return Collections.emptyList(); + } + return favorites.stream() + .map(SearchItem::fromFavorite) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + @Nullable + private static SearchItem fromFavorite(final Favorite favorite) { + if (favorite == null) return null; + final FavoriteType type = favorite.getType(); + if (type == null) return null; + final SearchItem searchItem; + switch (type) { + case USER: + searchItem = new SearchItem(getUser(favorite), null, null, 0); + break; + case HASHTAG: + searchItem = new SearchItem(null, null, getHashtag(favorite), 0); + break; + case LOCATION: + final Place place = getPlace(favorite); + if (place == null) return null; + searchItem = new SearchItem(null, place, null, 0); + break; + default: + return null; + } + searchItem.setFavorite(true); + return searchItem; + } + + @NonNull + private static User getUser(@NonNull final Favorite favorite) { + return new User( + 0, + favorite.getQuery(), + favorite.getDisplayName(), + false, + favorite.getPicUrl(), + null, null, false, false, false, false, false, + null, null, 0, 0, 0, 0, null, null, + 0, null, null, null, null, null, null + ); + } + + @NonNull + private static Hashtag getHashtag(@NonNull final Favorite favorite) { + return new Hashtag( + "0", + favorite.getQuery(), + 0, + null, + null + ); + } + + @Nullable + private static Place getPlace(@NonNull final Favorite favorite) { + try { + final Location location = new Location( + Long.parseLong(favorite.getQuery()), + favorite.getDisplayName(), + favorite.getDisplayName(), + null, null, 0, 0 + ); + return new Place( + location, + favorite.getDisplayName(), + null, + null, + null + ); + } catch (Exception e) { + Log.e(TAG, "getPlace: ", e); + return null; + } + } } diff --git a/app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java index ab1cbabb..84fd2ba9 100644 --- a/app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java +++ b/app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java @@ -8,8 +8,6 @@ import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; -import awais.instagrabber.db.datasources.AccountDataSource; -import awais.instagrabber.db.repositories.AccountRepository; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; @@ -23,20 +21,18 @@ public class AppStateViewModel extends AndroidViewModel { private static final String TAG = AppStateViewModel.class.getSimpleName(); private final String cookie; - private final boolean isLoggedIn; private final MutableLiveData currentUser = new MutableLiveData<>(); - private AccountRepository accountRepository; private UserService userService; public AppStateViewModel(@NonNull final Application application) { super(application); // Log.d(TAG, "AppStateViewModel: constructor"); cookie = settingsHelper.getString(Constants.COOKIE); - isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; + final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) != 0; if (!isLoggedIn) return; userService = UserService.getInstance(); - accountRepository = AccountRepository.getInstance(AccountDataSource.getInstance(application)); + // final AccountRepository accountRepository = AccountRepository.getInstance(AccountDataSource.getInstance(application)); fetchProfileDetails(); } @@ -50,6 +46,7 @@ public class AppStateViewModel extends AndroidViewModel { private void fetchProfileDetails() { final long uid = CookieUtils.getUserIdFromCookie(cookie); + if (userService == null) return; userService.getUserInfo(uid, new ServiceCallback() { @Override public void onSuccess(final User user) { diff --git a/app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.java new file mode 100644 index 00000000..229cc9c1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.java @@ -0,0 +1,352 @@ +package awais.instagrabber.viewmodels; + +import android.app.Application; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import awais.instagrabber.db.datasources.FavoriteDataSource; +import awais.instagrabber.db.datasources.RecentSearchDataSource; +import awais.instagrabber.db.entities.Favorite; +import awais.instagrabber.db.entities.RecentSearch; +import awais.instagrabber.db.repositories.FavoriteRepository; +import awais.instagrabber.db.repositories.RecentSearchRepository; +import awais.instagrabber.db.repositories.RepositoryCallback; +import awais.instagrabber.models.Resource; +import awais.instagrabber.models.enums.FavoriteType; +import awais.instagrabber.repositories.responses.search.SearchItem; +import awais.instagrabber.repositories.responses.search.SearchResponse; +import awais.instagrabber.utils.AppExecutors; +import awais.instagrabber.utils.Constants; +import awais.instagrabber.utils.CookieUtils; +import awais.instagrabber.utils.Debouncer; +import awais.instagrabber.utils.TextUtils; +import awais.instagrabber.webservices.SearchService; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +import static androidx.lifecycle.Transformations.distinctUntilChanged; +import static awais.instagrabber.utils.Utils.settingsHelper; + +public class SearchFragmentViewModel extends AppStateViewModel { + private static final String TAG = SearchFragmentViewModel.class.getSimpleName(); + private static final String QUERY = "query"; + + private final MutableLiveData query = new MutableLiveData<>(); + private final MutableLiveData>> topResults = new MutableLiveData<>(); + private final MutableLiveData>> userResults = new MutableLiveData<>(); + private final MutableLiveData>> hashtagResults = new MutableLiveData<>(); + private final MutableLiveData>> locationResults = new MutableLiveData<>(); + + private final SearchService searchService; + private final Debouncer searchDebouncer; + private final boolean isLoggedIn; + private final LiveData distinctQuery; + private final RecentSearchRepository recentSearchRepository; + private final FavoriteRepository favoriteRepository; + + private String tempQuery; + + public SearchFragmentViewModel(@NonNull final Application application) { + super(application); + final String cookie = settingsHelper.getString(Constants.COOKIE); + isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) != 0; + final Debouncer.Callback searchCallback = new Debouncer.Callback() { + @Override + public void call(final String key) { + if (tempQuery == null) return; + query.postValue(tempQuery); + } + + @Override + public void onError(final Throwable t) { + Log.e(TAG, "onError: ", t); + } + }; + searchDebouncer = new Debouncer<>(searchCallback, 500); + distinctQuery = distinctUntilChanged(query); + searchService = SearchService.getInstance(); + recentSearchRepository = RecentSearchRepository.getInstance(RecentSearchDataSource.getInstance(application)); + favoriteRepository = FavoriteRepository.getInstance(FavoriteDataSource.getInstance(application)); + } + + public LiveData getQuery() { + return distinctQuery; + } + + public LiveData>> getTopResults() { + return topResults; + } + + public LiveData>> getUserResults() { + return userResults; + } + + public LiveData>> getHashtagResults() { + return hashtagResults; + } + + public LiveData>> getLocationResults() { + return locationResults; + } + + public void submitQuery(@Nullable final String query) { + String localQuery = query; + if (query == null) { + localQuery = ""; + } + if (tempQuery != null && Objects.equals(localQuery.toLowerCase(), tempQuery.toLowerCase())) return; + tempQuery = query; + if (TextUtils.isEmpty(query)) { + // If empty immediately post it + searchDebouncer.cancel(QUERY); + this.query.postValue(""); + return; + } + searchDebouncer.call(QUERY); + } + + public void search(@NonNull final String query, + @NonNull final FavoriteType type) { + final MutableLiveData>> liveData = getLiveDataByType(type); + if (liveData == null) return; + if (TextUtils.isEmpty(query)) { + if (type != FavoriteType.TOP) { + liveData.postValue(Resource.success(Collections.emptyList())); + return; + } + showRecentSearchesAndFavorites(); + return; + } + if (query.equals("@") || query.equals("#")) return; + final String c; + switch (type) { + case TOP: + c = "blended"; + break; + case USER: + c = "user"; + break; + case HASHTAG: + c = "hashtag"; + break; + case LOCATION: + c = "place"; + break; + default: + return; + } + liveData.postValue(Resource.loading(null)); + final Call request = searchService.search(isLoggedIn, query, c); + request.enqueue(new Callback() { + @Override + public void onResponse(@NonNull final Call call, + @NonNull final Response response) { + if (!response.isSuccessful()) { + sendErrorResponse(type); + return; + } + final SearchResponse body = response.body(); + if (body == null) { + sendErrorResponse(type); + return; + } + parseResponse(body, type); + } + + @Override + public void onFailure(@NonNull final Call call, + @NonNull final Throwable t) { + Log.e(TAG, "onFailure: ", t); + } + }); + } + + private void showRecentSearchesAndFavorites() { + final SettableFuture> recentResultsFuture = SettableFuture.create(); + final SettableFuture> favoritesFuture = SettableFuture.create(); + recentSearchRepository.getAllRecentSearches(new RepositoryCallback>() { + @Override + public void onSuccess(final List result) { + recentResultsFuture.set(result); + } + + @Override + public void onDataNotAvailable() { + recentResultsFuture.set(Collections.emptyList()); + } + }); + favoriteRepository.getAllFavorites(new RepositoryCallback>() { + @Override + public void onSuccess(final List result) { + favoritesFuture.set(result); + } + + @Override + public void onDataNotAvailable() { + favoritesFuture.set(Collections.emptyList()); + } + }); + //noinspection UnstableApiUsage + final ListenableFuture>> listenableFuture = Futures.allAsList(recentResultsFuture, favoritesFuture); + Futures.addCallback(listenableFuture, new FutureCallback>>() { + @Override + public void onSuccess(@Nullable final List> result) { + if (!TextUtils.isEmpty(tempQuery)) return; // Make sure user has not entered anything before updating results + if (result == null) { + topResults.postValue(Resource.success(Collections.emptyList())); + return; + } + try { + //noinspection unchecked + topResults.postValue(Resource.success( + ImmutableList.builder() + .addAll(SearchItem.fromRecentSearch((List) result.get(0))) + .addAll(SearchItem.fromFavorite((List) result.get(1))) + .build() + )); + } catch (Exception e) { + Log.e(TAG, "onSuccess: ", e); + topResults.postValue(Resource.success(Collections.emptyList())); + } + } + + @Override + public void onFailure(@NonNull final Throwable t) { + if (!TextUtils.isEmpty(tempQuery)) return; + topResults.postValue(Resource.success(Collections.emptyList())); + Log.e(TAG, "onFailure: ", t); + } + }, AppExecutors.getInstance().mainThread()); + } + + private void sendErrorResponse(@NonNull final FavoriteType type) { + final MutableLiveData>> liveData = getLiveDataByType(type); + if (liveData == null) return; + liveData.postValue(Resource.error(null, Collections.emptyList())); + } + + private MutableLiveData>> getLiveDataByType(@NonNull final FavoriteType type) { + final MutableLiveData>> liveData; + switch (type) { + case TOP: + liveData = topResults; + break; + case USER: + liveData = userResults; + break; + case HASHTAG: + liveData = hashtagResults; + break; + case LOCATION: + liveData = locationResults; + break; + default: + return null; + } + return liveData; + } + + private void parseResponse(@NonNull final SearchResponse body, + @NonNull final FavoriteType type) { + final MutableLiveData>> liveData = getLiveDataByType(type); + if (liveData == null) return; + if (isLoggedIn) { + if (body.getList() == null) { + liveData.postValue(Resource.success(Collections.emptyList())); + return; + } + if (type == FavoriteType.HASHTAG || type == FavoriteType.LOCATION) { + liveData.postValue(Resource.success(body.getList() + .stream() + .filter(i -> i.getUser() == null) + .collect(Collectors.toList()))); + return; + } + liveData.postValue(Resource.success(body.getList())); + return; + } + + // anonymous + final List list; + switch (type) { + case TOP: + list = ImmutableList + .builder() + .addAll(body.getUsers()) + .addAll(body.getHashtags()) + .addAll(body.getPlaces()) + .build(); + break; + case USER: + list = body.getUsers(); + break; + case HASHTAG: + list = body.getHashtags(); + break; + case LOCATION: + list = body.getPlaces(); + break; + default: + return; + } + liveData.postValue(Resource.success(list)); + } + + public void saveToRecentSearches(final SearchItem searchItem) { + if (searchItem == null) return; + try { + final RecentSearch recentSearch = RecentSearch.fromSearchItem(searchItem); + if (recentSearch == null) return; + recentSearchRepository.insertOrUpdateRecentSearch(recentSearch, new RepositoryCallback() { + @Override + public void onSuccess(final Void result) { + // Log.d(TAG, "onSuccess: inserted recent: " + recentSearch); + } + + @Override + public void onDataNotAvailable() {} + }); + } catch (Exception e) { + Log.e(TAG, "saveToRecentSearches: ", e); + } + } + + @Nullable + public LiveData> deleteRecentSearch(final SearchItem searchItem) { + if (searchItem == null || !searchItem.isRecent()) return null; + final RecentSearch recentSearch = RecentSearch.fromSearchItem(searchItem); + if (recentSearch == null) return null; + final MutableLiveData> data = new MutableLiveData<>(); + data.postValue(Resource.loading(null)); + recentSearchRepository.deleteRecentSearchByIgIdAndType(recentSearch.getIgId(), recentSearch.getType(), new RepositoryCallback() { + @Override + public void onSuccess(final Void result) { + // Log.d(TAG, "onSuccess: deleted"); + data.postValue(Resource.success(new Object())); + } + + @Override + public void onDataNotAvailable() { + // Log.e(TAG, "onDataNotAvailable: not deleted"); + data.postValue(Resource.error("Error deleting recent item", null)); + } + }); + return data; + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 97bcb0bb..8fadd4a2 100755 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -32,7 +32,29 @@ android:background="?attr/colorSurface" app:layout_collapseMode="pin" app:title="@string/app_name" - tools:menu="@menu/main_menu" /> + tools:menu="@menu/main_menu"> + + + + + + diff --git a/app/src/main/res/layout/fragment_favorites.xml b/app/src/main/res/layout/fragment_favorites.xml index 05a808d6..d437e6e7 100644 --- a/app/src/main/res/layout/fragment_favorites.xml +++ b/app/src/main/res/layout/fragment_favorites.xml @@ -4,4 +4,4 @@ android:id="@+id/favorite_list" android:layout_width="match_parent" android:layout_height="match_parent" - tools:listitem="@layout/item_suggestion" /> \ No newline at end of file + tools:listitem="@layout/item_search_result" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml new file mode 100644 index 00000000..39c24c3c --- /dev/null +++ b/app/src/main/res/layout/fragment_search.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_suggestion.xml b/app/src/main/res/layout/item_search_result.xml old mode 100755 new mode 100644 similarity index 65% rename from app/src/main/res/layout/item_suggestion.xml rename to app/src/main/res/layout/item_search_result.xml index 8020090a..e6987925 --- a/app/src/main/res/layout/item_suggestion.xml +++ b/app/src/main/res/layout/item_search_result.xml @@ -13,7 +13,7 @@ android:paddingBottom="8dp"> + + \ No newline at end of file diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index 2ad8da42..b73c4b97 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -1,25 +1,10 @@ - - - - - - - - - - - - - - + android:title="@string/search" + app:showAsAction="always" /> \ No newline at end of file diff --git a/app/src/main/res/navigation/direct_messages_nav_graph.xml b/app/src/main/res/navigation/direct_messages_nav_graph.xml index 1cf39e25..51dfd27b 100644 --- a/app/src/main/res/navigation/direct_messages_nav_graph.xml +++ b/app/src/main/res/navigation/direct_messages_nav_graph.xml @@ -96,6 +96,10 @@ android:id="@+id/action_global_user_search" app:destination="@id/user_search_nav_graph" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/discover_nav_graph.xml b/app/src/main/res/navigation/discover_nav_graph.xml index 537e5eae..2c2d0f08 100644 --- a/app/src/main/res/navigation/discover_nav_graph.xml +++ b/app/src/main/res/navigation/discover_nav_graph.xml @@ -95,6 +95,10 @@ app:argType="long" /> + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/favorites_nav_graph.xml b/app/src/main/res/navigation/favorites_nav_graph.xml index 1a288bf1..a1a218ff 100644 --- a/app/src/main/res/navigation/favorites_nav_graph.xml +++ b/app/src/main/res/navigation/favorites_nav_graph.xml @@ -1,6 +1,7 @@ @@ -36,8 +37,18 @@ app:argType="long" /> + + + android:label="@string/title_favorites" + tools:layout="@layout/fragment_favorites" /> + \ No newline at end of file diff --git a/app/src/main/res/navigation/feed_nav_graph.xml b/app/src/main/res/navigation/feed_nav_graph.xml index f9e26cc8..19dec632 100644 --- a/app/src/main/res/navigation/feed_nav_graph.xml +++ b/app/src/main/res/navigation/feed_nav_graph.xml @@ -106,6 +106,10 @@ app:nullable="false" /> + + - + \ No newline at end of file diff --git a/app/src/main/res/navigation/hashtag_nav_graph.xml b/app/src/main/res/navigation/hashtag_nav_graph.xml index b4d6d31a..7446c366 100644 --- a/app/src/main/res/navigation/hashtag_nav_graph.xml +++ b/app/src/main/res/navigation/hashtag_nav_graph.xml @@ -65,6 +65,10 @@ app:argType="long" /> + + + diff --git a/app/src/main/res/navigation/location_nav_graph.xml b/app/src/main/res/navigation/location_nav_graph.xml index acc33413..3761330f 100644 --- a/app/src/main/res/navigation/location_nav_graph.xml +++ b/app/src/main/res/navigation/location_nav_graph.xml @@ -66,6 +66,10 @@ app:nullable="false" /> + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/profile_nav_graph.xml b/app/src/main/res/navigation/profile_nav_graph.xml index a07c4df1..4bc8c444 100644 --- a/app/src/main/res/navigation/profile_nav_graph.xml +++ b/app/src/main/res/navigation/profile_nav_graph.xml @@ -112,6 +112,10 @@ app:argType="boolean" /> + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9cf27407..8530174f 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -493,4 +493,7 @@ If saved, all DM related features will be disabled on next launch Copy caption Copy reply + Top + Recent + Clear diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f31e985d..c0d79025 100755 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -225,4 +225,13 @@ 0dp 0dp + + + +