Browse Source
Merge pull request #1077 from ammargitham/feature/search-history
Merge pull request #1077 from ammargitham/feature/search-history
Search history with separate search fragmentrenovate/org.robolectric-robolectric-4.x
Austin Huang
4 years ago
committed by
GitHub
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 2505 additions and 387 deletions
-
20app/build.gradle
-
227app/schemas/awais.instagrabber.db.AppDatabase/6.json
-
51app/src/androidTest/java/awais/instagrabber/db/MigrationTest.java
-
82app/src/androidTest/java/awais/instagrabber/db/dao/RecentSearchDaoTest.java
-
310app/src/main/java/awais/instagrabber/activities/MainActivity.java
-
4app/src/main/java/awais/instagrabber/adapters/FavoritesAdapter.java
-
33app/src/main/java/awais/instagrabber/adapters/SearchCategoryAdapter.java
-
215app/src/main/java/awais/instagrabber/adapters/SearchItemsAdapter.java
-
77app/src/main/java/awais/instagrabber/adapters/SuggestionsAdapter.java
-
20app/src/main/java/awais/instagrabber/adapters/viewholder/FavoriteViewHolder.java
-
80app/src/main/java/awais/instagrabber/adapters/viewholder/SearchItemViewHolder.java
-
25app/src/main/java/awais/instagrabber/db/AppDatabase.java
-
37app/src/main/java/awais/instagrabber/db/dao/RecentSearchDao.java
-
57app/src/main/java/awais/instagrabber/db/datasources/RecentSearchDataSource.java
-
185app/src/main/java/awais/instagrabber/db/entities/RecentSearch.java
-
124app/src/main/java/awais/instagrabber/db/repositories/RecentSearchRepository.java
-
4app/src/main/java/awais/instagrabber/fragments/FavoritesFragment.java
-
197app/src/main/java/awais/instagrabber/fragments/search/SearchCategoryFragment.java
-
248app/src/main/java/awais/instagrabber/fragments/search/SearchFragment.java
-
3app/src/main/java/awais/instagrabber/models/enums/FavoriteType.java
-
18app/src/main/java/awais/instagrabber/repositories/responses/Hashtag.java
-
19app/src/main/java/awais/instagrabber/repositories/responses/Place.java
-
236app/src/main/java/awais/instagrabber/repositories/responses/search/SearchItem.java
-
9app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java
-
352app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.java
-
26app/src/main/res/layout/activity_main.xml
-
2app/src/main/res/layout/fragment_favorites.xml
-
18app/src/main/res/layout/fragment_search.xml
-
41app/src/main/res/layout/item_search_result.xml
-
19app/src/main/res/menu/main_menu.xml
-
10app/src/main/res/navigation/direct_messages_nav_graph.xml
-
9app/src/main/res/navigation/discover_nav_graph.xml
-
13app/src/main/res/navigation/favorites_nav_graph.xml
-
10app/src/main/res/navigation/feed_nav_graph.xml
-
9app/src/main/res/navigation/hashtag_nav_graph.xml
-
9app/src/main/res/navigation/location_nav_graph.xml
-
37app/src/main/res/navigation/profile_nav_graph.xml
-
3app/src/main/res/values/attrs.xml
-
3app/src/main/res/values/strings.xml
-
35app/src/main/res/values/styles.xml
-
15app/src/main/res/values/themes.xml
@ -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')" |
||||
|
] |
||||
|
} |
||||
|
} |
@ -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(); |
||||
|
} |
||||
|
} |
@ -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<RecentSearch> insertListReversed = ImmutableList |
||||
|
.<RecentSearch>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<RecentSearch> 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; |
||||
|
} |
||||
|
} |
@ -0,0 +1,33 @@ |
|||||
|
package awais.instagrabber.adapters; |
||||
|
|
||||
|
import androidx.annotation.NonNull; |
||||
|
import androidx.fragment.app.Fragment; |
||||
|
import androidx.viewpager2.adapter.FragmentStateAdapter; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
import awais.instagrabber.fragments.search.SearchCategoryFragment; |
||||
|
import awais.instagrabber.models.enums.FavoriteType; |
||||
|
|
||||
|
public class SearchCategoryAdapter extends FragmentStateAdapter { |
||||
|
|
||||
|
private final List<FavoriteType> categories; |
||||
|
|
||||
|
public SearchCategoryAdapter(@NonNull final Fragment fragment, |
||||
|
@NonNull final List<FavoriteType> 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(); |
||||
|
} |
||||
|
} |
@ -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<RecyclerView.ViewHolder> { |
||||
|
private static final String TAG = SearchItemsAdapter.class.getSimpleName(); |
||||
|
private static final DiffUtil.ItemCallback<SearchItemOrHeader> DIFF_CALLBACK = new DiffUtil.ItemCallback<SearchItemOrHeader>() { |
||||
|
@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<SearchItemOrHeader> 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<SearchItem> list) { |
||||
|
if (list == null) { |
||||
|
differ.submitList(null); |
||||
|
return; |
||||
|
} |
||||
|
differ.submitList(sectionAndSort(list)); |
||||
|
} |
||||
|
|
||||
|
public void submitList(@Nullable final List<SearchItem> list, @Nullable final Runnable commitCallback) { |
||||
|
if (list == null) { |
||||
|
differ.submitList(null, commitCallback); |
||||
|
return; |
||||
|
} |
||||
|
differ.submitList(sectionAndSort(list), commitCallback); |
||||
|
} |
||||
|
|
||||
|
@NonNull |
||||
|
private List<SearchItemOrHeader> sectionAndSort(@NonNull final List<SearchItem> 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<SearchItem> 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<SearchItemOrHeader> 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); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -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); |
|
||||
} |
|
||||
} |
|
@ -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); |
||||
|
} |
||||
|
} |
@ -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<RecentSearch> 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<RecentSearch> 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(); |
||||
|
} |
@ -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<RecentSearch> 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); |
||||
|
} |
||||
|
} |
@ -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; |
||||
|
} |
||||
|
} |
@ -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<RecentSearch> 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<List<RecentSearch>> callback) { |
||||
|
// request on the I/O thread |
||||
|
appExecutors.diskIO().execute(() -> { |
||||
|
final List<RecentSearch> 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<Void> 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<Void> 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<Void> 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<Void> 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); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -0,0 +1,197 @@ |
|||||
|
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<Resource<List<SearchItem>>> resultsLiveData = getResultsLiveData(); |
||||
|
if (resultsLiveData != null) { |
||||
|
resultsLiveData.observe(getViewLifecycleOwner(), this::onResults); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void onResults(final Resource<List<SearchItem>> 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; |
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Nullable |
||||
|
private LiveData<Resource<List<SearchItem>>> 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); |
||||
|
} |
||||
|
} |
@ -0,0 +1,248 @@ |
|||||
|
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<FavoriteType> 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; |
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
} catch (Exception e) { |
||||
|
Log.e(TAG, "onSearchItemClick: ", e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onSearchItemDelete(final SearchItem searchItem) { |
||||
|
final LiveData<Resource<Object>> liveData = viewModel.deleteRecentSearch(searchItem); |
||||
|
if (liveData == null) return; |
||||
|
liveData.observe(getViewLifecycleOwner(), new Observer<Resource<Object>>() { |
||||
|
@Override |
||||
|
public void onChanged(final Resource<Object> 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: |
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -1,7 +1,8 @@ |
|||||
package awais.instagrabber.models.enums; |
package awais.instagrabber.models.enums; |
||||
|
|
||||
public enum FavoriteType { |
public enum FavoriteType { |
||||
|
TOP, // used just for searching |
||||
USER, |
USER, |
||||
HASHTAG, |
HASHTAG, |
||||
LOCATION |
|
||||
|
LOCATION, |
||||
} |
} |
@ -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<String> query = new MutableLiveData<>(); |
||||
|
private final MutableLiveData<Resource<List<SearchItem>>> topResults = new MutableLiveData<>(); |
||||
|
private final MutableLiveData<Resource<List<SearchItem>>> userResults = new MutableLiveData<>(); |
||||
|
private final MutableLiveData<Resource<List<SearchItem>>> hashtagResults = new MutableLiveData<>(); |
||||
|
private final MutableLiveData<Resource<List<SearchItem>>> locationResults = new MutableLiveData<>(); |
||||
|
|
||||
|
private final SearchService searchService; |
||||
|
private final Debouncer<String> searchDebouncer; |
||||
|
private final boolean isLoggedIn; |
||||
|
private final LiveData<String> 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<String> searchCallback = new Debouncer.Callback<String>() { |
||||
|
@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<String> getQuery() { |
||||
|
return distinctQuery; |
||||
|
} |
||||
|
|
||||
|
public LiveData<Resource<List<SearchItem>>> getTopResults() { |
||||
|
return topResults; |
||||
|
} |
||||
|
|
||||
|
public LiveData<Resource<List<SearchItem>>> getUserResults() { |
||||
|
return userResults; |
||||
|
} |
||||
|
|
||||
|
public LiveData<Resource<List<SearchItem>>> getHashtagResults() { |
||||
|
return hashtagResults; |
||||
|
} |
||||
|
|
||||
|
public LiveData<Resource<List<SearchItem>>> 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<Resource<List<SearchItem>>> 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<SearchResponse> request = searchService.search(isLoggedIn, query, c); |
||||
|
request.enqueue(new Callback<SearchResponse>() { |
||||
|
@Override |
||||
|
public void onResponse(@NonNull final Call<SearchResponse> call, |
||||
|
@NonNull final Response<SearchResponse> 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<SearchResponse> call, |
||||
|
@NonNull final Throwable t) { |
||||
|
Log.e(TAG, "onFailure: ", t); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private void showRecentSearchesAndFavorites() { |
||||
|
final SettableFuture<List<RecentSearch>> recentResultsFuture = SettableFuture.create(); |
||||
|
final SettableFuture<List<Favorite>> favoritesFuture = SettableFuture.create(); |
||||
|
recentSearchRepository.getAllRecentSearches(new RepositoryCallback<List<RecentSearch>>() { |
||||
|
@Override |
||||
|
public void onSuccess(final List<RecentSearch> result) { |
||||
|
recentResultsFuture.set(result); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onDataNotAvailable() { |
||||
|
recentResultsFuture.set(Collections.emptyList()); |
||||
|
} |
||||
|
}); |
||||
|
favoriteRepository.getAllFavorites(new RepositoryCallback<List<Favorite>>() { |
||||
|
@Override |
||||
|
public void onSuccess(final List<Favorite> result) { |
||||
|
favoritesFuture.set(result); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onDataNotAvailable() { |
||||
|
favoritesFuture.set(Collections.emptyList()); |
||||
|
} |
||||
|
}); |
||||
|
//noinspection UnstableApiUsage |
||||
|
final ListenableFuture<List<List<?>>> listenableFuture = Futures.allAsList(recentResultsFuture, favoritesFuture); |
||||
|
Futures.addCallback(listenableFuture, new FutureCallback<List<List<?>>>() { |
||||
|
@Override |
||||
|
public void onSuccess(@Nullable final List<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.<SearchItem>builder() |
||||
|
.addAll(SearchItem.fromRecentSearch((List<RecentSearch>) result.get(0))) |
||||
|
.addAll(SearchItem.fromFavorite((List<Favorite>) 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<Resource<List<SearchItem>>> liveData = getLiveDataByType(type); |
||||
|
if (liveData == null) return; |
||||
|
liveData.postValue(Resource.error(null, Collections.emptyList())); |
||||
|
} |
||||
|
|
||||
|
private MutableLiveData<Resource<List<SearchItem>>> getLiveDataByType(@NonNull final FavoriteType type) { |
||||
|
final MutableLiveData<Resource<List<SearchItem>>> 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<Resource<List<SearchItem>>> 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<SearchItem> list; |
||||
|
switch (type) { |
||||
|
case TOP: |
||||
|
list = ImmutableList |
||||
|
.<SearchItem>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<Void>() { |
||||
|
@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<Resource<Object>> deleteRecentSearch(final SearchItem searchItem) { |
||||
|
if (searchItem == null || !searchItem.isRecent()) return null; |
||||
|
final RecentSearch recentSearch = RecentSearch.fromSearchItem(searchItem); |
||||
|
if (recentSearch == null) return null; |
||||
|
final MutableLiveData<Resource<Object>> data = new MutableLiveData<>(); |
||||
|
data.postValue(Resource.loading(null)); |
||||
|
recentSearchRepository.deleteRecentSearchByIgIdAndType(recentSearch.getIgId(), recentSearch.getType(), new RepositoryCallback<Void>() { |
||||
|
@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; |
||||
|
} |
||||
|
} |
@ -0,0 +1,18 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="match_parent" |
||||
|
android:orientation="vertical"> |
||||
|
|
||||
|
<com.google.android.material.tabs.TabLayout |
||||
|
android:id="@+id/tab_layout" |
||||
|
style="@style/Widget.MaterialComponents.TabLayout.RegularCaps" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="wrap_content" /> |
||||
|
|
||||
|
<androidx.viewpager2.widget.ViewPager2 |
||||
|
android:id="@+id/pager" |
||||
|
android:layout_width="match_parent" |
||||
|
android:layout_height="0dp" |
||||
|
android:layout_weight="1" /> |
||||
|
</androidx.appcompat.widget.LinearLayoutCompat> |
@ -1,25 +1,10 @@ |
|||||
<?xml version="1.0" encoding="utf-8"?> |
<?xml version="1.0" encoding="utf-8"?> |
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android" |
<menu xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto"> |
xmlns:app="http://schemas.android.com/apk/res-auto"> |
||||
<!--<item--> |
|
||||
<!-- android:id="@+id/favourites"--> |
|
||||
<!-- android:enabled="true"--> |
|
||||
<!-- android:icon="@drawable/ic_star_24"--> |
|
||||
<!-- android:title="@string/title_favorites"--> |
|
||||
<!-- app:showAsAction="ifRoom" />--> |
|
||||
|
|
||||
<!--<item--> |
|
||||
<!-- android:id="@+id/direct_messages"--> |
|
||||
<!-- android:enabled="true"--> |
|
||||
<!-- android:icon="@drawable/ic_send_24"--> |
|
||||
<!-- android:title="@string/action_dms"--> |
|
||||
<!-- app:showAsAction="always" />--> |
|
||||
|
|
||||
<item |
<item |
||||
android:id="@+id/search" |
android:id="@+id/search" |
||||
android:enabled="true" |
android:enabled="true" |
||||
android:icon="@drawable/ic_search_24" |
android:icon="@drawable/ic_search_24" |
||||
android:title="@string/action_search" |
|
||||
app:actionViewClass="androidx.appcompat.widget.SearchView" |
|
||||
app:showAsAction="always|collapseActionView" /> |
|
||||
|
android:title="@string/search" |
||||
|
app:showAsAction="always" /> |
||||
</menu> |
</menu> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue